pi-agent: Implement final TUI hardening

This commit is contained in:
proxyctl coordinator
2026-07-18 01:07:54 +08:00
parent 954e7d977a
commit f15ffc2301
10 changed files with 598 additions and 39 deletions
@@ -4,17 +4,21 @@
**Blocked by:** 03 — Stream progressive startup and refresh; 04 — Unify operation coordination and status feedback; 05 — Add contextual Target actions and Drift Resolution; 06 — Add the searchable Active Profile picker; 07 — Build the dedicated Profile management page; 08 — Deliver the mature Profile editor; 09 — Attach Protocol Probe to the Profile field; 10 — Unify Help, dialogs, and navigation.
**Status:** ready-for-agent
**Status:** done
- [ ] Fixed-size render coverage includes 120×36, 80×24, and 59×15 across the completed workflows.
- [ ] Light, dark, and `NO_COLOR` renders retain readable state, focus, progress, warnings, disabled actions, and errors.
- [ ] Standard Unicode symbols have a safe rendering strategy and no Nerd Font dependency.
- [ ] Long Target names, Profile names, evidence, details, URLs, Bypass values, errors, and confirmation copy remain bounded and reachable.
- [ ] Secrets injected through valid and malformed Profile fields, evidence, notices, errors, progress, command output, and confirmations never appear in any rendered frame.
- [ ] Resize stress preserves valid page, dialog, focus, selection, viewport, in-flight operation, and deferred-exit state.
- [ ] Alternate-screen process tests verify normal exit and safe deferred exit restore the prior terminal screen.
- [ ] Startup, refresh, Target action, Active Profile switching, management, editor, Probe, Help, and Drift Resolution each have complete-Model behavioral coverage.
- [ ] Tests assert semantic behavior rather than private component structure or raw styling calls.
- [ ] Existing Application and Adapter suites remain green without duplicating Target configuration tests in the TUI.
- [ ] Documentation describes the redesigned keyboard model, responsive size behavior, Profile switching, contextual Target actions, and privacy boundaries.
- [ ] Full repository and hermetic verification pass without modifying real Shell, APT, Docker, GNOME, or systemd state.
- [x] Fixed-size render coverage includes 120×36, 80×24, and 59×15 across the completed workflows.
- [x] Light, dark, and `NO_COLOR` renders retain readable state, focus, progress, warnings, disabled actions, and errors.
- [x] Standard Unicode symbols have a safe rendering strategy and no Nerd Font dependency.
- [x] Long Target names, Profile names, evidence, details, URLs, Bypass values, errors, and confirmation copy remain bounded and reachable.
- [x] Secrets injected through valid and malformed Profile fields, evidence, notices, errors, progress, command output, and confirmations never appear in any rendered frame.
- [x] Resize stress preserves valid page, dialog, focus, selection, viewport, in-flight operation, and deferred-exit state.
- [x] Alternate-screen process tests verify normal exit and safe deferred exit restore the prior terminal screen.
- [x] Startup, refresh, Target action, Active Profile switching, management, editor, Probe, Help, and Drift Resolution each have complete-Model behavioral coverage.
- [x] Tests assert semantic behavior rather than private component structure or raw styling calls.
- [x] Existing Application and Adapter suites remain green without duplicating Target configuration tests in the TUI.
- [x] Documentation describes the redesigned keyboard model, responsive size behavior, Profile switching, contextual Target actions, and privacy boundaries.
- [x] Full repository and hermetic verification pass without modifying real Shell, APT, Docker, GNOME, or systemd state.
## Answer
Final hardening now exercises every completed workflow through the production Model at all supported size classes, checks semantic equivalence across light, dark, and `NO_COLOR`, uses cell-width bounds for Unicode content, rejects private-use/Nerd Font glyphs, and preserves complete interaction state through resize stress. The final rendering boundary redacts malformed as well as valid credential-bearing text, long Profile names and confirmation copy wrap into reachable viewports, and process tests prove alternate-screen restoration after normal and safely deferred exit. README documents the final keyboard, responsive, contextual-action, accessibility, lifecycle, and privacy contracts.
+9 -1
View File
@@ -54,6 +54,14 @@ The dashboard shows the Active Profile. Press `p` to open the searchable Active
Escape closes the top dialog or returns one page level. `q` returns from child pages and exits only from the unobscured dashboard (in the searchable Profile picker it remains search input). Ctrl+C requests exit everywhere. If a system mutation is running, either dashboard `q` or Ctrl+C visibly defers exit until the mutation completes safely; Target browsing and Help remain available while conflicting changes are disabled.
## TUI keyboard, responsive, and accessibility behavior
The dashboard is keyboard-only. Up/Down or `j`/`k` moves within the focused region, Tab moves between the Target list and details, Page Up/Page Down scrolls long details, and the fixed footer lists the actions currently accepted. Focus always has a text marker such as `[focused]`, not color alone. Confirmation dialogs start on Cancel; Left/Right or Tab changes the choice and Enter activates it. Errors remain visible until acknowledged or directly resolved and can be expanded with `e`; progress appears both on the affected Target row and in the status area.
At 100×28 and larger, Targets and details are side by side. From 60–99 columns, the same workflow uses a single-column layout. Below 60×16, proxyctl shows only a minimum-size warning; resizing back restores the current page, selection, focus, scroll position, dialog, and in-flight operation. The TUI uses the terminal alternate screen and restores the prior shell screen after normal or safely deferred exit.
State is always communicated by a standard Unicode/ASCII-safe symbol and an explicit word (`● Enabled`, `○ Disabled`, `◇ External`, `! Drifted`, `… Checking`); no Nerd Font is required. Styling adapts to light and dark terminal backgrounds. Set `NO_COLOR` to disable color without losing state, focus, progress, warning, disabled-action, or error meaning.
Profiles are human-readable under the user's XDG configuration directory (normally `~/.config/proxyctl/profiles.json`) and are stored owner-only because proxy URLs may contain credentials.
## Shell integration
@@ -116,7 +124,7 @@ Inspect failures and rerun until reset reports no failed managed Target. Then re
## Privacy and safety boundaries
proxyctl has no telemetry, automatic update checks, or automatic proxy-connectivity tests. It does not contact the network on its own; the only network traffic it generates is the user-initiated Protocol Probe (Ctrl+D in the Profile form), a handshake check that never forwards traffic through the proxy and never contacts anything beyond the endpoint itself. Local diagnostic logs are credential-redacted and bounded in size. Profile form frames mask the entire URL userinfo while retaining the underlying value for saving. Proxy credentials remain present where a selected Target requires them, so normal local file security still matters.
proxyctl has no telemetry, automatic update checks, or automatic proxy-connectivity tests. It does not contact the network on its own; the only network traffic it generates is the user-initiated Protocol Probe (Ctrl+D in the Profile form), a handshake check that never forwards traffic through the proxy and never contacts anything beyond the endpoint itself. Local diagnostic logs are credential-redacted and bounded in size. Every TUI frame defensively redacts credentials from Profile values, Target evidence, progress, notices, errors and command output, and confirmation copy immediately before rendering, including malformed credential-bearing URLs. Profile form frames mask the entire URL userinfo while retaining the underlying value for saving. Proxy credentials remain present where a selected Target requires them, so normal local file security still matters.
proxyctl does **not** manage or supervise proxy processes such as Clash, sing-box, or Xray. It does not manage subscriptions, nodes, latency, routing rules, transparent proxying, TUN/iptables/nftables, arbitrary services or commands, plugins, or a complete headless Profile/Target CLI.
+72
View File
@@ -2,10 +2,12 @@ package main
import (
"bytes"
"context"
"os"
"path/filepath"
"strings"
"testing"
"time"
"proxyctl/internal/profile"
"proxyctl/internal/target"
@@ -86,6 +88,76 @@ func TestDefaultInvocationUsesAlternateScreenAndRestoresItOnExit(t *testing.T) {
}
}
type blockingDetectionTarget struct {
started chan struct{}
release chan struct{}
}
func (t *blockingDetectionTarget) ID() string { return "blocking-test" }
func (t *blockingDetectionTarget) Description() target.Description {
return target.Description{Name: "Blocking test Target"}
}
func (t *blockingDetectionTarget) Availability(context.Context) target.Availability {
return target.Availability{Available: true}
}
func (t *blockingDetectionTarget) Inspect(context.Context, *profile.Profile) target.Inspection {
return target.Inspection{State: target.Disabled}
}
func (t *blockingDetectionTarget) Confirmation(bool) string { return "" }
func (t *blockingDetectionTarget) SetDesired(context.Context, bool, *profile.Profile) target.OperationResult {
return target.OperationResult{}
}
func (t *blockingDetectionTarget) Detect(context.Context, *profile.Profile) target.Detection {
select {
case t.started <- struct{}{}:
default:
}
<-t.release
return target.Detection{
Availability: target.Availability{Available: true, Evidence: "hermetic test Target"},
Inspection: target.Inspection{State: target.Disabled, Evidence: "no configuration"},
}
}
func TestDeferredExitCompletesSafelyAndRestoresAlternateScreen(t *testing.T) {
t.Setenv("XDG_CONFIG_HOME", t.TempDir())
adapter := &blockingDetectionTarget{started: make(chan struct{}, 1), release: make(chan struct{})}
var output bytes.Buffer
done := make(chan error, 1)
go func() {
done <- runWithSystemTargets(nil, strings.NewReader("q"), &output, []target.Target{adapter})
}()
select {
case <-adapter.started:
case err := <-done:
t.Fatalf("TUI exited before progressive startup began: %v", err)
case <-time.After(5 * time.Second):
t.Fatal("progressive startup did not reach the hermetic Target")
}
select {
case err := <-done:
t.Fatalf("deferred exit returned before safe operation completion: %v", err)
case <-time.After(50 * time.Millisecond):
}
close(adapter.release)
select {
case err := <-done:
if err != nil {
t.Fatalf("run proxyctl: %v", err)
}
case <-time.After(5 * time.Second):
t.Fatal("TUI did not exit after safe operation completion")
}
frame := output.String()
enter := strings.Index(frame, "\x1b[?1049h")
leave := strings.LastIndex(frame, "\x1b[?1049l")
if enter < 0 || leave <= enter {
t.Fatalf("deferred exit did not enter and restore the alternate screen in order: %q", frame)
}
}
func TestDefaultInvocationLoadsPersistedActiveProfile(t *testing.T) {
xdg := t.TempDir()
t.Setenv("XDG_CONFIG_HOME", xdg)
+18 -6
View File
@@ -263,19 +263,26 @@ func normalizeBypassEntry(raw string) (string, error) {
return host, nil
}
var credentialPattern = regexp.MustCompile(`(?i)((?:https?|socks5h?)://)?([^/@\s:]+):([^@\s]*)@`)
var (
schemeCredentialPattern = regexp.MustCompile(`(?i)((?:https?|socks5h?)://)([^/@:\r\n]+):([^@\r\n]*)@`)
bareCredentialPattern = regexp.MustCompile(`(?i)([^/@\s:]+):([^@/\s]*)@`)
)
// RedactText removes URL passwords from diagnostic text without requiring the
// surrounding text to be a valid URL or to include a scheme.
// surrounding text to be a valid URL. The scheme-aware first pass deliberately
// accepts spaces and other malformed password bytes up to @; diagnostics and
// form validation must not expose a credential merely because parsing failed.
func RedactText(value string) string {
return credentialPattern.ReplaceAllString(value, `${1}${2}:REDACTED@`)
value = schemeCredentialPattern.ReplaceAllString(value, `${1}${2}:REDACTED@`)
return bareCredentialPattern.ReplaceAllString(value, `${1}:REDACTED@`)
}
// RedactUserinfoText removes usernames as well as passwords. Form frames use
// this stricter boundary because even the identity of the probe source is
// private while a Profile is being edited.
func RedactUserinfoText(value string) string {
return credentialPattern.ReplaceAllString(value, `${1}REDACTED@`)
value = schemeCredentialPattern.ReplaceAllString(value, `${1}REDACTED@`)
return bareCredentialPattern.ReplaceAllString(value, `REDACTED@`)
}
func ObscureURL(value string) string {
@@ -294,14 +301,19 @@ func ObscureURL(value string) string {
// rune count. A text input clone can therefore retain its real cursor and
// horizontal viewport positions without rendering either username or password.
func ObscureURLForEditing(value string) string {
matches := credentialPattern.FindAllStringSubmatchIndex(value, -1)
masked := maskCredentialMatches(value, schemeCredentialPattern, 4, 7)
return maskCredentialMatches(masked, bareCredentialPattern, 2, 5)
}
func maskCredentialMatches(value string, pattern *regexp.Regexp, userinfoStartGroup, userinfoEndGroup int) string {
matches := pattern.FindAllStringSubmatchIndex(value, -1)
if len(matches) == 0 {
return value
}
var masked strings.Builder
last := 0
for _, match := range matches {
userinfoStart, userinfoEnd := match[4], match[7]
userinfoStart, userinfoEnd := match[userinfoStartGroup], match[userinfoEndGroup]
masked.WriteString(value[last:userinfoStart])
masked.WriteString(strings.Repeat("•", len([]rune(value[userinfoStart:userinfoEnd]))))
last = userinfoEnd
+2 -2
View File
@@ -60,7 +60,7 @@ func (p terminalPresentation) renderTargetListPane(state dashboardRenderState, w
if selectedIndex >= bodyHeight {
offset = selectedIndex - bodyHeight + 1
}
body := fixedViewport(strings.Join(lines, "\n"), width, bodyHeight, offset, false)
body := fixedViewport(protectRenderedText(strings.Join(lines, "\n")), width, bodyHeight, offset, false)
return fitPane(heading+"\n"+paneRule(width)+"\n"+body, width, height)
}
@@ -175,7 +175,7 @@ func (p terminalPresentation) targetDetails(item app.TargetSnapshot) string {
if item.Description.Details != "" {
fmt.Fprintf(&details, "\n\n%s", item.Description.Details)
}
return details.String()
return protectRenderedText(details.String())
}
func (p terminalPresentation) stateCountSummary(snapshot app.Snapshot) string {
+1 -1
View File
@@ -57,7 +57,7 @@ func (m Model) dashboardQuitAvailable() bool {
}
func (p terminalPresentation) helpDialogView(model presentationState) terminalPage {
content := lipgloss.NewStyle().Width(max(1, model.width)).Render(p.contextualHelp(model))
content := lipgloss.NewStyle().Width(max(1, model.width)).Render(protectRenderedText(p.contextualHelp(model)))
exitHint := "Ctrl+C: exit"
if model.operationActive {
exitHint = "Ctrl+C: defer safe exit"
+13 -4
View File
@@ -51,6 +51,8 @@ func (p terminalPresentation) mainView(model presentationState, width, height in
if selected, ok := newTargetCatalog(model.snapshot, true, model.driftResolutionTargetID).selected(model.driftResolutionTargetID); ok {
page.dashboard = nil
page.primary, page.context = p.driftResolutionView(selected)
page.wrapPrimary = true
page.wrapContext = true
page.footer = "r: Reapply Active Profile o: Restore Original\nEnter/q/Esc: cancel ?: help"
}
}
@@ -61,6 +63,7 @@ func (p terminalPresentation) mainView(model presentationState, width, height in
}
page.dashboard = nil
page.primary = dialog.view()
page.wrapPrimary = true
page.context = "Cancellation is selected by default."
page.footer = "←/→/Tab: choose y: confirm Enter: choose\nn/q/Esc: cancel ?: help"
}
@@ -174,10 +177,11 @@ func (p terminalPresentation) profilesView(model presentationState) terminalPage
}
if model.profileDelete != nil {
return terminalPage{
header: p.theme.title.Render("Proxy Profile management"),
primary: model.profileDelete.dialog.view(),
footer: "←/→/Tab: choose Enter: choose y: delete\nn/q/Esc: cancel ?: help",
focusLine: -1,
header: p.theme.title.Render("Proxy Profile management"),
primary: model.profileDelete.dialog.view(),
wrapPrimary: true,
footer: "←/→/Tab: choose Enter: choose y: delete\nn/q/Esc: cancel ?: help",
focusLine: -1,
}
}
return terminalPage{
@@ -188,6 +192,7 @@ func (p terminalPresentation) profilesView(model presentationState) terminalPage
focusLine: model.selected,
primaryOffset: model.profileOffset,
contextOffset: model.profileDetails,
wrapContext: true,
}
}
@@ -213,6 +218,10 @@ func selectedProfileDetails(p terminalPresentation, model presentationState) str
fmt.Fprintf(&details, "\n • %s", item)
}
}
// The list gives short names the strongest scan position. Repeating the
// complete value at the end of the scrollable details makes a clipped,
// wide Unicode name fully reachable without displacing connection details.
fmt.Fprintf(&details, "\nFull name: %s", selected.Name)
return details.String()
}
+92 -11
View File
@@ -4,6 +4,7 @@ import (
"fmt"
"io"
"os"
"regexp"
"strings"
"github.com/charmbracelet/bubbles/viewport"
@@ -102,6 +103,8 @@ type terminalPage struct {
contextOffset int
contextAnchor int
anchorContext bool
wrapPrimary bool
wrapContext bool
dashboard *dashboardRenderState
}
@@ -150,9 +153,9 @@ func (p terminalPresentation) Render(state presentationState) string {
// frames use the stricter boundary that removes the entire userinfo.
frame := p.renderShell(page, width, height)
if state.page == formPage {
return profile.RedactUserinfoText(frame)
return sanitizeStyledText(profile.RedactUserinfoText(frame))
}
return profile.RedactText(frame)
return sanitizeStyledText(profile.RedactText(frame))
}
func renderStatus(state presentationState) string {
@@ -175,14 +178,84 @@ func renderStatus(state presentationState) string {
}
func redactTerminalPage(page terminalPage) terminalPage {
page.header = profile.RedactText(page.header)
page.primary = profile.RedactText(page.primary)
page.context = profile.RedactText(page.context)
page.footer = profile.RedactText(page.footer)
page.status = profile.RedactText(page.status)
page.header = protectRenderedText(page.header)
page.primary = protectRenderedText(page.primary)
page.context = protectRenderedText(page.context)
page.footer = protectRenderedText(page.footer)
page.status = protectRenderedText(page.status)
return page
}
func protectRenderedText(value string) string {
return sanitizeStyledText(profile.RedactText(value))
}
var safeSGRPattern = regexp.MustCompile(`^(?:0|(?:(?:1;)?(?:4;)?38;5;(?:25|34|75|160|172|235|242|245|252)))$`)
// sanitizeStyledText removes terminal-control injection while retaining only
// the small SGR palette emitted by semanticTheme. OSC title/hyperlink commands,
// cursor movement, concealment, carriage returns, and private C1 controls are
// never meaningful application text and must not reach a rendered frame.
func sanitizeStyledText(value string) string {
var safe strings.Builder
for i := 0; i < len(value); {
if value[i] == '\x1b' {
if i+1 < len(value) && value[i+1] == '[' {
end := i + 2
for end < len(value) && (value[end] < 0x40 || value[end] > 0x7e) {
end++
}
if end < len(value) {
if value[end] == 'm' && safeSGRPattern.MatchString(value[i+2:end]) {
safe.WriteString(value[i : end+1])
}
i = end + 1
continue
}
i = len(value)
continue
}
if i+1 < len(value) && value[i+1] == ']' {
i += 2
for i < len(value) {
if value[i] == '\a' {
i++
break
}
if value[i] == '\x1b' && i+1 < len(value) && value[i+1] == '\\' {
i += 2
break
}
i++
}
continue
}
i++
continue
}
switch value[i] {
case '\n':
safe.WriteByte('\n')
case '\t':
safe.WriteString(" ")
default:
if value[i] >= 0x20 && value[i] != 0x7f {
safe.WriteByte(value[i])
}
}
i++
}
var visible strings.Builder
for _, r := range safe.String() {
if (r >= 0x80 && r <= 0x9f) || (r >= 0x202a && r <= 0x202e) || (r >= 0x2066 && r <= 0x2069) {
continue
}
visible.WriteRune(r)
}
return visible.String()
}
func (p terminalPresentation) renderShell(page terminalPage, width, height int) string {
header := viewportContent(page.header, width, lineCount(page.header), 0)
footer := viewportContent(page.footer, width, lineCount(page.footer), 0)
@@ -232,22 +305,30 @@ func (p terminalPresentation) renderShell(page terminalPage, width, height int)
}
func renderPageBody(page terminalPage, width, height int) string {
primaryContent := page.primary
if page.wrapPrimary {
primaryContent = lipgloss.NewStyle().Width(width).Render(primaryContent)
}
if page.context == "" || height < 5 {
return viewportContentAt(page.primary, width, height, page.primaryOffset, page.focusLine)
return viewportContentAt(primaryContent, width, height, page.primaryOffset, page.focusLine)
}
contextHeight := min(lineCount(page.context), max(3, height/2))
context := page.context
if page.wrapContext {
context = lipgloss.NewStyle().Width(width).Render(context)
}
contextHeight := min(lineCount(context), max(3, height/2))
primaryHeight := height - contextHeight - 1
if primaryHeight < 1 {
primaryHeight = 1
contextHeight = max(1, height-primaryHeight-1)
}
primary := viewportContentAt(page.primary, width, primaryHeight, page.primaryOffset, page.focusLine)
primary := viewportContentAt(primaryContent, width, primaryHeight, page.primaryOffset, page.focusLine)
contextFocus := -1
if page.anchorContext {
contextFocus = page.contextAnchor
}
context := viewportContentAt(page.context, width, contextHeight, page.contextOffset, contextFocus)
context = viewportContentAt(context, width, contextHeight, page.contextOffset, contextFocus)
return primary + "\n\n" + context
}
+4 -1
View File
@@ -148,6 +148,9 @@ func profileIndex(items []profile.Profile, name string) int {
func (p profilePickerState) view(snapshot app.Snapshot, width, height int) string {
search := p.search
search.Width = max(12, width-len(search.Prompt)-4)
position := search.Position()
search.SetValue(profile.ObscureURLForEditing(search.Value()))
search.SetCursor(position)
items := p.filtered(snapshot)
lines := make([]string, 0, len(items))
@@ -178,7 +181,7 @@ func (p profilePickerState) view(snapshot app.Snapshot, width, height int) strin
listHeight := max(2, height-5)
list := viewport.New(max(1, width), listHeight)
list.SetContent(strings.Join(lines, "\n"))
list.SetContent(protectRenderedText(strings.Join(lines, "\n")))
if selectedIndex >= listHeight {
list.SetYOffset(selectedIndex - listHeight + 1)
}
+370
View File
@@ -0,0 +1,370 @@
package tui
import (
"context"
"errors"
"strings"
"testing"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
"proxyctl/internal/app"
"proxyctl/internal/probe"
"proxyctl/internal/profile"
"proxyctl/internal/target"
)
// These tests deliberately drive the complete production Model. They verify
// user-visible rendering and accepted input, not page/component internals.
func TestCompleteModelFixedSizeCoverageAcrossCompletedWorkflows(t *testing.T) {
tests := []struct {
name string
setup func(t *testing.T, width, height int) Model
want []string
}{
{
name: "startup",
setup: func(t *testing.T, width, height int) Model {
snapshot := hardeningSnapshot()
for i := range snapshot.Targets {
snapshot.Targets[i].DetectionPhase = app.TargetChecking
}
return resizedModel(t, &recordingModule{snapshot: snapshot}, themeModeNoColor, width, height)
},
want: []string{"Operations Console", "Checking", "Target availability"},
},
{
name: "refresh",
setup: func(t *testing.T, width, height int) Model {
model := resizedModel(t, &recordingModule{snapshot: hardeningSnapshot()}, themeModeNoColor, width, height)
updated, command := model.Update(keyMessage("r"))
model = updated.(Model)
if command == nil {
t.Fatal("refresh did not start")
}
return model
},
want: []string{"Operations Console", "Checking Target availability and state"},
},
{
name: "Target action",
setup: func(t *testing.T, width, height int) Model {
model := resizedModel(t, &recordingModule{snapshot: hardeningSnapshot()}, themeModeNoColor, width, height)
updated, command := model.Update(tea.KeyMsg{Type: tea.KeySpace})
model = updated.(Model)
if command == nil {
t.Fatal("Target action did not start")
}
return model
},
want: []string{"Applying", "Starting Target mutation"},
},
{
name: "Active Profile switching",
setup: func(t *testing.T, width, height int) Model {
model := resizedModel(t, &recordingModule{snapshot: hardeningSnapshot()}, themeModeNoColor, width, height)
return updateKey(model, "p")
},
want: []string{"Switch Active Profile", "Search:", "remote"},
},
{
name: "management",
setup: func(t *testing.T, width, height int) Model {
model := resizedModel(t, &recordingModule{snapshot: hardeningSnapshot()}, themeModeNoColor, width, height)
return updateKey(model, "m")
},
want: []string{"Proxy Profile management", "Selected Profile", "n: create"},
},
{
name: "editor",
setup: func(t *testing.T, width, height int) Model {
model := resizedModel(t, &recordingModule{snapshot: hardeningSnapshot()}, themeModeNoColor, width, height)
model = updateKey(model, "m")
return updateKey(model, "e")
},
want: []string{"Edit Proxy Profile", "[focused]", "Ctrl+S: save"},
},
{
name: "Protocol Probe",
setup: func(t *testing.T, width, height int) Model {
model := resizedModel(t, &recordingModule{snapshot: hardeningSnapshot()}, themeModeNoColor, width, height)
model.detect = func(_ context.Context, _ string) (probe.Capabilities, error) {
return probe.Capabilities{HTTP: true}, nil
}
model = updateKey(model, "m")
model = updateKey(model, "e")
model = updateKey(model, "tab")
updated, command := model.Update(tea.KeyMsg{Type: tea.KeyCtrlD})
model = updated.(Model)
if command == nil {
t.Fatal("Protocol Probe did not start")
}
return model
},
want: []string{"Protocol Probe", "checking proxy.example:8080", "in progress"},
},
{
name: "Help",
setup: func(t *testing.T, width, height int) Model {
model := resizedModel(t, &recordingModule{snapshot: hardeningSnapshot()}, themeModeNoColor, width, height)
return updateKey(model, "?")
},
want: []string{"Contextual Help", "q/Esc: close Help"},
},
{
name: "Drift Resolution",
setup: func(t *testing.T, width, height int) Model {
model := resizedModel(t, &recordingModule{snapshot: hardeningSnapshot()}, themeModeNoColor, width, height)
model = updateKey(model, "down")
return updateKey(model, " ")
},
want: []string{"Drift Resolution", "Reapply Active Profile", "Restore Original"},
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
for _, size := range []struct{ width, height int }{{120, 36}, {80, 24}} {
model := test.setup(t, size.width, size.height)
assertCellBoundedFrame(t, model.View(), size.width, size.height, test.want...)
}
model := test.setup(t, 80, 24)
model = resize(model, 59, 15)
assertCellBoundedFrame(t, model.View(), 59, 15, "Terminal too small", "59×15")
model = resize(model, 80, 24)
assertCellBoundedFrame(t, model.View(), 80, 24, test.want...)
})
}
}
func TestCompleteModelLightDarkAndNoColorRetainEquivalentSemantics(t *testing.T) {
views := make(map[themeMode]string)
for _, mode := range []themeMode{themeModeDark, themeModeLight, themeModeNoColor} {
module := &targetModule{
snapshot: hardeningSnapshot(),
result: app.Result{Snapshot: hardeningSnapshot(), Err: errors.New("runner failed https://alice:theme-secret@proxy.example:8080")},
}
model := resizedModel(t, module, mode, 80, 24)
updated, command := model.Update(tea.KeyMsg{Type: tea.KeySpace})
model = updated.(Model)
model = updateKey(model, "tab")
progressFrame := model.View()
updated, _ = model.Update(command())
model = updated.(Model)
model = updateKey(model, "e")
views[mode] = progressFrame + "--- operation completed ---\n" + model.View()
for _, semantic := range []string{"[focused]", "Disabled", "Drifted", "Applying", "mutations disabled", "Current error:", "REDACTED", "Action: Enable"} {
if !strings.Contains(views[mode], semantic) {
t.Fatalf("theme %d lost %q:\n%s", mode, semantic, views[mode])
}
}
}
plain := views[themeModeNoColor]
for _, symbol := range []string{"● Enabled", "○ Disabled", "! Drifted", "▶ Target details [focused]"} {
if !strings.Contains(plain, symbol) {
t.Fatalf("standard Unicode/ASCII status strategy lost %q:\n%s", symbol, plain)
}
}
for _, r := range plain {
if (r >= '\ue000' && r <= '\uf8ff') || (r >= '\U000f0000' && r <= '\U000ffffd') || (r >= '\U00100000' && r <= '\U0010fffd') {
t.Fatalf("rendering depends on a private-use/Nerd Font glyph %U", r)
}
}
for _, mode := range []themeMode{themeModeDark, themeModeLight} {
if got := ansiPattern.ReplaceAllString(views[mode], ""); got != plain {
t.Fatalf("theme %d changed semantic layout\ncolored stripped:\n%s\nNO_COLOR:\n%s", mode, got, plain)
}
}
}
func TestCompleteModelBoundsUnicodeAndKeepsLongContentReachable(t *testing.T) {
longName := strings.Repeat("東京界", 50) + " TARGET-END"
longProfile := strings.Repeat("支店", 70) + " PROFILE-END"
snapshot := hardeningSnapshot()
snapshot.ActiveProfile = longProfile
snapshot.Profiles[0].Name = longProfile
snapshot.Targets[0].Description.Name = longName
snapshot.Targets[0].Evidence = strings.Repeat("証拠 and wrapped evidence ", 80) + " EVIDENCE-END"
snapshot.Targets[0].Description.Details = strings.Repeat("詳細 line ", 100) + " DETAILS-END"
model := resizedModel(t, &recordingModule{snapshot: snapshot}, themeModeNoColor, 80, 24)
assertCellBoundedFrame(t, model.View(), 80, 24, "Disabled")
model = updateKey(model, "tab")
for range 120 {
model = updateKey(model, "pgdown")
}
assertCellBoundedFrame(t, model.View(), 80, 24, "DETAILS-END")
model = updateKey(model, "m")
for range 60 {
model = updateKey(model, "pgdown")
}
assertCellBoundedFrame(t, model.View(), 80, 24, "PROFILE-END")
model = updateKey(model, "d")
for _, size := range []struct{ width, height int }{{80, 24}, {120, 36}, {59, 15}, {80, 24}} {
model = resize(model, size.width, size.height)
if size.width < minimumWidth {
assertCellBoundedFrame(t, model.View(), size.width, size.height, "Terminal too small")
continue
}
assertCellBoundedFrame(t, model.View(), size.width, size.height, "Delete Proxy Profile", "PROFILE-END", "> Cancel")
}
}
func TestCompleteModelResizeStressPreservesDialogFocusSelectionViewportInFlightAndDeferredExit(t *testing.T) {
snapshot := hardeningSnapshot()
snapshot.Targets[1].Description.Details = strings.Repeat("APT scrollable detail. ", 90) + " APT-VIEWPORT-END"
updates := make(chan app.OperationUpdate, 2)
module := &interactiveScriptModule{snapshot: snapshot, streams: []chan app.OperationUpdate{updates}}
model := resizedModel(t, module, themeModeNoColor, 120, 36)
updated, command := model.Update(tea.KeyMsg{Type: tea.KeySpace})
model = updated.(Model)
updates <- app.RunningOperationUpdate(snapshot,
app.OperationStatus{Summary: "Writing Shell configuration", TargetID: "shell"},
[]app.TargetProgress{{TargetID: "shell", Phase: app.TargetProgressRunning}})
model, command = applyOperationCommand(t, model, command)
model = updateKey(model, "down")
model = updateKey(model, "tab")
for range 120 {
model = updateKey(model, "pgdown")
}
model = updateKey(model, "?")
model = updateKey(model, "end")
updated, quit := model.Update(tea.KeyMsg{Type: tea.KeyCtrlC})
model = updated.(Model)
if quit != nil {
t.Fatal("exit was not deferred during the in-flight mutation")
}
for _, size := range []struct{ width, height int }{{80, 24}, {59, 15}, {120, 36}, {80, 24}} {
model = resize(model, size.width, size.height)
if size.width < minimumWidth {
assertCellBoundedFrame(t, model.View(), size.width, size.height, "Terminal too small")
continue
}
assertCellBoundedFrame(t, model.View(), size.width, size.height,
"Global exit safety", "q/Esc: close Help", "Exit pending until safe completion")
}
model = updateKey(model, "q")
assertCellBoundedFrame(t, model.View(), 80, 24,
"> ! Drifted APT", "▶ Target details [focused]", "APT-VIEWPORT-", "END",
"Applying", "Exit requested", "new work disabled")
updates <- app.SucceededOperationUpdate(snapshot,
app.OperationStatus{Summary: "Shell Target is now Enabled", TargetID: "shell"},
[]app.TargetProgress{{TargetID: "shell", Phase: app.TargetProgressSucceeded}})
close(updates)
model, quit = applyOperationCommand(t, model, command)
if quit == nil {
t.Fatal("safe completion did not retain deferred exit intent")
}
if _, ok := quit().(tea.QuitMsg); !ok {
t.Fatal("safe completion did not emit Bubble Tea quit")
}
}
func TestCompleteModelRedactsAdversarialSecretsFromEveryRenderedIngress(t *testing.T) {
const secret = "ticket11 secret"
credential := "https://alice:" + secret + "@proxy.example:8080"
hostile := "\x1b]2;hostile-title\x07\x1b[8m" + credential + "\r"
snapshot := hardeningSnapshot()
snapshot.ActiveProfile = hostile
snapshot.Profiles[0] = profile.Profile{Name: credential, Mode: profile.SingleMode, SingleURL: credential, Bypass: []string{credential}}
snapshot.Targets[0].ID = credential
snapshot.Targets[0].Description = target.Description{
Name: credential, Summary: credential, Details: credential, Scope: credential,
Permissions: credential, Ownership: credential, RestartRisk: credential,
}
snapshot.Targets[0].Evidence = hostile
snapshot.Targets[0].AvailabilityEvidence = hostile
snapshot.Targets[0].PrimaryAction.DisabledReason = hostile
updates := make(chan app.OperationUpdate, 3)
confirmation := make(chan app.OperationUpdate, 1)
module := &interactiveScriptModule{snapshot: snapshot, streams: []chan app.OperationUpdate{updates, confirmation}}
model := resizedModel(t, module, themeModeNoColor, 120, 36)
assertSecretAbsent(t, model.View(), secret)
model = updateKey(model, "?")
assertSecretAbsent(t, model.View(), secret)
model = updateKey(model, "q")
model = updateKey(model, "m")
assertSecretAbsent(t, model.View(), secret)
model = updateKey(model, "e")
assertSecretAbsent(t, model.View(), secret)
model = updateKey(model, "q")
model = updateKey(model, "q")
updated, command := model.Update(keyMessage("r"))
model = updated.(Model)
updates <- app.RunningOperationUpdate(snapshot,
app.OperationStatus{Summary: "notice " + credential, TargetID: credential},
[]app.TargetProgress{{TargetID: credential, Phase: app.TargetProgressRunning, Evidence: credential, Err: errors.New("runner output " + credential)}})
model, command = applyOperationCommand(t, model, command)
assertSecretAbsent(t, model.View(), secret)
updates <- app.FailedOperationUpdate(snapshot,
app.OperationStatus{Summary: "error " + credential, TargetID: credential},
[]app.TargetProgress{{TargetID: credential, Phase: app.TargetProgressFailed, Evidence: credential, Err: errors.New("progress output " + credential)}},
credential, errors.New("runner output "+credential))
close(updates)
model, _ = applyOperationCommand(t, model, command)
model = updateKey(model, "e")
assertSecretAbsent(t, model.View(), secret)
updated, command = model.Update(keyMessage("r"))
model = updated.(Model)
confirmation <- app.ConfirmationOperationUpdate(snapshot, app.Confirmation{
Title: "confirm " + credential, Summary: credential, Details: []string{credential},
ConfirmLabel: "continue " + credential, CancelLabel: "cancel " + credential,
})
model, _ = applyOperationCommand(t, model, command)
assertSecretAbsent(t, model.View(), secret)
}
func hardeningSnapshot() app.Snapshot {
return app.Snapshot{
ActiveProfile: "office",
Profiles: []profile.Profile{
{Name: "office", Mode: profile.SingleMode, SingleURL: "http://proxy.example:8080", Bypass: []string{"localhost", ".example.com"}},
{Name: "remote", Mode: profile.SingleMode, SingleURL: "socks5h://remote.example:1080"},
},
Targets: []app.TargetSnapshot{
{ID: "shell", Description: target.Description{Name: "Shell", Summary: "Shell summary", Details: "Shell details"}, Available: true, State: target.Disabled, Evidence: "fragment absent", PrimaryAction: app.TargetAction{Kind: app.EnableTargetAction, Available: true}},
{ID: "apt", Description: target.Description{Name: "APT", Summary: "APT summary", Details: "APT details"}, Available: true, State: target.Drifted, Evidence: "managed values differ", PrimaryAction: app.TargetAction{Kind: app.ResolveDriftTargetAction, Available: true}, DriftResolutionActions: []app.TargetAction{{Kind: app.ReapplyActiveProfileAction, Available: true}, {Kind: app.RestoreOriginalAction, Available: true}}},
},
}
}
func assertCellBoundedFrame(t *testing.T, view string, width, height int, semantics ...string) {
t.Helper()
plain := ansiPattern.ReplaceAllString(view, "")
lines := strings.Split(strings.TrimSuffix(plain, "\n"), "\n")
if len(lines) > height {
t.Fatalf("frame has %d lines, terminal height is %d:\n%s", len(lines), height, view)
}
for _, line := range lines {
if got := lipgloss.Width(line); got > width {
t.Fatalf("frame line is %d cells, terminal width is %d: %q", got, width, line)
}
}
for _, semantic := range semantics {
if !strings.Contains(plain, semantic) {
t.Fatalf("frame missing %q:\n%s", semantic, view)
}
}
}
func assertSecretAbsent(t *testing.T, frame, secret string) {
t.Helper()
if strings.Contains(frame, secret) {
t.Fatalf("rendered frame exposed secret %q:\n%s", secret, frame)
}
if strings.ContainsAny(frame, "\x1b\x07\r") {
t.Fatalf("rendered frame retained hostile terminal control bytes: %q", frame)
}
}