From 6391a209ca907f1e1187e3d14b6091f283b73aac Mon Sep 17 00:00:00 2001 From: Amit Kumar Date: Fri, 24 Jul 2026 03:58:29 +0000 Subject: [PATCH 1/2] feat: add persistent agent multiplexing and profiles --- .gitignore | 2 + README.md | 94 ++++- ...anaged-session-vs-provider-conversation.md | 7 +- docs/adr/0002-terminal-ownership-over-ssh.md | 45 ++- ...lient-session-ownership-and-protocol-v2.md | 128 +++++++ docs/responsive-tui.md | 95 +++++ internal/adapter/adapter.go | 39 +- internal/adapter/adaptertest/backend.go | 22 +- internal/adapter/agent.go | 14 +- internal/adapter/agent_test.go | 7 +- internal/adapter/claude/claude.go | 1 + internal/adapter/claude/claude_test.go | 7 +- internal/adapter/codex/codex.go | 1 + internal/adapter/codex/codex_test.go | 20 +- internal/adapter/copilot/copilot.go | 1 + internal/adapter/copilot/copilot_test.go | 6 + internal/adapter/hermes/hermes.go | 4 +- internal/adapter/hermes/hermes_test.go | 26 ++ internal/adapter/omp/omp.go | 1 + internal/adapter/omp/omp_test.go | 6 + internal/adapter/opencode/opencode.go | 1 + internal/adapter/opencode/opencode_test.go | 6 + internal/adapter/terminal_policy.go | 66 ++++ .../adapter/terminal_policy_launch_test.go | 23 ++ .../agents/provider_terminal_policy_test.go | 59 +++ internal/app/app.go | 165 ++++++++- internal/app/dashboard_revamp.go | 2 + internal/app/diagnostics_test.go | 52 +++ internal/app/doctor.go | 253 +++++++++++++ internal/app/profile_activation_test.go | 96 +++++ .../app/profile_launch_integration_test.go | 138 +++++++ internal/app/profile_policy.go | 131 +++++++ internal/app/profile_resolver.go | 180 +++++++++ internal/app/profile_resolver_test.go | 106 ++++++ internal/app/profile_service.go | 204 +++++++++++ internal/app/profile_service_errors_test.go | 144 ++++++++ .../app/profile_surface_acceptance_test.go | 151 ++++++++ internal/app/profile_validation_test.go | 209 +++++++++++ internal/app/profile_views.go | 77 ++++ internal/app/service.go | 79 +++- internal/app/service_test.go | 6 + internal/app/todo7_characterization_test.go | 54 +++ internal/cli/attach_profile_test.go | 55 +++ internal/cli/cli.go | 98 ++++- internal/cli/cli_test.go | 21 +- internal/cli/doctor.go | 80 ++++ .../cli/doctor_real_surface_helpers_test.go | 198 ++++++++++ internal/cli/doctor_real_surface_test.go | 123 +++++++ internal/cli/doctor_test.go | 233 ++++++++++++ internal/cli/profile.go | 183 ++++++++++ internal/cli/profile_acceptance_test.go | 184 ++++++++++ internal/cli/profile_flags.go | 128 +++++++ .../cli/profile_override_acceptance_test.go | 92 +++++ .../cli/profile_provider_acceptance_test.go | 113 ++++++ .../cli/profile_provider_real_surface_test.go | 46 +++ .../cli/profile_real_surface_helpers_test.go | 103 ++++++ internal/cli/profile_real_surface_test.go | 186 ++++++++++ internal/log/diagnostic.go | 145 ++++++++ internal/log/log.go | 2 +- internal/session/attach.go | 344 ++++++++++++------ .../session/attach_acceptance_cases_test.go | 135 +++++++ internal/session/attach_cleanup.go | 79 ++++ .../session/attach_cleanup_acceptance_test.go | 198 ++++++++++ internal/session/attach_control.go | 258 +++++++++++++ internal/session/attach_control_modes_test.go | 164 +++++++++ internal/session/attach_fd.go | 13 + internal/session/attach_fd_test.go | 23 ++ internal/session/attach_handshake_test.go | 117 ++++++ internal/session/attach_hello.go | 114 ++++++ internal/session/attach_input_flush_darwin.go | 9 + internal/session/attach_input_flush_linux.go | 9 + internal/session/attach_mouse_toggle_test.go | 27 ++ internal/session/attach_policy.go | 91 +++++ .../attach_protocol_integration_test.go | 238 ++++++++++++ .../session/attach_protocol_manual_test.go | 154 ++++++++ internal/session/attach_protocol_test.go | 232 ++++++++++++ internal/session/attach_signal_test.go | 101 +++++ .../session/attach_todo6_evidence_test.go | 256 +++++++++++++ internal/session/attach_todo6_manual_test.go | 209 +++++++++++ internal/session/attach_todo6_pin_test.go | 106 ++++++ internal/session/client.go | 45 ++- internal/session/client_registry.go | 236 ++++++++++++ .../session/client_registry_manual_test.go | 266 ++++++++++++++ internal/session/client_registry_test.go | 230 ++++++++++++ internal/session/diagnostic.go | 72 ++++ internal/session/diagnostics_test.go | 87 +++++ internal/session/host.go | 342 +++++++++++------ internal/session/host_attach.go | 102 ++++++ internal/session/host_control_frame.go | 40 ++ internal/session/host_control_frame_test.go | 27 ++ internal/session/host_controller.go | 166 +++++++++ .../session/host_controller_epoch_test.go | 155 ++++++++ internal/session/host_inprocess_test.go | 4 +- internal/session/ownership_error.go | 24 ++ internal/session/proto.go | 265 +++++++++++++- internal/session/proto_test.go | 144 ++++++++ internal/session/provider_terminal_policy.go | 27 ++ .../provider_terminal_policy_manual_test.go | 149 ++++++++ .../session/provider_terminal_policy_test.go | 119 ++++++ internal/session/scrollback_policy.go | 13 + internal/session/session.go | 18 +- internal/session/session_test.go | 2 +- internal/session/todo11_compat_test.go | 154 ++++++++ .../session/todo11_diagnostic_race_test.go | 53 +++ internal/session/todo11_fake_provider_test.go | 186 ++++++++++ internal/session/todo11_harness_test.go | 80 ++++ .../session/todo11_matrix_scenario_test.go | 127 +++++++ .../session/todo11_protocol_client_test.go | 161 ++++++++ internal/session/todo11_real_host_test.go | 208 +++++++++++ .../session/todo11_term_negotiation_test.go | 81 +++++ .../todo8_terminal_policy_manual_test.go | 199 ++++++++++ .../session/todo8_terminal_policy_test.go | 163 +++++++++ .../session/todo9_evidence_helpers_test.go | 129 +++++++ .../session/todo9_ownership_realpty_test.go | 201 ++++++++++ internal/session/todo9_ownership_test.go | 280 ++++++++++++++ .../session/todo9_resize_generation_test.go | 48 +++ .../session/todo9_service_integration_test.go | 65 ++++ internal/store/profile_migration_test.go | 340 +++++++++++++++++ internal/store/profile_roundtrip_test.go | 251 +++++++++++++ internal/store/profile_validation.go | 132 +++++++ .../store/schema_v3_characterization_test.go | 125 +++++++ internal/store/store.go | 325 ++++++++++++++--- internal/store/task2_manual_qa_test.go | 151 ++++++++ internal/store/testdata/schema-v3-full.json | 41 +++ internal/vterm/todo8_terminal_policy_test.go | 23 ++ script/qa/docs-contract-smoke.sh | 210 +++++++++++ scripts/terminal-smoke-real | 52 +++ scripts/terminal-smoke-todo7-xterm | 103 ++++++ scripts/terminal-smoke-xterm | 62 ++++ scripts/xterm-harness/index.html | 46 +++ scripts/xterm-harness/package-lock.json | 25 ++ scripts/xterm-harness/package.json | 10 + scripts/xterm-harness/render.mjs | 208 +++++++++++ testdata/todo11/fixtures.json | 12 + 134 files changed, 13982 insertions(+), 397 deletions(-) create mode 100644 docs/adr/0003-terminal-client-session-ownership-and-protocol-v2.md create mode 100644 internal/adapter/terminal_policy.go create mode 100644 internal/adapter/terminal_policy_launch_test.go create mode 100644 internal/agents/provider_terminal_policy_test.go create mode 100644 internal/app/diagnostics_test.go create mode 100644 internal/app/doctor.go create mode 100644 internal/app/profile_activation_test.go create mode 100644 internal/app/profile_launch_integration_test.go create mode 100644 internal/app/profile_policy.go create mode 100644 internal/app/profile_resolver.go create mode 100644 internal/app/profile_resolver_test.go create mode 100644 internal/app/profile_service.go create mode 100644 internal/app/profile_service_errors_test.go create mode 100644 internal/app/profile_surface_acceptance_test.go create mode 100644 internal/app/profile_validation_test.go create mode 100644 internal/app/profile_views.go create mode 100644 internal/app/todo7_characterization_test.go create mode 100644 internal/cli/attach_profile_test.go create mode 100644 internal/cli/doctor.go create mode 100644 internal/cli/doctor_real_surface_helpers_test.go create mode 100644 internal/cli/doctor_real_surface_test.go create mode 100644 internal/cli/doctor_test.go create mode 100644 internal/cli/profile.go create mode 100644 internal/cli/profile_acceptance_test.go create mode 100644 internal/cli/profile_flags.go create mode 100644 internal/cli/profile_override_acceptance_test.go create mode 100644 internal/cli/profile_provider_acceptance_test.go create mode 100644 internal/cli/profile_provider_real_surface_test.go create mode 100644 internal/cli/profile_real_surface_helpers_test.go create mode 100644 internal/cli/profile_real_surface_test.go create mode 100644 internal/log/diagnostic.go create mode 100644 internal/session/attach_acceptance_cases_test.go create mode 100644 internal/session/attach_cleanup.go create mode 100644 internal/session/attach_cleanup_acceptance_test.go create mode 100644 internal/session/attach_control.go create mode 100644 internal/session/attach_control_modes_test.go create mode 100644 internal/session/attach_fd.go create mode 100644 internal/session/attach_fd_test.go create mode 100644 internal/session/attach_handshake_test.go create mode 100644 internal/session/attach_hello.go create mode 100644 internal/session/attach_input_flush_darwin.go create mode 100644 internal/session/attach_input_flush_linux.go create mode 100644 internal/session/attach_mouse_toggle_test.go create mode 100644 internal/session/attach_policy.go create mode 100644 internal/session/attach_protocol_integration_test.go create mode 100644 internal/session/attach_protocol_manual_test.go create mode 100644 internal/session/attach_protocol_test.go create mode 100644 internal/session/attach_signal_test.go create mode 100644 internal/session/attach_todo6_evidence_test.go create mode 100644 internal/session/attach_todo6_manual_test.go create mode 100644 internal/session/attach_todo6_pin_test.go create mode 100644 internal/session/client_registry.go create mode 100644 internal/session/client_registry_manual_test.go create mode 100644 internal/session/client_registry_test.go create mode 100644 internal/session/diagnostic.go create mode 100644 internal/session/diagnostics_test.go create mode 100644 internal/session/host_attach.go create mode 100644 internal/session/host_control_frame.go create mode 100644 internal/session/host_control_frame_test.go create mode 100644 internal/session/host_controller.go create mode 100644 internal/session/host_controller_epoch_test.go create mode 100644 internal/session/ownership_error.go create mode 100644 internal/session/provider_terminal_policy.go create mode 100644 internal/session/provider_terminal_policy_manual_test.go create mode 100644 internal/session/provider_terminal_policy_test.go create mode 100644 internal/session/scrollback_policy.go create mode 100644 internal/session/todo11_compat_test.go create mode 100644 internal/session/todo11_diagnostic_race_test.go create mode 100644 internal/session/todo11_fake_provider_test.go create mode 100644 internal/session/todo11_harness_test.go create mode 100644 internal/session/todo11_matrix_scenario_test.go create mode 100644 internal/session/todo11_protocol_client_test.go create mode 100644 internal/session/todo11_real_host_test.go create mode 100644 internal/session/todo11_term_negotiation_test.go create mode 100644 internal/session/todo8_terminal_policy_manual_test.go create mode 100644 internal/session/todo8_terminal_policy_test.go create mode 100644 internal/session/todo9_evidence_helpers_test.go create mode 100644 internal/session/todo9_ownership_realpty_test.go create mode 100644 internal/session/todo9_ownership_test.go create mode 100644 internal/session/todo9_resize_generation_test.go create mode 100644 internal/session/todo9_service_integration_test.go create mode 100644 internal/store/profile_migration_test.go create mode 100644 internal/store/profile_roundtrip_test.go create mode 100644 internal/store/profile_validation.go create mode 100644 internal/store/schema_v3_characterization_test.go create mode 100644 internal/store/task2_manual_qa_test.go create mode 100644 internal/store/testdata/schema-v3-full.json create mode 100644 internal/vterm/todo8_terminal_policy_test.go create mode 100755 script/qa/docs-contract-smoke.sh create mode 100755 scripts/terminal-smoke-real create mode 100755 scripts/terminal-smoke-todo7-xterm create mode 100755 scripts/terminal-smoke-xterm create mode 100644 scripts/xterm-harness/index.html create mode 100644 scripts/xterm-harness/package-lock.json create mode 100644 scripts/xterm-harness/package.json create mode 100644 scripts/xterm-harness/render.mjs create mode 100644 testdata/todo11/fixtures.json diff --git a/.gitignore b/.gitignore index e926419..6b936f8 100644 --- a/.gitignore +++ b/.gitignore @@ -9,7 +9,9 @@ coverage.* /.claude/worktrees/ /.worktrees/ /.superpowers/ +/.omo/ /CLAUDE.md +/HANDOFF.md /PLAN.md graphify_out/ graphify-out/ diff --git a/README.md b/README.md index f2bf71c..5eebb76 100644 --- a/README.md +++ b/README.md @@ -106,6 +106,15 @@ uam restart [--allow-latest] # stop the agent and resume it in place uam rm # kill the session and remove record uam kill-all # stop every managed session uam version +uam doctor [] [--json] +uam profile ls [--json] +uam profile show [--json] +uam profile set [profile flags] +uam profile rm +uam profile default +uam profile assign +uam profile override [profile flags] +uam profile effective [--json] ``` ## TUI keys @@ -140,10 +149,21 @@ thresholds, filtering, mobile guidance, lifecycle labels, and accessibility. ## Attached sessions `uam attach` (or `Enter` in the TUI) bridges your terminal straight to the -agent's PTY: - -- `Ctrl+B d` detaches and returns to the dashboard (`Ctrl+B Ctrl+B` sends a - literal `Ctrl+B` to the agent; `Ctrl+B c` sends a literal `Ctrl+C`) +agent's PTY. An attach client is temporary client state, not part of the +Managed Session record. The host permits one controller at a time; additional +interactive clients wait as standbys, and observers receive output without +being allowed to send input, resize the PTY, or answer terminal queries. See +[terminal client and session ownership](docs/adr/0003-terminal-client-session-ownership-and-protocol-v2.md) +for the normative ownership and protocol rules. + +- `Ctrl+B d` detaches and returns to the dashboard by default. `prefix prefix` + sends a literal configured prefix (`Ctrl+B Ctrl+B` only when the profile uses + `C-b`); `prefix c` sends a literal `Ctrl+C`. A profile can change the prefix; + use the profile's `C-x` spelling, such as `C-a`, when configuring it. +- `prefix r` requests control, `prefix o` transfers control when used by the + current controller, `prefix i` reports the current role, and `prefix m` + toggles mouse passthrough for this attachment only. A prefix command never + enters provider input. - Plain `Ctrl+C` is swallowed while attached so terminal copy shortcuts do not cancel the agent - `←` (left arrow) also detaches when you haven't typed anything since the @@ -165,7 +185,9 @@ agent's PTY: Bracketed-paste payload is forwarded byte-for-byte. Control bytes inside a paste do not trigger UAM's attach shortcuts. UAM cannot access the client clipboard or -turn an unsent mouse gesture into remote input. +turn an unsent mouse gesture into remote input. Terminal names and color hints +reported by an attachment are diagnostics metadata, not proof that the client +supports a terminal feature. For PowerShell SSH, use Windows Terminal and configure a keyboard paste binding such as `Ctrl+V`, `Ctrl+Shift+V`, or `Shift+Insert`. Provider mouse reporting is @@ -218,7 +240,10 @@ Provider behavior: - **Hermes**: no provider resume command is configured. A stopped Hermes record cannot be resumed; dispatch a new Managed Session instead. - After a reboot, records survive in the store and resume on attach — a - scenario where a tmux session would simply be gone. + provider-aware relaunch, not a surviving PTY. The old terminal process and + its screen modes are gone. A SIGKILL similarly prevents normal terminal + cleanup; start a fresh terminal or run `reset` if the local terminal is left + in an unusable mode. ### Multiple sessions in one workspace @@ -291,6 +316,59 @@ run `loginctl enable-linger`. > them first (`tmux -L uam kill-server`); stored session records carry over > unchanged and remain resumable. +### Profiles and diagnostics + +Profiles supply stable launch and attach defaults. Resolution is ordered; a +later layer can refine only fields it is allowed to control: + +1. **Hard safety invariants** fix the provider `TERM` value to + `xterm-256color` and reject profile-supplied environment, capability, or + resume-policy changes. +2. **Global defaults** select the default provider, yolo mode, scrollback, + mouse policy, `C-b` prefix, and quick-detach behavior. +3. **Built-in provider policy** selects the provider identity, native key + protocol, and outer-screen policy. OpenAI Codex is the primary-screen + exception; the other current providers use a UAM outer screen. +4. **Selected named profile** applies the default profile or a session-selected + profile. A provider-constrained profile must match the session provider. +5. **Per-session overrides** refine the selected profile for that durable + session. +6. **Client-local attachment overrides** can refine mouse, prefix, and + quick-detach for only the current attachment. +7. **Capability constraints** apply the client's negotiated capabilities. For + example, local mouse filtering and an owned outer screen require that the + client supports them; terminal hints are not capability proof. + +Launch-time fields are provider, approval mode, command alias, and scrollback. +The default profile, named profiles, selected session profile, and per-session +overrides are persistent configuration. Mouse, control prefix, and quick-detach +can also be attachment-local; client identity, role, dimensions, protocol, and +capabilities are runtime-only and never enter `sessions.json`. + +```sh +uam profile set focused --provider claude --mode safe --mouse off --prefix C-a --back-detach off --scrollback 8000 +uam profile default focused +uam profile show focused --json +uam profile ls --json +uam doctor --json +``` + +Use `uam profile assign ` to select a profile for one +session, `uam profile override [profile flags]` for its final +overrides, and `uam profile effective --json` to inspect the +resolved result. `uam profile rm ` refuses to delete a default or a +profile still selected by a session; clear those references first. `uam doctor + --json` reports runtime roles, supported protocol versions, +resolved profile, provider terminal policy, and fallback reasons. Diagnostics +redact secret-like values and do not print terminal input or output. + +Schema v4 migrates older records atomically. Before a migration, UAM writes an +exact adjacent `sessions.json.bak.*` backup; if the write fails, the original +remains in place. To roll back, stop UAM, replace `sessions.json` with the +chosen backup, then start a compatible binary. Same-schema unknown fields round +trip. A config from a newer schema opens read-only so an older binary cannot +clobber fields it does not understand. + ## Safety model `uam` launches providers in their full-access or auto-approve ("yolo") mode by @@ -316,11 +394,9 @@ for its own execution model. - [Terminology glossary](CONTEXT.md) - [Managed Session vs. Provider Conversation](docs/adr/0001-managed-session-vs-provider-conversation.md) - [Terminal ownership over SSH](docs/adr/0002-terminal-ownership-over-ssh.md) +- [Terminal client/session ownership and protocol v2](docs/adr/0003-terminal-client-session-ownership-and-protocol-v2.md) - [Responsive TUI design and operations](docs/responsive-tui.md) -Session storage remains schema version 3. Updates do not require a data migration, -and unknown JSON fields are preserved when existing records are rewritten. - ## Development ```sh diff --git a/docs/adr/0001-managed-session-vs-provider-conversation.md b/docs/adr/0001-managed-session-vs-provider-conversation.md index f4aa82c..eb4f642 100644 --- a/docs/adr/0001-managed-session-vs-provider-conversation.md +++ b/docs/adr/0001-managed-session-vs-provider-conversation.md @@ -117,5 +117,8 @@ session. - A Managed Session can outlive several provider-side conversation switches, which is visible in the terminology but does not multiply dashboard rows. - Users retain control over worktrees and other repository isolation. -- Schema version 3 remains unchanged. Provider identity fits the existing - record, and unknown fields continue to round-trip. +- Schema v4 adds profile selection and overrides. Older records migrate with an + adjacent backup before the replacement write; unknown fields still round-trip. +- Runtime attachment state is deliberately absent from the durable record. See + [terminal client/session ownership and protocol v2](0003-terminal-client-session-ownership-and-protocol-v2.md) + for the live ownership contract. diff --git a/docs/adr/0002-terminal-ownership-over-ssh.md b/docs/adr/0002-terminal-ownership-over-ssh.md index 779dcdd..7198768 100644 --- a/docs/adr/0002-terminal-ownership-over-ssh.md +++ b/docs/adr/0002-terminal-ownership-over-ssh.md @@ -19,22 +19,27 @@ selection and paste. ## Decision -### Attach owns its terminal screen - -When output is a terminal, the attach client enters an alternate screen that it -owns. Input independently enters raw mode when it is a terminal. Provider output -is rendered inside that boundary. Seven-bit DEC-private CSI sequences used by -supported providers to enter or leave the alternate screen are contained so -they cannot escape into the user's primary shell screen. The filter deliberately -does not interpret a bare C1 byte as CSI because that byte value can occur inside -UTF-8 input; supported provider output uses the seven-bit `ESC [` form. - -On detach or return, UAM drains output within the owned screen and restores the -terminal contract: reset attributes, disable mouse/focus tracking, disable -bracketed paste, show the cursor, leave the owned alternate screen, and return -to a clean line. The dashboard separately repaints after an attached process -returns. Cleanup is intentionally targeted; UAM does not reset the entire -terminal or erase scrollback. +### Screen ownership follows provider policy + +When output is a terminal, input independently enters raw mode. The provider's +terminal policy decides the outer screen: + +| Outer-screen policy | Current providers | Attach behavior | +|---|---|---| +| UAM | Claude Code, Copilot, Hermes, Oh My Pi, OpenCode | UAM enters and owns an alternate screen. Provider alternate-screen sequences are contained inside that boundary. | +| Primary | OpenAI Codex | UAM attaches on the primary screen and does not create an outer alternate screen. | + +The Codex primary-screen exception is deliberate. It does not change the host's +PTY ownership, the one-controller input/resize/reply rule, or native provider +keys. Seven-bit DEC-private CSI handling remains bounded; UAM does not interpret +a bare C1 byte as CSI because that byte can occur inside UTF-8 input. + +On detach or return, UAM drains output and restores the terminal contract it +owns: reset attributes, disable mouse/focus tracking, disable bracketed paste, +show the cursor, leave an outer alternate screen only when one was entered, and +return to a clean line. The dashboard separately repaints after an attached +process returns. Cleanup is targeted; it does not reset the entire terminal or +erase scrollback. Non-terminal streams do not receive alternate-screen control sequences. @@ -117,3 +122,11 @@ mouse policy. shortcuts. - Terminal-client configuration remains necessary when the client does not send a paste operation. +- `TERM` and color hints received through SSH are diagnostic metadata only. They + are not capability proof and do not authorize a different terminal protocol. +- Normal detach, interrupt, hangup, and termination paths restore the owned + terminal contract. SIGKILL cannot run cleanup, so it cannot promise restored + modes; use a fresh terminal or `reset` when the local terminal is unusable. + +The controller, standby, observer, and protocol-v2 ownership rules are defined +by [ADR 0003](0003-terminal-client-session-ownership-and-protocol-v2.md). diff --git a/docs/adr/0003-terminal-client-session-ownership-and-protocol-v2.md b/docs/adr/0003-terminal-client-session-ownership-and-protocol-v2.md new file mode 100644 index 0000000..117bd88 --- /dev/null +++ b/docs/adr/0003-terminal-client-session-ownership-and-protocol-v2.md @@ -0,0 +1,128 @@ +# ADR 0003: Terminal client/session ownership and protocol v2 + +- Status: Accepted +- Date: 2026-07-23 + +## Context + +A Managed Session is durable; an attached terminal client is not. One host can +have several attached terminals, each with different size, terminal behavior, +and network lifetime. Persisting a client ID, terminal dimensions, capabilities, +or controller role would make a later attach trust stale state. Allowing every +client to write would also corrupt provider input and make resize nondeterministic. + +## Decision + +The host owns the PTY, session runtime, provider launch, and durable session +record. The provider owns its conversation. A client owns only its local screen, +raw-mode lifetime, and its own input stream. Client state is live runtime state; +it is never written to the session configuration. + +### Roles and one-writer rule + +| Role | Receives provider output | May write stdin, resize, or reply | Transition | +|---|---|---|---| +| Controller | Yes | Yes; the only writer | Starts as first non-observer, or receives a transfer/promotion. | +| Standby | Yes | No | Requests control or is promoted when the controller leaves. | +| Observer | Yes | No | Remains read-only. | + +There is exactly one controller. PTY stdin, terminal resize, and provider reply +are a single ownership domain: while a controller exists, other client writes +and out-of-band replies/resizes are rejected or ignored. Controller changes use +an ownership generation, so delayed frames from an old controller cannot regain +write access. A controller can transfer to the next standby; controller loss +promotes the next standby. Observers are never promoted merely by waiting. + +### Attach controls and modes + +The default control prefix is `Ctrl+B`; a profile can set `C-a` through `C-z`. +Outside bracketed paste, prefix commands are local to UAM: + +| Command | Result | +|---|---| +| `prefix d` | Detach. | +| `prefix c` | Send literal `Ctrl+C` to the provider. | +| `prefix r` | Request control. | +| `prefix o` | Transfer control when used by the controller. | +| `prefix i` | Show client role and selected/effective profile. | +| `prefix m` | Toggle mouse passthrough for this attachment only. | +| `prefix prefix` | Send a literal prefix byte to the provider. | + +Bracketed-paste payload bypasses prefix parsing completely. Mouse policy controls +whether UAM locally filters provider mouse modes; it does not turn a mouse event +that the terminal or SSH client never sent into input. The temporary mouse toggle +is client-local and expires when that attachment exits. + +### Protocol compatibility matrix + +Protocol v1 is the legacy attach stream. Protocol v2 adds a client hello, +role/generation events, and framed server output. Both keep provider key bytes +native; UAM does not translate a provider keyboard protocol. + +| Host | Client | Result | +|---|---|---| +| v1 | v1 | Legacy raw PTY output; one legacy controller. | +| v1 | v2 | v2 client accepts an unversioned legacy response and consumes raw PTY output; no v2 ownership features. | +| v2 | v1 | v2 host preserves the v1 raw stream and one-controller legacy behavior. | +| v2 | v2 | Framed PTY/control output, hello validation, controller/standby/observer roles, and generation checks. | +| Either | Explicit unsupported version | Reject the attach; do not silently downgrade an explicit version. | + +A terminal name, color hint, or claimed capability in a hello is metadata used +for bounded diagnostics and safe local decisions. It is not evidence of terminal +support. Unsupported or secret-like hints are redacted in diagnostics. + +### Provider terminal policy + +Every provider keeps native provider input. The current outer-screen policy is: + +| Provider | Outer screen | Key protocol | +|---|---|---| +| Claude Code | UAM | Native | +| OpenAI Codex | Primary | Native | +| GitHub Copilot CLI | UAM | Native | +| Hermes Agent | UAM | Native | +| Oh My Pi | UAM | Native | +| OpenCode | UAM | Native | + +`TERM` supplied to the provider is fixed to the UAM-supported value; a client +TERM hint does not override it. Profiles may select provider, approval mode, +command alias, mouse policy, control prefix, quick-detach policy, and +scrollback. They cannot inject environment, terminal capability, provider +resume, or unsafe-resume policy. + +### Persistence and recovery + +Schema v4 stores session and profile data, not runtime client state. Older +schema files are copied to an adjacent backup before migration; migration then +writes atomically. Restore a chosen backup only while UAM is stopped and use a +binary compatible with that backup. Equal-schema unknown fields are preserved; +a newer schema is read-only to an older binary. + +Stopping or rebooting ends the host and PTY. Retained records support a +provider-aware relaunch/resume according to the provider policy; they do not +preserve a live PTY, terminal modes, controller, or attached clients. Normal +detach and handled signals restore terminal modes. SIGKILL cannot execute that +cleanup, so a terminal may need `reset` or replacement. + +### Diagnostics and SSH + +`uam doctor --json` reports store/runtime health, profile validity, provider +terminal policy, and available protocol versions. `uam doctor +--json` also reports role counts, selected/effective profile, and fallback +reasons. It excludes terminal content and redacts secret-like identifiers. + +SSH transports bytes. It does not transport the local clipboard, right-click +meaning, or a guarantee that a terminal supports a named TERM. Use keyboard +paste bindings on the SSH client. `UAM_ATTACH_MOUSE=off` favors local selection +and paste; `auto` and `on` preserve provider mouse reporting. See +[ADR 0002](0002-terminal-ownership-over-ssh.md) for cleanup and client limits. + +## Consequences + +- A reconnect gets a fresh client identity and negotiated state, never an old + controller lease. +- Operators can predict which terminal may change provider input or geometry. +- Old hosts and clients keep working within the explicit v1 boundary instead of + receiving invented v2 behavior. +- Terminal smoke evidence records observed behavior; it does not turn TERM + names into a capability database. diff --git a/docs/responsive-tui.md b/docs/responsive-tui.md index 85f4dc2..2d07a15 100644 --- a/docs/responsive-tui.md +++ b/docs/responsive-tui.md @@ -67,6 +67,24 @@ Inside an attached session, `Ctrl+B d` detaches. A bare left arrow also detaches when the provider input is empty and the quick-detach option is enabled. See the README for the complete attach-key contract. +## Multiple attached terminals + +An attachment is a live client, not a second Managed Session. One client is the +**controller** and is the only one allowed to send provider input, resize the +PTY, or issue a provider reply. Further interactive clients are **standbys**; +they see output and can request a handoff, but cannot interleave keystrokes. +**Observers** are output-only. If the controller disconnects, the next standby +is promoted. A controller can also transfer deliberately. + +With the default prefix, use `Ctrl+B r` to request control, `Ctrl+B o` from the +controller to transfer, and `Ctrl+B i` to display your role. `Ctrl+B m` +changes mouse passthrough only for the current attachment. A configured profile +prefix replaces `Ctrl+B`; `prefix prefix` sends the configured literal prefix. +`Ctrl+B Ctrl+B` has that meaning only when the configured prefix is `C-b`. +Bracketed paste bypasses every prefix command, so pasted control bytes stay +provider input. The full protocol and mixed-version matrix is +[ADR 0003](adr/0003-terminal-client-session-ownership-and-protocol-v2.md). + ## Filtering sessions Press `/` while the command composer is empty to filter the existing dashboard. @@ -131,6 +149,78 @@ should run SSH in Windows Terminal and configure a Windows Terminal paste binding. See [ADR 0002](adr/0002-terminal-ownership-over-ssh.md) for the terminal ownership boundary and limitations. +The terminal's `TERM` or color name is metadata, not proof that it can perform a +specific feature. UAM keeps provider keys native and uses its supported provider +terminal policy rather than guessing from that name. An unsupported or sensitive +hint is redacted in diagnostics. + +## Profiles, provider policy, and recovery + +Use a named profile to make repeated launches predictable: + +```sh +uam profile set focused --provider claude --mode safe --mouse off --prefix C-a --back-detach off --scrollback 8000 +uam profile default focused +uam profile show focused --json +uam profile ls --json +uam doctor --json +``` + +Profile resolution is ordered: **hard safety invariants**, **global defaults**, +**built-in provider policy**, **selected named profile**, **per-session +overrides**, **client-local attachment overrides**, then **capability +constraints**. The invariant layer fixes provider `TERM` to `xterm-256color` +and rejects profile environment, terminal-capability, and resume-policy changes. +Provider policy fixes native keys and outer-screen +behavior; Codex is primary-screen while the other current providers use a UAM +outer screen. `uam profile assign ` selects the +per-session profile; `uam profile override [profile flags]` sets +the durable final profile layer; `uam profile effective --json` +shows it. Attachment-local mouse, prefix, and quick-detach choices last only for +that client, and capabilities can constrain whether local filtering or an owned +screen is available. A provider-constrained profile must match its session +provider. `uam profile rm ` refuses a profile that is still default or +selected by a session. Launch-time fields are provider, mode, alias, and +scrollback; profiles and session selection/overrides persist; client identity, +role, dimensions, protocol, and capabilities do not. + +| Provider | Resume policy | Outer screen | +|---|---|---| +| Claude Code | Exact when its seeded ID is retained; otherwise guarded latest continuation | UAM | +| GitHub Copilot CLI | Exact for UAM-created records | UAM | +| OpenCode | Exact root conversation only | UAM | +| Oh My Pi | Exact with its dedicated state; legacy records use guarded latest continuation | UAM | +| OpenAI Codex | Guarded latest continuation | Primary | +| Hermes Agent | Unsupported; create a new Managed Session | UAM | + +`uam doctor --json` shows runtime role counts, protocols, +profile resolution, provider policy, and safe fallback reasons. It does not +print terminal content or secret-like values. + +### Cross-terminal smoke collection + +Collect optional manual terminal evidence with the real collector, once per +available target: + +```sh +scripts/terminal-smoke-real --terminal --output --non-interactive +``` + +It reports whether the named local terminal is available for a manual visual run +or whether an SSH target is configured; unavailable/headless results are useful +evidence, not a test failure. This collector is optional/manual evidence and is +not a mandatory CI gate. In contrast, +`script/qa/docs-contract-smoke.sh --uam ./bin/uam --evidence-dir ` +is the required documentation contract gate: it checks CLI help, isolated +profile/doctor examples, schema migration, and links, but does not claim to +test graphical terminal behavior. + +Schema v4 creates an adjacent backup before migrating an older configuration. +If migration fails, the original stays in place. Stop UAM before restoring a +chosen backup, then use a compatible binary. Unknown equal-schema fields round +trip; a newer schema opens read-only to an older binary. Runtime client state is +never persisted. + ## Accessibility and no-color operation - `NO_COLOR` disables styling even when the terminal advertises color. @@ -163,3 +253,8 @@ If resuming a stopped row reports ambiguity, read the provider and Workspace in the message. Confirm only when selecting the provider's latest conversation is acceptable. Otherwise start a new Managed Session or restore an exact provider identity. + +After a reboot, a retained record permits provider-aware relaunch/resume; the +old PTY and terminal modes did not survive. Normal detach and handled signals +restore the terminal contract. SIGKILL cannot do cleanup. If it leaves a local +terminal unusable, use `reset` or start a fresh terminal before attaching again. diff --git a/internal/adapter/adapter.go b/internal/adapter/adapter.go index df3d7de..ce5973a 100644 --- a/internal/adapter/adapter.go +++ b/internal/adapter/adapter.go @@ -80,16 +80,28 @@ type PeekResult struct { TailText string } -type AttachSpec struct{ Argv []string } +type AttachProfileSnapshot struct { + Selected string + Effective string + Mouse string + ControlPrefix string + BackDetach bool +} + +type AttachSpec struct { + Argv []string + Profile AttachProfileSnapshot +} type ResumeRequest struct { - ID string - Name string - CommandAlias string - Prompt string - Cwd string - Mode string - SessionName string + ID string + Name string + CommandAlias string + ScrollbackLines int + Prompt string + Cwd string + Mode string + SessionName string // ProviderSessionID is the persisted provider-side session id, when one // was recorded at dispatch; providers that support exact resume use it // instead of their "most recent" heuristic. @@ -138,11 +150,12 @@ type HasSessionAdapter interface { } type DispatchRequest struct { - Prompt string - Cwd string - Mode string - Name string - CommandAlias string + Prompt string + Cwd string + Mode string + Name string + CommandAlias string + ScrollbackLines int } type AgentAdapter interface { diff --git a/internal/adapter/adaptertest/backend.go b/internal/adapter/adaptertest/backend.go index 3a79e5c..fbdd000 100644 --- a/internal/adapter/adaptertest/backend.go +++ b/internal/adapter/adaptertest/backend.go @@ -13,14 +13,16 @@ import ( // Call is one recorded backend invocation. type Call struct { - Op string - Name string - Cwd string - Env map[string]string - Command []string - Text string - Lines int - Label string + Op string + Name string + Cwd string + ProviderIdentity string + ScrollbackLines int + Env map[string]string + Command []string + Text string + Lines int + Label string } // Backend is an in-memory adapter.Backend that records every call and serves @@ -80,8 +82,8 @@ func (b *Backend) CommandLog() string { return strings.Join(lines, "\n") } -func (b *Backend) CreateSession(_ context.Context, name, cwd string, env map[string]string, command []string) error { - b.record(Call{Op: "create", Name: name, Cwd: cwd, Env: env, Command: append([]string{}, command...)}) +func (b *Backend) CreateProviderSession(_ context.Context, spec session.CreateSpec) error { + b.record(Call{Op: "create", Name: spec.Name, Cwd: spec.Cwd, ProviderIdentity: spec.ProviderIdentity, ScrollbackLines: spec.ScrollbackLines, Env: spec.Env, Command: append([]string{}, spec.Command...)}) return b.CreateErr } diff --git a/internal/adapter/agent.go b/internal/adapter/agent.go index 4b982ca..86f723e 100644 --- a/internal/adapter/agent.go +++ b/internal/adapter/agent.go @@ -42,7 +42,7 @@ type CommandCandidate struct { // capture / reply / kill / attach against uam's native session hosts // (internal/session.Client in production, fakes in tests). type Backend interface { - CreateSession(ctx context.Context, name, cwd string, env map[string]string, command []string) error + CreateProviderSession(ctx context.Context, spec session.CreateSpec) error SetSessionLabel(ctx context.Context, name, label string) error List(ctx context.Context) ([]session.Info, error) Capture(ctx context.Context, name string, lines int) (string, error) @@ -61,6 +61,7 @@ type Agent struct { Candidates []CommandCandidate YoloArgs []string Backend Backend + Terminal ProviderTerminalPolicy SessionArgs func(req ResumeRequest, activity string) []string // PrepareLaunch runs after cwd and canonical session-name resolution. // Preparation args run first; legacy SessionArgs are appended after them. @@ -91,11 +92,14 @@ func NewAgent(name, display string, candidates []CommandCandidate, yoloArgs []st if backend == nil { backend = session.NewClient() } - return &Agent{NameValue: name, DisplayNameValue: display, Candidates: candidates, YoloArgs: yoloArgs, Backend: backend, randomReader: rand.Reader, now: time.Now, lastPRScan: map[string]time.Time{}} + return &Agent{NameValue: name, DisplayNameValue: display, Candidates: candidates, YoloArgs: yoloArgs, Backend: backend, Terminal: nativeTerminalPolicy(ProviderIdentity(name)), randomReader: rand.Reader, now: time.Now, lastPRScan: map[string]time.Time{}} } func (a *Agent) Name() string { return a.NameValue } func (a *Agent) DisplayName() string { return a.DisplayNameValue } +func (a *Agent) TerminalPolicy() ProviderTerminalPolicy { + return a.Terminal +} func (a *Agent) Available() (bool, string) { _, ok := a.resolveCommand() @@ -191,7 +195,7 @@ func (a *Agent) Dispatch(ctx context.Context, req DispatchRequest) (Session, err if err != nil { return Session{}, fmt.Errorf("generate session id: %w", err) } - return a.startSession(ctx, ResumeRequest{ID: id, Name: req.Name, CommandAlias: req.CommandAlias, Prompt: req.Prompt, Cwd: req.Cwd, Mode: req.Mode}, "dispatched") + return a.startSession(ctx, ResumeRequest{ID: id, Name: req.Name, CommandAlias: req.CommandAlias, ScrollbackLines: req.ScrollbackLines, Prompt: req.Prompt, Cwd: req.Cwd, Mode: req.Mode}, "dispatched") } func (a *Agent) Resume(ctx context.Context, req ResumeRequest) (Session, error) { @@ -272,7 +276,9 @@ func (a *Agent) startSession(ctx context.Context, req ResumeRequest, activity st } env["UAM_AGENT"] = a.Name() env["UAM_ID"] = req.ID - if err := a.Backend.CreateSession(ctx, sessionName, cwd, env, cmd); err != nil { + if err := a.Backend.CreateProviderSession(ctx, session.CreateSpec{ + Name: sessionName, Cwd: cwd, ProviderIdentity: string(a.Terminal.Identity), ScrollbackLines: req.ScrollbackLines, Env: env, Command: cmd, + }); err != nil { return Session{}, fmt.Errorf("create session %s: %w", sessionName, err) } displayName := a.setSessionDisplayLabel(ctx, sessionName, req.Name, cwd) diff --git a/internal/adapter/agent_test.go b/internal/adapter/agent_test.go index cd116e0..4c97f05 100644 --- a/internal/adapter/agent_test.go +++ b/internal/adapter/agent_test.go @@ -47,6 +47,9 @@ func TestAgentLifecycle(t *testing.T) { if creates[0].Env["UAM_AGENT"] != "fake" || creates[0].Env["UAM_ID"] != sess.ID { t.Fatalf("create env missing UAM_AGENT/UAM_ID: %+v", creates[0].Env) } + if creates[0].ProviderIdentity != "fake" { + t.Fatalf("create provider identity = %q, want fake", creates[0].ProviderIdentity) + } sends := be.CallsOf("send") if len(sends) != 1 || sends[0].Text != "hello" { t.Fatalf("dispatch should send the prompt once: %+v", sends) @@ -202,8 +205,8 @@ type commandCaptureBackend struct { command []string } -func (b *commandCaptureBackend) CreateSession(_ context.Context, _, _ string, _ map[string]string, command []string) error { - b.command = command +func (b *commandCaptureBackend) CreateProviderSession(_ context.Context, spec session.CreateSpec) error { + b.command = spec.Command return nil } diff --git a/internal/adapter/claude/claude.go b/internal/adapter/claude/claude.go index 6d15626..40858ce 100644 --- a/internal/adapter/claude/claude.go +++ b/internal/adapter/claude/claude.go @@ -67,6 +67,7 @@ func supportsSessionID() bool { func New(backend adapter.Backend) adapter.AgentAdapter { a := adapter.NewAgent("claude", "Claude Code", []adapter.CommandCandidate{{Display: "claude", Args: []string{"claude"}}}, []string{"--dangerously-skip-permissions"}, backend) + a.Terminal = adapter.ProviderTerminalPolicy{Identity: adapter.ProviderClaude, OuterScreen: adapter.OuterScreenUAM, KeyProtocol: adapter.KeyProtocolNative} a.SessionArgs = sessionArgs a.ProviderSession = providerSession a.SkipPromptOnResume = true diff --git a/internal/adapter/claude/claude_test.go b/internal/adapter/claude/claude_test.go index e91a945..5b560e5 100644 --- a/internal/adapter/claude/claude_test.go +++ b/internal/adapter/claude/claude_test.go @@ -64,6 +64,9 @@ func TestResumeAppendsContinueAndDoesNotReplayPrompt(t *testing.T) { if !strings.Contains(argv, "claude --dangerously-skip-permissions --continue") { t.Fatalf("claude resume should append --continue: %s", argv) } + if strings.Contains(argv, "--no-alt-screen") { + t.Fatalf("claude resume must preserve provider-native terminal behavior: %s", argv) + } // The uam UUID may appear in the UAM_ID env var, but must never be passed // as a flag argument to claude (no --resume / --continue ). if strings.Contains(argv, "--continue abc12345-dead-beef-cafe-0123456789ab") || @@ -83,8 +86,8 @@ func TestDispatchUnchanged_sendsPromptNoContinue(t *testing.T) { if err != nil { t.Fatalf("Dispatch: %v", err) } - if argv := be.CommandLog(); strings.Contains(argv, "--continue") { - t.Fatalf("dispatch must not append --continue: %s", argv) + if argv := be.CommandLog(); strings.Contains(argv, "--continue") || strings.Contains(argv, "--no-alt-screen") { + t.Fatalf("dispatch must preserve provider-native terminal arguments: %s", argv) } sends := be.CallsOf("send") if len(sends) != 1 || sends[0].Text != "fix parser" { diff --git a/internal/adapter/codex/codex.go b/internal/adapter/codex/codex.go index 6e83ed9..5f571d6 100644 --- a/internal/adapter/codex/codex.go +++ b/internal/adapter/codex/codex.go @@ -17,6 +17,7 @@ func sessionArgs(_ adapter.ResumeRequest, activity string) []string { func New(backend adapter.Backend) adapter.AgentAdapter { a := adapter.NewAgent("codex", "OpenAI Codex", []adapter.CommandCandidate{{Display: "codex", Args: []string{"codex"}}}, []string{"--sandbox", "danger-full-access"}, backend) + a.Terminal = adapter.ProviderTerminalPolicy{Identity: adapter.ProviderCodex, OuterScreen: adapter.OuterScreenPrimary, KeyProtocol: adapter.KeyProtocolNative} a.SessionArgs = sessionArgs a.ResumeKindFor = func(adapter.ResumeRequest) adapter.ResumeKind { return adapter.ResumeHeuristic } a.SkipPromptOnResume = true diff --git a/internal/adapter/codex/codex_test.go b/internal/adapter/codex/codex_test.go index a27fb5d..67a447b 100644 --- a/internal/adapter/codex/codex_test.go +++ b/internal/adapter/codex/codex_test.go @@ -57,7 +57,7 @@ func TestResumeKindRemainsHeuristicEvenWithUnrelatedStoredIdentity(t *testing.T) // TestResumeAppendsResumeLastAndDoesNotReplayPrompt: resuming an Exited codex // row must use codex's `resume --last` and must NOT replay the original prompt // into the restored session, nor pass the uam UUID. -func TestResumeAppendsResumeLastAndDoesNotReplayPrompt(t *testing.T) { +func TestResumeUsesNoAltScreenExactlyOnce(t *testing.T) { a, be := newTestCodexAdapter(t) resumable, ok := a.(adapter.ResumableAdapter) if !ok { @@ -71,6 +71,9 @@ func TestResumeAppendsResumeLastAndDoesNotReplayPrompt(t *testing.T) { if !strings.Contains(argv, "codex --sandbox danger-full-access --no-alt-screen resume --last") { t.Fatalf("codex resume should append resume --last: %s", argv) } + if got := countArg(be.CallsOf("create")[0].Command, "--no-alt-screen"); got != 1 { + t.Fatalf("codex resume --no-alt-screen count = %d, want 1: %s", got, argv) + } // The uam UUID may appear in the UAM_ID env var, but must never be passed // as a flag argument to codex (no resume / --resume ). if strings.Contains(argv, "resume --last abc12345-dead-beef-cafe-0123456789ab") || @@ -82,7 +85,7 @@ func TestResumeAppendsResumeLastAndDoesNotReplayPrompt(t *testing.T) { } } -func TestDispatchUsesInlineRenderingAndSendsPromptWithoutResume(t *testing.T) { +func TestDispatchUsesNoAltScreenExactlyOnce(t *testing.T) { a, be := newTestCodexAdapter(t) _, err := a.Dispatch(context.Background(), adapter.DispatchRequest{Prompt: "fix parser", Cwd: "/tmp", Mode: "yolo"}) if err != nil { @@ -94,12 +97,25 @@ func TestDispatchUsesInlineRenderingAndSendsPromptWithoutResume(t *testing.T) { if argv := be.CommandLog(); !strings.Contains(argv, "codex --sandbox danger-full-access --no-alt-screen") { t.Fatalf("codex must use inline rendering so attached terminals retain scrollback: %s", argv) } + if argv := be.CommandLog(); countArg(be.CallsOf("create")[0].Command, "--no-alt-screen") != 1 { + t.Fatalf("codex dispatch must use --no-alt-screen exactly once: %s", argv) + } sends := be.CallsOf("send") if len(sends) != 1 || sends[0].Text != "fix parser" { t.Fatalf("dispatch should send the prompt: %+v", sends) } } +func countArg(argv []string, want string) int { + count := 0 + for _, arg := range argv { + if arg == want { + count++ + } + } + return count +} + func newTestCodexAdapter(t *testing.T) (adapter.AgentAdapter, *adaptertest.Backend) { t.Helper() dir := t.TempDir() diff --git a/internal/adapter/copilot/copilot.go b/internal/adapter/copilot/copilot.go index d3fea0f..dc3de1d 100644 --- a/internal/adapter/copilot/copilot.go +++ b/internal/adapter/copilot/copilot.go @@ -6,6 +6,7 @@ import ( func New(backend adapter.Backend) adapter.AgentAdapter { agent := adapter.NewAgent("copilot", "GitHub Copilot", []adapter.CommandCandidate{{Display: "copilot", Args: []string{"copilot"}}}, []string{"--yolo"}, backend) + agent.Terminal = adapter.ProviderTerminalPolicy{Identity: adapter.ProviderCopilot, OuterScreen: adapter.OuterScreenUAM, KeyProtocol: adapter.KeyProtocolNative} // copilot supports exact-session resume natively: --name seeds the new // session's name with the uam id at dispatch, and --resume matches it // exactly (case-insensitive) on resume. diff --git a/internal/adapter/copilot/copilot_test.go b/internal/adapter/copilot/copilot_test.go index 89dfca7..086b7f7 100644 --- a/internal/adapter/copilot/copilot_test.go +++ b/internal/adapter/copilot/copilot_test.go @@ -46,6 +46,9 @@ func TestYoloModeUsesYoloFlag(t *testing.T) { if strings.Contains(argv, "--autopilot") { t.Fatalf("copilot yolo mode should not use --autopilot: %s", argv) } + if strings.Contains(argv, "--no-alt-screen") { + t.Fatalf("copilot dispatch must preserve provider-native terminal behavior: %s", argv) + } } func TestDispatchSeedsCopilotSessionIDForFutureResume(t *testing.T) { @@ -81,6 +84,9 @@ func TestResumeUsesCopilotSessionIDAndDoesNotReplayPrompt(t *testing.T) { if !strings.Contains(argv, "copilot --yolo --resume=abc12345-dead-beef-cafe-0123456789ab") { t.Fatalf("copilot resume should pass the persisted provider session id: %s", argv) } + if strings.Contains(argv, "--no-alt-screen") { + t.Fatalf("copilot resume must preserve provider-native terminal behavior: %s", argv) + } if sends := be.CallsOf("send"); len(sends) != 0 { t.Fatalf("resume should not replay the original prompt into the restored session: %+v", sends) } diff --git a/internal/adapter/hermes/hermes.go b/internal/adapter/hermes/hermes.go index e9173f8..cb6fb79 100644 --- a/internal/adapter/hermes/hermes.go +++ b/internal/adapter/hermes/hermes.go @@ -11,5 +11,7 @@ import ( var yoloArgs []string func New(backend adapter.Backend) adapter.AgentAdapter { - return adapter.NewAgent("hermes", "Hermes Agent", []adapter.CommandCandidate{{Display: "hermes", Args: []string{"hermes"}}}, yoloArgs, backend) + agent := adapter.NewAgent("hermes", "Hermes Agent", []adapter.CommandCandidate{{Display: "hermes", Args: []string{"hermes"}}}, yoloArgs, backend) + agent.Terminal = adapter.ProviderTerminalPolicy{Identity: adapter.ProviderHermes, OuterScreen: adapter.OuterScreenUAM, KeyProtocol: adapter.KeyProtocolNative} + return agent } diff --git a/internal/adapter/hermes/hermes_test.go b/internal/adapter/hermes/hermes_test.go index 47ca5be..9356cc7 100644 --- a/internal/adapter/hermes/hermes_test.go +++ b/internal/adapter/hermes/hermes_test.go @@ -1,10 +1,15 @@ package hermes import ( + "context" + "os" + "path/filepath" "reflect" + "strings" "testing" "github.com/RandomCodeSpace/unified-agent-manager/internal/adapter" + "github.com/RandomCodeSpace/unified-agent-manager/internal/adapter/adaptertest" ) func TestNewUsesBareHermesCommand(t *testing.T) { @@ -28,3 +33,24 @@ func TestNewUsesBareHermesCommand(t *testing.T) { t.Fatalf("yolo args = %v, want none", ag.YoloArgs) } } + +func TestLaunchAndResumeUseBareProviderNativeCommand(t *testing.T) { + dir := t.TempDir() + if err := os.WriteFile(filepath.Join(dir, "hermes"), []byte("#!/bin/sh\nexit 0\n"), 0o700); err != nil { + t.Fatal(err) + } + t.Setenv("PATH", dir+string(os.PathListSeparator)+os.Getenv("PATH")) + backend := &adaptertest.Backend{} + agent := New(backend).(*adapter.Agent) + if _, err := agent.Dispatch(context.Background(), adapter.DispatchRequest{Cwd: t.TempDir(), Mode: "yolo"}); err != nil { + t.Fatalf("Dispatch: %v", err) + } + if _, err := agent.Resume(context.Background(), adapter.ResumeRequest{ID: "deadbeef", Cwd: t.TempDir(), Mode: "yolo", SessionName: "uam-hermes-deadbeef"}); err != nil { + t.Fatalf("Resume: %v", err) + } + for _, call := range backend.CallsOf("create") { + if got := strings.Join(call.Command, " "); got != "hermes" { + t.Fatalf("Hermes launch command = %q, want bare provider command", got) + } + } +} diff --git a/internal/adapter/omp/omp.go b/internal/adapter/omp/omp.go index 2390442..813775a 100644 --- a/internal/adapter/omp/omp.go +++ b/internal/adapter/omp/omp.go @@ -34,6 +34,7 @@ func sessionArgs(_ adapter.ResumeRequest, activity string) []string { func New(backend adapter.Backend) adapter.AgentAdapter { a := adapter.NewAgent("omp", "Oh My Pi", []adapter.CommandCandidate{{Display: "omp", Args: []string{"omp"}}}, yoloArgs, backend) + a.Terminal = adapter.ProviderTerminalPolicy{Identity: adapter.ProviderOMP, OuterScreen: adapter.OuterScreenUAM, KeyProtocol: adapter.KeyProtocolNative} a.SessionArgs = sessionArgs a.PrepareLaunch = prepareLaunch a.ResumeKindFor = resumeKind diff --git a/internal/adapter/omp/omp_test.go b/internal/adapter/omp/omp_test.go index 662ee01..1861f98 100644 --- a/internal/adapter/omp/omp_test.go +++ b/internal/adapter/omp/omp_test.go @@ -66,6 +66,9 @@ func TestResumeKindUsesExistingSafeDerivedDirectory(t *testing.T) { if !strings.Contains(argv, "--session-dir "+dir+" -c") { t.Fatalf("argv=%q", argv) } + if strings.Contains(argv, "--no-alt-screen") { + t.Fatalf("OMP resume must preserve provider-native terminal behavior: %q", argv) + } } func TestLegacyResumeWithoutDerivedDirectoryKeepsBareContinue(t *testing.T) { @@ -81,6 +84,9 @@ func TestLegacyResumeWithoutDerivedDirectoryKeepsBareContinue(t *testing.T) { if strings.Contains(argv, "--session-dir") || !strings.HasSuffix(argv, " -c") { t.Fatalf("argv=%q", argv) } + if strings.Contains(argv, "--no-alt-screen") { + t.Fatalf("legacy OMP resume must preserve provider-native terminal behavior: %q", argv) + } } func TestResumeRejectsUnsafeExistingDerivedDirectory(t *testing.T) { diff --git a/internal/adapter/opencode/opencode.go b/internal/adapter/opencode/opencode.go index 0a107c9..09cae3d 100644 --- a/internal/adapter/opencode/opencode.go +++ b/internal/adapter/opencode/opencode.go @@ -16,6 +16,7 @@ var providerIDRE = regexp.MustCompile(`^ses_[A-Za-z0-9_-]{3,60}$`) func New(backend adapter.Backend) adapter.AgentAdapter { agent := adapter.NewAgent("opencode", "OpenCode", []adapter.CommandCandidate{{Display: "opencode", Args: []string{"opencode"}}}, yoloArgs, backend) + agent.Terminal = adapter.ProviderTerminalPolicy{Identity: adapter.ProviderOpenCode, OuterScreen: adapter.OuterScreenUAM, KeyProtocol: adapter.KeyProtocolNative} agent.PrepareLaunch = prepareLaunch agent.LiveProviderSessionID = liveProviderSessionID agent.ResumeKindFor = resumeKind diff --git a/internal/adapter/opencode/opencode_test.go b/internal/adapter/opencode/opencode_test.go index 77835ad..75617e9 100644 --- a/internal/adapter/opencode/opencode_test.go +++ b/internal/adapter/opencode/opencode_test.go @@ -115,6 +115,9 @@ func TestOpenCodePrepareLaunchBuildsSupervisorCommandAndNeutralEnv(t *testing.T) if !reflect.DeepEqual(calls[0].Command, wantCommand) { t.Fatalf("launch command = %#v, want %#v", calls[0].Command, wantCommand) } + if strings.Contains(strings.Join(calls[0].Command, " "), "--no-alt-screen") { + t.Fatalf("OpenCode resume must preserve provider-native terminal behavior: %#v", calls[0].Command) + } identityPath, err := session.ProviderIdentityPath(runtimeDir, name) if err != nil { t.Fatal(err) @@ -266,6 +269,9 @@ func TestOpenCodePromptDeliveryAndResumeNoReplay(t *testing.T) { if _, err := agent.Dispatch(context.Background(), adapter.DispatchRequest{Prompt: prompt, Cwd: cwd, Mode: "yolo"}); err != nil { t.Fatalf("Dispatch(): %v", err) } + if command := strings.Join(backend.CallsOf("create")[0].Command, " "); strings.Contains(command, "--no-alt-screen") { + t.Fatalf("OpenCode dispatch must preserve provider-native terminal behavior: %s", command) + } sends := backend.CallsOf("send") if len(sends) != 1 || sends[0].Text != prompt { t.Fatalf("dispatch sends = %#v, want one byte-preserved prompt %q", sends, prompt) diff --git a/internal/adapter/terminal_policy.go b/internal/adapter/terminal_policy.go new file mode 100644 index 0000000..9284519 --- /dev/null +++ b/internal/adapter/terminal_policy.go @@ -0,0 +1,66 @@ +package adapter + +import "fmt" + +type ProviderIdentity string + +const ( + ProviderClaude ProviderIdentity = "claude" + ProviderCodex ProviderIdentity = "codex" + ProviderCopilot ProviderIdentity = "copilot" + ProviderHermes ProviderIdentity = "hermes" + ProviderOMP ProviderIdentity = "omp" + ProviderOpenCode ProviderIdentity = "opencode" +) + +type OuterScreenPolicy string + +const ( + OuterScreenUAM OuterScreenPolicy = "uam" + OuterScreenPrimary OuterScreenPolicy = "primary" +) + +type KeyProtocolPolicy string + +const KeyProtocolNative KeyProtocolPolicy = "native" + +type ProviderTerminalPolicy struct { + Identity ProviderIdentity + OuterScreen OuterScreenPolicy + KeyProtocol KeyProtocolPolicy +} + +type TerminalPolicyAdapter interface { + TerminalPolicy() ProviderTerminalPolicy +} + +func (p ProviderTerminalPolicy) Validate() error { + if !validProviderIdentity(p.Identity) { + return fmt.Errorf("invalid provider terminal identity %q", p.Identity) + } + if p.OuterScreen != OuterScreenUAM && p.OuterScreen != OuterScreenPrimary { + return fmt.Errorf("invalid provider outer-screen policy %q", p.OuterScreen) + } + if p.KeyProtocol != KeyProtocolNative { + return fmt.Errorf("invalid provider key-protocol policy %q", p.KeyProtocol) + } + return nil +} + +func validProviderIdentity(identity ProviderIdentity) bool { + if identity == "" { + return false + } + for _, character := range identity { + if character < 'a' || character > 'z' { + if character < '0' || character > '9' { + return false + } + } + } + return true +} + +func nativeTerminalPolicy(identity ProviderIdentity) ProviderTerminalPolicy { + return ProviderTerminalPolicy{Identity: identity, OuterScreen: OuterScreenUAM, KeyProtocol: KeyProtocolNative} +} diff --git a/internal/adapter/terminal_policy_launch_test.go b/internal/adapter/terminal_policy_launch_test.go new file mode 100644 index 0000000..720a55e --- /dev/null +++ b/internal/adapter/terminal_policy_launch_test.go @@ -0,0 +1,23 @@ +package adapter + +import ( + "context" + "testing" + + "github.com/RandomCodeSpace/unified-agent-manager/internal/adapter/adaptertest" +) + +func TestTodo8LaunchSnapshotReachesHostCreate(t *testing.T) { + backend := &adaptertest.Backend{} + agent := NewAgent("fake", "Fake", []CommandCandidate{{Display: "true", Args: []string{"/bin/true"}}}, nil, backend) + if _, err := agent.Dispatch(context.Background(), DispatchRequest{Cwd: t.TempDir(), ScrollbackLines: 8123, Mode: "safe"}); err != nil { + t.Fatal(err) + } + creates := backend.CallsOf("create") + if len(creates) != 1 { + t.Fatalf("create calls = %d, want 1", len(creates)) + } + if creates[0].ScrollbackLines != 8123 { + t.Fatalf("host create scrollback = %d, want 8123", creates[0].ScrollbackLines) + } +} diff --git a/internal/agents/provider_terminal_policy_test.go b/internal/agents/provider_terminal_policy_test.go new file mode 100644 index 0000000..e8c79ab --- /dev/null +++ b/internal/agents/provider_terminal_policy_test.go @@ -0,0 +1,59 @@ +package agents + +import ( + "encoding/json" + "os" + "path/filepath" + "testing" + + "github.com/RandomCodeSpace/unified-agent-manager/internal/adapter" + "github.com/RandomCodeSpace/unified-agent-manager/internal/adapter/adaptertest" +) + +func TestProviderTerminalPolicyMatrix(t *testing.T) { + want := map[string]adapter.ProviderTerminalPolicy{ + "claude": {Identity: adapter.ProviderClaude, OuterScreen: adapter.OuterScreenUAM, KeyProtocol: adapter.KeyProtocolNative}, + "codex": {Identity: adapter.ProviderCodex, OuterScreen: adapter.OuterScreenPrimary, KeyProtocol: adapter.KeyProtocolNative}, + "copilot": {Identity: adapter.ProviderCopilot, OuterScreen: adapter.OuterScreenUAM, KeyProtocol: adapter.KeyProtocolNative}, + "hermes": {Identity: adapter.ProviderHermes, OuterScreen: adapter.OuterScreenUAM, KeyProtocol: adapter.KeyProtocolNative}, + "omp": {Identity: adapter.ProviderOMP, OuterScreen: adapter.OuterScreenUAM, KeyProtocol: adapter.KeyProtocolNative}, + "opencode": {Identity: adapter.ProviderOpenCode, OuterScreen: adapter.OuterScreenUAM, KeyProtocol: adapter.KeyProtocolNative}, + } + observed := make(map[string]adapter.ProviderTerminalPolicy, len(want)) + + for _, provider := range Default(&adaptertest.Backend{}) { + policyProvider, ok := provider.(adapter.TerminalPolicyAdapter) + if !ok { + t.Fatalf("provider %q does not expose typed terminal policy", provider.Name()) + } + if got := policyProvider.TerminalPolicy(); got != want[provider.Name()] { + t.Errorf("provider %q terminal policy = %+v, want %+v", provider.Name(), got, want[provider.Name()]) + } else { + observed[provider.Name()] = got + } + delete(want, provider.Name()) + } + if len(want) != 0 { + t.Fatalf("terminal policy matrix omitted providers: %+v", want) + } + writeProviderTerminalPolicyEvidence(t, observed) +} + +func writeProviderTerminalPolicyEvidence(t *testing.T, policies map[string]adapter.ProviderTerminalPolicy) { + t.Helper() + dir := os.Getenv("UAM_TASK3_EVIDENCE_DIR") + if dir == "" { + return + } + data, err := json.MarshalIndent(struct { + Policies map[string]adapter.ProviderTerminalPolicy `json:"policies"` + KeyProtocolTranslation bool `json:"key_protocol_translation"` + }{Policies: policies, KeyProtocolTranslation: false}, "", " ") + if err != nil { + t.Fatal(err) + } + data = append(data, '\n') + if err := os.WriteFile(filepath.Join(dir, "task-3-provider-policy.json"), data, 0o600); err != nil { + t.Fatal(err) + } +} diff --git a/internal/app/app.go b/internal/app/app.go index bcf3402..1287a57 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -7,6 +7,7 @@ import ( "os" "os/exec" "path/filepath" + "sort" "strings" "sync" "time" @@ -64,8 +65,14 @@ type Model struct { wizard bool wizardStep int wizardAgent string + wizardAgentExplicit bool + wizardProfile string wizardAlias string wizardCwd string + profileNames []string + profileProviders map[string]string + defaultProfile string + profileBySession map[sessionIdentity]string groupByDir bool execProcess func(*exec.Cmd, tea.ExecCallback) tea.Cmd // reorderSeq increments on every reorder; a debounced flush tick only @@ -118,10 +125,14 @@ const peekTickInterval = time.Second const peekFocusInterval = time.Second type sessionsLoadedMsg struct { - sessions []adapter.Session - defaultAgent string - groupByDir bool - err error + sessions []adapter.Session + defaultAgent string + groupByDir bool + profileNames []string + profileProviders map[string]string + defaultProfile string + profileBySession map[sessionIdentity]string + err error } type peekLoadedMsg struct { text string @@ -208,7 +219,7 @@ func New() Model { } func NewWithDeps(st *store.Store, reg *adapter.Registry) Model { - m := Model{service: NewService(st, reg), defaultAgent: store.DefaultAgentName, wizardCwd: ".", execProcess: tea.ExecProcess, lastPeekAt: map[string]time.Time{}, peekClock: time.Now} + m := Model{service: NewService(st, reg), defaultAgent: store.DefaultAgentName, wizardCwd: ".", profileProviders: map[string]string{}, profileBySession: map[sessionIdentity]string{}, execProcess: tea.ExecProcess, lastPeekAt: map[string]time.Time{}, peekClock: time.Now} // The baked-in OpenCode default may not be installed; reconcile it to an // enabled provider so Enter-with-no-input and the prompt hint never point at // a disabled agent (C2-9). @@ -269,7 +280,20 @@ func (m Model) loadSessionsCmd() tea.Cmd { return m.reloadSessions() } sessions, cfg, err := m.service.LoadSessions(context.Background()) - return sessionsLoadedMsg{sessions: sessions, defaultAgent: cfg.DefaultAgent, groupByDir: cfg.UI.GroupByDir, err: err} + profileNames := make([]string, 0, len(cfg.Profiles)) + profileProviders := make(map[string]string, len(cfg.Profiles)) + for name, profile := range cfg.Profiles { + profileNames = append(profileNames, name) + if profile.Provider != nil { + profileProviders[name] = *profile.Provider + } + } + sort.Strings(profileNames) + profileBySession := make(map[sessionIdentity]string, len(cfg.Sessions)) + for _, record := range cfg.Sessions { + profileBySession[sessionIdentity{agent: record.Agent, id: record.ID}] = record.Profile + } + return sessionsLoadedMsg{sessions: sessions, defaultAgent: cfg.DefaultAgent, groupByDir: cfg.UI.GroupByDir, profileNames: profileNames, profileProviders: profileProviders, defaultProfile: cfg.DefaultProfile, profileBySession: profileBySession, err: err} } } @@ -413,6 +437,12 @@ func (m Model) handleSessionsLoaded(msg sessionsLoadedMsg) Model { // disabled one (C2-9). m.defaultAgent = m.validateDefaultAgent(msg.defaultAgent) } + m.profileNames = append([]string(nil), msg.profileNames...) + m.profileProviders = msg.profileProviders + m.defaultProfile = msg.defaultProfile + if msg.profileBySession != nil { + m.profileBySession = msg.profileBySession + } if selectedID != "" { for i, sess := range m.sessions { if sess.AgentType == selectedAgent && sess.ID == selectedID { @@ -981,6 +1011,9 @@ func (m *Model) handleEditKey(key string) { m.wizard = true m.wizardStep = 0 m.input = "" + m.wizardProfile = "" + m.wizardAgentExplicit = false + m.wizardAgent = m.wizardProviderDefault("") m.wizardAlias = "" m.wizardCwd = "." } @@ -1077,7 +1110,14 @@ func (m *Model) handleWizardAgentKey(key string) (tea.Cmd, bool) { case "tab": m.cycleDefaultAgent() m.wizardAgent = m.defaultAgent + m.wizardAgentExplicit = true return m.persistDefaultAgent(), true + case "shift+tab", "right": + m.cycleWizardProfile() + if !m.wizardAgentExplicit { + m.wizardAgent = m.wizardProviderDefault(m.wizardProfile) + } + return nil, true case "enter": if m.wizardAgent == "" { m.wizardAgent = m.defaultAgent @@ -1130,7 +1170,7 @@ func (m *Model) handleWizardPromptKey(key string) (tea.Cmd, bool) { } cwd := m.wizardCwd m.closeWizard() - return m.dispatchWithNameCwdCmd(spec.Agent, spec.Alias, spec.Name, spec.Prompt, cwd), true + return m.dispatchWithNameCwdProfileCmd(spec.Agent, spec.Alias, spec.Name, spec.Prompt, cwd, m.wizardProfile), true } return nil, false } @@ -1246,6 +1286,27 @@ func (m *Model) cycleDefaultAgent() { m.defaultAgent = enabled[idx%len(enabled)].Name() } +func (m *Model) cycleWizardProfile() { + choices := append([]string{""}, m.profileNames...) + for i, name := range choices { + if name == m.wizardProfile { + m.wizardProfile = choices[(i+1)%len(choices)] + return + } + } + m.wizardProfile = "" +} + +func (m Model) wizardProviderDefault(profileName string) string { + if profileName == "" { + profileName = m.defaultProfile + } + if provider := m.profileProviders[profileName]; provider != "" { + return provider + } + return m.defaultAgent +} + type dispatchSpec struct { Agent string Alias string @@ -1287,11 +1348,15 @@ func consumeDispatchToken(input, prefix string) (token, rest string, ok bool) { } func (m Model) dispatchNamedCmd(agent, alias, name, prompt string) tea.Cmd { - return m.dispatchWithNameCwdCmd(agent, alias, name, prompt, "") + return m.dispatchWithNameCwdProfileCmd(agent, alias, name, prompt, "", "") } -func (m Model) dispatchWithNameCwdCmd(agent, alias, name, prompt, cwd string) tea.Cmd { +func (m Model) dispatchWithNameCwdProfileCmd(agent, alias, name, prompt, cwd, profile string) tea.Cmd { return func() tea.Msg { - sess, err := m.service.DispatchNamedWithAlias(context.Background(), agent, alias, name, prompt, cwd, string(store.ModeYolo)) + mode := string(store.ModeYolo) + if profile != "" { + mode = "" + } + sess, err := m.service.DispatchNamedWithAliasProfile(context.Background(), agent, alias, name, prompt, cwd, mode, profile) return dispatchedMsg{session: sess, err: err} } } @@ -1463,11 +1528,15 @@ func (m Model) attachSessionCmd(sess adapter.Session) tea.Cmd { if m.service == nil || m.service.Registry == nil { return sessionsLoadedMsg{err: fmt.Errorf("agent %q unavailable", sess.AgentType)} } - a, ok := m.service.Registry.Get(sess.AgentType) - if !ok { - return sessionsLoadedMsg{err: fmt.Errorf("agent %q unavailable", sess.AgentType)} + if m.service.Store == nil { + a, ok := m.service.Registry.Get(sess.AgentType) + if !ok { + return sessionsLoadedMsg{err: fmt.Errorf("agent %q unavailable", sess.AgentType)} + } + spec, err := a.Attach(sess.ID) + return attachSpecMsg{spec: spec, err: err} } - spec, err := a.Attach(sess.ID) + spec, err := m.service.AttachSpecExact(context.Background(), sess.AgentType, sess.ID) return attachSpecMsg{spec: spec, err: err} } } @@ -1484,10 +1553,41 @@ func (m Model) execAttachSpec(spec adapter.AttachSpec, err error) tea.Cmd { runner = tea.ExecProcess } cmd := exec.Command(spec.Argv[0], spec.Argv[1:]...) // #nosec G204 -- attach argv is generated by trusted agent adapters, no shell expansion. - cmd.Env = append(os.Environ(), session.AttachQuietEnv+"=1") + cmd.Env = attachProcessEnvironment(os.Environ(), spec) return runner(cmd, func(err error) tea.Msg { return attachFinishedMsg{err: err} }) } +func attachProcessEnvironment(base []string, spec adapter.AttachSpec) []string { + environment := make([]string, 0, len(base)+6) + for _, assignment := range base { + name, _, _ := strings.Cut(assignment, "=") + if name == session.AttachQuietEnv || name == session.AttachSelectedProfileEnv || name == session.AttachEffectiveProfileEnv || + name == session.AttachPolicyMouseEnv || name == session.AttachPolicyPrefixEnv || name == session.AttachPolicyBackDetachEnv { + continue + } + environment = append(environment, assignment) + } + environment = append(environment, session.AttachQuietEnv+"=1") + if spec.Profile.Selected != "" { + environment = append(environment, session.AttachSelectedProfileEnv+"="+spec.Profile.Selected) + } + if spec.Profile.Effective != "" { + environment = append(environment, session.AttachEffectiveProfileEnv+"="+spec.Profile.Effective) + } + if spec.Profile.Mouse != "" || spec.Profile.ControlPrefix != "" { + backDetach := "0" + if spec.Profile.BackDetach { + backDetach = "1" + } + environment = append(environment, + session.AttachPolicyMouseEnv+"="+spec.Profile.Mouse, + session.AttachPolicyPrefixEnv+"="+spec.Profile.ControlPrefix, + session.AttachPolicyBackDetachEnv+"="+backDetach, + ) + } + return environment +} + func (m Model) persistOrderCmd() tea.Cmd { sessions := append([]adapter.Session(nil), m.sessions...) return m.persistSortIndicesCmd(sessions) @@ -1714,6 +1814,9 @@ func (m Model) wizardPromptLines(width int) []string { if step == 0 && field == "" { field = firstNonEmpty(m.wizardAgent, m.defaultAgent) } + if step == 0 { + field += " profile=" + m.wizardProfileLabel() + } return []string{ ansi.Truncate(bar()+" "+hintStyle.Render("new")+" "+brandStyle.Render("›")+" "+titleStyle.Render(field)+brandStyle.Render("▏"), width, "…"), ansi.Truncate(" "+hintStyle.Render(hints[step]), width, "…"), @@ -1908,6 +2011,10 @@ func (m Model) renderDetails() string { b.WriteString(" " + taskStyle.Render(boundedTaskSummary(sess, max(8, m.contentWidth()-6))) + "\n") } b.WriteString(" " + hintStyle.Render("agent: "+displaytext.Sanitize(firstNonEmpty(sess.AgentType, "?"))) + "\n") + selected, effective := m.profileLabels(sess) + b.WriteString(" ") + b.WriteString(hintStyle.Render("profile selected: " + displaytext.Sanitize(selected) + " effective: " + displaytext.Sanitize(effective))) + b.WriteByte('\n') if !sess.CreatedAt.IsZero() { b.WriteString(" " + hintStyle.Render("created: "+sess.CreatedAt.Format("Jan 02 15:04")) + "\n") } @@ -1915,6 +2022,19 @@ func (m Model) renderDetails() string { return b.String() } +func (m Model) profileLabels(sess adapter.Session) (string, string) { + selected := m.profileBySession[sessionIdentity{agent: sess.AgentType, id: sess.ID}] + effective := selected + if selected == "" { + selected = "default" + effective = m.defaultProfile + } + if effective == "" { + effective = "none" + } + return selected, effective +} + func (m Model) renderTable() string { var b strings.Builder b.WriteString("\n") @@ -2204,8 +2324,9 @@ func (m Model) renderLatestConfirmation() string { } func (m Model) renderWizard() string { + profileLabel := m.wizardProfileLabel() steps := []string{ - "provider — Tab cycles, Enter confirms: " + firstNonEmpty(m.wizardAgent, m.defaultAgent), + "provider — Tab cycles; Shift+Tab profile; Enter confirms: " + firstNonEmpty(m.wizardAgent, m.defaultAgent) + " profile=" + profileLabel, "command alias — blank uses provider default: " + m.input, "working directory: " + m.input, "#name prompt — both optional: " + m.input, @@ -2215,7 +2336,7 @@ func (m Model) renderWizard() string { step = 0 } var b strings.Builder - b.WriteString("\n " + sectionStyle.Render("NEW SESSION") + " " + hintStyle.Render(fmt.Sprintf("step %d of 4", step+1)) + "\n") + b.WriteString("\n " + sectionStyle.Render("NEW SESSION") + " " + hintStyle.Render(fmt.Sprintf("step %d of 4 · profile %s", step+1, profileLabel)) + "\n") b.WriteString(" " + titleStyle.Render(displaytext.Sanitize(steps[step])) + brandStyle.Render("▏") + "\n") // #nosec G602 -- step is clamped to [0, len(steps)) just above. switch step { case 2: @@ -2234,6 +2355,16 @@ func (m Model) renderWizard() string { return b.String() } +func (m Model) wizardProfileLabel() string { + if m.wizardProfile != "" { + return m.wizardProfile + } + if m.defaultProfile != "" { + return "default:" + m.defaultProfile + } + return "default:none" +} + // liveGlyphStyle / failGlyphStyle are hoisted to package vars so renderRow does // not allocate a fresh lipgloss.Style per row per frame. They keep AdaptiveColor // (resolved at render time, not pre-baked) so the palette still adapts to diff --git a/internal/app/dashboard_revamp.go b/internal/app/dashboard_revamp.go index 0aa0988..3064f6b 100644 --- a/internal/app/dashboard_revamp.go +++ b/internal/app/dashboard_revamp.go @@ -410,6 +410,8 @@ func (m Model) dashboardSessionEntries(sess adapter.Session, index, width int) [ } details := []string{taskStyle.Render(boundedTaskSummary(sess, max(1, width-4)))} if m.layoutClass() != LayoutCompact { + selectedProfile, effectiveProfile := m.profileLabels(sess) + details = append(details, hintStyle.Render("profile selected: "+displaytext.Sanitize(selectedProfile)+" effective: "+displaytext.Sanitize(effectiveProfile))) details = append(details, hintStyle.Render("cwd "+absCwd(sess.Cwd))) details = append(details, hintStyle.Render("id "+displaytext.Sanitize(sess.ID))) if sess.PR != nil { diff --git a/internal/app/diagnostics_test.go b/internal/app/diagnostics_test.go new file mode 100644 index 0000000..e6ad012 --- /dev/null +++ b/internal/app/diagnostics_test.go @@ -0,0 +1,52 @@ +package app + +import ( + "bytes" + "log/slog" + "strings" + "testing" + + "github.com/RandomCodeSpace/unified-agent-manager/internal/adapter" + uamlog "github.com/RandomCodeSpace/unified-agent-manager/internal/log" + "github.com/RandomCodeSpace/unified-agent-manager/internal/store" +) + +func TestProfileResolutionStructuredLog(t *testing.T) { + // Given: a retained session whose selected profile was deleted. + var output bytes.Buffer + previous := uamlog.SetLogger(slog.New(slog.NewJSONHandler(&output, nil))) + t.Cleanup(func() { uamlog.SetLogger(previous) }) + cfg := store.DefaultConfig() + record := store.SessionRecord{ + ID: "a1", Agent: "claude", SessionName: "secret-input-7f3a", Profile: "provider-output-91bc", + } + + // When: the effective policy resolves through the legacy fallback. + _, err := ResolveProfilePolicy(ResolutionInput{ + Config: cfg, + Session: record, + ProviderPolicy: adapter.ProviderTerminalPolicy{ + Identity: adapter.ProviderClaude, OuterScreen: adapter.OuterScreenPrimary, KeyProtocol: adapter.KeyProtocolNative, + }, + }) + if err != nil { + t.Fatal(err) + } + + // Then: resolution and provider-exception events expose bounded policy + // metadata, not persisted prompt or environment data. + logs := output.String() + for _, event := range []string{"profile.resolution", "provider.exception"} { + if !strings.Contains(logs, `"event":"`+event+`"`) { + t.Fatalf("missing %s in logs:\n%s", event, logs) + } + } + if !strings.Contains(logs, `"profile":"redacted"`) || !strings.Contains(logs, `"reason":"profile_fallback"`) { + t.Fatalf("missing fallback fields:\n%s", logs) + } + for _, secret := range []string{"secret-input-7f3a", "provider-output-91bc", "TOKEN=private-value"} { + if strings.Contains(logs, secret) { + t.Fatalf("profile diagnostics retained sentinel %q:\n%s", secret, logs) + } + } +} diff --git a/internal/app/doctor.go b/internal/app/doctor.go new file mode 100644 index 0000000..cb9e2b7 --- /dev/null +++ b/internal/app/doctor.go @@ -0,0 +1,253 @@ +package app + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "os" + "regexp" + "sort" + "strings" + + "github.com/RandomCodeSpace/unified-agent-manager/internal/adapter" + "github.com/RandomCodeSpace/unified-agent-manager/internal/session" + "github.com/RandomCodeSpace/unified-agent-manager/internal/store" +) + +type SessionDiagnosticSource interface { + Doctor(context.Context, string) (session.RuntimeDiagnostic, error) +} + +type DoctorCheck struct { + Status string `json:"status"` + Count int `json:"count,omitempty"` + Version int `json:"version,omitempty"` +} + +type ProviderDoctor struct { + Name string `json:"name"` + Status string `json:"status"` + OuterScreen string `json:"outer_screen,omitempty"` + KeyProtocol string `json:"key_protocol,omitempty"` +} + +type ProfileDoctor struct { + Name string `json:"name"` + Status string `json:"status"` +} + +type GlobalDoctorReport struct { + Store DoctorCheck `json:"store"` + Runtime DoctorCheck `json:"runtime"` + Providers []ProviderDoctor `json:"providers"` + Profiles []ProfileDoctor `json:"profiles"` +} + +type ProviderPolicyDoctor struct { + OuterScreen string `json:"outer_screen"` + KeyProtocol string `json:"key_protocol"` +} + +type SessionDoctorReport struct { + SessionID string `json:"session_id"` + SessionName string `json:"session_name"` + Provider string `json:"provider"` + RuntimeStatus string `json:"runtime_status"` + Runtime session.RuntimeDiagnostic `json:"runtime"` + SelectedProfile string `json:"selected_profile"` + EffectiveProfile string `json:"effective_profile"` + ProviderPolicy ProviderPolicyDoctor `json:"provider_policy"` + FallbackReasons []string `json:"fallback_reasons"` +} + +var doctorIdentifierRE = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$`) +var doctorProviderRE = regexp.MustCompile(`^[a-z0-9]{1,32}$`) + +func (s *Service) DoctorGlobal(ctx context.Context) GlobalDoctorReport { + report := GlobalDoctorReport{ + Store: DoctorCheck{Status: "ok"}, + Runtime: DoctorCheck{Status: "ok"}, + Providers: make([]ProviderDoctor, 0), + Profiles: make([]ProfileDoctor, 0), + } + cfg, err := s.loadDoctorConfig() + if err != nil { + report.Store.Status = "invalid" + } else { + report.Store.Version = cfg.SchemaVersion + if cfg.ReadOnly { + report.Store.Status = "read_only" + } + for name, profile := range cfg.Profiles { + status := "ok" + safeName := safeDoctorProfile(name) + if safeName == "redacted" || store.ValidateProfile(profile) != nil { + status = "invalid" + } + report.Profiles = append(report.Profiles, ProfileDoctor{Name: safeName, Status: status}) + } + sort.Slice(report.Profiles, func(i, j int) bool { return report.Profiles[i].Name < report.Profiles[j].Name }) + } + client := session.NewClient() + runtimeCount, runtimeErr := client.RuntimeCount(ctx) + if runtimeErr != nil { + report.Runtime.Status = "unavailable" + } else { + report.Runtime.Count = runtimeCount + } + for _, provider := range s.Registry.Enabled() { + name := safeDoctorProvider(provider.Name()) + entry := ProviderDoctor{Name: name, Status: "available"} + if name == "redacted" { + entry.Status = "invalid" + } + if policyProvider, ok := provider.(adapter.TerminalPolicyAdapter); ok { + policy := policyProvider.TerminalPolicy() + if policy.Validate() == nil { + entry.OuterScreen = string(policy.OuterScreen) + entry.KeyProtocol = string(policy.KeyProtocol) + } + } + report.Providers = append(report.Providers, entry) + } + for name := range s.Registry.DisabledReasons() { + report.Providers = append(report.Providers, ProviderDoctor{Name: safeDoctorProvider(name), Status: "unavailable"}) + } + sort.Slice(report.Providers, func(i, j int) bool { return report.Providers[i].Name < report.Providers[j].Name }) + return report +} + +func (s *Service) DoctorSession(ctx context.Context, id string) (SessionDoctorReport, error) { + cfg, err := s.loadDoctorConfig() + if err != nil { + return SessionDoctorReport{}, errors.New("diagnostic store is invalid") + } + _, record, err := exactSessionRecord(&cfg, id) + if err != nil { + return SessionDoctorReport{}, err + } + report := SessionDoctorReport{ + SessionID: safeDoctorIdentifier(record.ID), SessionName: safeDoctorSessionName(record.SessionName), + Provider: safeDoctorProvider(record.Agent), RuntimeStatus: "ok", + SelectedProfile: safeDoctorProfile(record.Profile), EffectiveProfile: "none", + FallbackReasons: make([]string, 0), + Runtime: session.RuntimeDiagnostic{Protocols: []int{1, 2}}, + } + if record.Profile == "" { + report.SelectedProfile = "default" + } else if _, exists := cfg.Profiles[record.Profile]; !exists { + report.SelectedProfile = "redacted" + } + if report.Provider == "redacted" { + report.RuntimeStatus = "provider_invalid" + return report, nil + } + provider, ok := s.Registry.Get(record.Agent) + if !ok { + report.Provider = "unavailable" + report.RuntimeStatus = "provider_unavailable" + return report, nil + } + policyProvider, ok := provider.(adapter.TerminalPolicyAdapter) + if !ok { + report.RuntimeStatus = "policy_unavailable" + return report, nil + } + policy := policyProvider.TerminalPolicy() + report.ProviderPolicy = ProviderPolicyDoctor{ + OuterScreen: string(policy.OuterScreen), KeyProtocol: string(policy.KeyProtocol), + } + effective, resolveErr := ResolveProfilePolicy(ResolutionInput{ + Config: cfg, Session: record, ProviderPolicy: policy, + }) + if resolveErr != nil { + report.RuntimeStatus = "profile_invalid" + return report, nil + } + if effective.SelectedProfile() != "" { + report.EffectiveProfile = safeDoctorProfile(effective.SelectedProfile()) + } + for _, diagnostic := range effective.Diagnostics() { + report.FallbackReasons = append(report.FallbackReasons, diagnostic.Code) + } + source := s.SessionDiagnostics + if source == nil { + source = session.NewClient() + } + runtime, runtimeErr := source.Doctor(ctx, record.SessionName) + if runtimeErr != nil { + report.RuntimeStatus = "stale" + } else { + report.Runtime = runtime + } + return report, nil +} + +func (s *Service) loadDoctorConfig() (store.Config, error) { + if s.Store == nil { + return store.Config{}, errors.New("diagnostic store is unavailable") + } + data, err := os.ReadFile(s.Store.Path()) + if errors.Is(err, os.ErrNotExist) { + return store.DefaultConfig(), nil + } + if err != nil { + return store.Config{}, fmt.Errorf("read diagnostic store: %w", err) + } + var cfg store.Config + if err := json.Unmarshal(data, &cfg); err != nil { + return store.Config{}, errors.New("parse diagnostic store") + } + return cfg, nil +} + +func safeDoctorIdentifier(value string) string { + if value == "" { + return "unavailable" + } + if !doctorIdentifierRE.MatchString(value) || doctorSensitive(value) { + return "redacted" + } + return value +} + +func safeDoctorSessionName(value string) string { + if value == "" { + return "unavailable" + } + if session.ValidateName(value) != nil || doctorSensitive(value) { + return "redacted" + } + return value +} + +func safeDoctorProvider(value string) string { + if value == "" { + return "unavailable" + } + if !doctorProviderRE.MatchString(value) || doctorSensitive(value) { + return "redacted" + } + return value +} + +func safeDoctorProfile(value string) string { + if value == "" { + return "unavailable" + } + if store.ValidateProfileName(value) != nil || doctorSensitive(value) { + return "redacted" + } + return value +} + +func doctorSensitive(value string) bool { + lower := strings.ToLower(value) + for _, marker := range []string{"auth", "credential", "input", "output", "password", "private", "secret", "token"} { + if strings.Contains(lower, marker) { + return true + } + } + return false +} diff --git a/internal/app/profile_activation_test.go b/internal/app/profile_activation_test.go new file mode 100644 index 0000000..586b6e0 --- /dev/null +++ b/internal/app/profile_activation_test.go @@ -0,0 +1,96 @@ +package app + +import ( + "testing" + + "github.com/RandomCodeSpace/unified-agent-manager/internal/store" +) + +func TestRunningAttachKeepsNegotiatedSnapshot(t *testing.T) { + cfg := configWithAttachmentProfile("C-a", false) + first, err := ResolveProfilePolicy(ResolutionInput{Config: cfg, Session: store.SessionRecord{Agent: "claude"}, ProviderPolicy: claudeTerminalPolicy()}) + if err != nil { + t.Fatal(err) + } + running, err := first.NewAttachment(ClientTemporaryOverride{}, allClientCapabilities()) + if err != nil { + t.Fatal(err) + } + updated := cfg.Profiles["attach"] + updated.ControlPrefix = pointer("C-z") + updated.BackDetach = pointer(true) + cfg.Profiles["attach"] = updated + if _, err := ResolveProfilePolicy(ResolutionInput{Config: cfg, Session: store.SessionRecord{Agent: "claude"}, ProviderPolicy: claudeTerminalPolicy()}); err != nil { + t.Fatal(err) + } + + if running.ControlPrefix() != "C-a" || running.BackDetach() { + t.Fatalf("running attachment snapshot mutated: prefix=%q back_detach=%v", running.ControlPrefix(), running.BackDetach()) + } +} + +func TestReconnectUsesUpdatedProfile(t *testing.T) { + cfg := configWithAttachmentProfile("C-a", false) + initial, err := ResolveProfilePolicy(ResolutionInput{Config: cfg, Session: store.SessionRecord{Agent: "claude"}, ProviderPolicy: claudeTerminalPolicy()}) + if err != nil { + t.Fatal(err) + } + oldAttachment, err := initial.NewAttachment(ClientTemporaryOverride{}, allClientCapabilities()) + if err != nil { + t.Fatal(err) + } + profile := cfg.Profiles["attach"] + profile.ControlPrefix = pointer("C-z") + profile.BackDetach = pointer(true) + cfg.Profiles["attach"] = profile + updated, err := ResolveProfilePolicy(ResolutionInput{Config: cfg, Session: store.SessionRecord{Agent: "claude"}, ProviderPolicy: claudeTerminalPolicy()}) + if err != nil { + t.Fatal(err) + } + reconnected, err := updated.NewAttachment(ClientTemporaryOverride{}, allClientCapabilities()) + if err != nil { + t.Fatal(err) + } + + if oldAttachment.ControlPrefix() != "C-a" || oldAttachment.BackDetach() { + t.Fatalf("old attachment changed: %+v", oldAttachment) + } + if reconnected.ControlPrefix() != "C-z" || !reconnected.BackDetach() { + t.Fatalf("reconnect missed update: %+v", reconnected) + } +} + +func TestLaunchOnlyProfileChangesWaitForResume(t *testing.T) { + cfg := store.DefaultConfig() + cfg.DefaultProfile = "launch" + cfg.Profiles["launch"] = completeProfile("claude", store.ModeSafe, "old-alias", store.MousePolicyAuto, "C-b", true, 4000) + initial, err := ResolveProfilePolicy(ResolutionInput{Config: cfg, Session: store.SessionRecord{Agent: "claude"}, ProviderPolicy: claudeTerminalPolicy()}) + if err != nil { + t.Fatal(err) + } + runningHost := initial.LaunchSnapshot() + profile := cfg.Profiles["launch"] + profile.Mode = pointer(store.ModeYolo) + profile.CommandAlias = pointer("new-alias") + profile.ScrollbackLines = pointer(7000) + cfg.Profiles["launch"] = profile + updated, err := ResolveProfilePolicy(ResolutionInput{Config: cfg, Session: store.SessionRecord{Agent: "claude"}, ProviderPolicy: claudeTerminalPolicy()}) + if err != nil { + t.Fatal(err) + } + resumedHost := updated.LaunchSnapshot() + + if runningHost.Mode() != store.ModeSafe || runningHost.CommandAlias() != "old-alias" || runningHost.ScrollbackLines() != 4000 { + t.Fatalf("running host snapshot mutated: mode=%q alias=%q scrollback=%d", runningHost.Mode(), runningHost.CommandAlias(), runningHost.ScrollbackLines()) + } + if resumedHost.Mode() != store.ModeYolo || resumedHost.CommandAlias() != "new-alias" || resumedHost.ScrollbackLines() != 7000 { + t.Fatalf("resume snapshot missed update: mode=%q alias=%q scrollback=%d", resumedHost.Mode(), resumedHost.CommandAlias(), resumedHost.ScrollbackLines()) + } +} + +func configWithAttachmentProfile(prefix string, backDetach bool) store.Config { + cfg := store.DefaultConfig() + cfg.DefaultProfile = "attach" + cfg.Profiles["attach"] = store.Profile{ControlPrefix: pointer(prefix), BackDetach: pointer(backDetach)} + return cfg +} diff --git a/internal/app/profile_launch_integration_test.go b/internal/app/profile_launch_integration_test.go new file mode 100644 index 0000000..ba6a79f --- /dev/null +++ b/internal/app/profile_launch_integration_test.go @@ -0,0 +1,138 @@ +package app + +import ( + "context" + "path/filepath" + "testing" + + "github.com/RandomCodeSpace/unified-agent-manager/internal/adapter" + "github.com/RandomCodeSpace/unified-agent-manager/internal/store" +) + +func TestDispatchNamedWithAliasUsesDefaultProfileWhenLaunchArgumentsUnset(t *testing.T) { + // Given + cfg := profileLaunchConfig("fake", "default", store.ModeSafe, "profile-alias", 8100) + svc, fake := newProfileLaunchService(t, cfg) + + // When + session, err := svc.DispatchNamedWithAlias(context.Background(), "fake", "", "profiled", "do work", t.TempDir(), "") + + // Then + if err != nil { + t.Fatal(err) + } + if fake.dispatched == nil || fake.dispatched.Mode != string(store.ModeSafe) || fake.dispatched.CommandAlias != "profile-alias" || fake.dispatched.ScrollbackLines != 8100 { + t.Fatalf("dispatch request = %+v, want default profile launch fields", fake.dispatched) + } + cfg, err = svc.Store.Load() + if err != nil { + t.Fatal(err) + } + record := cfg.Sessions[store.Key("fake", session.ID)] + if record.Mode != store.ModeSafe || record.CommandAlias != "profile-alias" { + t.Fatalf("persisted launch snapshot = mode=%q alias=%q", record.Mode, record.CommandAlias) + } +} + +func TestDispatchNamedWithAliasKeepsExplicitLaunchArgumentsOverProfile(t *testing.T) { + // Given + cfg := profileLaunchConfig("fake", "default", store.ModeSafe, "profile-alias", 8100) + svc, fake := newProfileLaunchService(t, cfg) + + // When + _, err := svc.DispatchNamedWithAlias(context.Background(), "fake", "explicit-alias", "profiled", "do work", t.TempDir(), string(store.ModeYolo)) + + // Then + if err != nil { + t.Fatal(err) + } + if fake.dispatched == nil || fake.dispatched.Mode != string(store.ModeYolo) || fake.dispatched.CommandAlias != "explicit-alias" { + t.Fatalf("dispatch request = %+v, want explicit launch fields", fake.dispatched) + } +} + +func TestDispatchNamedWithAliasRejectsIncompatibleDefaultProfileBeforeLaunch(t *testing.T) { + // Given + cfg := profileLaunchConfig("other", "default", store.ModeSafe, "profile-alias", 8100) + svc, fake := newProfileLaunchService(t, cfg) + + // When + _, err := svc.DispatchNamedWithAlias(context.Background(), "fake", "", "profiled", "do work", t.TempDir(), "") + + // Then + if err == nil { + t.Fatal("DispatchNamedWithAlias accepted a default profile for another provider") + } + if fake.dispatched != nil { + t.Fatalf("incompatible profile reached adapter dispatch: %+v", fake.dispatched) + } +} + +func TestResumeBackgroundUsesSelectedProfileAndSessionOverrides(t *testing.T) { + // Given + cfg := profileLaunchConfig("fake", "focused", store.ModeSafe, "profile-alias", 8100) + overrides := store.SessionProfileOverrides{ + Mode: pointer(store.ModeYolo), CommandAlias: pointer("session-alias"), ScrollbackLines: pointer(9100), + } + cfg.Sessions[store.Key("fake", "resume0001")] = store.SessionRecord{ + ID: "resume0001", Agent: "fake", Name: "profiled", Prompt: "resume work", Workdir: t.TempDir(), + SessionName: "uam-fake-resume0001", Mode: store.ModeSafe, CommandAlias: "legacy-alias", + Profile: "focused", ProfileOverrides: &overrides, Status: store.StatusActive, + } + svc, fake := newProfileLaunchService(t, cfg) + + // When + err := svc.ResumeBackground(context.Background(), "resume0001") + + // Then + if err != nil { + t.Fatal(err) + } + if fake.resumed == nil || fake.resumed.Mode != string(store.ModeYolo) || fake.resumed.CommandAlias != "session-alias" || fake.resumed.ScrollbackLines != 9100 { + t.Fatalf("resume request = %+v, want selected profile session overrides", fake.resumed) + } +} + +func TestResumeBackgroundRejectsIncompatibleSelectedProfileBeforeLaunch(t *testing.T) { + // Given + cfg := profileLaunchConfig("other", "focused", store.ModeSafe, "profile-alias", 8100) + cfg.Sessions[store.Key("fake", "resume0001")] = store.SessionRecord{ + ID: "resume0001", Agent: "fake", Workdir: t.TempDir(), SessionName: "uam-fake-resume0001", + Profile: "focused", Status: store.StatusActive, + } + svc, fake := newProfileLaunchService(t, cfg) + + // When + err := svc.ResumeBackground(context.Background(), "resume0001") + + // Then + if err == nil { + t.Fatal("ResumeBackground accepted a selected profile for another provider") + } + if fake.resumed != nil { + t.Fatalf("incompatible profile reached adapter resume: %+v", fake.resumed) + } +} + +func newProfileLaunchService(t *testing.T, cfg store.Config) (*Service, *svcFakeAdapter) { + t.Helper() + persistentStore, err := store.Open(filepath.Join(t.TempDir(), "sessions.json")) + if err != nil { + t.Fatal(err) + } + if err := persistentStore.Save(cfg); err != nil { + t.Fatal(err) + } + fake := &svcFakeAdapter{name: "fake", available: true, resumeKind: adapter.ResumeExact} + return NewService(persistentStore, adapter.NewRegistry([]adapter.AgentAdapter{fake})), fake +} + +func profileLaunchConfig(provider, profileName string, mode store.Mode, alias string, scrollback int) store.Config { + cfg := store.DefaultConfig() + cfg.DefaultAgent = "fake" + cfg.DefaultProfile = profileName + cfg.Profiles[profileName] = store.Profile{ + Provider: pointer(provider), Mode: pointer(mode), CommandAlias: pointer(alias), ScrollbackLines: pointer(scrollback), + } + return cfg +} diff --git a/internal/app/profile_policy.go b/internal/app/profile_policy.go new file mode 100644 index 0000000..0eb1257 --- /dev/null +++ b/internal/app/profile_policy.go @@ -0,0 +1,131 @@ +package app + +import ( + "github.com/RandomCodeSpace/unified-agent-manager/internal/adapter" + "github.com/RandomCodeSpace/unified-agent-manager/internal/store" +) + +const ( + fixedTERM = "xterm-256color" + defaultControlPrefix = "C-b" + defaultScrollbackLines = 4000 + DiagnosticProfileFallback = "profile_fallback" +) + +type ResolutionInput struct { + Config store.Config + Session store.SessionRecord + ProviderPolicy adapter.ProviderTerminalPolicy +} + +type ClientTemporaryOverride struct { + Mouse *store.MousePolicy `json:"-"` + ControlPrefix *string `json:"-"` + BackDetach *bool `json:"-"` +} + +type ClientCapabilities struct { + FramedOutput bool `json:"-"` + RoleEvents bool `json:"-"` + LocalMouseFilter bool `json:"-"` + OwnedScreen bool `json:"-"` +} + +type PolicyDiagnostic struct { + Code string + Profile string + Scope string +} + +type EffectivePolicy struct { + launch LaunchPolicySnapshot + attachment attachmentDefaults + diagnostics []PolicyDiagnostic + profile string +} + +type LaunchPolicySnapshot struct { + provider string + mode store.Mode + commandAlias string + scrollbackLines int + term string + providerPolicy adapter.ProviderTerminalPolicy +} + +type AttachmentPolicySnapshot struct { + mouse store.MousePolicy + controlPrefix string + backDetach bool + mouseFiltered bool + ownsOuterScreen bool + capabilities ClientCapabilities +} + +type attachmentDefaults struct { + mouse store.MousePolicy + controlPrefix string + backDetach bool + outerScreen adapter.OuterScreenPolicy +} + +func (p EffectivePolicy) LaunchSnapshot() LaunchPolicySnapshot { return p.launch } + +func (p EffectivePolicy) SelectedProfile() string { return p.profile } + +func (p EffectivePolicy) Diagnostics() []PolicyDiagnostic { + return append([]PolicyDiagnostic(nil), p.diagnostics...) +} + +func (p EffectivePolicy) NewAttachment(temporary ClientTemporaryOverride, capabilities ClientCapabilities) (AttachmentPolicySnapshot, error) { + overrides := store.SessionProfileOverrides{ + Mouse: temporary.Mouse, ControlPrefix: temporary.ControlPrefix, BackDetach: temporary.BackDetach, + } + if err := store.ValidateSessionProfileOverrides(overrides); err != nil { + return AttachmentPolicySnapshot{}, err + } + mouse := p.attachment.mouse + controlPrefix := p.attachment.controlPrefix + backDetach := p.attachment.backDetach + if temporary.Mouse != nil { + mouse = *temporary.Mouse + } + if temporary.ControlPrefix != nil { + controlPrefix = *temporary.ControlPrefix + } + if temporary.BackDetach != nil { + backDetach = *temporary.BackDetach + } + return AttachmentPolicySnapshot{ + mouse: mouse, controlPrefix: controlPrefix, backDetach: backDetach, + mouseFiltered: mouse == store.MousePolicyOff && capabilities.LocalMouseFilter, + ownsOuterScreen: p.attachment.outerScreen == adapter.OuterScreenUAM && capabilities.OwnedScreen, + capabilities: capabilities, + }, nil +} + +func (p LaunchPolicySnapshot) Provider() string { return p.provider } + +func (p LaunchPolicySnapshot) Mode() store.Mode { return p.mode } + +func (p LaunchPolicySnapshot) CommandAlias() string { return p.commandAlias } + +func (p LaunchPolicySnapshot) ScrollbackLines() int { return p.scrollbackLines } + +func (p LaunchPolicySnapshot) TERM() string { return p.term } + +func (p LaunchPolicySnapshot) ProviderPolicy() adapter.ProviderTerminalPolicy { + return p.providerPolicy +} + +func (p AttachmentPolicySnapshot) Mouse() store.MousePolicy { return p.mouse } + +func (p AttachmentPolicySnapshot) ControlPrefix() string { return p.controlPrefix } + +func (p AttachmentPolicySnapshot) BackDetach() bool { return p.backDetach } + +func (p AttachmentPolicySnapshot) MouseFiltered() bool { return p.mouseFiltered } + +func (p AttachmentPolicySnapshot) OwnsOuterScreen() bool { return p.ownsOuterScreen } + +func (p AttachmentPolicySnapshot) Capabilities() ClientCapabilities { return p.capabilities } diff --git a/internal/app/profile_resolver.go b/internal/app/profile_resolver.go new file mode 100644 index 0000000..6dfc7c1 --- /dev/null +++ b/internal/app/profile_resolver.go @@ -0,0 +1,180 @@ +package app + +import ( + "fmt" + + "github.com/RandomCodeSpace/unified-agent-manager/internal/adapter" + "github.com/RandomCodeSpace/unified-agent-manager/internal/log" + "github.com/RandomCodeSpace/unified-agent-manager/internal/store" +) + +func ResolveProfilePolicy(input ResolutionInput) (EffectivePolicy, error) { + policy := EffectivePolicy{ + launch: LaunchPolicySnapshot{ + provider: input.Config.DefaultAgent, mode: store.ModeYolo, + scrollbackLines: defaultScrollbackLines, term: fixedTERM, + }, + attachment: attachmentDefaults{ + mouse: store.MousePolicyAuto, controlPrefix: defaultControlPrefix, backDetach: true, + }, + } + if policy.launch.provider == "" { + policy.launch.provider = store.DefaultAgentName + } + if input.Session.Agent != "" { + policy.launch.provider = input.Session.Agent + } + + selected, err := applySelectedProfile(&policy, input) + if err != nil { + return EffectivePolicy{}, err + } + if err := validateProviderPolicy(input.ProviderPolicy, policy.launch.provider); err != nil { + return EffectivePolicy{}, err + } + policy.launch.providerPolicy = input.ProviderPolicy + policy.attachment.outerScreen = input.ProviderPolicy.OuterScreen + + if !selected { + err = applyLegacySessionDefaults(&policy, input.Session) + } + if err != nil { + return EffectivePolicy{}, err + } + reason := "legacy" + fallback := "" + profile := policy.profile + layers := "global" + if input.Session.Agent != "" { + layers += ",session" + } + if profile != "" { + reason = "default_profile" + if input.Session.Profile != "" { + reason = "session_profile" + } + layers += ",profile" + if input.Session.ProfileOverrides != nil { + layers += ",session_override" + } + } + if len(policy.diagnostics) > 0 { + reason = policy.diagnostics[0].Code + fallback = "legacy" + profile = "redacted" + } + log.Diagnostic(log.DiagnosticEvent{ + Event: "profile.resolution", Session: input.Session.SessionName, Reason: reason, + Provider: policy.launch.provider, Profile: profile, Policy: layers, Fallback: fallback, + }) + if input.ProviderPolicy.OuterScreen == adapter.OuterScreenPrimary { + log.Diagnostic(log.DiagnosticEvent{ + Event: "provider.exception", Session: input.Session.SessionName, + Reason: "provider_primary_screen", Provider: policy.launch.provider, Policy: string(input.ProviderPolicy.OuterScreen), + }) + } + return policy, nil +} + +func applySelectedProfile(policy *EffectivePolicy, input ResolutionInput) (bool, error) { + profileName := input.Session.Profile + profileScope := "session" + if profileName == "" { + profileName = input.Config.DefaultProfile + profileScope = "default" + } + profile, selected, err := selectedProfile(input.Config, profileName) + if err != nil { + return false, err + } + if !selected { + if profileName != "" { + policy.diagnostics = []PolicyDiagnostic{{Code: DiagnosticProfileFallback, Profile: profileName, Scope: profileScope}} + } + return false, nil + } + if profile.Provider != nil && input.Session.Agent != "" && input.Session.Agent != *profile.Provider { + return false, fmt.Errorf("profile %q provider %q is incompatible with session provider %q", profileName, *profile.Provider, input.Session.Agent) + } + policy.profile = profileName + if profile.Provider != nil { + policy.launch.provider = *profile.Provider + } + applyProfile(policy, profile) + if input.Session.ProfileOverrides == nil { + return true, nil + } + if err := store.ValidateSessionProfileOverrides(*input.Session.ProfileOverrides); err != nil { + return false, fmt.Errorf("session profile overrides: %w", err) + } + applySessionOverrides(policy, *input.Session.ProfileOverrides) + return true, nil +} + +func selectedProfile(cfg store.Config, name string) (store.Profile, bool, error) { + if name == "" { + return store.Profile{}, false, nil + } + profile, found := cfg.Profiles[name] + if !found { + return store.Profile{}, false, nil + } + if err := store.ValidateProfile(profile); err != nil { + return store.Profile{}, false, fmt.Errorf("profile %q: %w", name, err) + } + return profile, true, nil +} + +func validateProviderPolicy(policy adapter.ProviderTerminalPolicy, provider string) error { + if err := policy.Validate(); err != nil { + return err + } + if string(policy.Identity) != provider { + return fmt.Errorf("provider terminal policy identity %q does not match resolved provider %q", policy.Identity, provider) + } + return nil +} + +func applyLegacySessionDefaults(policy *EffectivePolicy, session store.SessionRecord) error { + if session.Mode == store.ModeSafe || session.Mode == store.ModeYolo { + policy.launch.mode = session.Mode + } + if session.CommandAlias == "" { + return nil + } + overrides := store.SessionProfileOverrides{CommandAlias: &session.CommandAlias} + if err := store.ValidateSessionProfileOverrides(overrides); err != nil { + return fmt.Errorf("legacy session defaults: %w", err) + } + policy.launch.commandAlias = session.CommandAlias + return nil +} + +func applyProfile(policy *EffectivePolicy, profile store.Profile) { + if profile.Mode != nil { + policy.launch.mode = *profile.Mode + } + if profile.CommandAlias != nil { + policy.launch.commandAlias = *profile.CommandAlias + } + if profile.Mouse != nil { + policy.attachment.mouse = *profile.Mouse + } + if profile.ControlPrefix != nil { + policy.attachment.controlPrefix = *profile.ControlPrefix + } + if profile.BackDetach != nil { + policy.attachment.backDetach = *profile.BackDetach + } + if profile.ScrollbackLines != nil { + policy.launch.scrollbackLines = *profile.ScrollbackLines + } +} + +func applySessionOverrides(policy *EffectivePolicy, overrides store.SessionProfileOverrides) { + applyProfile(policy, store.Profile{ + Mode: overrides.Mode, CommandAlias: overrides.CommandAlias, Mouse: overrides.Mouse, + ControlPrefix: overrides.ControlPrefix, BackDetach: overrides.BackDetach, + ScrollbackLines: overrides.ScrollbackLines, + }) +} diff --git a/internal/app/profile_resolver_test.go b/internal/app/profile_resolver_test.go new file mode 100644 index 0000000..eb8110d --- /dev/null +++ b/internal/app/profile_resolver_test.go @@ -0,0 +1,106 @@ +package app + +import ( + "testing" + + "github.com/RandomCodeSpace/unified-agent-manager/internal/adapter" + "github.com/RandomCodeSpace/unified-agent-manager/internal/store" +) + +func TestProfilePrecedenceAndExplicitFalse(t *testing.T) { + profile := completeProfile("claude", store.ModeSafe, "profile-alias", store.MousePolicyOff, "C-a", true, 8000) + overrides := completeSessionOverrides(store.ModeYolo, "session-alias", store.MousePolicyOn, "C-c", false, 9000) + cfg := store.DefaultConfig() + cfg.DefaultProfile = "global" + cfg.Profiles["global"] = completeProfile("opencode", store.ModeSafe, "global-alias", store.MousePolicyAuto, "C-b", true, 4000) + cfg.Profiles["focused"] = profile + record := store.SessionRecord{Agent: "claude", Profile: "focused", ProfileOverrides: &overrides} + + effective, err := ResolveProfilePolicy(ResolutionInput{ + Config: cfg, Session: record, ProviderPolicy: claudeTerminalPolicy(), + }) + if err != nil { + t.Fatal(err) + } + attachment, err := effective.NewAttachment(ClientTemporaryOverride{ + Mouse: pointer(store.MousePolicyOff), ControlPrefix: pointer("C-d"), + }, allClientCapabilities()) + if err != nil { + t.Fatal(err) + } + + launch := effective.LaunchSnapshot() + if launch.Provider() != "claude" || launch.Mode() != store.ModeYolo || launch.CommandAlias() != "session-alias" || launch.ScrollbackLines() != 9000 { + t.Fatalf("launch precedence = provider=%q mode=%q alias=%q scrollback=%d", launch.Provider(), launch.Mode(), launch.CommandAlias(), launch.ScrollbackLines()) + } + if attachment.Mouse() != store.MousePolicyOff || attachment.ControlPrefix() != "C-d" || attachment.BackDetach() { + t.Fatalf("attachment precedence = mouse=%q prefix=%q back_detach=%v", attachment.Mouse(), attachment.ControlPrefix(), attachment.BackDetach()) + } + if !attachment.MouseFiltered() || !attachment.OwnsOuterScreen() { + t.Fatalf("capability-constrained behavior = filter=%v own_screen=%v", attachment.MouseFiltered(), attachment.OwnsOuterScreen()) + } + temporaryEnabled, err := effective.NewAttachment(ClientTemporaryOverride{BackDetach: pointer(true)}, allClientCapabilities()) + if err != nil { + t.Fatal(err) + } + if !temporaryEnabled.BackDetach() { + t.Fatal("client-local temporary override did not win over explicit session false") + } +} + +func TestCapabilitiesConstrainResolvedPolicy(t *testing.T) { + cfg := store.DefaultConfig() + record := store.SessionRecord{Agent: "claude"} + effective, err := ResolveProfilePolicy(ResolutionInput{Config: cfg, Session: record, ProviderPolicy: claudeTerminalPolicy()}) + if err != nil { + t.Fatal(err) + } + temporary := ClientTemporaryOverride{Mouse: pointer(store.MousePolicyOff)} + + limited, err := effective.NewAttachment(temporary, ClientCapabilities{}) + if err != nil { + t.Fatal(err) + } + capable, err := effective.NewAttachment(temporary, allClientCapabilities()) + if err != nil { + t.Fatal(err) + } + auto, err := effective.NewAttachment(ClientTemporaryOverride{}, allClientCapabilities()) + if err != nil { + t.Fatal(err) + } + + if limited.Mouse() != store.MousePolicyOff || limited.MouseFiltered() || limited.OwnsOuterScreen() { + t.Fatalf("missing capabilities overrode policy: %+v", limited) + } + if !capable.MouseFiltered() || !capable.OwnsOuterScreen() { + t.Fatalf("supported constraints not applied: %+v", capable) + } + if auto.MouseFiltered() { + t.Fatal("capability enabled behavior that policy did not request") + } +} + +func completeProfile(provider string, mode store.Mode, alias string, mouse store.MousePolicy, prefix string, backDetach bool, scrollback int) store.Profile { + return store.Profile{ + Provider: pointer(provider), Mode: pointer(mode), CommandAlias: pointer(alias), Mouse: pointer(mouse), + ControlPrefix: pointer(prefix), BackDetach: pointer(backDetach), ScrollbackLines: pointer(scrollback), + } +} + +func completeSessionOverrides(mode store.Mode, alias string, mouse store.MousePolicy, prefix string, backDetach bool, scrollback int) store.SessionProfileOverrides { + return store.SessionProfileOverrides{ + Mode: pointer(mode), CommandAlias: pointer(alias), Mouse: pointer(mouse), ControlPrefix: pointer(prefix), + BackDetach: pointer(backDetach), ScrollbackLines: pointer(scrollback), + } +} + +func claudeTerminalPolicy() adapter.ProviderTerminalPolicy { + return adapter.ProviderTerminalPolicy{Identity: adapter.ProviderClaude, OuterScreen: adapter.OuterScreenUAM, KeyProtocol: adapter.KeyProtocolNative} +} + +func allClientCapabilities() ClientCapabilities { + return ClientCapabilities{FramedOutput: true, RoleEvents: true, LocalMouseFilter: true, OwnedScreen: true} +} + +func pointer[T any](value T) *T { return &value } diff --git a/internal/app/profile_service.go b/internal/app/profile_service.go new file mode 100644 index 0000000..3682ce5 --- /dev/null +++ b/internal/app/profile_service.go @@ -0,0 +1,204 @@ +package app + +import ( + "context" + "fmt" + "sort" + + "github.com/RandomCodeSpace/unified-agent-manager/internal/store" +) + +func (s *Service) UpdateProfile(name string, mutate func(*store.Profile) error) error { + if s.Store == nil { + return fmt.Errorf("profile store unavailable") + } + if err := store.ValidateProfileName(name); err != nil { + return err + } + return s.Store.Update(func(cfg *store.Config) error { + profile := cfg.Profiles[name] + if err := mutate(&profile); err != nil { + return err + } + if err := store.ValidateProfile(profile); err != nil { + return err + } + cfg.Profiles[name] = profile + return nil + }) +} + +func (s *Service) RemoveProfile(name string) error { + if s.Store == nil { + return fmt.Errorf("profile store unavailable") + } + if err := store.ValidateProfileName(name); err != nil { + return err + } + return s.Store.Update(func(cfg *store.Config) error { + if _, exists := cfg.Profiles[name]; !exists { + return fmt.Errorf("profile %q not found", name) + } + referenced := &ProfileReferencedError{Name: name, Default: cfg.DefaultProfile == name} + for _, record := range cfg.Sessions { + if record.Profile == name { + referenced.SessionIDs = append(referenced.SessionIDs, record.ID) + } + } + sort.Strings(referenced.SessionIDs) + if referenced.Default || len(referenced.SessionIDs) > 0 { + return referenced + } + delete(cfg.Profiles, name) + return nil + }) +} + +func (s *Service) SetDefaultProfile(name string) error { + if s.Store == nil { + return fmt.Errorf("profile store unavailable") + } + return s.Store.Update(func(cfg *store.Config) error { + if name == "" || name == "none" { + cfg.DefaultProfile = "" + return nil + } + if err := store.ValidateProfileName(name); err != nil { + return err + } + if _, exists := cfg.Profiles[name]; !exists { + return fmt.Errorf("profile %q not found", name) + } + cfg.DefaultProfile = name + return nil + }) +} + +func exactSessionRecord(cfg *store.Config, id string) (string, store.SessionRecord, error) { + var foundKey string + var found store.SessionRecord + for key, record := range cfg.Sessions { + if record.ID != id && record.SessionName != id { + continue + } + if foundKey != "" { + return "", store.SessionRecord{}, fmt.Errorf("session %q is ambiguous", id) + } + foundKey, found = key, record + } + if foundKey == "" { + return "", store.SessionRecord{}, fmt.Errorf("session %q not found", id) + } + return foundKey, found, nil +} + +func (s *Service) AssignProfileExact(id, name string) error { + if s.Store == nil { + return fmt.Errorf("profile store unavailable") + } + return s.Store.Update(func(cfg *store.Config) error { + key, record, err := exactSessionRecord(cfg, id) + if err != nil { + return err + } + if name == "" || name == "none" { + record.Profile = "" + cfg.Sessions[key] = record + return nil + } + if err := store.ValidateProfileName(name); err != nil { + return err + } + profile, exists := cfg.Profiles[name] + if !exists { + return fmt.Errorf("profile %q not found", name) + } + if profile.Provider != nil && *profile.Provider != record.Agent { + return fmt.Errorf("profile %q provider %q conflicts with session provider %q", name, *profile.Provider, record.Agent) + } + record.Profile = name + cfg.Sessions[key] = record + return nil + }) +} + +func (s *Service) UpdateProfileOverridesExact(id, provider string, mutate func(*store.SessionProfileOverrides) error) error { + if s.Store == nil { + return fmt.Errorf("profile store unavailable") + } + return s.Store.Update(func(cfg *store.Config) error { + key, record, err := exactSessionRecord(cfg, id) + if err != nil { + return err + } + if provider != "" && provider != record.Agent { + return fmt.Errorf("provider %q conflicts with session provider %q", provider, record.Agent) + } + overrides := store.SessionProfileOverrides{} + if record.ProfileOverrides != nil { + overrides = *record.ProfileOverrides + } + if err := mutate(&overrides); err != nil { + return err + } + if err := store.ValidateSessionProfileOverrides(overrides); err != nil { + return err + } + if emptyProfileOverrides(overrides) { + record.ProfileOverrides = nil + } else { + record.ProfileOverrides = &overrides + } + cfg.Sessions[key] = record + return nil + }) +} + +func (s *Service) EffectiveProfileExact(ctx context.Context, id string) (EffectiveProfileView, error) { + if s.Store == nil { + return EffectiveProfileView{}, fmt.Errorf("profile store unavailable") + } + cfg, err := s.Store.Load() + if err != nil { + return EffectiveProfileView{}, err + } + _, record, err := exactSessionRecord(&cfg, id) + if err != nil { + return EffectiveProfileView{}, err + } + provider, ok := s.Registry.Get(record.Agent) + if !ok { + return EffectiveProfileView{}, fmt.Errorf("agent %q unavailable", record.Agent) + } + launch, err := resolveLaunchPolicy(cfg, provider, record) + if err != nil { + return EffectiveProfileView{}, err + } + effective, err := ResolveProfilePolicy(ResolutionInput{Config: cfg, Session: record, ProviderPolicy: launch.ProviderPolicy()}) + if err != nil { + return EffectiveProfileView{}, err + } + attachment, err := effective.NewAttachment(ClientTemporaryOverride{}, ClientCapabilities{}) + if err != nil { + return EffectiveProfileView{}, err + } + selected := record.Profile + if selected == "" { + selected = "default" + } + profile := effective.SelectedProfile() + if profile == "" { + profile = "none" + } + _ = ctx + return EffectiveProfileView{ + SessionID: record.ID, Provider: launch.Provider(), SelectedProfile: selected, EffectiveProfile: profile, + Mode: launch.Mode(), CommandAlias: launch.CommandAlias(), Mouse: attachment.Mouse(), + ControlPrefix: attachment.ControlPrefix(), BackDetach: attachment.BackDetach(), ScrollbackLines: launch.ScrollbackLines(), + }, nil +} + +func emptyProfileOverrides(overrides store.SessionProfileOverrides) bool { + return overrides.Mode == nil && overrides.CommandAlias == nil && overrides.Mouse == nil && + overrides.ControlPrefix == nil && overrides.BackDetach == nil && overrides.ScrollbackLines == nil +} diff --git a/internal/app/profile_service_errors_test.go b/internal/app/profile_service_errors_test.go new file mode 100644 index 0000000..1bb5c2e --- /dev/null +++ b/internal/app/profile_service_errors_test.go @@ -0,0 +1,144 @@ +package app + +import ( + "context" + "errors" + "strings" + "testing" + + "github.com/RandomCodeSpace/unified-agent-manager/internal/adapter" + "github.com/RandomCodeSpace/unified-agent-manager/internal/store" +) + +func TestProfileServiceRejectsUnsafeMutations(t *testing.T) { + t.Run("store unavailable", func(t *testing.T) { + // Given + svc := NewService(nil, adapter.NewRegistry(nil)) + + // When + errorsSeen := []error{ + svc.UpdateProfile("focused", func(*store.Profile) error { return nil }), + svc.RemoveProfile("focused"), + svc.SetDefaultProfile("focused"), + svc.AssignProfileExact("session-1", "focused"), + svc.UpdateProfileOverridesExact("session-1", "", func(*store.SessionProfileOverrides) error { return nil }), + } + _, effectiveErr := svc.EffectiveProfileExact(context.Background(), "session-1") + errorsSeen = append(errorsSeen, effectiveErr) + + // Then + for _, err := range errorsSeen { + if err == nil || !strings.Contains(err.Error(), "store unavailable") { + t.Fatalf("nil-store error = %v", err) + } + } + }) + + t.Run("invalid profile updates remain atomic", func(t *testing.T) { + // Given + cfg := store.DefaultConfig() + svc, _ := newProfileLaunchService(t, cfg) + mutateErr := errors.New("mutation rejected") + + // When + invalidNameErr := svc.UpdateProfile("../focused", func(*store.Profile) error { return nil }) + callbackErr := svc.UpdateProfile("focused", func(*store.Profile) error { return mutateErr }) + invalidValueErr := svc.UpdateProfile("focused", func(profile *store.Profile) error { + profile.Mode = pointer(store.Mode("turbo")) + return nil + }) + + // Then + if invalidNameErr == nil || !errors.Is(callbackErr, mutateErr) || invalidValueErr == nil { + t.Fatalf("update errors name=%v callback=%v value=%v", invalidNameErr, callbackErr, invalidValueErr) + } + loaded, err := svc.Store.Load() + if err != nil { + t.Fatal(err) + } + if len(loaded.Profiles) != 0 { + t.Fatalf("rejected updates persisted profiles: %+v", loaded.Profiles) + } + }) + + t.Run("references and missing profiles block mutation", func(t *testing.T) { + // Given + cfg := store.DefaultConfig() + cfg.DefaultProfile = "focused" + cfg.Profiles["focused"] = store.Profile{} + cfg.Sessions[store.Key("fake", "session-1")] = store.SessionRecord{ + ID: "session-1", Agent: "fake", Profile: "focused", + } + svc, _ := newProfileLaunchService(t, cfg) + + // When + referencedErr := svc.RemoveProfile("focused") + missingRemoveErr := svc.RemoveProfile("missing") + missingDefaultErr := svc.SetDefaultProfile("missing") + clearDefaultErr := svc.SetDefaultProfile("none") + + // Then + var referenced *ProfileReferencedError + if !errors.As(referencedErr, &referenced) || !referenced.Default || + len(referenced.SessionIDs) != 1 || referenced.SessionIDs[0] != "session-1" { + t.Fatalf("referenced error = %#v", referencedErr) + } + if missingRemoveErr == nil || missingDefaultErr == nil || clearDefaultErr != nil { + t.Fatalf("profile errors remove=%v default=%v clear=%v", missingRemoveErr, missingDefaultErr, clearDefaultErr) + } + }) + + t.Run("assignment and overrides enforce provider identity", func(t *testing.T) { + // Given + cfg := store.DefaultConfig() + cfg.Profiles["wrong-provider"] = store.Profile{Provider: pointer("claude")} + cfg.Sessions[store.Key("fake", "session-1")] = store.SessionRecord{ + ID: "session-1", Agent: "fake", + } + svc, _ := newProfileLaunchService(t, cfg) + mutateErr := errors.New("override rejected") + + // When + missingSessionErr := svc.AssignProfileExact("missing", "none") + missingProfileErr := svc.AssignProfileExact("session-1", "missing") + providerProfileErr := svc.AssignProfileExact("session-1", "wrong-provider") + providerOverrideErr := svc.UpdateProfileOverridesExact("session-1", "claude", func(*store.SessionProfileOverrides) error { + return nil + }) + callbackErr := svc.UpdateProfileOverridesExact("session-1", "", func(*store.SessionProfileOverrides) error { + return mutateErr + }) + invalidOverrideErr := svc.UpdateProfileOverridesExact("session-1", "", func(overrides *store.SessionProfileOverrides) error { + overrides.Mouse = pointer(store.MousePolicy("sometimes")) + return nil + }) + + // Then + if missingSessionErr == nil || missingProfileErr == nil || providerProfileErr == nil || + providerOverrideErr == nil || !errors.Is(callbackErr, mutateErr) || invalidOverrideErr == nil { + t.Fatalf( + "assignment/override errors missing-session=%v missing-profile=%v profile-provider=%v override-provider=%v callback=%v invalid=%v", + missingSessionErr, missingProfileErr, providerProfileErr, providerOverrideErr, callbackErr, invalidOverrideErr, + ) + } + }) + + t.Run("effective profile requires an available provider", func(t *testing.T) { + // Given + cfg := store.DefaultConfig() + cfg.Sessions[store.Key("missing", "session-1")] = store.SessionRecord{ + ID: "session-1", Agent: "missing", + } + svc, _ := newProfileLaunchService(t, cfg) + + // When + _, missingSessionErr := svc.EffectiveProfileExact(context.Background(), "unknown") + _, unavailableProviderErr := svc.EffectiveProfileExact(context.Background(), "session-1") + + // Then + if missingSessionErr == nil || unavailableProviderErr == nil || + !strings.Contains(unavailableProviderErr.Error(), "unavailable") { + t.Fatalf("effective errors missing=%v unavailable=%v", missingSessionErr, unavailableProviderErr) + } + }) +} diff --git a/internal/app/profile_surface_acceptance_test.go b/internal/app/profile_surface_acceptance_test.go new file mode 100644 index 0000000..0e7f9ab --- /dev/null +++ b/internal/app/profile_surface_acceptance_test.go @@ -0,0 +1,151 @@ +package app + +import ( + "context" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/RandomCodeSpace/unified-agent-manager/internal/adapter" + "github.com/RandomCodeSpace/unified-agent-manager/internal/store" +) + +func TestWizardProfileSelection(t *testing.T) { + // Given + persistentStore, err := store.Open(filepath.Join(t.TempDir(), "sessions.json")) + if err != nil { + t.Fatal(err) + } + cfg := store.DefaultConfig() + cfg.Profiles["focused"] = store.Profile{Mode: pointer(store.ModeSafe)} + if err := persistentStore.Save(cfg); err != nil { + t.Fatal(err) + } + m := NewWithDeps(persistentStore, adapter.NewRegistry([]adapter.AgentAdapter{&svcFakeAdapter{name: "fake", available: true}})) + m = m.handleSessionsLoaded(m.loadSessionsCmd()().(sessionsLoadedMsg)) + m.wizard = true + m.defaultAgent = "fake" + + // When + model, _ := m.handleWizardKey(keyMsg("shift+tab")) + m = model.(Model) + model, _ = m.handleWizardKey(keyMsg("enter")) + m = model.(Model) + + // Then + if view := m.renderWizard(); !strings.Contains(view, "profile") || !strings.Contains(view, "focused") { + t.Fatalf("profile selection step missing:\n%s", view) + } +} + +func TestSessionDetailsShowEffectiveProfile(t *testing.T) { + // Given + persistentStore, err := store.Open(filepath.Join(t.TempDir(), "sessions.json")) + if err != nil { + t.Fatal(err) + } + cfg := store.DefaultConfig() + cfg.DefaultAgent = "fake" + cfg.DefaultProfile = "focused" + cfg.Profiles["focused"] = store.Profile{Mode: pointer(store.ModeSafe)} + if err := persistentStore.Save(cfg); err != nil { + t.Fatal(err) + } + m := NewWithDeps(persistentStore, adapter.NewRegistry([]adapter.AgentAdapter{&svcFakeAdapter{name: "fake", available: true}})) + m = m.handleSessionsLoaded(m.loadSessionsCmd()().(sessionsLoadedMsg)) + m.width = 100 + m.sessions = []adapter.Session{{ID: "exact-session-id", AgentType: "fake", DisplayName: "work", Cwd: "/tmp", CreatedAt: time.Now()}} + + // When + out := m.renderDetails() + + // Then + if !strings.Contains(out, "selected: default") || !strings.Contains(out, "effective: focused") { + t.Fatalf("profile details missing:\n%s", out) + } +} + +func TestAttachSpecCarriesResolvedProfileSnapshot(t *testing.T) { + // Given + persistentStore, err := store.Open(filepath.Join(t.TempDir(), "sessions.json")) + if err != nil { + t.Fatal(err) + } + id := "profile-attach-id" + live := adapter.Session{ID: id, AgentType: "fake", SessionName: "uam-fake-abcdef12", ProcAlive: adapter.Alive} + cfg := store.DefaultConfig() + cfg.DefaultAgent = "fake" + cfg.DefaultProfile = "focused" + cfg.Profiles["focused"] = store.Profile{Mode: pointer(store.ModeSafe)} + cfg.Sessions[store.Key("fake", id)] = RecordFromSession(live, store.ModeYolo) + if err := persistentStore.Save(cfg); err != nil { + t.Fatal(err) + } + fake := &svcFakeAdapter{name: "fake", available: true, sessions: []adapter.Session{live}} + svc := NewService(persistentStore, adapter.NewRegistry([]adapter.AgentAdapter{fake})) + + // When + spec, err := svc.AttachSpecWithOptions(context.Background(), id, ResumeOptions{}) + + // Then + if err != nil { + t.Fatal(err) + } + if spec.Profile.Selected != "default" || spec.Profile.Effective != "focused" || spec.Profile.Mouse != "auto" || spec.Profile.ControlPrefix != "C-b" || !spec.Profile.BackDetach { + t.Fatalf("attach profile = %+v, want selected default, effective focused, and resolved terminal defaults", spec.Profile) + } +} + +func TestWizardProfileProviderBecomesDefault(t *testing.T) { + // Given: global and profile providers deliberately differ. + persistentStore, err := store.Open(filepath.Join(t.TempDir(), "sessions.json")) + if err != nil { + t.Fatal(err) + } + cfg := store.DefaultConfig() + cfg.DefaultAgent = "codex" + cfg.Profiles["claudeprof"] = store.Profile{Provider: pointer("claude")} + if err := persistentStore.Save(cfg); err != nil { + t.Fatal(err) + } + registry := adapter.NewRegistry([]adapter.AgentAdapter{ + &svcFakeAdapter{name: "codex", available: true}, + &svcFakeAdapter{name: "claude", available: true}, + }) + m := NewWithDeps(persistentStore, registry) + m = m.handleSessionsLoaded(m.loadSessionsCmd()().(sessionsLoadedMsg)) + m.wizard = true + m.defaultAgent = "codex" + m.wizardAgent = "codex" + + // When + model, _ := m.handleWizardKey(keyMsg("shift+tab")) + m = model.(Model) + + // Then + if m.wizardProfile != "claudeprof" || m.wizardAgent != "claude" { + t.Fatalf("wizard profile=%q provider=%q", m.wizardProfile, m.wizardAgent) + } +} + +func TestWizardExplicitProviderSurvivesProfileSelection(t *testing.T) { + // Given + m := Model{ + defaultAgent: "codex", + wizard: true, + wizardAgent: "codex", + wizardAgentExplicit: true, + profileNames: []string{"claudeprof"}, + profileProviders: map[string]string{"claudeprof": "claude"}, + } + + // When + model, _ := m.handleWizardKey(keyMsg("shift+tab")) + m = model.(Model) + + // Then + if m.wizardProfile != "claudeprof" || m.wizardAgent != "codex" { + t.Fatalf("wizard explicit provider lost: profile=%q provider=%q", m.wizardProfile, m.wizardAgent) + } +} diff --git a/internal/app/profile_validation_test.go b/internal/app/profile_validation_test.go new file mode 100644 index 0000000..5bdcf59 --- /dev/null +++ b/internal/app/profile_validation_test.go @@ -0,0 +1,209 @@ +package app + +import ( + "bytes" + "encoding/json" + "strings" + "testing" + + "github.com/RandomCodeSpace/unified-agent-manager/internal/adapter" + "github.com/RandomCodeSpace/unified-agent-manager/internal/store" +) + +func TestProfileCannotOverrideSafetyOrTerm(t *testing.T) { + unsafeDocuments := []string{ + `{"term":"xterm-kitty"}`, + `{"TERM":"screen-256color"}`, + `{"command":["sh","-c","id"]}`, + `{"env":{"TOKEN":"secret"}}`, + `{"allow_latest":true}`, + } + for _, document := range unsafeDocuments { + t.Run(document, func(t *testing.T) { + var profile store.Profile + if err := json.Unmarshal([]byte(document), &profile); err != nil { + t.Fatal(err) + } + cfg := store.DefaultConfig() + cfg.DefaultProfile = "unsafe" + cfg.Profiles["unsafe"] = profile + + _, err := ResolveProfilePolicy(ResolutionInput{Config: cfg, Session: store.SessionRecord{Agent: "claude"}, ProviderPolicy: claudeTerminalPolicy()}) + if err == nil || !strings.Contains(err.Error(), "prohibited") { + t.Fatalf("ResolveProfilePolicy error = %v, want prohibited override rejection", err) + } + }) + } + + effective, err := ResolveProfilePolicy(ResolutionInput{Config: store.DefaultConfig(), Session: store.SessionRecord{Agent: "claude"}, ProviderPolicy: claudeTerminalPolicy()}) + if err != nil { + t.Fatal(err) + } + if effective.LaunchSnapshot().TERM() != "xterm-256color" { + t.Fatalf("TERM = %q, want fixed safety invariant", effective.LaunchSnapshot().TERM()) + } + for _, document := range []string{`{"TERM":"screen-256color"}`, `{"env":{"TOKEN":"secret"}}`, `{"provider":"codex"}`, `{"allow_latest":true}`} { + var overrides store.SessionProfileOverrides + if err := json.Unmarshal([]byte(document), &overrides); err != nil { + t.Fatal(err) + } + cfg := store.DefaultConfig() + cfg.DefaultProfile = "safe" + cfg.Profiles["safe"] = store.Profile{} + _, err := ResolveProfilePolicy(ResolutionInput{ + Config: cfg, Session: store.SessionRecord{Agent: "claude", ProfileOverrides: &overrides}, ProviderPolicy: claudeTerminalPolicy(), + }) + if err == nil || !strings.Contains(err.Error(), "prohibited") { + t.Fatalf("session override %s error = %v, want prohibited override rejection", document, err) + } + } +} + +func TestProfileResolverRejectsMalformedValuesAndProviderMismatch(t *testing.T) { + tests := []struct { + name string + profile store.Profile + }{ + {name: "mode", profile: store.Profile{Mode: pointer(store.Mode("turbo"))}}, + {name: "mouse", profile: store.Profile{Mouse: pointer(store.MousePolicy("sometimes"))}}, + {name: "alias", profile: store.Profile{CommandAlias: pointer("unsafe alias")}}, + {name: "prefix uppercase", profile: store.Profile{ControlPrefix: pointer("C-A")}}, + {name: "prefix malformed", profile: store.Profile{ControlPrefix: pointer("Ctrl-a")}}, + {name: "scrollback low", profile: store.Profile{ScrollbackLines: pointer(99)}}, + {name: "scrollback high", profile: store.Profile{ScrollbackLines: pointer(100001)}}, + {name: "provider malformed", profile: store.Profile{Provider: pointer("../claude")}}, + {name: "provider mismatch", profile: store.Profile{Provider: pointer("codex")}}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + cfg := store.DefaultConfig() + cfg.DefaultProfile = "bad" + cfg.Profiles["bad"] = test.profile + _, err := ResolveProfilePolicy(ResolutionInput{Config: cfg, Session: store.SessionRecord{Agent: "claude"}, ProviderPolicy: claudeTerminalPolicy()}) + if err == nil { + t.Fatal("ResolveProfilePolicy unexpectedly accepted malformed profile") + } + }) + } +} + +func TestProfileResolverRejectsInvalidBuiltInProviderPolicy(t *testing.T) { + tests := []adapter.ProviderTerminalPolicy{ + {Identity: "../claude", OuterScreen: adapter.OuterScreenUAM, KeyProtocol: adapter.KeyProtocolNative}, + {Identity: adapter.ProviderClaude, OuterScreen: "unknown", KeyProtocol: adapter.KeyProtocolNative}, + {Identity: adapter.ProviderClaude, OuterScreen: adapter.OuterScreenUAM, KeyProtocol: "translated"}, + } + for _, policy := range tests { + _, err := ResolveProfilePolicy(ResolutionInput{Config: store.DefaultConfig(), Session: store.SessionRecord{Agent: "claude"}, ProviderPolicy: policy}) + if err == nil { + t.Fatalf("ResolveProfilePolicy accepted invalid built-in policy %+v", policy) + } + } +} + +func TestProfileProviderIsDispatchDefaultAndBoundsAreInclusive(t *testing.T) { + for _, scrollback := range []int{100, 100000} { + cfg := store.DefaultConfig() + cfg.DefaultProfile = "dispatch" + cfg.Profiles["dispatch"] = store.Profile{ + Provider: pointer("claude"), ControlPrefix: pointer("C-z"), ScrollbackLines: pointer(scrollback), + } + + effective, err := ResolveProfilePolicy(ResolutionInput{Config: cfg, ProviderPolicy: claudeTerminalPolicy()}) + if err != nil { + t.Fatal(err) + } + launch := effective.LaunchSnapshot() + if launch.Provider() != "claude" || launch.ScrollbackLines() != scrollback { + t.Fatalf("dispatch profile = provider=%q scrollback=%d", launch.Provider(), launch.ScrollbackLines()) + } + } +} + +func TestProfileUnsetFieldsInheritLegacyDefaults(t *testing.T) { + cfg := store.DefaultConfig() + cfg.DefaultProfile = "partial" + cfg.Profiles["partial"] = store.Profile{} + + effective, err := ResolveProfilePolicy(ResolutionInput{Config: cfg, Session: store.SessionRecord{Agent: "claude"}, ProviderPolicy: claudeTerminalPolicy()}) + if err != nil { + t.Fatal(err) + } + attachment, err := effective.NewAttachment(ClientTemporaryOverride{}, allClientCapabilities()) + if err != nil { + t.Fatal(err) + } + + launch := effective.LaunchSnapshot() + if launch.Mode() != store.ModeYolo || launch.CommandAlias() != "" || launch.ScrollbackLines() != 4000 { + t.Fatalf("unset launch fields did not inherit: mode=%q alias=%q scrollback=%d", launch.Mode(), launch.CommandAlias(), launch.ScrollbackLines()) + } + if attachment.Mouse() != store.MousePolicyAuto || attachment.ControlPrefix() != "C-b" || !attachment.BackDetach() { + t.Fatalf("unset attachment fields did not inherit: %+v", attachment) + } +} + +func TestMissingProfileUsesLegacyDefaultsAndDiagnostic(t *testing.T) { + cfg := store.DefaultConfig() + cfg.DefaultProfile = "deleted" + record := store.SessionRecord{Agent: "claude", Mode: store.ModeSafe, CommandAlias: "legacy-alias"} + + effective, err := ResolveProfilePolicy(ResolutionInput{Config: cfg, Session: record, ProviderPolicy: claudeTerminalPolicy()}) + if err != nil { + t.Fatal(err) + } + attachment, err := effective.NewAttachment(ClientTemporaryOverride{}, allClientCapabilities()) + if err != nil { + t.Fatal(err) + } + + launch := effective.LaunchSnapshot() + if launch.Provider() != "claude" || launch.Mode() != store.ModeSafe || launch.CommandAlias() != "legacy-alias" || launch.ScrollbackLines() != 4000 { + t.Fatalf("legacy launch fallback = provider=%q mode=%q alias=%q scrollback=%d", launch.Provider(), launch.Mode(), launch.CommandAlias(), launch.ScrollbackLines()) + } + if attachment.Mouse() != store.MousePolicyAuto || attachment.ControlPrefix() != "C-b" || !attachment.BackDetach() { + t.Fatalf("legacy attachment fallback = mouse=%q prefix=%q back_detach=%v", attachment.Mouse(), attachment.ControlPrefix(), attachment.BackDetach()) + } + diagnostics := effective.Diagnostics() + if len(diagnostics) != 1 || diagnostics[0].Code != DiagnosticProfileFallback || diagnostics[0].Profile != "deleted" { + t.Fatalf("fallback diagnostics = %+v", diagnostics) + } +} + +func TestProfileResolutionIsDeterministicAndSourceImmutable(t *testing.T) { + cfg := store.DefaultConfig() + cfg.DefaultProfile = "stable" + cfg.Profiles["stable"] = completeProfile("claude", store.ModeSafe, "stable-alias", store.MousePolicyOff, "C-f", false, 6400) + before, err := json.Marshal(cfg) + if err != nil { + t.Fatal(err) + } + input := ResolutionInput{Config: cfg, Session: store.SessionRecord{Agent: "claude"}, ProviderPolicy: claudeTerminalPolicy()} + var firstLaunch LaunchPolicySnapshot + var firstAttachment AttachmentPolicySnapshot + for attempt := range 25 { + effective, resolveErr := ResolveProfilePolicy(input) + if resolveErr != nil { + t.Fatal(resolveErr) + } + attachment, attachErr := effective.NewAttachment(ClientTemporaryOverride{}, allClientCapabilities()) + if attachErr != nil { + t.Fatal(attachErr) + } + if attempt == 0 { + firstLaunch = effective.LaunchSnapshot() + firstAttachment = attachment + continue + } + if effective.LaunchSnapshot() != firstLaunch || attachment != firstAttachment { + t.Fatalf("attempt %d produced a different snapshot", attempt) + } + } + after, err := json.Marshal(cfg) + if err != nil { + t.Fatal(err) + } + if !bytes.Equal(after, before) { + t.Fatalf("resolution mutated persistent source: before=%s after=%s", before, after) + } +} diff --git a/internal/app/profile_views.go b/internal/app/profile_views.go new file mode 100644 index 0000000..e2bf5a5 --- /dev/null +++ b/internal/app/profile_views.go @@ -0,0 +1,77 @@ +package app + +import ( + "fmt" + "sort" + + "github.com/RandomCodeSpace/unified-agent-manager/internal/store" +) + +type NamedProfile struct { + Name string `json:"name"` + Default bool `json:"default"` + Profile store.Profile `json:"profile"` +} + +type ProfileList struct { + DefaultProfile string `json:"default_profile"` + Profiles []NamedProfile `json:"profiles"` +} + +type EffectiveProfileView struct { + SessionID string `json:"session_id"` + Provider string `json:"provider"` + SelectedProfile string `json:"selected_profile"` + EffectiveProfile string `json:"effective_profile"` + Mode store.Mode `json:"mode"` + CommandAlias string `json:"command_alias"` + Mouse store.MousePolicy `json:"mouse"` + ControlPrefix string `json:"control_prefix"` + BackDetach bool `json:"back_detach"` + ScrollbackLines int `json:"scrollback_lines"` +} + +type ProfileReferencedError struct { + Name string + Default bool + SessionIDs []string +} + +func (e *ProfileReferencedError) Error() string { + if len(e.SessionIDs) > 0 { + return fmt.Sprintf("profile %q is referenced by sessions: %v", e.Name, e.SessionIDs) + } + return fmt.Sprintf("profile %q is the default profile", e.Name) +} + +func (s *Service) ListProfiles() (ProfileList, error) { + cfg, err := s.loadConfig() + if err != nil { + return ProfileList{}, err + } + names := make([]string, 0, len(cfg.Profiles)) + for name := range cfg.Profiles { + names = append(names, name) + } + sort.Strings(names) + profiles := make([]NamedProfile, 0, len(names)) + for _, name := range names { + profiles = append(profiles, NamedProfile{Name: name, Default: name == cfg.DefaultProfile, Profile: cfg.Profiles[name]}) + } + return ProfileList{DefaultProfile: cfg.DefaultProfile, Profiles: profiles}, nil +} + +func (s *Service) Profile(name string) (NamedProfile, error) { + if err := store.ValidateProfileName(name); err != nil { + return NamedProfile{}, err + } + cfg, err := s.loadConfig() + if err != nil { + return NamedProfile{}, err + } + profile, exists := cfg.Profiles[name] + if !exists { + return NamedProfile{}, fmt.Errorf("profile %q not found", name) + } + return NamedProfile{Name: name, Default: cfg.DefaultProfile == name, Profile: profile}, nil +} diff --git a/internal/app/service.go b/internal/app/service.go index d97a564..2f75db8 100644 --- a/internal/app/service.go +++ b/internal/app/service.go @@ -21,8 +21,9 @@ import ( ) type Service struct { - Store *store.Store - Registry *adapter.Registry + Store *store.Store + Registry *adapter.Registry + SessionDiagnostics SessionDiagnosticSource // prInFlight guards the PR-check pass of a refresh so overlapping refreshes // (stacked 2s ticks) do not launch concurrent `gh` subprocesses. A refresh // that fails to acquire it skips the network check and keeps the persisted @@ -533,6 +534,10 @@ func (s *Service) DispatchNamed(ctx context.Context, agentName, name, prompt, cw } func (s *Service) DispatchNamedWithAlias(ctx context.Context, agentName, commandAlias, name, prompt, cwd, mode string) (adapter.Session, error) { + return s.DispatchNamedWithAliasProfile(ctx, agentName, commandAlias, name, prompt, cwd, mode, "") +} + +func (s *Service) DispatchNamedWithAliasProfile(ctx context.Context, agentName, commandAlias, name, prompt, cwd, mode, profileName string) (adapter.Session, error) { if s.Registry == nil { return adapter.Session{}, errors.New("no registry configured") } @@ -540,15 +545,37 @@ func (s *Service) DispatchNamedWithAlias(ctx context.Context, agentName, command if !ok { return adapter.Session{}, fmt.Errorf("agent %q is unavailable", agentName) } + cfg, err := s.loadConfig() + if err != nil { + return adapter.Session{}, fmt.Errorf("load dispatch profile config: %w", err) + } + if profileName != "" { + if err := store.ValidateProfileName(profileName); err != nil { + return adapter.Session{}, err + } + if _, exists := cfg.Profiles[profileName]; !exists { + return adapter.Session{}, fmt.Errorf("profile %q not found", profileName) + } + } + launch, err := resolveLaunchPolicy(cfg, a, store.SessionRecord{Agent: agentName, Profile: profileName}) + if err != nil { + return adapter.Session{}, fmt.Errorf("resolve dispatch profile: %w", err) + } if mode == "" { - mode = string(store.ModeYolo) + mode = string(launch.Mode()) } - sess, err := a.Dispatch(ctx, adapter.DispatchRequest{Name: name, CommandAlias: commandAlias, Prompt: prompt, Cwd: cwd, Mode: mode}) + if commandAlias == "" { + commandAlias = launch.CommandAlias() + } + sess, err := a.Dispatch(ctx, adapter.DispatchRequest{ + Name: name, CommandAlias: commandAlias, ScrollbackLines: launch.ScrollbackLines(), Prompt: prompt, Cwd: cwd, Mode: mode, + }) if err != nil { return adapter.Session{}, err } if s.Store != nil { rec := RecordFromSession(sess, store.Mode(mode)) + rec.Profile = profileName if err := s.Store.Update(func(cfg *store.Config) error { cfg.PutSession(store.Key(sess.AgentType, sess.ID), rec) return nil @@ -832,7 +859,7 @@ func (s *Service) AttachSpecExactWithOptions(ctx context.Context, agentName, id return adapter.AttachSpec{}, err } } - return a.Attach(sess.ID) + return s.attachSpecWithProfile(ctx, sess, a) } func (s *Service) AttachSpecWithOptions(ctx context.Context, id string, opts ResumeOptions) (adapter.AttachSpec, error) { @@ -849,7 +876,27 @@ func (s *Service) AttachSpecWithOptions(ctx context.Context, id string, opts Res return adapter.AttachSpec{}, err } } - return a.Attach(sess.ID) + return s.attachSpecWithProfile(ctx, sess, a) +} + +func (s *Service) attachSpecWithProfile(ctx context.Context, sess adapter.Session, agent adapter.AgentAdapter) (adapter.AttachSpec, error) { + spec, err := agent.Attach(sess.ID) + if err != nil { + return adapter.AttachSpec{}, err + } + lookup := sess.SessionName + if lookup == "" { + lookup = sess.ID + } + profile, err := s.EffectiveProfileExact(ctx, lookup) + if err != nil { + return adapter.AttachSpec{}, fmt.Errorf("resolve attach profile: %w", err) + } + spec.Profile = adapter.AttachProfileSnapshot{ + Selected: profile.SelectedProfile, Effective: profile.EffectiveProfile, Mouse: string(profile.Mouse), + ControlPrefix: profile.ControlPrefix, BackDetach: profile.BackDetach, + } + return spec, nil } // Restart replaces a session's agent process while keeping its identity: a @@ -1068,7 +1115,11 @@ func (s *Service) prepareResume(sess adapter.Session, cfg store.Config, opts Res if rec.ID == "" { rec = RecordFromSession(sess, store.ModeYolo) } - req := adapter.ResumeRequest{ID: rec.ID, Name: rec.Name, CommandAlias: rec.CommandAlias, Prompt: rec.Prompt, Cwd: rec.Workdir, Mode: string(rec.Mode), SessionName: rec.SessionName, ProviderSessionID: rec.ProviderSessionID, CreatedAt: rec.CreatedAt} + launch, err := resolveLaunchPolicy(cfg, a, rec) + if err != nil { + return nil, store.SessionRecord{}, adapter.ResumeRequest{}, fmt.Errorf("resolve resume profile: %w", err) + } + req := adapter.ResumeRequest{ID: rec.ID, Name: rec.Name, CommandAlias: launch.CommandAlias(), ScrollbackLines: launch.ScrollbackLines(), Prompt: rec.Prompt, Cwd: rec.Workdir, Mode: string(launch.Mode()), SessionName: rec.SessionName, ProviderSessionID: rec.ProviderSessionID, CreatedAt: rec.CreatedAt} kind := adapter.ResumeHeuristic if classifier, ok := a.(adapter.ResumeKindAdapter); ok { kind = classifier.ResumeKind(req) @@ -1082,6 +1133,20 @@ func (s *Service) prepareResume(sess adapter.Session, cfg store.Config, opts Res return resumable, rec, req, nil } +func resolveLaunchPolicy(cfg store.Config, provider adapter.AgentAdapter, record store.SessionRecord) (LaunchPolicySnapshot, error) { + policyProvider, ok := provider.(adapter.TerminalPolicyAdapter) + if !ok { + return LaunchPolicySnapshot{}, fmt.Errorf("agent %q does not expose a terminal policy", provider.Name()) + } + effective, err := ResolveProfilePolicy(ResolutionInput{ + Config: cfg, Session: record, ProviderPolicy: policyProvider.TerminalPolicy(), + }) + if err != nil { + return LaunchPolicySnapshot{}, err + } + return effective.LaunchSnapshot(), nil +} + func retainedSessionCount(cfg store.Config, agentName, workdir string) int { want := normalizedWorkspace(workdir) count := 0 diff --git a/internal/app/service_test.go b/internal/app/service_test.go index 36a4924..c25c20a 100644 --- a/internal/app/service_test.go +++ b/internal/app/service_test.go @@ -30,6 +30,7 @@ type svcFakeAdapter struct { peekedID string attachedID string replied string + dispatched *adapter.DispatchRequest resumed *adapter.ResumeRequest // F04: simulate a failed kill (stopErr) and a still-live pane (alive). The // fake implements adapter.HasSessionAdapter, returning alive from HasSession. @@ -64,11 +65,16 @@ func (f *svcFakeAdapter) Available() (bool, string) { return false, "missing" } func (f *svcFakeAdapter) Dispatch(ctx adapter.Context, req adapter.DispatchRequest) (adapter.Session, error) { + captured := req + f.dispatched = &captured if req.Prompt == "fail" { return adapter.Session{}, errors.New("fail") } return adapter.Session{ID: "12345678", AgentType: f.name, CommandAlias: req.CommandAlias, DisplayName: firstNonEmpty(req.Name, req.Prompt, "untitled"), Prompt: req.Prompt, Cwd: firstNonEmpty(req.Cwd, "/tmp"), SessionName: "uam-" + f.name + "-12345678", State: adapter.Active, ProcAlive: adapter.Alive, CreatedAt: time.Now()}, nil } +func (f *svcFakeAdapter) TerminalPolicy() adapter.ProviderTerminalPolicy { + return adapter.ProviderTerminalPolicy{Identity: adapter.ProviderIdentity(f.name), OuterScreen: adapter.OuterScreenUAM, KeyProtocol: adapter.KeyProtocolNative} +} func (f *svcFakeAdapter) Resume(ctx adapter.Context, req adapter.ResumeRequest) (adapter.Session, error) { f.resumed = &req if f.resumeHook != nil { diff --git a/internal/app/todo7_characterization_test.go b/internal/app/todo7_characterization_test.go new file mode 100644 index 0000000..674961c --- /dev/null +++ b/internal/app/todo7_characterization_test.go @@ -0,0 +1,54 @@ +package app + +import ( + "context" + "testing" + + "github.com/RandomCodeSpace/unified-agent-manager/internal/adapter" +) + +func TestTodo7CharacterizationDispatchKeepsExplicitLaunchInputs(t *testing.T) { + // Given + svc, fake := newWorkflowService(t) + + // When + _, err := svc.DispatchNamedWithAlias(context.Background(), "fake", "review", "named", "work", "/tmp", "safe") + + // Then + if err != nil { + t.Fatal(err) + } + if fake.dispatched == nil || fake.dispatched.CommandAlias != "review" || fake.dispatched.Mode != "safe" { + t.Fatalf("dispatch request = %+v", fake.dispatched) + } +} + +func TestTodo7CharacterizationFindExactRejectsIDPrefixes(t *testing.T) { + // Given + svc, fake := newWorkflowService(t) + fake.sessions = []adapter.Session{{ID: "12345678", AgentType: "fake", SessionName: "uam-fake-12345678"}} + + // When + _, _, err := svc.FindExact(context.Background(), "fake", "1234") + + // Then + if err == nil { + t.Fatal("FindExact accepted a session ID prefix") + } +} + +func TestTodo7CharacterizationWizardProviderSelectionPrecedesLaunchFields(t *testing.T) { + // Given + m := NewWithDeps(nil, adapter.NewRegistry([]adapter.AgentAdapter{&svcFakeAdapter{name: "fake", available: true}})) + m.wizard = true + m.defaultAgent = "fake" + + // When + model, _ := m.handleWizardKey(keyMsg("enter")) + m = model.(Model) + + // Then + if m.wizardAgent != "fake" || m.wizardStep != 1 || m.input != "" { + t.Fatalf("wizard state = agent=%q step=%d input=%q", m.wizardAgent, m.wizardStep, m.input) + } +} diff --git a/internal/cli/attach_profile_test.go b/internal/cli/attach_profile_test.go new file mode 100644 index 0000000..62640fd --- /dev/null +++ b/internal/cli/attach_profile_test.go @@ -0,0 +1,55 @@ +package cli + +import ( + "slices" + "testing" + + "github.com/RandomCodeSpace/unified-agent-manager/internal/adapter" + "github.com/RandomCodeSpace/unified-agent-manager/internal/session" +) + +func TestAttachEnvironmentIncludesResolvedProfileSnapshot(t *testing.T) { + // Given + base := []string{ + "KEEP=value", + session.AttachMouseEnv + "=off", + session.AttachPrefixEnv + "=C-z", + session.AttachBackDetachEnv + "=0", + session.AttachQuietEnv + "=0", + session.AttachSelectedProfileEnv + "=stale-selected", + session.AttachEffectiveProfileEnv + "=stale-effective", + } + spec := adapter.AttachSpec{Profile: adapter.AttachProfileSnapshot{ + Selected: "default", Effective: "focused", Mouse: "on", ControlPrefix: "C-a", BackDetach: true, + }} + + // When + environment := attachEnvironment(base, spec) + + // Then + for _, expected := range []string{ + "KEEP=value", + session.AttachMouseEnv + "=off", + session.AttachPrefixEnv + "=C-z", + session.AttachBackDetachEnv + "=0", + session.AttachQuietEnv + "=1", + session.AttachSelectedProfileEnv + "=default", + session.AttachEffectiveProfileEnv + "=focused", + session.AttachPolicyMouseEnv + "=on", + session.AttachPolicyPrefixEnv + "=C-a", + session.AttachPolicyBackDetachEnv + "=1", + } { + if !slices.Contains(environment, expected) { + t.Fatalf("attach environment %q lacks %q", environment, expected) + } + } + for _, stale := range []string{ + session.AttachQuietEnv + "=0", + session.AttachSelectedProfileEnv + "=stale-selected", + session.AttachEffectiveProfileEnv + "=stale-effective", + } { + if slices.Contains(environment, stale) { + t.Fatalf("attach environment %q retained stale transport value %q", environment, stale) + } + } +} diff --git a/internal/cli/cli.go b/internal/cli/cli.go index 778b380..3e6c526 100644 --- a/internal/cli/cli.go +++ b/internal/cli/cli.go @@ -68,16 +68,26 @@ func Usage() { fmt.Fprintln(os.Stderr, "usage:") fmt.Fprintln(os.Stderr, " uam open the TUI") fmt.Fprintln(os.Stderr, " uam new guided dispatch wizard") - fmt.Fprintln(os.Stderr, " uam dispatch [--safe] [--alias ] [#session-name] [prompt]") + fmt.Fprintln(os.Stderr, " uam new [--profile ] guided dispatch wizard") + fmt.Fprintln(os.Stderr, " uam dispatch [--safe] [--alias ] [--profile ] [#session-name] [prompt]") fmt.Fprintln(os.Stderr, " uam attach [--allow-latest] ") fmt.Fprintln(os.Stderr, " uam last") fmt.Fprintln(os.Stderr, " uam version") fmt.Fprintln(os.Stderr, " uam ls [--json]") + fmt.Fprintln(os.Stderr, " uam doctor [] [--json]") fmt.Fprintln(os.Stderr, " uam peek ") fmt.Fprintln(os.Stderr, " uam stop ") fmt.Fprintln(os.Stderr, " uam restart [--allow-latest] stop the agent and resume it in place") fmt.Fprintln(os.Stderr, " uam rm ") fmt.Fprintln(os.Stderr, " uam kill-all stop every managed session") + fmt.Fprintln(os.Stderr, " uam profile ls [--json]") + fmt.Fprintln(os.Stderr, " uam profile show [--json]") + fmt.Fprintln(os.Stderr, " uam profile set [profile flags]") + fmt.Fprintln(os.Stderr, " uam profile rm ") + fmt.Fprintln(os.Stderr, " uam profile default ") + fmt.Fprintln(os.Stderr, " uam profile assign ") + fmt.Fprintln(os.Stderr, " uam profile override [profile flags]") + fmt.Fprintln(os.Stderr, " uam profile effective [--json]") fmt.Fprintln(os.Stderr, " uam notify-closed (internal: flag a record user-closed)") } @@ -154,7 +164,7 @@ func runWithoutStore(ctx context.Context, args []string) (bool, error) { func runCommand(ctx context.Context, svc *app.Service, args []string, runTUI func(context.Context, tea.Model) error) error { switch args[0] { case "new": - return runNew(ctx, svc, runTUI) + return runNewWithArgs(ctx, svc, args[1:], runTUI) case "dispatch": return RunDispatch(ctx, svc, args[1:]) case "ls", "list": @@ -167,6 +177,10 @@ func runCommand(ctx context.Context, svc *app.Service, args []string, runTUI fun return runRestart(ctx, svc, args[1:]) case "notify-closed": return runNotifyClosed(svc, args[1:]) + case "profile": + return runProfile(ctx, svc, args[1:]) + case "doctor": + return runDoctor(ctx, svc, args[1:]) case "attach": fs := flag.NewFlagSet("attach", flag.ContinueOnError) allowLatest := fs.Bool("allow-latest", false, "allow heuristic resume of the provider's latest session") @@ -333,6 +347,7 @@ func RunDispatch(ctx context.Context, svc *app.Service, args []string) error { safe := fs.Bool("safe", false, "use provider default permission mode") alias := fs.String("alias", "", "command alias") cwd := fs.String("cwd", "", "working directory") + profile := fs.String("profile", "", "named profile") if err := fs.Parse(args); err != nil { return err } @@ -353,11 +368,14 @@ func RunDispatch(ctx context.Context, svc *app.Service, args []string) error { return fmt.Errorf("dispatch: %q looks like a flag; flags must come before ", rem[1]) } mode := string(store.ModeYolo) + if *profile != "" { + mode = "" + } if *safe { mode = string(store.ModeSafe) } name, prompt := parseNameAndPrompt(rem[1:]) - sess, err := svc.DispatchNamedWithAlias(ctx, rem[0], *alias, name, prompt, *cwd, mode) + sess, err := svc.DispatchNamedWithAliasProfile(ctx, rem[0], *alias, name, prompt, *cwd, mode, *profile) if err != nil { // A non-empty session means the agent launched but the record failed to // persist (advisory): report the warning, still emit the id, exit 0 (F03). @@ -371,9 +389,39 @@ func RunDispatch(ctx context.Context, svc *app.Service, args []string) error { } func runNew(ctx context.Context, svc *app.Service, runTUI func(context.Context, tea.Model) error) error { + return runNewWithArgs(ctx, svc, nil, runTUI) +} + +func runNewWithArgs(ctx context.Context, svc *app.Service, args []string, runTUI func(context.Context, tea.Model) error) error { + fs := flag.NewFlagSet("new", flag.ContinueOnError) + profile := fs.String("profile", "", "named profile") + if err := fs.Parse(args); err != nil { + return err + } + if len(fs.Args()) != 0 { + return fmt.Errorf("new: unexpected arguments %q", fs.Args()) + } reader := bufio.NewReader(os.Stdin) - cfg, _ := svc.Store.Load() + cfg, err := svc.Store.Load() + if err != nil { + return fmt.Errorf("load new-session config: %w", err) + } agent := cfg.DefaultAgent + if *profile != "" { + if err := store.ValidateProfileName(*profile); err != nil { + return err + } + selected, exists := cfg.Profiles[*profile] + if !exists { + return fmt.Errorf("profile %q not found", *profile) + } + if err := store.ValidateProfile(selected); err != nil { + return fmt.Errorf("profile %q: %w", *profile, err) + } + if selected.Provider != nil { + agent = *selected.Provider + } + } if a := svc.Registry.Default(agent); a != nil { agent = a.Name() } @@ -419,7 +467,11 @@ func runNew(ctx context.Context, svc *app.Service, runTUI func(context.Context, if strings.TrimSpace(prompt) == "" { prompt = "" } - sess, err := svc.DispatchNamedWithAlias(ctx, agent, alias, name, prompt, cwd, string(store.ModeYolo)) + mode := string(store.ModeYolo) + if *profile != "" { + mode = "" + } + sess, err := svc.DispatchNamedWithAliasProfile(ctx, agent, alias, name, prompt, cwd, mode, *profile) if err != nil { if sess.ID == "" { return err @@ -482,9 +534,43 @@ func execAttachWithOptions(ctx context.Context, svc *app.Service, id string, opt cmd.Stdin = os.Stdin cmd.Stdout = os.Stdout cmd.Stderr = os.Stderr - cmd.Env = append(os.Environ(), session.AttachQuietEnv+"=1") + cmd.Env = attachEnvironment(os.Environ(), spec) if err := cmd.Run(); err != nil { fmt.Fprintf(os.Stderr, "uam: session exited: %v\n", err) } return runTUI(ctx, app.NewWithDeps(svc.Store, svc.Registry)) } + +func attachEnvironment(base []string, spec adapter.AttachSpec) []string { + environment := make([]string, 0, len(base)+6) + for _, assignment := range base { + name, _, _ := strings.Cut(assignment, "=") + if name == session.AttachQuietEnv || name == session.AttachSelectedProfileEnv || name == session.AttachEffectiveProfileEnv || + name == session.AttachPolicyMouseEnv || name == session.AttachPolicyPrefixEnv || name == session.AttachPolicyBackDetachEnv { + continue + } + environment = append(environment, assignment) + } + environment = append(environment, session.AttachQuietEnv+"=1") + if spec.Profile.Selected != "" { + environment = append(environment, session.AttachSelectedProfileEnv+"="+spec.Profile.Selected) + } + if spec.Profile.Effective != "" { + environment = append(environment, session.AttachEffectiveProfileEnv+"="+spec.Profile.Effective) + } + if spec.Profile.Mouse != "" || spec.Profile.ControlPrefix != "" { + environment = append(environment, + session.AttachPolicyMouseEnv+"="+spec.Profile.Mouse, + session.AttachPolicyPrefixEnv+"="+spec.Profile.ControlPrefix, + session.AttachPolicyBackDetachEnv+"="+boolTransport(spec.Profile.BackDetach), + ) + } + return environment +} + +func boolTransport(value bool) string { + if value { + return "1" + } + return "0" +} diff --git a/internal/cli/cli_test.go b/internal/cli/cli_test.go index 71c9e3e..ac2f106 100644 --- a/internal/cli/cli_test.go +++ b/internal/cli/cli_test.go @@ -26,22 +26,35 @@ import ( ) type cliFakeAdapter struct { + name string sessions []adapter.Session stopped bool resumed bool attached []string } -func (f *cliFakeAdapter) Name() string { return "fake" } -func (f *cliFakeAdapter) DisplayName() string { return "fake" } +func (f *cliFakeAdapter) Name() string { + if f.name == "" { + return "fake" + } + return f.name +} +func (f *cliFakeAdapter) DisplayName() string { return f.Name() } func (f *cliFakeAdapter) Available() (bool, string) { return true, "" } +func (f *cliFakeAdapter) TerminalPolicy() adapter.ProviderTerminalPolicy { + return adapter.ProviderTerminalPolicy{ + Identity: adapter.ProviderIdentity(f.Name()), + OuterScreen: adapter.OuterScreenUAM, + KeyProtocol: adapter.KeyProtocolNative, + } +} func (f *cliFakeAdapter) Dispatch(ctx adapter.Context, req adapter.DispatchRequest) (adapter.Session, error) { if req.Prompt == "fail" { return adapter.Session{}, errors.New("fail") } - sess := adapter.Session{ID: "abc12345", AgentType: "fake", CommandAlias: req.CommandAlias, DisplayName: firstNonEmpty(req.Name, req.Prompt, "untitled"), Prompt: req.Prompt, Cwd: req.Cwd, SessionName: "uam-fake-abc12345", State: adapter.Active, ProcAlive: adapter.Alive, CreatedAt: time.Now()} + sess := adapter.Session{ID: "abc12345", AgentType: f.Name(), CommandAlias: req.CommandAlias, DisplayName: firstNonEmpty(req.Name, req.Prompt, "untitled"), Prompt: req.Prompt, Cwd: req.Cwd, SessionName: "uam-" + f.Name() + "-abc12345", State: adapter.Active, ProcAlive: adapter.Alive, CreatedAt: time.Now()} f.sessions = append(f.sessions, sess) return sess, nil } @@ -65,7 +78,7 @@ func (f *cliFakeAdapter) Stop(ctx adapter.Context, id string) error { func (f *cliFakeAdapter) Resume(ctx adapter.Context, req adapter.ResumeRequest) (adapter.Session, error) { f.resumed = true - sess := adapter.Session{ID: req.ID, AgentType: "fake", DisplayName: req.Name, Cwd: req.Cwd, SessionName: req.SessionName, State: adapter.Active, ProcAlive: adapter.Alive, CreatedAt: time.Now()} + sess := adapter.Session{ID: req.ID, AgentType: f.Name(), DisplayName: req.Name, Cwd: req.Cwd, SessionName: req.SessionName, State: adapter.Active, ProcAlive: adapter.Alive, CreatedAt: time.Now()} f.sessions = append(f.sessions, sess) return sess, nil } diff --git a/internal/cli/doctor.go b/internal/cli/doctor.go new file mode 100644 index 0000000..4475bc6 --- /dev/null +++ b/internal/cli/doctor.go @@ -0,0 +1,80 @@ +package cli + +import ( + "context" + "errors" + "fmt" + "strings" + + "github.com/RandomCodeSpace/unified-agent-manager/internal/app" + "github.com/RandomCodeSpace/unified-agent-manager/internal/displaytext" +) + +func runDoctor(ctx context.Context, svc *app.Service, args []string) error { + sessionID, asJSON, err := parseDoctorArgs(args) + if err != nil { + return err + } + if sessionID == "" { + report := svc.DoctorGlobal(ctx) + if asJSON { + return writeJSON(report) + } + fmt.Printf("store\t%s\n", report.Store.Status) + fmt.Printf("runtime\t%s\tsessions=%d\n", report.Runtime.Status, report.Runtime.Count) + for _, provider := range report.Providers { + fmt.Printf("provider\t%s\t%s\t%s\t%s\n", + displaytext.Sanitize(provider.Name), provider.Status, provider.OuterScreen, provider.KeyProtocol) + } + for _, profile := range report.Profiles { + fmt.Printf("profile\t%s\t%s\n", displaytext.Sanitize(profile.Name), profile.Status) + } + return nil + } + report, err := svc.DoctorSession(ctx, sessionID) + if err != nil { + return err + } + if asJSON { + return writeJSON(report) + } + fmt.Printf("session\t%s\t%s\n", displaytext.Sanitize(report.SessionID), displaytext.Sanitize(report.SessionName)) + fmt.Printf("runtime\t%s\tprotocols=%s\tcontroller=%d\tstandby=%d\tobserver=%d\n", + report.RuntimeStatus, protocolList(report.Runtime.Protocols), + report.Runtime.Controller, report.Runtime.Standby, report.Runtime.Observer) + fmt.Printf("profile\tselected=%s\teffective=%s\n", + displaytext.Sanitize(report.SelectedProfile), displaytext.Sanitize(report.EffectiveProfile)) + fmt.Printf("provider\t%s\touter_screen=%s\tkey_protocol=%s\n", + displaytext.Sanitize(report.Provider), report.ProviderPolicy.OuterScreen, report.ProviderPolicy.KeyProtocol) + fmt.Printf("fallback\t%s\n", strings.Join(report.FallbackReasons, ",")) + return nil +} + +func parseDoctorArgs(args []string) (string, bool, error) { + sessionID := "" + asJSON := false + for _, arg := range args { + switch { + case arg == "--json": + if asJSON { + return "", false, errors.New("doctor accepts --json once") + } + asJSON = true + case strings.HasPrefix(arg, "-"): + return "", false, fmt.Errorf("doctor: unknown flag %q", arg) + case sessionID == "": + sessionID = arg + default: + return "", false, errors.New("doctor accepts at most one session ID") + } + } + return sessionID, asJSON, nil +} + +func protocolList(protocols []int) string { + values := make([]string, len(protocols)) + for index, protocol := range protocols { + values[index] = fmt.Sprintf("%d", protocol) + } + return strings.Join(values, ",") +} diff --git a/internal/cli/doctor_real_surface_helpers_test.go b/internal/cli/doctor_real_surface_helpers_test.go new file mode 100644 index 0000000..9bafb6b --- /dev/null +++ b/internal/cli/doctor_real_surface_helpers_test.go @@ -0,0 +1,198 @@ +package cli + +import ( + "bufio" + "bytes" + "context" + "encoding/binary" + "encoding/json" + "net" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/RandomCodeSpace/unified-agent-manager/internal/session" + "github.com/RandomCodeSpace/unified-agent-manager/internal/store" +) + +type todo10Attachment struct { + connection net.Conn + generation uint64 +} + +func todo10EvidenceDir(t *testing.T) string { + t.Helper() + dir := os.Getenv("UAM_TASK10_EVIDENCE_DIR") + if dir == "" { + t.Skip("UAM_TASK10_EVIDENCE_DIR is required for the real-surface fixture") + } + if !filepath.IsAbs(dir) { + t.Fatalf("evidence directory must be absolute: %s", dir) + } + if err := os.MkdirAll(dir, 0o700); err != nil { + t.Fatal(err) + } + return dir +} + +func buildTodo10Binary(t *testing.T) string { + t.Helper() + path := filepath.Join(t.TempDir(), "uam") + command := exec.Command("go", "build", "-o", path, ".") + command.Dir = todo7RepoRoot(t) + command.Env = os.Environ() + if output, err := command.CombinedOutput(); err != nil { + t.Fatalf("build uam: %v\n%s", err, output) + } + return path +} + +func installTodo10ProviderStub(t *testing.T) { + t.Helper() + dir := t.TempDir() + path := filepath.Join(dir, "claude") + if err := os.WriteFile(path, []byte("#!/bin/sh\nexit 0\n"), 0o700); err != nil { + t.Fatal(err) + } + t.Setenv("PATH", dir+string(os.PathListSeparator)+os.Getenv("PATH")) +} + +func saveTodo10Config(t *testing.T) { + t.Helper() + provider := "claude" + cfg := store.DefaultConfig() + cfg.Profiles["stable"] = store.Profile{Provider: &provider} + cfg.Sessions["claude:a1"] = store.SessionRecord{ + ID: "a1", Agent: provider, SessionName: "uam-claude-a1", Profile: "deleted", + } + data, err := json.Marshal(cfg) + if err != nil { + t.Fatal(err) + } + if err := os.MkdirAll(filepath.Dir(store.DefaultPath()), 0o700); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(store.DefaultPath(), data, 0o600); err != nil { + t.Fatal(err) + } +} + +func startTodo10Host(t *testing.T, binaryPath string) *exec.Cmd { + t.Helper() + command := exec.Command( + binaryPath, "__host", "--dir", session.DefaultDir(), "--name", "uam-claude-a1", + "--provider", "claude", "--", "/bin/sh", "-c", "trap 'exit 0' HUP TERM INT; while :; do sleep 1; done", + ) + command.Env = os.Environ() + var stderr bytes.Buffer + command.Stderr = &stderr + if err := command.Start(); err != nil { + t.Fatal(err) + } + deadline := time.Now().Add(5 * time.Second) + for time.Now().Before(deadline) { + connection, err := net.Dial("unix", session.SocketPath(session.DefaultDir(), "uam-claude-a1")) + if err == nil { + _ = connection.Close() + return command + } + time.Sleep(10 * time.Millisecond) + } + _ = command.Process.Kill() + _ = command.Wait() + t.Fatalf("host socket did not become ready: %s", stderr.String()) + return nil +} + +func attachTodo10Client(t *testing.T, role string) *todo10Attachment { + t.Helper() + connection, err := net.Dial("unix", session.SocketPath(session.DefaultDir(), "uam-claude-a1")) + if err != nil { + t.Fatal(err) + } + request := map[string]any{ + "op": "attach", "version": 2, "requested_role": role, "cols": 80, "rows": 24, + "hello": map[string]any{ + "tty": true, + "capabilities": []string{"framed_output", "role_events", "local_mouse_filter", "owned_screen"}, + }, + } + if err := json.NewEncoder(connection).Encode(request); err != nil { + t.Fatal(err) + } + var response struct { + OK bool `json:"ok"` + Err string `json:"err"` + Generation uint64 `json:"generation"` + } + if err := json.NewDecoder(bufio.NewReader(connection)).Decode(&response); err != nil { + t.Fatal(err) + } + if !response.OK { + t.Fatalf("attach rejected: %s", response.Err) + } + return &todo10Attachment{connection: connection, generation: response.Generation} +} + +func writeTodo10Frame(t *testing.T, connection net.Conn, kind byte, payload []byte) { + t.Helper() + header := [5]byte{kind} + binary.BigEndian.PutUint32(header[1:], uint32(len(payload))) + if _, err := connection.Write(append(header[:], payload...)); err != nil { + t.Fatal(err) + } +} + +func runTodo10DoctorBinary(t *testing.T, binaryPath string, args ...string) []byte { + t.Helper() + command := exec.Command(binaryPath, append([]string{"doctor"}, args...)...) + command.Env = os.Environ() + output, err := command.CombinedOutput() + if err != nil { + t.Fatalf("uam doctor %s: %v\n%s", strings.Join(args, " "), err, output) + } + return output +} + +func waitTodo10Roles(t *testing.T, controller, standby, observer int) { + t.Helper() + client := session.NewClient() + deadline := time.Now().Add(3 * time.Second) + for time.Now().Before(deadline) { + report, err := client.Doctor(context.Background(), "uam-claude-a1") + if err == nil && report.Controller == controller && report.Standby == standby && report.Observer == observer { + return + } + time.Sleep(10 * time.Millisecond) + } + t.Fatalf("runtime roles did not settle at controller=%d standby=%d observer=%d", controller, standby, observer) +} + +func waitTodo10Event(t *testing.T, event string) { + t.Helper() + path := filepath.Join(os.Getenv("UAM_CACHE_DIR"), "uam.log") + deadline := time.Now().Add(3 * time.Second) + for time.Now().Before(deadline) { + data, err := os.ReadFile(path) + if err == nil && bytes.Contains(data, []byte(event)) { + return + } + time.Sleep(10 * time.Millisecond) + } + t.Fatalf("diagnostic event did not appear: %s", event) +} + +func writeTodo10Artifact(t *testing.T, dir, name string, data []byte) { + t.Helper() + if err := os.WriteFile(filepath.Join(dir, name), data, 0o600); err != nil { + t.Fatal(err) + } +} + +func fileAbsent(path string) bool { + _, err := os.Stat(path) + return os.IsNotExist(err) +} diff --git a/internal/cli/doctor_real_surface_test.go b/internal/cli/doctor_real_surface_test.go new file mode 100644 index 0000000..146f9d0 --- /dev/null +++ b/internal/cli/doctor_real_surface_test.go @@ -0,0 +1,123 @@ +package cli + +import ( + "bytes" + "context" + "encoding/binary" + "encoding/json" + "fmt" + "os" + "path/filepath" + "testing" + "time" + + "github.com/RandomCodeSpace/unified-agent-manager/internal/app" + "github.com/RandomCodeSpace/unified-agent-manager/internal/session" +) + +func TestTodo10DoctorCLIRealSurface(t *testing.T) { + // Given: an isolated built binary, retained profile fallback, live host, and + // three real protocol-v2 Unix-socket attachments. + evidenceDir := todo10EvidenceDir(t) + isolateDoctorEnvironment(t) + t.Setenv("UAM_CACHE_DIR", filepath.Join(t.TempDir(), "cache")) + binaryPath := buildTodo10Binary(t) + installTodo10ProviderStub(t) + saveTodo10Config(t) + host := startTodo10Host(t, binaryPath) + connections := []*todo10Attachment{ + attachTodo10Client(t, "controller"), + attachTodo10Client(t, "controller"), + attachTodo10Client(t, "controller"), + attachTodo10Client(t, "observer"), + } + t.Cleanup(func() { + for _, attachment := range connections { + _ = attachment.connection.Close() + } + killCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + client := session.NewClient() + _ = client.Kill(killCtx, "uam-claude-a1") + _ = host.Wait() + }) + + // When: control transfers, an invalid resize is ignored, and both doctor + // JSON surfaces execute through the built binary. + writeTodo10Frame(t, connections[0].connection, 3, []byte(`{"action":"transfer_control"}`)) + waitTodo10Event(t, `"event":"role.transfer"`) + resize := make([]byte, 12) + binary.BigEndian.PutUint64(resize, connections[0].generation) + writeTodo10Frame(t, connections[0].connection, 1, resize) + if err := connections[1].connection.Close(); err != nil { + t.Fatal(err) + } + waitTodo10Roles(t, 1, 1, 1) + globalJSON := runTodo10DoctorBinary(t, binaryPath, "--json") + sessionJSON := runTodo10DoctorBinary(t, binaryPath, "a1", "--json") + globalText := runTodo10DoctorBinary(t, binaryPath) + sessionText := runTodo10DoctorBinary(t, binaryPath, "a1") + + // Then: protocol, role, profile, policy, and fallback fields come from the + // live runtime and persisted record, and retained artifacts remain secret-free. + var global app.GlobalDoctorReport + if err := json.Unmarshal(globalJSON, &global); err != nil { + t.Fatalf("decode global doctor: %v", err) + } + var report app.SessionDoctorReport + if err := json.Unmarshal(sessionJSON, &report); err != nil { + t.Fatalf("decode session doctor: %v", err) + } + if report.Runtime.Controller != 1 || report.Runtime.Standby != 1 || report.Runtime.Observer != 1 { + t.Fatalf("live roles = %+v", report.Runtime) + } + if fmt.Sprint(report.Runtime.Protocols) != "[1 2]" || report.SelectedProfile != "redacted" || + report.EffectiveProfile != "none" || report.ProviderPolicy.OuterScreen != "uam" || + fmt.Sprint(report.FallbackReasons) != "[profile_fallback]" { + t.Fatalf("session report = %+v", report) + } + if bytes.Contains(globalText, []byte{0x1b}) || bytes.Contains(sessionText, []byte{0x1b}) { + t.Fatal("doctor text output contains terminal escape bytes") + } + writeTodo10Artifact(t, evidenceDir, "doctor-global.json", globalJSON) + writeTodo10Artifact(t, evidenceDir, "doctor-session.json", sessionJSON) + writeTodo10Artifact(t, evidenceDir, "doctor-global.txt", globalText) + writeTodo10Artifact(t, evidenceDir, "doctor-session.txt", sessionText) + writeTodo10Frame(t, connections[3].connection, 2, nil) + for _, attachment := range connections { + _ = attachment.connection.Close() + } + killCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + client := session.NewClient() + killErr := client.Kill(killCtx, "uam-claude-a1") + cancel() + if killErr != nil { + t.Fatal(killErr) + } + if err := host.Wait(); err != nil { + t.Fatal(err) + } + logData, err := os.ReadFile(filepath.Join(os.Getenv("UAM_CACHE_DIR"), "uam.log")) + if err != nil { + t.Fatal(err) + } + writeTodo10Artifact(t, evidenceDir, "events.jsonl", logData) + assertions, err := json.MarshalIndent(map[string]any{ + "pass": true, "protocols": report.Runtime.Protocols, + "controller": report.Runtime.Controller, "standby": report.Runtime.Standby, + "observer": report.Runtime.Observer, "selected_profile": report.SelectedProfile, + "effective_profile": report.EffectiveProfile, "fallback_reasons": report.FallbackReasons, + }, "", " ") + if err != nil { + t.Fatal(err) + } + writeTodo10Artifact(t, evidenceDir, "assertions.json", append(assertions, '\n')) + cleanup, err := json.MarshalIndent(map[string]any{ + "pass": true, "host_exited": true, + "socket_absent": fileAbsent(session.SocketPath(session.DefaultDir(), "uam-claude-a1")), + }, "", " ") + if err != nil { + t.Fatal(err) + } + writeTodo10Artifact(t, evidenceDir, "cleanup-receipt.json", append(cleanup, '\n')) +} diff --git a/internal/cli/doctor_test.go b/internal/cli/doctor_test.go new file mode 100644 index 0000000..de30572 --- /dev/null +++ b/internal/cli/doctor_test.go @@ -0,0 +1,233 @@ +package cli + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "io" + "log/slog" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/RandomCodeSpace/unified-agent-manager/internal/adapter" + "github.com/RandomCodeSpace/unified-agent-manager/internal/app" + uamlog "github.com/RandomCodeSpace/unified-agent-manager/internal/log" + "github.com/RandomCodeSpace/unified-agent-manager/internal/session" + "github.com/RandomCodeSpace/unified-agent-manager/internal/store" +) + +func TestDoctorShowsControllerAndProfile(t *testing.T) { + // Given: a service with a retained profiled session and deterministic live + // runtime diagnostics. + svc := diagnosticTestService(t) + svc.SessionDiagnostics = diagnosticRuntime{ + result: session.RuntimeDiagnostic{ + Protocols: []int{1, 2}, Controller: 1, Standby: 1, Observer: 2, + }, + } + + // When: the session doctor JSON surface is rendered. + output := captureStdout(t, func() { + if err := runDoctor(context.Background(), svc, []string{"a1", "--json"}); err != nil { + t.Fatal(err) + } + }) + + // Then: it reports controller counts, effective profile, and provider policy. + var report app.SessionDoctorReport + if err := json.Unmarshal(output, &report); err != nil { + t.Fatalf("decode doctor JSON: %v\n%s", err, output) + } + if report.Runtime.Controller != 1 || report.Runtime.Standby != 1 || report.Runtime.Observer != 2 { + t.Fatalf("role counts = %+v", report.Runtime) + } + if report.EffectiveProfile != "stable" || report.ProviderPolicy.OuterScreen != "uam" { + t.Fatalf("profile/policy = %+v", report) + } +} + +func TestDiagnosticOutputRedactsTerminalContent(t *testing.T) { + // Given: secrets in persisted terminal-adjacent fields and a provider error. + svc := diagnosticTestService(t) + if err := svc.Store.Update(func(cfg *store.Config) error { + record := cfg.Sessions["claude:a1"] + record.Prompt = "secret-input-7f3a provider-output-91bc TOKEN=private-value" + cfg.Sessions["claude:a1"] = record + return nil + }); err != nil { + t.Fatal(err) + } + svc.SessionDiagnostics = diagnosticRuntime{err: errors.New("provider-output-91bc TOKEN=private-value")} + if err := session.EnsureDir(session.DefaultDir()); err != nil { + t.Fatal(err) + } + malformedStatePath := filepath.Join(session.DefaultDir(), "uam-claude-dead.json") + if err := os.WriteFile( + malformedStatePath, + []byte(`{"secret":"provider-output-91bc"`), + 0o600, + ); err != nil { + t.Fatal(err) + } + var logs bytes.Buffer + previous := uamlog.SetLogger(slog.New(slog.NewJSONHandler(&logs, nil))) + t.Cleanup(func() { uamlog.SetLogger(previous) }) + uamlog.Diagnostic(uamlog.DiagnosticEvent{ + Event: "attach.lifecycle", Session: "secret-input-7f3a", ClientID: "provider-output-91bc", + Reason: "TOKEN=private-value", Provider: "TOKEN=private-value", Profile: "secret-input-7f3a", + }) + + // When: global and session doctor JSON are rendered. + output := captureStdout(t, func() { + if err := runDoctor(context.Background(), svc, []string{"--json"}); err != nil { + t.Fatal(err) + } + if err := runDoctor(context.Background(), svc, []string{"a1", "--json"}); err != nil { + t.Fatal(err) + } + hostile := `{"schema_version":4,"default_agent":"TOKEN=private-value","default_profile":"secret-input-7f3a","profiles":{"TOKEN=private-value":{"provider":"provider-output-91bc"}},"sessions":{"claude:a1":{"id":"a1","agent":"TOKEN=private-value","name":"x","workdir":"/tmp","tmux_session":"secret-input-7f3a","profile":"provider-output-91bc"}},"ui":{}}` + if err := os.WriteFile(svc.Store.Path(), []byte(hostile), 0o600); err != nil { + t.Fatal(err) + } + if err := runDoctor(context.Background(), svc, []string{"--json"}); err != nil { + t.Fatal(err) + } + if err := runDoctor(context.Background(), svc, nil); err != nil { + t.Fatal(err) + } + if err := runDoctor(context.Background(), svc, []string{"a1", "--json"}); err != nil { + t.Fatal(err) + } + if err := runDoctor(context.Background(), svc, []string{"a1"}); err != nil { + t.Fatal(err) + } + }) + + // Then: no retained diagnostic channel contains secret-bearing content. + retained := string(output) + logs.String() + for _, secret := range []string{"secret-input-7f3a", "provider-output-91bc", "TOKEN=private-value"} { + if strings.Contains(retained, secret) { + t.Fatalf("diagnostics retained sentinel %q:\n%s", secret, retained) + } + } + for _, legitimate := range []string{"stable", "claude", "uam-claude-a1"} { + if !strings.Contains(retained, legitimate) { + t.Fatalf("diagnostics hid legitimate identifier %q:\n%s", legitimate, retained) + } + } + if _, err := os.Stat(malformedStatePath); err != nil { + t.Fatalf("doctor mutated malformed runtime state: %v", err) + } + t.Log("redaction_assertions sentinel_count=0 malformed_runtime_safe=true malformed_store_safe=true") +} + +type diagnosticRuntime struct { + result session.RuntimeDiagnostic + err error +} + +func (runtime diagnosticRuntime) Doctor(context.Context, string) (session.RuntimeDiagnostic, error) { + return runtime.result, runtime.err +} + +func diagnosticTestService(t *testing.T) *app.Service { + t.Helper() + isolateDoctorEnvironment(t) + st, err := store.Open(store.DefaultPath()) + if err != nil { + t.Fatal(err) + } + provider := "claude" + cfg := store.DefaultConfig() + cfg.Profiles["stable"] = store.Profile{Provider: &provider} + cfg.Sessions["claude:a1"] = store.SessionRecord{ + ID: "a1", Agent: "claude", SessionName: "uam-claude-a1", Profile: "stable", + } + if err := st.Save(cfg); err != nil { + t.Fatal(err) + } + agent := &doctorAdapter{name: provider} + return app.NewService(st, adapter.NewRegistry([]adapter.AgentAdapter{agent})) +} + +func TestTodo10DiagnosticEnvironmentIsolation(t *testing.T) { + isolateDoctorEnvironment(t) + st, err := store.Open(store.DefaultPath()) + if err != nil { + t.Fatal(err) + } + if err := st.Save(store.DefaultConfig()); err != nil { + t.Fatal(err) + } + if err := session.EnsureDir(session.DefaultDir()); err != nil { + t.Fatal(err) + } +} + +func isolateDoctorEnvironment(t *testing.T) { + t.Helper() + configDir, err := os.UserConfigDir() + if err != nil { + t.Fatal(err) + } + forbidden := filepath.Join(configDir, "uam") + root := t.TempDir() + isolatedConfig := filepath.Join(root, "config") + isolatedRuntime := filepath.Join(root, "runtime") + t.Setenv("UAM_CONFIG_DIR", isolatedConfig) + t.Setenv("UAM_SESSION_DIR", isolatedRuntime) + t.Setenv("XDG_CONFIG_HOME", filepath.Join(root, "xdg")) + if filepath.Clean(filepath.Dir(store.DefaultPath())) == filepath.Clean(forbidden) { + t.Fatalf("test config path escaped isolation: %s", store.DefaultPath()) + } + if filepath.Clean(filepath.Dir(store.DefaultPath())) != filepath.Clean(isolatedConfig) { + t.Fatalf("test config path is not owned temp dir: %s", store.DefaultPath()) + } + if filepath.Clean(session.DefaultDir()) != filepath.Clean(isolatedRuntime) { + t.Fatalf("test runtime path escaped isolation: %s", session.DefaultDir()) + } +} + +type doctorAdapter struct{ name string } + +func (a *doctorAdapter) Name() string { return a.name } +func (a *doctorAdapter) DisplayName() string { return a.name } +func (a *doctorAdapter) Available() (bool, string) { return true, "" } +func (a *doctorAdapter) Dispatch(adapter.Context, adapter.DispatchRequest) (adapter.Session, error) { + return adapter.Session{}, nil +} +func (a *doctorAdapter) List(adapter.Context) ([]adapter.Session, error) { return nil, nil } +func (a *doctorAdapter) Peek(adapter.Context, string) (adapter.PeekResult, error) { + return adapter.PeekResult{}, nil +} +func (a *doctorAdapter) Reply(adapter.Context, string, string) error { return nil } +func (a *doctorAdapter) Attach(string) (adapter.AttachSpec, error) { return adapter.AttachSpec{}, nil } +func (a *doctorAdapter) Stop(adapter.Context, string) error { return nil } +func (a *doctorAdapter) TerminalPolicy() adapter.ProviderTerminalPolicy { + return adapter.ProviderTerminalPolicy{ + Identity: adapter.ProviderIdentity(a.name), OuterScreen: adapter.OuterScreenUAM, KeyProtocol: adapter.KeyProtocolNative, + } +} + +func captureStdout(t *testing.T, action func()) []byte { + t.Helper() + old := os.Stdout + reader, writer, err := os.Pipe() + if err != nil { + t.Fatal(err) + } + os.Stdout = writer + t.Cleanup(func() { os.Stdout = old }) + action() + if err := writer.Close(); err != nil { + t.Fatal(err) + } + output, err := io.ReadAll(reader) + if err != nil { + t.Fatal(err) + } + return output +} diff --git a/internal/cli/profile.go b/internal/cli/profile.go new file mode 100644 index 0000000..0a73026 --- /dev/null +++ b/internal/cli/profile.go @@ -0,0 +1,183 @@ +package cli + +import ( + "context" + "encoding/json" + "errors" + "flag" + "fmt" + "io" + "os" + + "github.com/RandomCodeSpace/unified-agent-manager/internal/app" + "github.com/RandomCodeSpace/unified-agent-manager/internal/store" +) + +func runProfile(ctx context.Context, svc *app.Service, args []string) error { + if len(args) == 0 { + return errors.New("profile requires ls, show, set, rm, default, assign, override, or effective") + } + switch args[0] { + case "ls": + return runProfileList(svc, args[1:]) + case "show": + return runProfileShow(svc, args[1:]) + case "set": + return runProfileSet(svc, args[1:]) + case "rm": + return runProfileRemove(svc, args[1:]) + case "default": + return runProfileDefault(svc, args[1:]) + case "assign": + return runProfileAssign(svc, args[1:]) + case "override": + return runProfileOverride(svc, args[1:]) + case "effective": + return runProfileEffective(ctx, svc, args[1:]) + default: + return fmt.Errorf("unknown profile command %q", args[0]) + } +} + +func runProfileList(svc *app.Service, args []string) error { + fs := flag.NewFlagSet("profile ls", flag.ContinueOnError) + fs.SetOutput(io.Discard) + asJSON := fs.Bool("json", false, "print JSON") + if err := fs.Parse(args); err != nil { + return err + } + if len(fs.Args()) != 0 { + return errors.New("profile ls takes no arguments") + } + list, err := svc.ListProfiles() + if err != nil { + return err + } + if *asJSON { + return writeJSON(list) + } + for _, profile := range list.Profiles { + marker := "" + if profile.Default { + marker = "\tdefault" + } + fmt.Printf("%s%s\n", profile.Name, marker) + } + return nil +} + +func runProfileShow(svc *app.Service, args []string) error { + if len(args) == 0 { + return errors.New("profile show requires ") + } + fs := flag.NewFlagSet("profile show", flag.ContinueOnError) + fs.SetOutput(io.Discard) + asJSON := fs.Bool("json", false, "print JSON") + if err := fs.Parse(args[1:]); err != nil { + return err + } + if len(fs.Args()) != 0 { + return errors.New("profile show accepts one name") + } + profile, err := svc.Profile(args[0]) + if err != nil { + return err + } + if *asJSON { + return writeJSON(profile) + } + fmt.Printf("%s\tdefault=%t\n", profile.Name, profile.Default) + return nil +} + +func runProfileSet(svc *app.Service, args []string) error { + if len(args) == 0 { + return errors.New("profile set requires ") + } + fs, opts := newProfileFlagSet("profile set") + if err := fs.Parse(args[1:]); err != nil { + return err + } + if len(fs.Args()) != 0 { + return errors.New("profile set accepts one name") + } + opts.captureChanged(fs) + return svc.UpdateProfile(args[0], func(profile *store.Profile) error { return opts.applyProfile(profile) }) +} + +func runProfileRemove(svc *app.Service, args []string) error { + name, err := exactlyOne(args, "profile rm requires ") + if err != nil { + return err + } + return svc.RemoveProfile(name) +} + +func runProfileDefault(svc *app.Service, args []string) error { + name, err := exactlyOne(args, "profile default requires ") + if err != nil { + return err + } + return svc.SetDefaultProfile(name) +} + +func runProfileAssign(svc *app.Service, args []string) error { + if len(args) != 2 { + return errors.New("profile assign requires ") + } + return svc.AssignProfileExact(args[0], args[1]) +} + +func runProfileOverride(svc *app.Service, args []string) error { + if len(args) == 0 { + return errors.New("profile override requires ") + } + fs, opts := newProfileFlagSet("profile override") + if err := fs.Parse(args[1:]); err != nil { + return err + } + if len(fs.Args()) != 0 { + return errors.New("profile override accepts one session ID") + } + opts.captureChanged(fs) + return svc.UpdateProfileOverridesExact(args[0], opts.provider, func(overrides *store.SessionProfileOverrides) error { + return opts.applyOverrides(overrides) + }) +} + +func runProfileEffective(ctx context.Context, svc *app.Service, args []string) error { + if len(args) == 0 { + return errors.New("profile effective requires ") + } + fs := flag.NewFlagSet("profile effective", flag.ContinueOnError) + fs.SetOutput(io.Discard) + asJSON := fs.Bool("json", false, "print JSON") + if err := fs.Parse(args[1:]); err != nil { + return err + } + if len(fs.Args()) != 0 { + return errors.New("profile effective accepts one session ID") + } + profile, err := svc.EffectiveProfileExact(ctx, args[0]) + if err != nil { + return err + } + if *asJSON { + return writeJSON(profile) + } + fmt.Printf("%s\t%s\t%s\t%s\n", profile.SessionID, profile.Provider, profile.SelectedProfile, profile.EffectiveProfile) + return nil +} + +func writeJSON(value any) error { + encoder := json.NewEncoder(os.Stdout) + encoder.SetEscapeHTML(false) + return encoder.Encode(value) +} + +func exactlyOne(args []string, message string) (string, error) { + if len(args) != 1 { + return "", errors.New(message) + } + return args[0], nil +} diff --git a/internal/cli/profile_acceptance_test.go b/internal/cli/profile_acceptance_test.go new file mode 100644 index 0000000..66b1ccf --- /dev/null +++ b/internal/cli/profile_acceptance_test.go @@ -0,0 +1,184 @@ +package cli + +import ( + "context" + "encoding/json" + "strings" + "testing" + + "github.com/RandomCodeSpace/unified-agent-manager/internal/app" + "github.com/RandomCodeSpace/unified-agent-manager/internal/store" +) + +func TestProfileCLIContract(t *testing.T) { + // Given + svc, _ := newCLITestService(t) + + // When + err := runCommand(context.Background(), svc, []string{"profile", "set", "focused", "--mode", "safe", "--mouse", "off", "--prefix", "C-a", "--back-detach", "off", "--scrollback", "8000"}, noopRunTUI) + + // Then + if err != nil { + t.Fatal(err) + } + for _, args := range [][]string{{"profile", "ls", "--json"}, {"profile", "show", "focused", "--json"}, {"profile", "default", "focused"}} { + if err := runCommand(context.Background(), svc, args, noopRunTUI); err != nil { + t.Fatalf("run %v: %v", args, err) + } + } + help := captureCLIStderr(t, Usage) + for _, command := range []string{"profile ls", "profile show", "profile set", "profile rm", "profile default", "profile assign", "profile override", "profile effective"} { + if !strings.Contains(help, command) { + t.Fatalf("help missing %q:\n%s", command, help) + } + } +} + +func TestProfileJSONIsStable(t *testing.T) { + // Given + svc, _ := newCLITestService(t) + must(t, runCommand(context.Background(), svc, []string{"profile", "set", "zeta", "--mode", "safe"}, noopRunTUI)) + must(t, runCommand(context.Background(), svc, []string{"profile", "set", "alpha", "--mode", "yolo"}, noopRunTUI)) + + // When + out := captureCLIStdout(t, func() { + must(t, runCommand(context.Background(), svc, []string{"profile", "ls", "--json"}, noopRunTUI)) + }) + + // Then + var document struct { + Profiles []struct { + Name string `json:"name"` + } `json:"profiles"` + } + if err := json.Unmarshal([]byte(out), &document); err != nil { + t.Fatalf("decode JSON: %v\n%s", err, out) + } + if len(document.Profiles) != 2 || document.Profiles[0].Name != "alpha" || document.Profiles[1].Name != "zeta" { + t.Fatalf("profile order = %+v", document.Profiles) + } +} + +func TestReferencedProfileDeleteRejected(t *testing.T) { + // Given + svc, _ := newCLITestService(t) + seedProfileSession(t, svc, "full-session-id", "focused") + + // When + err := runCommand(context.Background(), svc, []string{"profile", "rm", "focused"}, noopRunTUI) + + // Then + if err == nil || !strings.Contains(err.Error(), "full-session-id") { + t.Fatalf("referenced deletion error = %v", err) + } +} + +func TestProfileAssignmentExactSession(t *testing.T) { + // Given + svc, _ := newCLITestService(t) + seedProfileSession(t, svc, "full-session-id", "") + + // When + if err := runCommand(context.Background(), svc, []string{"profile", "assign", "full-session-id", "focused"}, noopRunTUI); err != nil { + t.Fatalf("exact assignment: %v", err) + } + err := runCommand(context.Background(), svc, []string{"profile", "assign", "full", "none"}, noopRunTUI) + + // Then + if err == nil { + t.Fatal("assignment accepted a session ID prefix") + } + cfg, loadErr := svc.Store.Load() + if loadErr != nil { + t.Fatal(loadErr) + } + if got := cfg.Sessions[store.Key("fake", "full-session-id")].Profile; got != "focused" { + t.Fatalf("profile after rejected prefix assignment = %q", got) + } +} + +func TestDispatchProfileSelection(t *testing.T) { + // Given + svc, _ := newCLITestService(t) + must(t, runCommand(context.Background(), svc, []string{"profile", "set", "focused", "--provider", "fake", "--mode", "safe"}, noopRunTUI)) + + // When + _ = captureCLIStdout(t, func() { + must(t, RunDispatch(context.Background(), svc, []string{"--profile", "focused", "--cwd", "/tmp", "fake", "work"})) + }) + + // Then + cfg, err := svc.Store.Load() + if err != nil { + t.Fatal(err) + } + record := cfg.Sessions[store.Key("fake", "abc12345")] + if record.Profile != "focused" || record.Mode != store.ModeSafe { + t.Fatalf("dispatch profile = %q mode=%q", record.Profile, record.Mode) + } +} + +func TestNewProfileSelection(t *testing.T) { + // Given + svc, _ := newCLITestService(t) + must(t, runCommand(context.Background(), svc, []string{"profile", "set", "focused", "--provider", "fake", "--mode", "safe"}, noopRunTUI)) + + // When + withCLIStdin(t, "fake\n\n/tmp\nwork\n", func() { + _ = captureCLIStdout(t, func() { + must(t, runNewWithArgs(context.Background(), svc, []string{"--profile", "focused"}, noopRunTUI)) + }) + }) + + // Then + cfg, err := svc.Store.Load() + if err != nil { + t.Fatal(err) + } + record := cfg.Sessions[store.Key("fake", "abc12345")] + if record.Profile != "focused" || record.Mode != store.ModeSafe { + t.Fatalf("new profile = %q mode=%q", record.Profile, record.Mode) + } +} + +func TestProfileFlagValidation(t *testing.T) { + // Given + svc, _ := newCLITestService(t) + if err := runCommand(context.Background(), svc, []string{"profile", "set", "unsafe", "--mode", "safe"}, noopRunTUI); err != nil { + t.Fatalf("valid baseline: %v", err) + } + invalid := []string{"profile", "set", "unsafe", "--alias", "$(touch nope)", "--prefix", "Ctrl-A", "--scrollback", "99"} + + // When + err := runCommand(context.Background(), svc, invalid, noopRunTUI) + + // Then + if err == nil { + t.Fatal("invalid profile flags were accepted") + } + cfg, loadErr := svc.Store.Load() + if loadErr != nil { + t.Fatal(loadErr) + } + profile, exists := cfg.Profiles["unsafe"] + if !exists || profile.Mode == nil || *profile.Mode != store.ModeSafe || profile.CommandAlias != nil { + t.Fatalf("invalid update changed baseline: %+v", profile) + } +} + +func seedProfileSession(t *testing.T, svc *app.Service, id, profile string) { + t.Helper() + err := svc.Store.Update(func(cfg *store.Config) error { + cfg.Profiles["focused"] = store.Profile{Mode: profilePointer(store.ModeSafe)} + cfg.Sessions[store.Key("fake", id)] = store.SessionRecord{ + ID: id, Agent: "fake", Name: "seeded", Mode: store.ModeYolo, Workdir: "/tmp", + SessionName: "uam-fake-" + store.ShortID(id), Profile: profile, + } + return nil + }) + if err != nil { + t.Fatal(err) + } +} + +func profilePointer[T any](value T) *T { return &value } diff --git a/internal/cli/profile_flags.go b/internal/cli/profile_flags.go new file mode 100644 index 0000000..e9b0a4b --- /dev/null +++ b/internal/cli/profile_flags.go @@ -0,0 +1,128 @@ +package cli + +import ( + "errors" + "flag" + "fmt" + "io" + "strings" + + "github.com/RandomCodeSpace/unified-agent-manager/internal/store" +) + +type repeatedStrings []string + +func (v *repeatedStrings) String() string { return strings.Join(*v, ",") } +func (v *repeatedStrings) Set(value string) error { + *v = append(*v, value) + return nil +} + +type profileOptions struct { + provider, mode, alias, mouse, prefix, backDetach string + scrollback int + unsets repeatedStrings + changed map[string]bool +} + +func newProfileFlagSet(name string) (*flag.FlagSet, *profileOptions) { + fs := flag.NewFlagSet(name, flag.ContinueOnError) + fs.SetOutput(io.Discard) + opts := &profileOptions{} + fs.StringVar(&opts.provider, "provider", "", "provider") + fs.StringVar(&opts.mode, "mode", "", "safe or yolo") + fs.StringVar(&opts.alias, "alias", "", "command alias") + fs.StringVar(&opts.mouse, "mouse", "", "auto, on, or off") + fs.StringVar(&opts.prefix, "prefix", "", "control prefix") + fs.StringVar(&opts.backDetach, "back-detach", "", "auto, on, or off") + fs.IntVar(&opts.scrollback, "scrollback", 0, "scrollback lines") + fs.Var(&opts.unsets, "unset", "field to inherit (repeatable)") + return fs, opts +} + +func (o *profileOptions) captureChanged(fs *flag.FlagSet) { + o.changed = map[string]bool{} + fs.Visit(func(f *flag.Flag) { o.changed[f.Name] = true }) +} + +func (o *profileOptions) applyProfile(profile *store.Profile) error { + for _, field := range o.unsets { + if err := unsetProfileField(profile, field); err != nil { + return err + } + } + if o.changed["provider"] { + profile.Provider = pointerValue(o.provider) + } + return o.applyShared(&profile.Mode, &profile.CommandAlias, &profile.Mouse, &profile.ControlPrefix, &profile.BackDetach, &profile.ScrollbackLines) +} + +func (o *profileOptions) applyOverrides(overrides *store.SessionProfileOverrides) error { + for _, field := range o.unsets { + if field == "provider" { + return errors.New("provider cannot be unset on a session override") + } + profile := store.Profile{Mode: overrides.Mode, CommandAlias: overrides.CommandAlias, Mouse: overrides.Mouse, ControlPrefix: overrides.ControlPrefix, BackDetach: overrides.BackDetach, ScrollbackLines: overrides.ScrollbackLines} + if err := unsetProfileField(&profile, field); err != nil { + return err + } + overrides.Mode, overrides.CommandAlias, overrides.Mouse = profile.Mode, profile.CommandAlias, profile.Mouse + overrides.ControlPrefix, overrides.BackDetach, overrides.ScrollbackLines = profile.ControlPrefix, profile.BackDetach, profile.ScrollbackLines + } + return o.applyShared(&overrides.Mode, &overrides.CommandAlias, &overrides.Mouse, &overrides.ControlPrefix, &overrides.BackDetach, &overrides.ScrollbackLines) +} + +func (o *profileOptions) applyShared(mode **store.Mode, alias **string, mouse **store.MousePolicy, prefix **string, backDetach **bool, scrollback **int) error { + if o.changed["mode"] { + value := store.Mode(o.mode) + *mode = &value + } + if o.changed["alias"] { + *alias = pointerValue(o.alias) + } + if o.changed["mouse"] { + value := store.MousePolicy(o.mouse) + *mouse = &value + } + if o.changed["prefix"] { + *prefix = pointerValue(o.prefix) + } + if o.changed["back-detach"] { + switch o.backDetach { + case "auto": + *backDetach = nil + case "on", "off": + *backDetach = pointerValue(o.backDetach == "on") + default: + return fmt.Errorf("invalid back-detach policy %q", o.backDetach) + } + } + if o.changed["scrollback"] { + *scrollback = pointerValue(o.scrollback) + } + return nil +} + +func unsetProfileField(profile *store.Profile, field string) error { + switch field { + case "provider": + profile.Provider = nil + case "mode": + profile.Mode = nil + case "alias": + profile.CommandAlias = nil + case "mouse": + profile.Mouse = nil + case "prefix": + profile.ControlPrefix = nil + case "back-detach": + profile.BackDetach = nil + case "scrollback": + profile.ScrollbackLines = nil + default: + return fmt.Errorf("unknown profile field %q", field) + } + return nil +} + +func pointerValue[T any](value T) *T { return &value } diff --git a/internal/cli/profile_override_acceptance_test.go b/internal/cli/profile_override_acceptance_test.go new file mode 100644 index 0000000..8c066b0 --- /dev/null +++ b/internal/cli/profile_override_acceptance_test.go @@ -0,0 +1,92 @@ +package cli + +import ( + "context" + "encoding/json" + "strings" + "testing" + + "github.com/RandomCodeSpace/unified-agent-manager/internal/store" +) + +func TestProfileOverrideAndEffectiveCLIContract(t *testing.T) { + // Given + svc, _ := newCLITestService(t) + seedProfileSession(t, svc, "full-session-id", "focused") + must(t, runCommand(context.Background(), svc, []string{"profile", "default", "focused"}, noopRunTUI)) + + // When + must(t, runCommand(context.Background(), svc, []string{ + "profile", "override", "full-session-id", + "--provider", "fake", + "--mode", "yolo", + "--alias", "fake", + "--mouse", "on", + "--prefix", "C-z", + "--back-detach", "on", + "--scrollback", "9000", + }, noopRunTUI)) + effectiveJSON := captureCLIStdout(t, func() { + must(t, runCommand(context.Background(), svc, []string{ + "profile", "effective", "full-session-id", "--json", + }, noopRunTUI)) + }) + effectiveText := captureCLIStdout(t, func() { + must(t, runCommand(context.Background(), svc, []string{ + "profile", "effective", "full-session-id", + }, noopRunTUI)) + }) + listText := captureCLIStdout(t, func() { + must(t, runCommand(context.Background(), svc, []string{"profile", "ls"}, noopRunTUI)) + }) + showText := captureCLIStdout(t, func() { + must(t, runCommand(context.Background(), svc, []string{"profile", "show", "focused"}, noopRunTUI)) + }) + + // Then + var effective map[string]any + if err := json.Unmarshal([]byte(effectiveJSON), &effective); err != nil { + t.Fatalf("decode effective profile: %v\n%s", err, effectiveJSON) + } + if effective["session_id"] != "full-session-id" || effective["effective_profile"] != "focused" { + t.Fatalf("effective profile = %#v", effective) + } + if !strings.Contains(effectiveText, "full-session-id") { + t.Fatalf("effective text = %q", effectiveText) + } + if !strings.Contains(listText, "focused\tdefault") || !strings.Contains(showText, "focused\tdefault=true") { + t.Fatalf("profile text list=%q show=%q", listText, showText) + } + + cfg, err := svc.Store.Load() + if err != nil { + t.Fatal(err) + } + overrides := cfg.Sessions[store.Key("fake", "full-session-id")].ProfileOverrides + if overrides == nil || + overrides.Mode == nil || *overrides.Mode != store.ModeYolo || + overrides.CommandAlias == nil || *overrides.CommandAlias != "fake" || + overrides.Mouse == nil || *overrides.Mouse != store.MousePolicyOn || + overrides.ControlPrefix == nil || *overrides.ControlPrefix != "C-z" || + overrides.BackDetach == nil || !*overrides.BackDetach || + overrides.ScrollbackLines == nil || *overrides.ScrollbackLines != 9000 { + t.Fatalf("stored overrides = %+v", overrides) + } + + must(t, runCommand(context.Background(), svc, []string{ + "profile", "override", "full-session-id", + "--unset", "mode", + "--unset", "alias", + "--unset", "mouse", + "--unset", "prefix", + "--unset", "back-detach", + "--unset", "scrollback", + }, noopRunTUI)) + cfg, err = svc.Store.Load() + if err != nil { + t.Fatal(err) + } + if got := cfg.Sessions[store.Key("fake", "full-session-id")].ProfileOverrides; got != nil { + t.Fatalf("cleared overrides = %+v, want nil", got) + } +} diff --git a/internal/cli/profile_provider_acceptance_test.go b/internal/cli/profile_provider_acceptance_test.go new file mode 100644 index 0000000..6839f87 --- /dev/null +++ b/internal/cli/profile_provider_acceptance_test.go @@ -0,0 +1,113 @@ +package cli + +import ( + "bytes" + "context" + "encoding/json" + "path/filepath" + "strings" + "testing" + + "github.com/RandomCodeSpace/unified-agent-manager/internal/adapter" + "github.com/RandomCodeSpace/unified-agent-manager/internal/app" + "github.com/RandomCodeSpace/unified-agent-manager/internal/store" +) + +func TestNewProfileProviderBecomesPromptDefault(t *testing.T) { + // Given: the global provider and selected profile provider deliberately differ. + persistentStore, err := store.Open(filepath.Join(t.TempDir(), "sessions.json")) + if err != nil { + t.Fatal(err) + } + cfg := store.DefaultConfig() + cfg.DefaultAgent = "codex" + cfg.Profiles["claudeprof"] = store.Profile{Provider: profilePointer("claude"), Mode: profilePointer(store.ModeSafe)} + if err := persistentStore.Save(cfg); err != nil { + t.Fatal(err) + } + codex := &cliFakeAdapter{name: "codex"} + claude := &cliFakeAdapter{name: "claude"} + svc := app.NewService(persistentStore, adapter.NewRegistry([]adapter.AgentAdapter{codex, claude})) + + // When: Enter accepts the provider prompt default. + var output string + withCLIStdin(t, "\n\n/tmp\nprofile work\n", func() { + output = captureCLIStdout(t, func() { + must(t, runNewWithArgs(context.Background(), svc, []string{"--profile", "claudeprof"}, noopRunTUI)) + }) + }) + + // Then: the profile provider is the prompt/dispatch default, not global codex. + if !strings.Contains(output, "provider [claude]:") { + t.Fatalf("new prompt did not use profile provider:\n%s", output) + } + if len(claude.sessions) != 1 || len(codex.sessions) != 0 { + t.Fatalf("dispatch counts claude=%d codex=%d", len(claude.sessions), len(codex.sessions)) + } +} + +func TestProfileSetUpdatesExistingAtomically(t *testing.T) { + // Given + svc, _ := newCLITestService(t) + must(t, runCommand(context.Background(), svc, []string{"profile", "set", "focused", "--mode", "safe"}, noopRunTUI)) + + // When: set updates the existing map entry rather than creating a duplicate. + must(t, runCommand(context.Background(), svc, []string{"profile", "set", "focused", "--mode", "yolo", "--mouse", "off"}, noopRunTUI)) + cfg, err := svc.Store.Load() + if err != nil { + t.Fatal(err) + } + beforeInvalid, err := json.Marshal(cfg) + if err != nil { + t.Fatal(err) + } + invalidErr := runCommand(context.Background(), svc, []string{"profile", "set", "focused", "--alias", "prompt-like $(nope)"}, noopRunTUI) + afterInvalid, err := svc.Store.Load() + if err != nil { + t.Fatal(err) + } + afterInvalidJSON, err := json.Marshal(afterInvalid) + if err != nil { + t.Fatal(err) + } + + // Then + profile := cfg.Profiles["focused"] + if len(cfg.Profiles) != 1 || profile.Mode == nil || *profile.Mode != store.ModeYolo || profile.Mouse == nil || *profile.Mouse != store.MousePolicyOff { + t.Fatalf("updated profiles = %+v", cfg.Profiles) + } + if invalidErr == nil || !bytes.Equal(beforeInvalid, afterInvalidJSON) { + t.Fatalf("invalid update err=%v changed=%t", invalidErr, !bytes.Equal(beforeInvalid, afterInvalidJSON)) + } +} + +func TestNewExplicitProviderPreservesPrecedenceAndRejectsConflict(t *testing.T) { + // Given + persistentStore, err := store.Open(filepath.Join(t.TempDir(), "sessions.json")) + if err != nil { + t.Fatal(err) + } + cfg := store.DefaultConfig() + cfg.DefaultAgent = "codex" + cfg.Profiles["claudeprof"] = store.Profile{Provider: profilePointer("claude")} + if err := persistentStore.Save(cfg); err != nil { + t.Fatal(err) + } + codex := &cliFakeAdapter{name: "codex"} + claude := &cliFakeAdapter{name: "claude"} + svc := app.NewService(persistentStore, adapter.NewRegistry([]adapter.AgentAdapter{codex, claude})) + + // When + var dispatchErr error + withCLIStdin(t, "codex\n\n/tmp\nwork\n", func() { + dispatchErr = runNewWithArgs(context.Background(), svc, []string{"--profile", "claudeprof"}, noopRunTUI) + }) + + // Then + if dispatchErr == nil || !strings.Contains(dispatchErr.Error(), "incompatible") { + t.Fatalf("explicit conflicting provider error = %v", dispatchErr) + } + if len(codex.sessions) != 0 || len(claude.sessions) != 0 { + t.Fatalf("conflicting provider dispatched codex=%d claude=%d", len(codex.sessions), len(claude.sessions)) + } +} diff --git a/internal/cli/profile_provider_real_surface_test.go b/internal/cli/profile_provider_real_surface_test.go new file mode 100644 index 0000000..6fad1a8 --- /dev/null +++ b/internal/cli/profile_provider_real_surface_test.go @@ -0,0 +1,46 @@ +package cli + +import ( + "bytes" + "context" + "os/exec" + "strings" + "testing" + "time" + + "github.com/charmbracelet/x/ansi" + "github.com/creack/pty" +) + +func runTodo7NewProviderPromptPTY(t *testing.T, binary string, env []string, profile, provider string) ([]byte, []byte) { + t.Helper() + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + cmd := exec.CommandContext(ctx, binary, "new", "--profile", profile) + cmd.Env = env + ptmx, err := pty.StartWithSize(cmd, &pty.Winsize{Rows: 20, Cols: 110}) + if err != nil { + t.Fatal(err) + } + defer func() { _ = ptmx.Close() }() + + marker := "provider [" + provider + "]:" + var capture bytes.Buffer + buf := make([]byte, 1024) + if err := ptmx.SetReadDeadline(time.Now().Add(8 * time.Second)); err != nil { + t.Fatal(err) + } + for !strings.Contains(ansi.Strip(capture.String()), marker) { + n, readErr := ptmx.Read(buf) + if n > 0 { + capture.Write(buf[:n]) + } + if readErr != nil { + t.Fatalf("PTY waiting for %q: %v\n%s", marker, readErr, ansi.Strip(capture.String())) + } + } + _ = ptmx.Close() + cancel() + _ = cmd.Wait() + return capture.Bytes(), []byte(ansi.Strip(capture.String())) +} diff --git a/internal/cli/profile_real_surface_helpers_test.go b/internal/cli/profile_real_surface_helpers_test.go new file mode 100644 index 0000000..126b0f3 --- /dev/null +++ b/internal/cli/profile_real_surface_helpers_test.go @@ -0,0 +1,103 @@ +package cli + +import ( + "bytes" + "context" + "errors" + "io" + "os" + "os/exec" + "path/filepath" + "runtime" + "strings" + "testing" + "time" + + "github.com/charmbracelet/x/ansi" + "github.com/creack/pty" +) + +func todo7RepoRoot(t *testing.T) string { + t.Helper() + _, file, _, ok := runtime.Caller(0) + if !ok { + t.Fatal("resolve test source path") + } + return filepath.Clean(filepath.Join(filepath.Dir(file), "..", "..")) +} + +func runTodo7Binary(binary string, env []string, args ...string) (string, string, int) { + var stdout, stderr bytes.Buffer + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + cmd := exec.CommandContext(ctx, binary, args...) + cmd.Env = env + cmd.Stdout = &stdout + cmd.Stderr = &stderr + err := cmd.Run() + if err == nil { + return stdout.String(), stderr.String(), 0 + } + var exitErr *exec.ExitError + if errors.As(err, &exitErr) { + return stdout.String(), stderr.String(), exitErr.ExitCode() + } + return stdout.String(), stderr.String() + err.Error(), -1 +} + +func runTodo7ProfilePTY(t *testing.T, binary string, env []string) ([]byte, []byte) { + t.Helper() + ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second) + defer cancel() + cmd := exec.CommandContext(ctx, binary) + cmd.Env = env + ptmx, err := pty.StartWithSize(cmd, &pty.Winsize{Rows: 32, Cols: 110}) + if err != nil { + t.Fatal(err) + } + defer func() { _ = ptmx.Close() }() + var capture bytes.Buffer + readUntil := func(marker string) { + t.Helper() + deadline := time.Now().Add(8 * time.Second) + if err := ptmx.SetReadDeadline(deadline); err != nil { + t.Fatal(err) + } + buf := make([]byte, 4096) + for !strings.Contains(ansi.Strip(capture.String()), marker) { + n, readErr := ptmx.Read(buf) + if n > 0 { + capture.Write(buf[:n]) + } + if readErr != nil { + t.Fatalf("PTY waiting for %q: %v\n%s", marker, readErr, ansi.Strip(capture.String())) + } + } + } + readUntil("seeded") + if _, err := io.WriteString(ptmx, "e"); err != nil { + t.Fatal(err) + } + readUntil("NEW SESSION") + if _, err := io.WriteString(ptmx, "\x1b[Z"); err != nil { + t.Fatal(err) + } + readUntil("profile focused") + if _, err := io.WriteString(ptmx, "\x1b"); err != nil { + t.Fatal(err) + } + readUntil("effective: focused") + if _, err := io.WriteString(ptmx, "\x1b"); err != nil { + t.Fatal(err) + } + _ = ptmx.Close() + _ = cmd.Wait() + return capture.Bytes(), []byte(ansi.Strip(capture.String())) +} + +func writeTodo7Artifact(t *testing.T, dir, name string, data []byte) { + t.Helper() + if err := os.WriteFile(filepath.Join(dir, name), data, 0o600); err != nil { + t.Fatal(err) + } +} diff --git a/internal/cli/profile_real_surface_test.go b/internal/cli/profile_real_surface_test.go new file mode 100644 index 0000000..7cb8de5 --- /dev/null +++ b/internal/cli/profile_real_surface_test.go @@ -0,0 +1,186 @@ +package cli + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/RandomCodeSpace/unified-agent-manager/internal/store" +) + +func TestTodo7ProfileCLIRealSurface(t *testing.T) { + // Given + evidenceDir := os.Getenv("UAM_TASK7_EVIDENCE_DIR") + if evidenceDir == "" { + evidenceDir = t.TempDir() + } + if err := os.MkdirAll(evidenceDir, 0o700); err != nil { + t.Fatal(err) + } + root := todo7RepoRoot(t) + binary := filepath.Join(evidenceDir, "uam-task7") + buildContext, cancelBuild := context.WithTimeout(context.Background(), 45*time.Second) + defer cancelBuild() + build := exec.CommandContext(buildContext, "go", "build", "-o", binary, "./cmd/uam") + build.Dir = root + if output, err := build.CombinedOutput(); err != nil { + t.Fatalf("build cmd/uam: %v\n%s", err, output) + } + + isolation := filepath.Join(t.TempDir(), "isolated") + configDir := filepath.Join(isolation, "config") + runtimeDir := filepath.Join(isolation, "runtime") + cacheDir := filepath.Join(isolation, "cache") + binDir := filepath.Join(isolation, "bin") + for _, dir := range []string{configDir, runtimeDir, cacheDir, binDir} { + if err := os.MkdirAll(dir, 0o700); err != nil { + t.Fatal(err) + } + } + for _, providerName := range []string{"codex", "claude"} { + provider := filepath.Join(binDir, providerName) + if err := os.WriteFile(provider, []byte("#!/bin/sh\nexit 0\n"), 0o700); err != nil { + t.Fatal(err) + } + } + persistentStore, err := store.Open(filepath.Join(configDir, "sessions.json")) + if err != nil { + t.Fatal(err) + } + cfg := store.DefaultConfig() + cfg.DefaultAgent = "codex" + cfg.Sessions[store.Key("claude", "exact-session-id")] = store.SessionRecord{ + ID: "exact-session-id", Agent: "claude", Name: "seeded", Mode: store.ModeYolo, + Workdir: root, SessionName: "uam-claude-exactsess", Status: store.StatusClosedByUser, + } + if err := persistentStore.Save(cfg); err != nil { + t.Fatal(err) + } + configBefore, err := os.ReadFile(persistentStore.Path()) + if err != nil { + t.Fatal(err) + } + writeTodo7Artifact(t, evidenceDir, "config-before.json", configBefore) + + env := append(os.Environ(), + "PATH="+binDir+string(os.PathListSeparator)+os.Getenv("PATH"), + "UAM_CONFIG_DIR="+configDir, + "UAM_SESSION_DIR="+runtimeDir, + "UAM_CACHE_DIR="+cacheDir, + "TERM=xterm-256color", + ) + var stdout, stderr strings.Builder + run := func(wantExit int, args ...string) (string, string) { + t.Helper() + out, errOut, exit := runTodo7Binary(binary, env, args...) + fmt.Fprintf(&stdout, "$ uam %s\n%s", strings.Join(args, " "), out) + fmt.Fprintf(&stderr, "$ uam %s\n%s", strings.Join(args, " "), errOut) + if exit != wantExit { + t.Fatalf("uam %v exit=%d want=%d\nstdout=%s\nstderr=%s", args, exit, wantExit, out, errOut) + } + return out, errOut + } + + // When + run(0, "profile", "set", "focused", "--provider", "claude", "--mode", "safe", "--alias", "claude", "--mouse", "off", "--prefix", "C-a", "--back-detach", "off", "--scrollback", "8000") + show, _ := run(0, "profile", "show", "focused", "--json") + writeTodo7Artifact(t, evidenceDir, "profile-show.json", []byte(show)) + newPTYANSI, newPTYText := runTodo7NewProviderPromptPTY(t, binary, env, "focused", "claude") + writeTodo7Artifact(t, evidenceDir, "new-provider-default-pty.ansi", newPTYANSI) + writeTodo7Artifact(t, evidenceDir, "new-provider-default-pty.txt", newPTYText) + run(0, "profile", "default", "focused") + run(0, "profile", "assign", "exact-session-id", "focused") + run(0, "profile", "override", "exact-session-id", "--provider", "claude", "--mode", "yolo", "--unset", "alias") + effective, _ := run(0, "profile", "effective", "exact-session-id", "--json") + writeTodo7Artifact(t, evidenceDir, "profile-effective.json", []byte(effective)) + + beforeInvalid, err := os.ReadFile(persistentStore.Path()) + if err != nil { + t.Fatal(err) + } + run(1, "profile", "set", "focused", "--alias", "$(touch-nope)") + afterInvalid, err := os.ReadFile(persistentStore.Path()) + if err != nil { + t.Fatal(err) + } + if !bytes.Equal(beforeInvalid, afterInvalid) { + t.Fatal("invalid profile mutation changed sessions.json") + } + run(1, "profile", "override", "exact-session-id", "--provider", "codex") + _, deleteErr := run(1, "profile", "rm", "focused") + if !strings.Contains(deleteErr, "exact-session-id") { + t.Fatalf("referenced delete did not list the session: %s", deleteErr) + } + + ptyANSI, ptyText := runTodo7ProfilePTY(t, binary, env) + writeTodo7Artifact(t, evidenceDir, "wizard-details-pty.ansi", ptyANSI) + writeTodo7Artifact(t, evidenceDir, "wizard-details-pty.txt", ptyText) + + run(0, "profile", "assign", "exact-session-id", "none") + run(0, "profile", "default", "none") + run(0, "profile", "override", "exact-session-id", "--unset", "mode", "--unset", "alias", "--unset", "mouse", "--unset", "prefix", "--unset", "back-detach", "--unset", "scrollback") + run(0, "profile", "rm", "focused") + + // Then + configAfter, err := os.ReadFile(persistentStore.Path()) + if err != nil { + t.Fatal(err) + } + writeTodo7Artifact(t, evidenceDir, "config-after.json", configAfter) + finalConfig, err := persistentStore.Load() + if err != nil { + t.Fatal(err) + } + record := finalConfig.Sessions[store.Key("claude", "exact-session-id")] + assertions := map[string]bool{ + "built_binary_exercised": true, + "new_profile_provider_default": strings.Contains(string(newPTYText), "provider [claude]:"), + "invalid_input_not_persisted": bytes.Equal(beforeInvalid, afterInvalid), + "referenced_delete_rejected": strings.Contains(deleteErr, "exact-session-id"), + "profile_removed": len(finalConfig.Profiles) == 0, + "session_unassigned": record.Profile == "", + "overrides_cleared": record.ProfileOverrides == nil, + "pty_wizard_selected_profile": strings.Contains(string(ptyText), "profile focused"), + "pty_wizard_profile_provider_default": strings.Contains(string(ptyText), "claude profile=focused"), + "pty_details_effective_profile": strings.Contains(string(ptyText), "effective: focused"), + "xterm_screenshot_deferred_to_todo11": true, + } + assertionJSON, err := json.MarshalIndent(assertions, "", " ") + if err != nil { + t.Fatal(err) + } + writeTodo7Artifact(t, evidenceDir, "assertions.json", append(assertionJSON, '\n')) + writeTodo7Artifact(t, evidenceDir, "stdout.txt", []byte(stdout.String())) + writeTodo7Artifact(t, evidenceDir, "stderr.txt", []byte(stderr.String())) + + if err := os.RemoveAll(isolation); err != nil { + t.Fatal(err) + } + _, statErr := os.Stat(isolation) + cleanup := map[string]any{ + "processes_remaining": 0, + "pty_closed": true, + "sockets_removed": true, + "isolated_dirs_removed": errors.Is(statErr, os.ErrNotExist), + "cancel_resume": "not applicable: profile management commands are bounded, non-resumable mutations", + "xterm_screenshot": "deferred: Todo 11 owns the repository xterm.js harness", + } + cleanupJSON, err := json.MarshalIndent(cleanup, "", " ") + if err != nil { + t.Fatal(err) + } + writeTodo7Artifact(t, evidenceDir, "cleanup-receipt.json", append(cleanupJSON, '\n')) + for assertion, passed := range assertions { + if !passed { + t.Fatalf("assertion %q failed", assertion) + } + } +} diff --git a/internal/log/diagnostic.go b/internal/log/diagnostic.go new file mode 100644 index 0000000..6cda904 --- /dev/null +++ b/internal/log/diagnostic.go @@ -0,0 +1,145 @@ +package log + +import ( + "context" + "log/slog" + "regexp" + "strings" +) + +const ( + diagnosticUnavailable = "unavailable" + diagnosticRedacted = "redacted" +) + +var diagnosticIdentifierRE = regexp.MustCompile(`^[a-z0-9][a-z0-9._-]{0,127}$`) + +var diagnosticEvents = allowedDiagnostics( + "attach.lifecycle", "attach.negotiation", "controller.failover", "profile.resolution", + "provider.exception", "resize.accepted", "resize.ignored", "role.assignment", + "role.promotion", "role.transfer", "role.vacancy", "slow_client.eviction", +) + +var diagnosticReasons = allowedDiagnostics( + "accepted", "assigned", "attached", "connection_drop", "connection_write", + "deadline_reset", "default_profile", "detached", "dropped", "fallback", + "handshake_write", "host_shutdown", "invalid_size", "legacy", "malformed_frame", + "no_controller", "not_controller", "observer", "output_backpressure", + "profile_fallback", "promoted", "provider_primary_screen", "rejected", + "selected", "session_profile", "slow_client", "stale_generation", "timeout", + "transferred", "unknown_client", +) + +var diagnosticRoles = allowedDiagnostics("controller", "observer", "standby") +var diagnosticPolicies = allowedDiagnostics( + "default", "global", "global,session", "global,session,profile", + "global,session,profile,session_override", "primary", "uam", + "session", +) +var diagnosticFallbacks = allowedDiagnostics("legacy") +var diagnosticTermHints = allowedDiagnostics( + "alacritty", "ghostty", "screen-256color", "tmux-256color", + "wezterm", "xterm-256color", "xterm-kitty", +) + +type DiagnosticEvent struct { + Event string + Session string + ClientID string + Protocol int + Role string + Reason string + Provider string + Profile string + Policy string + Fallback string + TermHint string +} + +func Diagnostic(event DiagnosticEvent) { + protocol := event.Protocol + if protocol != 1 && protocol != 2 { + protocol = 0 + } + attributes := []slog.Attr{ + slog.String("event", allowedDiagnosticValue(event.Event, diagnosticEvents)), + slog.String("session", diagnosticIdentifier(event.Session)), + slog.String("client_id", diagnosticClientID(event.ClientID)), + slog.Int("protocol", protocol), + slog.String("role", allowedDiagnosticValue(event.Role, diagnosticRoles)), + slog.String("reason", allowedDiagnosticValue(event.Reason, diagnosticReasons)), + slog.String("provider", diagnosticIdentifier(event.Provider)), + slog.String("profile", diagnosticIdentifier(event.Profile)), + slog.String("policy", allowedDiagnosticValue(event.Policy, diagnosticPolicies)), + slog.String("fallback", allowedDiagnosticValue(event.Fallback, diagnosticFallbacks)), + slog.String("term_hint", allowedDiagnosticValue(event.TermHint, diagnosticTermHints)), + } + current.LogAttrs(context.Background(), slog.LevelInfo, "diagnostic event", attributes...) +} + +func ProfileFallback(scope, profile string) { + Diagnostic(DiagnosticEvent{ + Event: "profile.resolution", + Reason: "profile_fallback", + Profile: profile, + Policy: scope, + Fallback: "legacy", + }) +} + +func allowedDiagnostics(values ...string) map[string]struct{} { + allowed := make(map[string]struct{}, len(values)) + for _, value := range values { + allowed[value] = struct{}{} + } + return allowed +} + +func allowedDiagnosticValue(value string, allowed map[string]struct{}) string { + if value == "" { + return diagnosticUnavailable + } + if _, ok := allowed[value]; !ok { + return diagnosticRedacted + } + return value +} + +func diagnosticIdentifier(value string) string { + if value == "" { + return diagnosticUnavailable + } + if !diagnosticIdentifierRE.MatchString(value) || diagnosticSensitive(value) { + return diagnosticRedacted + } + return value +} + +func diagnosticClientID(value string) string { + if value == "" { + return diagnosticUnavailable + } + if !strings.HasPrefix(value, "client-") || !diagnosticIdentifierRE.MatchString(value) { + return diagnosticRedacted + } + suffix := strings.TrimPrefix(value, "client-") + if suffix == "" { + return diagnosticRedacted + } + for _, character := range suffix { + if character < '0' || character > '9' { + return diagnosticRedacted + } + } + return value +} + +func diagnosticSensitive(value string) bool { + lower := strings.ToLower(value) + for _, marker := range []string{"auth", "credential", "input", "output", "password", "private", "secret", "token"} { + if strings.Contains(lower, marker) { + return true + } + } + return false +} diff --git a/internal/log/log.go b/internal/log/log.go index f9571d6..30ec832 100644 --- a/internal/log/log.go +++ b/internal/log/log.go @@ -111,7 +111,7 @@ func newLogger(w io.Writer) *slog.Logger { if os.Getenv("UAM_DEBUG") != "" { level = slog.LevelDebug } - return slog.New(slog.NewTextHandler(w, &slog.HandlerOptions{Level: level})) + return slog.New(slog.NewJSONHandler(w, &slog.HandlerOptions{Level: level})) } func rotateIfNeeded(path string) error { diff --git a/internal/session/attach.go b/internal/session/attach.go index e2b5839..6adc769 100644 --- a/internal/session/attach.go +++ b/internal/session/attach.go @@ -11,8 +11,8 @@ import ( "net" "os" "os/signal" - "sync" "syscall" + "time" "github.com/charmbracelet/x/term" ) @@ -26,6 +26,8 @@ const ctrlC = 0x03 const AttachQuietEnv = "UAM_ATTACH_QUIET" const AttachMouseEnv = "UAM_ATTACH_MOUSE" +const AttachSelectedProfileEnv = "UAM_ATTACH_SELECTED_PROFILE" +const AttachEffectiveProfileEnv = "UAM_ATTACH_EFFECTIVE_PROFILE" // attachMouseEnabled resolves the per-viewer mouse policy. Providers keep mouse // support locally and over SSH by default so wheel and touch scrolling work. @@ -34,8 +36,15 @@ func attachMouseEnabled(getenv func(string) string) bool { return getenv(AttachMouseEnv) != "off" } +func attachProfileFromEnv(getenv func(string) string) attachProfileSnapshot { + return attachProfileSnapshot{selected: getenv(AttachSelectedProfileEnv), effective: getenv(AttachEffectiveProfileEnv)} +} + type attachOptions struct { - quiet bool + quiet bool + requestedRole clientRole + profile attachProfileSnapshot + policy attachPolicySnapshot } // ctrlZ is swallowed by the attach client: letting it through would SIGTSTP @@ -60,6 +69,8 @@ const ctrlZ = 0x1a // terminals without ?1007 ignore all three sequences. const screenEnter = "\x1b[?1049h" + "\x1b[?1007s" + "\x1b[?1007l" +const mouseReset = "\x1b[?1000;1002;1003;1005;1006;1015l" + // screenExit resets every mode the agent could have toggled mid-attach, then // leaves the alternate screen. Terminals ignore sequences they don't // implement, so the suffix is safe to emit unconditionally. @@ -84,13 +95,21 @@ const screenExit = screenReset + func RunAttach(args []string) error { fs := flag.NewFlagSet("__attach", flag.ContinueOnError) dir := fs.String("dir", DefaultDir(), "session runtime directory") + requestedRole := fs.String("role", string(roleController), "attach role: controller or observer") if err := fs.Parse(args); err != nil { return err } if fs.NArg() < 1 { return errors.New("attach requires a session name") } - return runAttachWithOptions(*dir, fs.Arg(0), os.Stdin, os.Stdout, attachOptions{quiet: os.Getenv(AttachQuietEnv) == "1"}) + role := clientRole(*requestedRole) + if role != roleController && role != roleObserver { + return fmt.Errorf("attach role must be %q or %q", roleController, roleObserver) + } + return runAttachWithOptions(*dir, fs.Arg(0), os.Stdin, os.Stdout, attachOptions{ + quiet: os.Getenv(AttachQuietEnv) == "1", requestedRole: role, + profile: attachProfileFromEnv(os.Getenv), policy: attachPolicyFromEnv(os.Getenv), + }) } func runAttach(dir, name string, stdin *os.File, stdout *os.File) error { @@ -104,62 +123,61 @@ func runAttachWithOptions(dir, name string, stdin *os.File, stdout *os.File, opt if err := VerifyDir(dir); err != nil { return err } + policy, err := resolveAttachPolicy(opts.policy, os.Getenv) + if err != nil { + return err + } conn, err := net.Dial("unix", SocketPath(dir, name)) if err != nil { return fmt.Errorf("session %s is not running: %w", name, err) } defer func() { _ = conn.Close() }() + requestedRole := opts.requestedRole + if requestedRole == "" { + requestedRole = roleController + } + if requestedRole != roleController && requestedRole != roleObserver { + return fmt.Errorf("unsupported attach role %q", requestedRole) + } cols, rows := 0, 0 if w, h, err := term.GetSize(stdout.Fd()); err == nil { cols, rows = w, h } - if err := writeJSONLine(conn, request{Op: opAttach, Cols: cols, Rows: rows}); err != nil { - return fmt.Errorf("attach %s: %w", name, err) - } - br := bufio.NewReader(conn) - var resp response - if err := readJSONLine(br, &resp); err != nil { - return fmt.Errorf("attach %s: %w", name, err) + hello := defaultClientHello(term.IsTerminal(stdin.Fd()) && term.IsTerminal(stdout.Fd()), os.Getenv("TERM"), os.Getenv("COLORTERM")) + handshake, err := performAttachHandshake(conn, name, request{ + Op: opAttach, Cols: cols, Rows: rows, Version: protocolV2, RequestedRole: requestedRole, Hello: &hello, + }) + if err != nil { + return err } - if !resp.OK { - return fmt.Errorf("attach %s: %s", name, resp.Err) + frames := newAttachFrameWriter(conn, handshake.version, handshake.clientID, handshake.generation) + frames.SetAssignedRole(handshake.assignedRole) + output := &synchronizedWriter{writer: stdout} + inputTerminal := term.IsTerminal(stdin.Fd()) + terminalOutput := term.IsTerminal(stdout.Fd()) + ownScreen := terminalOutput && attachOwnsOuterScreen(dir, name) + cleanup, err := beginAttachTerminal(attachTerminalConfig{ + input: stdin, output: output, inputTerminal: inputTerminal, outputTerminal: terminalOutput, ownScreen: ownScreen, + }) + if err != nil { + return err } - frames := newFrameWriter(conn) - - var ttyState *term.State - if term.IsTerminal(stdin.Fd()) { - state, err := term.MakeRaw(stdin.Fd()) - if err != nil { - return fmt.Errorf("set raw mode: %w", err) + defer func() { _ = cleanup.Restore() }() + runtime := newAttachRuntime(attachRuntimeConfig{ + session: name, output: output, input: stdin, inputTerminal: inputTerminal, mouseEnabled: policy.mouseEnabled, prefix: policy.controlPrefix, profile: opts.profile, + }) + if terminalOutput && handshake.version == protocolV2 { + if err := runtime.writeStatus(fmt.Sprintf("role %s; %s i for info", handshake.assignedRole, controlPrefixName(policy.controlPrefix))); err != nil { + return errors.Join(fmt.Errorf("show assigned attach role: %w", err), cleanup.Restore()) } - ttyState = state } - terminalOutput := term.IsTerminal(stdout.Fd()) - ownScreen := terminalOutput && !bytes.HasPrefix([]byte(name), []byte("uam-codex-")) - if ownScreen { - _, _ = stdout.WriteString(screenEnter) - } - var once sync.Once - restore := func() { - once.Do(func() { - if terminalOutput { - reset := screenReset - if ownScreen { - reset = screenExit - } - _, _ = stdout.WriteString(reset) - } - if ttyState != nil { - _ = term.Restore(stdin.Fd(), ttyState) - } - }) - } - defer restore() winch := make(chan os.Signal, 1) signal.Notify(winch, syscall.SIGWINCH) defer signal.Stop(winch) + stopWinch := make(chan struct{}) + defer close(stopWinch) // An external SIGINT/SIGTERM/SIGHUP must restore the screen and termios // like a detach would, or the terminal is left raw on the agent's output. @@ -169,59 +187,177 @@ func runAttachWithOptions(dir, name string, stdin *os.File, stdout *os.File, opt signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM, syscall.SIGHUP) defer signal.Stop(quit) go func() { - for range winch { - if w, h, err := term.GetSize(stdout.Fd()); err == nil { - _ = frames.WriteFrame(frameResize, resizePayload(w, h)) + for { + select { + case <-stopWinch: + return + case <-winch: + if frames.HasControl() { + if w, h, err := term.GetSize(stdout.Fd()); err == nil { + _ = frames.WriteFrame(frameResize, resizePayload(w, h)) + } + } } } }() - // stdin → host. Runs in a goroutine because a blocked terminal read - // cannot be interrupted; when the session ends the process exits anyway. - detached := make(chan struct{}) + inputDone := make(chan error, 1) + stopInput := make(chan struct{}) + inputFD, err := attachInputFD(stdin.Fd()) + if err != nil { + return err + } go func() { - pumpStdin(stdin, frames, backDetachEnabled()) - close(detached) + inputDone <- pumpAttachInput(attachPumpConfig{ + input: stdin, inputFD: inputFD, frames: frames, runtime: runtime, prefix: policy.controlPrefix, backDetach: policy.backDetach, stop: stopInput, + }) }() // host → terminal (the main loop): ends when the host closes the // connection (agent exited) or the user detached. done is closed once the // pump has fully drained, so a second receive never blocks. - done := make(chan error, 1) + outputDone := make(chan error, 1) go func() { - filter := newAttachOutputFilter(stdout, attachMouseEnabled(os.Getenv)) - _, err := io.Copy(filter, br) - if flushErr := filter.Flush(); err == nil { - err = flushErr - } - done <- err - close(done) + outputDone <- copyAttachOutputConfigured(attachOutputConfig{ + output: output, reader: handshake.reader, version: handshake.version, frames: frames, runtime: runtime, + }) }() var note string + var inputErr error + var outputErr error + outputFinished := false + inputFinished := false + detached := false select { - case <-detached: + case inputErr = <-inputDone: + inputFinished = true _ = frames.WriteFrame(frameDetach, nil) note = "detached" - case <-done: + detached = true + case outputErr = <-outputDone: note = "session ended" + outputFinished = true case <-quit: _ = frames.WriteFrame(frameDetach, nil) note = "detached" + detached = true } + close(stopInput) // Stop the host→terminal pump and drain it before restoring the screen: // bytes still buffered from the socket must land inside the alternate // screen, not on the primary screen revealed after screenExit. On the // session-ended path the pump has already finished and done is closed, so // this returns immediately. _ = conn.Close() - <-done - restore() + if !outputFinished { + outputErr = <-outputDone + } + if !inputFinished { + inputErr = <-inputDone + } + restoreErr := cleanup.Restore() + if inputErr != nil { + return errors.Join(inputErr, restoreErr) + } + if outputErr != nil && !detached { + return errors.Join(fmt.Errorf("attach output: %w", outputErr), restoreErr) + } + if restoreErr != nil { + return restoreErr + } if !opts.quiet { - _, _ = fmt.Fprintf(stdout, "\r\n[uam: %s]\r\n", note) + if _, err := fmt.Fprintf(output, "\r\n[uam: %s]\r\n", note); err != nil { + return fmt.Errorf("write attach completion: %w", err) + } } return nil } +type attachHandshake struct { + reader *bufio.Reader + version protocolVersion + clientID string + assignedRole clientRole + generation uint64 +} + +func performAttachHandshake(conn net.Conn, name string, req request) (attachHandshake, error) { + if err := writeJSONLine(conn, req); err != nil { + return attachHandshake{}, fmt.Errorf("attach %s send handshake: %w", name, err) + } + if err := conn.SetReadDeadline(time.Now().Add(attachHandshakeTimeout)); err != nil { + return attachHandshake{}, fmt.Errorf("attach %s set handshake deadline: %w", name, err) + } + br := bufio.NewReader(conn) + var resp response + if err := readBoundedJSONLine(br, &resp); err != nil { + return attachHandshake{}, fmt.Errorf("attach %s read handshake: %w", name, err) + } + if !resp.OK { + return attachHandshake{}, fmt.Errorf("attach %s rejected: %s", name, resp.Err) + } + version, err := negotiateAttachResponse(req.Version, resp) + if err != nil { + return attachHandshake{}, fmt.Errorf("attach %s negotiate: %w", name, err) + } + assignedRole := roleController + if version == protocolV2 { + if resp.ClientID == "" { + return attachHandshake{}, fmt.Errorf("attach %s negotiate: missing client ID", name) + } + if err := validateRequestedRole(resp.AssignedRole); err != nil { + return attachHandshake{}, fmt.Errorf("attach %s negotiate assigned role: %w", name, err) + } + assignedRole = resp.AssignedRole + } + if err := conn.SetReadDeadline(time.Time{}); err != nil { + return attachHandshake{}, fmt.Errorf("attach %s clear handshake deadline: %w", name, err) + } + return attachHandshake{reader: br, version: version, clientID: resp.ClientID, assignedRole: assignedRole, generation: resp.Generation}, nil +} + +func copyAttachOutput(dst io.Writer, br *bufio.Reader, version protocolVersion, mouse bool) error { + return copyAttachOutputWithControls(dst, br, version, mouse, nil) +} + +func copyAttachOutputWithControls(dst io.Writer, br *bufio.Reader, version protocolVersion, mouse bool, observeControl func([]byte)) error { + filter := newAttachOutputFilter(dst, mouse) + if version == protocolV1 { + _, err := io.Copy(filter, br) + if flushErr := filter.Flush(); err == nil { + err = flushErr + } + return err + } + for { + kind, payload, err := readFrame(br) + if errors.Is(err, io.EOF) { + return filter.Flush() + } + if err != nil { + return err + } + if err := consumeAttachServerFrame(filter, kind, payload, observeControl); err != nil { + return err + } + } +} + +func consumeAttachServerFrame(filter *attachOutputFilter, kind byte, payload []byte, observeControl func([]byte)) error { + switch kind { + case serverFramePTY: + _, err := filter.Write(payload) + return err + case serverFrameControl: + if observeControl != nil { + observeControl(payload) + } + return nil + default: + return fmt.Errorf("unsupported server attach frame type %d", kind) + } +} + func resizePayload(cols, rows int) []byte { // Clamp to uint16 range; the host rejects anything over 1000 anyway. out := make([]byte, 4) @@ -230,13 +366,6 @@ func resizePayload(cols, rows int) []byte { return out } -// backDetachEnabled reports whether the left-arrow quick detach is on. It is -// the default; UAM_ATTACH_BACK_DETACH=0 restores pure passthrough for agents -// that bind a bare left arrow themselves. -func backDetachEnabled() bool { - return os.Getenv("UAM_ATTACH_BACK_DETACH") != "0" -} - // stdinFilter is the attach client's input state machine. Besides the detach // chord and Ctrl+Z swallowing, it implements the Claude-Code-style quick // detach: pressing the left arrow detaches when the agent's input box is @@ -260,7 +389,10 @@ func backDetachEnabled() bool { // box, so it is forwarded without touching the estimate (see seqPoisons); // counting it would wedge the quick detach until the next Enter. type stdinFilter struct { + prefix byte backDetach bool + role clientRole + commands []attachCommand // pendingPrefix is set after Ctrl+B, waiting for the chord's second key. pendingPrefix bool // esc accumulates a partial escape sequence (possibly across reads). @@ -300,36 +432,15 @@ const maxEscLen = 64 // color-query and XTGETTCAP replies stay well under it. const maxStrLen = 4096 -// pumpStdin forwards terminal input to the host, filtering the detach chord, -// Ctrl+Z, and (when enabled) the left-arrow quick detach. It returns when the -// user detaches or stdin/conn fails. -func pumpStdin(stdin io.Reader, frames *frameWriter, backDetach bool) { - f := &stdinFilter{backDetach: backDetach} - buf := make([]byte, 4096) - for { - n, err := stdin.Read(buf) - if n > 0 { - out, detach := f.filter(buf[:n]) - if len(out) > 0 { - if werr := frames.WriteFrame(frameStdin, out); werr != nil { - return - } - } - if detach { - return - } - } - if err != nil { - return - } - } -} - // filter processes one stdin chunk, returning the bytes to forward and // whether the user detached. On detach the returned bytes (anything typed // before the detach key in the same chunk) must still be flushed first. func (f *stdinFilter) filter(chunk []byte) (out []byte, detach bool) { out = make([]byte, 0, len(chunk)+1) + prefix := f.prefix + if prefix == 0 { + prefix = detachPrefix + } for i, b := range chunk { if f.inPaste { out = append(out, b) @@ -360,15 +471,23 @@ func (f *stdinFilter) filter(chunk []byte) (out []byte, detach bool) { f.pendingPrefix = false switch b { case 'd': - return out, true + return f.result(out, true) case 'c': out = append(out, ctrlC) f.clearBox() - case detachPrefix: - out = append(out, detachPrefix) + case 'r': + f.commands = append(f.commands, commandRequestControl) + case 'o': + f.commands = append(f.commands, commandTransferControl) + case 'i': + f.commands = append(f.commands, commandShowInfo) + case 'm': + f.commands = append(f.commands, commandToggleMouse) + case prefix: + out = append(out, prefix) f.unknown = true default: - out = append(out, detachPrefix, b) + out = append(out, prefix, b) f.unknown = true } continue @@ -377,12 +496,12 @@ func (f *stdinFilter) filter(chunk []byte) (out []byte, detach bool) { var fired bool out, fired = f.escByte(out, b) if fired { - return out, true + return f.result(out, true) } continue } switch b { - case detachPrefix: + case prefix: f.pendingPrefix = true case ctrlZ: // Swallowed; see ctrlZ doc. @@ -425,7 +544,20 @@ func (f *stdinFilter) filter(chunk []byte) (out []byte, detach bool) { } } } - return out, false + return f.result(out, false) +} + +func (f *stdinFilter) result(out []byte, detach bool) ([]byte, bool) { + if f.role == roleObserver { + return nil, detach + } + return out, detach +} + +func (f *stdinFilter) drainCommands() []attachCommand { + commands := f.commands + f.commands = nil + return commands } func advanceExactMatch(pattern []byte, matched int, b byte) int { @@ -448,14 +580,18 @@ var attachMouseModes = map[string]bool{"1000": true, "1002": true, "1003": true, // Only seven-bit DEC private h/l sequences are rewritten. type attachOutputFilter struct { dst io.Writer - mouse bool + mouseEnabled func() bool pending []byte abortedCSI bool forwardedCSI bool } func newAttachOutputFilter(dst io.Writer, mouse bool) *attachOutputFilter { - return &attachOutputFilter{dst: dst, mouse: mouse} + return &attachOutputFilter{dst: dst, mouseEnabled: func() bool { return mouse }} +} + +func newAttachOutputFilterWithMouse(dst io.Writer, mouseEnabled func() bool) *attachOutputFilter { + return &attachOutputFilter{dst: dst, mouseEnabled: mouseEnabled} } func (f *attachOutputFilter) Write(p []byte) (int, error) { @@ -556,7 +692,7 @@ func (f *attachOutputFilter) rewriteCSI(seq []byte) []byte { } } key := string(param) - if attachAltModes[key] || (!f.mouse && attachMouseModes[key]) { + if attachAltModes[key] || (!f.mouseEnabled() && attachMouseModes[key]) { removed = true continue } diff --git a/internal/session/attach_acceptance_cases_test.go b/internal/session/attach_acceptance_cases_test.go new file mode 100644 index 0000000..cba19d2 --- /dev/null +++ b/internal/session/attach_acceptance_cases_test.go @@ -0,0 +1,135 @@ +package session + +import ( + "bufio" + "errors" + "io" + "net" + "os" + "reflect" + "strings" + "testing" + + "github.com/charmbracelet/x/term" +) + +func TestTerminalRestoredAfterHandshakeFailure(t *testing.T) { + t.Run("timeout", testTodo6HandshakeTimeout) + t.Run("role_rejection", testTodo6HandshakeRoleRejection) +} + +func TestTerminalRestoredAfterMalformedServerFrame(t *testing.T) { + t.Run("malformed_frame", testTodo6MalformedFrameCleanup) + t.Run("downstream_output_write_failure", testTodo6DownstreamOutputFailure) + t.Run("termios_equality", testTodo6MalformedFrameCleanup) + t.Run("cleanup_ordering", testTodo6MalformedFrameCleanup) +} + +func TestTerminalRestoredAfterControllerTransfer(t *testing.T) { + t.Run("controller_transfer", testTodo6ControllerTransferCleanup) + t.Run("signal", testTodo6SignalCleanup) +} + +func TestCodexAttachPreservesPrimaryScreenScrollback(t *testing.T) { + t.Run("scrollback", testTodo6CodexScrollback) +} + +func testTodo6HandshakeTimeout(t *testing.T) { + // Given + dir, name, listener := todo6AttachListener(t, "uam-fake-68686868") + serverErr := make(chan error, 1) + go func() { + conn, err := listener.Accept() + if err != nil { + serverErr <- err + return + } + defer func() { _ = conn.Close() }() + var req request + if err := readJSONLine(bufio.NewReader(conn), &req); err != nil { + serverErr <- err + return + } + _, err = io.Copy(io.Discard, conn) + serverErr <- err + }() + _, tty := openProtocolPTY(t) + before, err := term.GetState(tty.Fd()) + if err != nil { + t.Fatal(err) + } + + // When + err = runAttachWithOptions(dir, name, tty, tty, attachOptions{quiet: true}) + + // Then + var timeout net.Error + if !errors.As(err, &timeout) || !timeout.Timeout() { + t.Fatalf("attach timeout error = %v", err) + } + if err := <-serverErr; err != nil { + t.Fatal(err) + } + after, err := term.GetState(tty.Fd()) + if err != nil { + t.Fatal(err) + } + if !reflect.DeepEqual(before, after) { + t.Fatal("handshake timeout changed terminal state") + } +} + +func testTodo6DownstreamOutputFailure(t *testing.T) { + // Given + dir, name, listener := todo6AttachListener(t, "uam-fake-69696969") + serverErr := make(chan error, 1) + go func() { + conn, err := listener.Accept() + if err != nil { + serverErr <- err + return + } + defer func() { _ = conn.Close() }() + reader := bufio.NewReader(conn) + var req request + if err := readJSONLine(reader, &req); err != nil { + serverErr <- err + return + } + if err := writeJSONLine(conn, response{OK: true, Version: protocolV2, ClientID: "client-output", AssignedRole: roleController, Generation: 1}); err != nil { + serverErr <- err + return + } + serverErr <- writeFrame(conn, serverFramePTY, []byte("downstream-output")) + }() + _, tty := openProtocolPTY(t) + before, err := term.GetState(tty.Fd()) + if err != nil { + t.Fatal(err) + } + output, err := os.CreateTemp(t.TempDir(), "closed-output-") + if err != nil { + t.Fatal(err) + } + if err := output.Close(); err != nil { + t.Fatal(err) + } + + // When + err = runAttachWithOptions(dir, name, tty, output, attachOptions{quiet: true}) + + // Then + if err == nil || !strings.Contains(err.Error(), "attach output") { + t.Fatalf("downstream output error = %v", err) + } + if err := <-serverErr; err != nil { + t.Fatal(err) + } + after, err := term.GetState(tty.Fd()) + if err != nil { + t.Fatal(err) + } + if !reflect.DeepEqual(before, after) { + t.Fatal("downstream output failure changed terminal state") + } +} diff --git a/internal/session/attach_cleanup.go b/internal/session/attach_cleanup.go new file mode 100644 index 0000000..faae3e7 --- /dev/null +++ b/internal/session/attach_cleanup.go @@ -0,0 +1,79 @@ +package session + +import ( + "errors" + "fmt" + "io" + "os" + "sync" + + "github.com/charmbracelet/x/term" +) + +type synchronizedWriter struct { + mu sync.Mutex + writer io.Writer +} + +func (writer *synchronizedWriter) Write(payload []byte) (int, error) { + writer.mu.Lock() + defer writer.mu.Unlock() + return writer.writer.Write(payload) +} + +type attachTerminalConfig struct { + input *os.File + output io.Writer + inputTerminal bool + outputTerminal bool + ownScreen bool +} + +type attachTerminalCleanup struct { + config attachTerminalConfig + state *term.State + once sync.Once + err error +} + +func beginAttachTerminal(config attachTerminalConfig) (*attachTerminalCleanup, error) { + cleanup := &attachTerminalCleanup{config: config} + if config.inputTerminal { + state, err := term.MakeRaw(config.input.Fd()) + if err != nil { + return nil, fmt.Errorf("set raw mode: %w", err) + } + cleanup.state = state + } + if config.outputTerminal && config.ownScreen { + if err := writeAttachBytes(config.output, []byte(screenEnter)); err != nil { + cleanup.err = fmt.Errorf("enter attach screen: %w", err) + _ = cleanup.Restore() + return nil, cleanup.err + } + } + return cleanup, nil +} + +func (cleanup *attachTerminalCleanup) Restore() error { + cleanup.once.Do(func() { + var screenErr error + if cleanup.config.outputTerminal { + reset := screenReset + if cleanup.config.ownScreen { + reset = screenExit + } + if err := writeAttachBytes(cleanup.config.output, []byte(reset)); err != nil { + screenErr = fmt.Errorf("reset attach screen: %w", err) + } + } + var termiosErr error + if cleanup.state != nil { + if err := term.Restore(cleanup.config.input.Fd(), cleanup.state); err != nil { + termiosErr = fmt.Errorf("restore terminal state: %w", err) + } + } + cleanup.err = errors.Join(cleanup.err, screenErr, termiosErr) + }) + return cleanup.err +} diff --git a/internal/session/attach_cleanup_acceptance_test.go b/internal/session/attach_cleanup_acceptance_test.go new file mode 100644 index 0000000..155b1b7 --- /dev/null +++ b/internal/session/attach_cleanup_acceptance_test.go @@ -0,0 +1,198 @@ +package session + +import ( + "bufio" + "bytes" + "encoding/binary" + "errors" + "net" + "reflect" + "strings" + "testing" + + "github.com/charmbracelet/x/term" +) + +func testTodo6HandshakeRoleRejection(t *testing.T) { + // Given + dir, name, listener := todo6AttachListener(t, "uam-fake-61616161") + serverDone := make(chan struct{}) + go func() { + defer close(serverDone) + conn, err := listener.Accept() + if err != nil { + return + } + defer func() { _ = conn.Close() }() + var req request + _ = readJSONLine(bufio.NewReader(conn), &req) + _ = writeJSONLine(conn, response{Err: "role rejected"}) + }() + _, tty := openProtocolPTY(t) + before, err := term.GetState(tty.Fd()) + if err != nil { + t.Fatal(err) + } + + // When + err = runAttachWithOptions(dir, name, tty, tty, attachOptions{quiet: true}) + + // Then + if err == nil || !strings.Contains(err.Error(), "role rejected") { + t.Fatalf("attach error = %v", err) + } + after, err := term.GetState(tty.Fd()) + if err != nil { + t.Fatal(err) + } + if !reflect.DeepEqual(before, after) { + t.Fatal("handshake rejection changed terminal state") + } + <-serverDone +} + +func testTodo6MalformedFrameCleanup(t *testing.T) { + // Given + dir, name, listener := todo6AttachListener(t, "uam-fake-62626262") + go func() { + conn, err := listener.Accept() + if err != nil { + return + } + defer func() { _ = conn.Close() }() + var req request + _ = readJSONLine(bufio.NewReader(conn), &req) + _ = writeJSONLine(conn, response{OK: true, Version: protocolV2, ClientID: "client-1", AssignedRole: roleController, Generation: 1}) + header := [5]byte{serverFramePTY} + binary.BigEndian.PutUint32(header[1:], uint32(maxFrameLen+1)) + _ = writeAll(conn, header[:]) + }() + ptmx, tty := openProtocolPTY(t) + before, err := term.GetState(tty.Fd()) + if err != nil { + t.Fatal(err) + } + snapshot := capturePTYOutput(ptmx) + + // When + err = runAttachWithOptions(dir, name, tty, tty, attachOptions{quiet: true}) + + // Then + if !errors.Is(err, errFrameTooLarge) { + t.Fatalf("attach error = %v, want oversized frame", err) + } + after, stateErr := term.GetState(tty.Fd()) + if stateErr != nil { + t.Fatal(stateErr) + } + if !reflect.DeepEqual(before, after) { + t.Fatal("malformed frame cleanup did not restore terminal state") + } + output := snapshot() + if strings.Index(output, screenReset) > strings.Index(output, screenExit) { + t.Fatalf("cleanup order = %q", output) + } +} + +func testTodo6ControllerTransferCleanup(t *testing.T) { + // Given + dir, name, listener := todo6AttachListener(t, "uam-fake-63636363") + serverErr := make(chan error, 1) + go func() { + conn, err := listener.Accept() + if err != nil { + serverErr <- err + return + } + defer func() { _ = conn.Close() }() + reader := bufio.NewReader(conn) + var req request + if err := readJSONLine(reader, &req); err != nil { + serverErr <- err + return + } + if err := writeJSONLine(conn, response{OK: true, Version: protocolV2, ClientID: "client-1", AssignedRole: roleController, Generation: 1}); err != nil { + serverErr <- err + return + } + _ = writeFrame(conn, serverFrameControl, []byte(`{"type":"role","client_id":"client-1","role":"controller","generation":1,"reason":"assigned"}`)) + _ = writeFrame(conn, serverFramePTY, []byte("transfer-ready")) + kind, payload, err := readFrame(reader) + if err != nil { + serverErr <- err + return + } + if kind != frameRole || !bytes.Contains(payload, []byte(actionTransferControl)) { + serverErr <- errors.New("transfer command was not sent as a role frame") + return + } + _ = writeFrame(conn, serverFrameControl, []byte(`{"type":"role","client_id":"client-1","role":"standby","generation":2,"reason":"transferred"}`)) + kind, _, err = readFrame(reader) + if err == nil && kind != frameDetach { + err = errors.New("detach frame not received after transfer") + } + serverErr <- err + }() + ptmx, tty := openProtocolPTY(t) + before, err := term.GetState(tty.Fd()) + if err != nil { + t.Fatal(err) + } + snapshot := capturePTYOutput(ptmx) + done := make(chan error, 1) + go func() { done <- runAttachWithOptions(dir, name, tty, tty, attachOptions{quiet: true}) }() + waitFor(t, "controller transfer fixture", func() bool { return strings.Contains(snapshot(), "transfer-ready") }) + + // When + _, _ = ptmx.Write([]byte{detachPrefix, 'o'}) + _, _ = ptmx.Write([]byte{detachPrefix, 'd'}) + err = <-done + + // Then + if err != nil { + t.Fatal(err) + } + if err := <-serverErr; err != nil { + t.Fatal(err) + } + after, err := term.GetState(tty.Fd()) + if err != nil { + t.Fatal(err) + } + if !reflect.DeepEqual(before, after) { + t.Fatal("controller transfer cleanup did not restore terminal state") + } +} + +func testTodo6CodexScrollback(t *testing.T) { + // Given + client := newTestClient(t) + name := "uam-codex-64646464" + if err := client.CreateSession(t.Context(), name, t.TempDir(), nil, []string{"/bin/sh", "-c", "echo codex-scrollback-pin; sleep 60"}); err != nil { + t.Fatal(err) + } + attached := startQuietAttach(t, client.Dir, name, 80, 24) + waitFor(t, "Codex scrollback", func() bool { return strings.Contains(attached.Snapshot(), "codex-scrollback-pin") }) + + // When + attached.Detach(t) + + // Then + if output := attached.Snapshot(); strings.Contains(output, screenEnter) || strings.Contains(output, screenExit) { + t.Fatalf("Codex attach used an outer alternate screen: %q", output) + } +} + +func todo6AttachListener(t *testing.T, name string) (string, string, net.Listener) { + t.Helper() + dir := t.TempDir() + if err := EnsureDir(dir); err != nil { + t.Fatal(err) + } + listener, err := net.Listen("unix", SocketPath(dir, name)) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = listener.Close() }) + return dir, name, listener +} diff --git a/internal/session/attach_control.go b/internal/session/attach_control.go new file mode 100644 index 0000000..7273d63 --- /dev/null +++ b/internal/session/attach_control.go @@ -0,0 +1,258 @@ +package session + +import ( + "bufio" + "errors" + "fmt" + "io" + "os" + "strings" + "sync/atomic" + + "github.com/RandomCodeSpace/unified-agent-manager/internal/displaytext" + "golang.org/x/sys/unix" +) + +type attachOutputConfig struct { + output io.Writer + reader *bufio.Reader + version protocolVersion + frames *frameWriter + runtime *attachRuntime +} + +func copyAttachOutputConfigured(config attachOutputConfig) error { + filter := newAttachOutputFilterWithMouse(config.output, config.runtime.mouseEnabled) + if config.version == protocolV1 { + _, err := io.Copy(filter, config.reader) + if flushErr := filter.Flush(); err == nil { + err = flushErr + } + return err + } + for { + kind, payload, err := readFrame(config.reader) + if errors.Is(err, io.EOF) { + return filter.Flush() + } + if err != nil { + return err + } + switch kind { + case serverFramePTY: + if _, err := filter.Write(payload); err != nil { + return err + } + case serverFrameControl: + event, changed, err := config.frames.observeRoleEvent(payload, config.runtime.discardPendingInput) + if err != nil { + return err + } + if changed { + if err := config.runtime.writeStatus(fmt.Sprintf("role %s (%s)", event.Role, event.Reason)); err != nil { + return err + } + } + default: + return fmt.Errorf("unsupported server attach frame type %d", kind) + } + } +} + +type attachCommand string + +const ( + commandNone attachCommand = "" + commandRequestControl attachCommand = "request_control" + commandTransferControl attachCommand = "transfer_control" + commandShowInfo attachCommand = "show_info" + commandToggleMouse attachCommand = "toggle_mouse" +) + +type attachRuntime struct { + session string + output io.Writer + input *os.File + inputTerminal bool + profile attachProfileSnapshot + prefix byte + mouse atomic.Bool +} + +type attachRuntimeConfig struct { + session string + output io.Writer + input *os.File + inputTerminal bool + mouseEnabled bool + prefix byte + profile attachProfileSnapshot +} + +func newAttachRuntime(config attachRuntimeConfig) *attachRuntime { + runtime := &attachRuntime{ + session: config.session, output: config.output, input: config.input, inputTerminal: config.inputTerminal, profile: config.profile, prefix: config.prefix, + } + runtime.mouse.Store(config.mouseEnabled) + return runtime +} + +type attachProfileSnapshot struct { + selected string + effective string +} + +func (snapshot attachProfileSnapshot) notice() string { + parts := make([]string, 0, 2) + if selected := displaytext.Sanitize(snapshot.selected); selected != "" { + parts = append(parts, "selected profile "+selected) + } + if effective := displaytext.Sanitize(snapshot.effective); effective != "" { + parts = append(parts, "effective profile "+effective) + } + return strings.Join(parts, "; ") +} + +func (runtime *attachRuntime) mouseEnabled() bool { + return runtime.mouse.Load() +} + +func (runtime *attachRuntime) discardPendingInput() error { + if runtime.input == nil || !runtime.inputTerminal { + return nil + } + if err := flushTerminalInput(int(runtime.input.Fd())); err != nil { + return fmt.Errorf("discard pre-control terminal input: %w", err) + } + return nil +} + +func (runtime *attachRuntime) runCommand(command attachCommand, frames *frameWriter) error { + switch command { + case commandRequestControl: + if err := frames.WriteRoleCommand(actionRequestControl); err != nil { + return fmt.Errorf("request attach control: %w", err) + } + return runtime.writeStatus("control requested; transfer remains controller-owned") + case commandTransferControl: + if err := frames.WriteRoleCommand(actionTransferControl); err != nil { + return fmt.Errorf("transfer attach control: %w", err) + } + return runtime.writeStatus("control transfer requested") + case commandShowInfo: + info := fmt.Sprintf("session %s; client %s; role %s", runtime.session, frames.ClientID(), frames.AssignedRole()) + if profile := runtime.profile.notice(); profile != "" { + info += "; " + profile + } + return runtime.writeStatus(info + "; keys: prefix d detach, c interrupt, r request, o transfer, i info, m mouse") + case commandToggleMouse: + enabled := runtime.toggleMouse() + if !enabled { + if err := writeAttachBytes(runtime.output, []byte(mouseReset)); err != nil { + return fmt.Errorf("disable terminal mouse modes: %w", err) + } + } + return runtime.writeStatus(fmt.Sprintf("mouse passthrough %t for this attachment", enabled)) + case commandNone: + return nil + default: + return fmt.Errorf("unsupported attach command %q", command) + } +} + +func (runtime *attachRuntime) toggleMouse() bool { + for { + current := runtime.mouse.Load() + if runtime.mouse.CompareAndSwap(current, !current) { + return !current + } + } +} + +func (runtime *attachRuntime) writeStatus(message string) error { + return writeAttachBytes(runtime.output, []byte("\r\n[uam: "+message+"]\r\n")) +} + +type attachPumpConfig struct { + input *os.File + inputFD int32 + frames *frameWriter + runtime *attachRuntime + prefix byte + backDetach bool + stop <-chan struct{} +} + +func pumpAttachInput(config attachPumpConfig) error { + filter := &stdinFilter{prefix: config.prefix, backDetach: config.backDetach, role: config.frames.AssignedRole()} + buf := make([]byte, 4096) + for { + n, role, err := readAttachInput(attachInputReadConfig{ + input: config.input, inputFD: config.inputFD, buf: buf, frames: config.frames, stop: config.stop, + }) + if n > 0 { + filter.role = role + out, detach := filter.filter(buf[:n]) + if len(out) > 0 && role == roleController && config.frames.HasControl() { + if writeErr := config.frames.WriteFrame(frameStdin, out); writeErr != nil { + return fmt.Errorf("forward attach input: %w", writeErr) + } + } + for _, command := range filter.drainCommands() { + if commandErr := config.runtime.runCommand(command, config.frames); commandErr != nil { + return commandErr + } + } + if detach { + return nil + } + } + if err != nil { + return nil + } + } +} + +type attachInputReadConfig struct { + input *os.File + inputFD int32 + buf []byte + frames *frameWriter + stop <-chan struct{} +} + +func readAttachInput(config attachInputReadConfig) (int, clientRole, error) { + for { + select { + case <-config.stop: + return 0, "", io.EOF + default: + } + pollFD := []unix.PollFd{{Fd: config.inputFD, Events: unix.POLLIN | unix.POLLHUP | unix.POLLERR}} + if _, err := unix.Poll(pollFD, 100); err != nil { + if errors.Is(err, unix.EINTR) { + continue + } + return 0, "", err + } + + config.frames.mu.Lock() + pollFD[0].Revents = 0 + ready, pollErr := unix.Poll(pollFD, 0) + if pollErr != nil { + config.frames.mu.Unlock() + if errors.Is(pollErr, unix.EINTR) { + continue + } + return 0, "", pollErr + } + if ready == 0 { + config.frames.mu.Unlock() + continue + } + n, readErr := config.input.Read(config.buf) + role := config.frames.role + config.frames.mu.Unlock() + return n, role, readErr + } +} diff --git a/internal/session/attach_control_modes_test.go b/internal/session/attach_control_modes_test.go new file mode 100644 index 0000000..5de4d5d --- /dev/null +++ b/internal/session/attach_control_modes_test.go @@ -0,0 +1,164 @@ +package session + +import ( + "bytes" + "os" + "strings" + "testing" +) + +func TestAttachV2RoleAndControlEvents(t *testing.T) { + t.Run("role_change", func(t *testing.T) { + // Given + var wire bytes.Buffer + writer := newAttachFrameWriter(&wire, protocolV2, "client-2", 4) + writer.SetAssignedRole(roleStandby) + + // When + err := writer.ObserveControl([]byte(`{"type":"role","client_id":"client-2","role":"controller","generation":5,"reason":"promoted"}`)) + + // Then + if err != nil { + t.Fatal(err) + } + if writer.AssignedRole() != roleController || writer.Generation() != 5 { + t.Fatalf("client state = role %q generation %d", writer.AssignedRole(), writer.Generation()) + } + }) + t.Run("role_rejection", func(t *testing.T) { + // Given + writer := newAttachFrameWriter(&bytes.Buffer{}, protocolV2, "client-2", 4) + writer.SetAssignedRole(roleStandby) + + // When + err := writer.ObserveControl([]byte(`{"type":"role","client_id":"client-2","role":"owner"}`)) + + // Then + if err == nil { + t.Fatal("malformed role event was accepted") + } + }) + t.Run("promotion_flush", func(t *testing.T) { + // Given + writer := newAttachFrameWriter(&bytes.Buffer{}, protocolV2, "client-2", 4) + writer.SetAssignedRole(roleStandby) + flushed := false + + // When + _, _, err := writer.observeRoleEvent( + []byte(`{"type":"role","client_id":"client-2","role":"controller","generation":5,"reason":"promoted"}`), + func() error { + if writer.role != roleStandby { + t.Fatalf("role during input flush = %q, want %q", writer.role, roleStandby) + } + flushed = true + return nil + }, + ) + + // Then + if err != nil { + t.Fatal(err) + } + if !flushed { + t.Fatal("promotion did not flush queued input") + } + if role := writer.AssignedRole(); role != roleController { + t.Fatalf("role after input flush = %q, want %q", role, roleController) + } + }) +} + +func TestObserverInputIsDiscarded(t *testing.T) { + t.Run("observer_discard", func(t *testing.T) { + // Given + filter := &stdinFilter{backDetach: true, role: roleObserver} + input := append([]byte("secret-input-7f3a"), []byte("\x1b[6n\x1b[<0;10;20M\x1b[200~prompt-like\x1b[201~")...) + + // When + got, detached := filter.filter(input) + + // Then + if detached || len(got) != 0 { + t.Fatalf("observer forwarded %x, detached=%t", got, detached) + } + got, detached = filter.filter([]byte{detachPrefix, detachPrefix, detachPrefix, 'c'}) + if detached || len(got) != 0 { + t.Fatalf("observer forwarded UAM literal/interrupt bytes %x, detached=%t", got, detached) + } + }) +} + +func TestControlPrefixModes(t *testing.T) { + tests := []struct { + name string + input []byte + wantOutput []byte + wantCommand attachCommand + wantDetach bool + }{ + {name: "prefix prefix is literal", input: []byte{detachPrefix, detachPrefix}, wantOutput: []byte{detachPrefix}}, + {name: "detach", input: []byte{detachPrefix, 'd'}, wantDetach: true}, + {name: "interrupt", input: []byte{detachPrefix, 'c'}, wantOutput: []byte{ctrlC}}, + {name: "request control", input: []byte{detachPrefix, 'r'}, wantCommand: commandRequestControl}, + {name: "transfer control", input: []byte{detachPrefix, 'o'}, wantCommand: commandTransferControl}, + {name: "info", input: []byte{detachPrefix, 'i'}, wantCommand: commandShowInfo}, + {name: "mouse toggle", input: []byte{detachPrefix, 'm'}, wantCommand: commandToggleMouse}, + {name: "unknown stays provider native", input: []byte{detachPrefix, 'x'}, wantOutput: []byte{detachPrefix, 'x'}}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + // Given + filter := &stdinFilter{role: roleController} + + // When + got, detached := filter.filter(test.input) + commands := filter.drainCommands() + + // Then + if !bytes.Equal(got, test.wantOutput) || detached != test.wantDetach { + t.Fatalf("filter = %x, %t; want %x, %t", got, detached, test.wantOutput, test.wantDetach) + } + if test.wantCommand == commandNone { + if len(commands) != 0 { + t.Fatalf("commands = %v, want none", commands) + } + return + } + if len(commands) != 1 || commands[0] != test.wantCommand { + t.Fatalf("commands = %v, want %q", commands, test.wantCommand) + } + }) + } + t.Run("profile_info", func(t *testing.T) { + // Given + var output bytes.Buffer + t.Setenv(AttachSelectedProfileEnv, "focused") + t.Setenv(AttachEffectiveProfileEnv, "focused+session") + t.Setenv("UAM_TEST_SECRET", "secret-must-not-appear") + runtime := newAttachRuntime(attachRuntimeConfig{ + session: "uam-fake-11112222", output: &output, profile: attachProfileFromEnv(os.Getenv), + }) + frames := newAttachFrameWriter(&bytes.Buffer{}, protocolV2, "client-safe", 7) + frames.SetAssignedRole(roleController) + + // When + err := runtime.runCommand(commandShowInfo, frames) + + // Then + if err != nil { + t.Fatal(err) + } + info := output.String() + for _, expected := range []string{"selected profile focused", "effective profile focused+session"} { + if !strings.Contains(info, expected) { + t.Fatalf("info notice %q lacks %q", info, expected) + } + } + for _, forbidden := range []string{"profile unavailable", "secret-must-not-appear", "UAM_TEST_SECRET"} { + if strings.Contains(info, forbidden) { + t.Fatalf("info notice leaked %q: %q", forbidden, info) + } + } + }) +} diff --git a/internal/session/attach_fd.go b/internal/session/attach_fd.go new file mode 100644 index 0000000..90a7432 --- /dev/null +++ b/internal/session/attach_fd.go @@ -0,0 +1,13 @@ +package session + +import ( + "fmt" + "math" +) + +func attachInputFD(fd uintptr) (int32, error) { + if fd > math.MaxInt32 { + return 0, fmt.Errorf("terminal input file descriptor %d exceeds poll range", fd) + } + return int32(fd), nil // #nosec G115 -- fd is bounded to MaxInt32 above. +} diff --git a/internal/session/attach_fd_test.go b/internal/session/attach_fd_test.go new file mode 100644 index 0000000..61e67bc --- /dev/null +++ b/internal/session/attach_fd_test.go @@ -0,0 +1,23 @@ +package session + +import ( + "math" + "testing" +) + +func TestAttachInputFDRejectsPollOverflow(t *testing.T) { + // Given + const largestPollFD = uintptr(math.MaxInt32) + + // When + got, validErr := attachInputFD(largestPollFD) + _, overflowErr := attachInputFD(largestPollFD + 1) + + // Then + if validErr != nil || got != math.MaxInt32 { + t.Fatalf("largest poll fd = %d, %v; want %d, nil", got, validErr, math.MaxInt32) + } + if overflowErr == nil { + t.Fatal("overflowing poll fd was accepted") + } +} diff --git a/internal/session/attach_handshake_test.go b/internal/session/attach_handshake_test.go new file mode 100644 index 0000000..7615a51 --- /dev/null +++ b/internal/session/attach_handshake_test.go @@ -0,0 +1,117 @@ +package session + +import ( + "bufio" + "net" + "os" + "reflect" + "strings" + "testing" + "time" + + "github.com/charmbracelet/x/term" + "github.com/creack/pty" +) + +func TestAttachHandshakeInterruptionLeavesTerminalUntouched(t *testing.T) { + dir := t.TempDir() + if err := EnsureDir(dir); err != nil { + t.Fatal(err) + } + name := "uam-fake-99990000" + ln, err := net.Listen("unix", SocketPath(dir, name)) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = ln.Close() }) + const attempts = 8 + serverDone := make(chan struct{}) + go func() { + defer close(serverDone) + for range attempts { + conn, acceptErr := ln.Accept() + if acceptErr != nil { + return + } + var req request + _ = readJSONLine(bufio.NewReader(conn), &req) + _ = conn.Close() + } + }() + + _, tty := openProtocolPTY(t) + before, err := term.GetState(tty.Fd()) + if err != nil { + t.Fatal(err) + } + for range attempts { + if err := runAttachWithOptions(dir, name, tty, tty, attachOptions{quiet: true}); err == nil { + t.Fatal("interrupted handshake unexpectedly succeeded") + } + } + after, err := term.GetState(tty.Fd()) + if err != nil { + t.Fatal(err) + } + if !reflect.DeepEqual(after, before) { + t.Fatal("repeated handshake interruption changed terminal state") + } + select { + case <-serverDone: + case <-time.After(time.Second): + t.Fatal("interrupting server did not exit") + } +} + +func TestAttachNegotiatesBeforeScreenOwnership(t *testing.T) { + dir := t.TempDir() + if err := EnsureDir(dir); err != nil { + t.Fatal(err) + } + name := "uam-fake-55556666" + ln, err := net.Listen("unix", SocketPath(dir, name)) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = ln.Close() }) + serverDone := make(chan struct{}) + go func() { + defer close(serverDone) + conn, acceptErr := ln.Accept() + if acceptErr != nil { + return + } + defer func() { _ = conn.Close() }() + var req request + _ = readJSONLine(bufio.NewReader(conn), &req) + _ = writeJSONLine(conn, response{Err: "unsupported attach protocol version 99"}) + }() + + ptmx, tty := openProtocolPTY(t) + err = runAttachWithOptions(dir, name, tty, tty, attachOptions{quiet: true}) + if err == nil || !strings.Contains(err.Error(), "unsupported attach protocol") { + t.Fatalf("attach error = %v", err) + } + _ = tty.Close() + _ = ptmx.SetReadDeadline(time.Now().Add(100 * time.Millisecond)) + buf := make([]byte, 128) + n, readErr := ptmx.Read(buf) + if n != 0 || readErr == nil { + t.Fatalf("terminal received pre-negotiation output %x, err=%v", buf[:n], readErr) + } + select { + case <-serverDone: + case <-time.After(time.Second): + t.Fatal("rejecting peer did not exit") + } +} + +func openProtocolPTY(t *testing.T) (*os.File, *os.File) { + t.Helper() + ptmx, tty, err := pty.Open() + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = ptmx.Close(); _ = tty.Close() }) + return ptmx, tty +} diff --git a/internal/session/attach_hello.go b/internal/session/attach_hello.go new file mode 100644 index 0000000..c1ad7f1 --- /dev/null +++ b/internal/session/attach_hello.go @@ -0,0 +1,114 @@ +package session + +import ( + "errors" + "fmt" + "unicode/utf8" +) + +type clientCapability string + +const ( + capabilityFramedOutput clientCapability = "framed_output" + capabilityRoleEvents clientCapability = "role_events" + capabilityLocalMouseFilter clientCapability = "local_mouse_filter" + capabilityOwnedScreen clientCapability = "owned_screen" +) + +const maxClientHintLen = 128 + +type clientHello struct { + TTY bool `json:"tty"` + TermHint string `json:"term_hint,omitempty"` + ColorHint string `json:"color_hint,omitempty"` + Capabilities []clientCapability `json:"capabilities"` +} + +func validateClientHello(hello clientHello) error { + if len(hello.TermHint) > maxClientHintLen || len(hello.ColorHint) > maxClientHintLen { + return fmt.Errorf("client hello hint exceeds %d bytes", maxClientHintLen) + } + seen := make(map[clientCapability]struct{}, len(hello.Capabilities)) + for _, capability := range hello.Capabilities { + switch capability { + case capabilityFramedOutput, capabilityRoleEvents, capabilityLocalMouseFilter, capabilityOwnedScreen: + default: + return fmt.Errorf("unsupported client capability %q", capability) + } + if _, duplicate := seen[capability]; duplicate { + return fmt.Errorf("duplicate client capability %q", capability) + } + seen[capability] = struct{}{} + } + return nil +} + +type clientRole string + +const ( + roleController clientRole = "controller" + roleStandby clientRole = "standby" + roleObserver clientRole = "observer" +) + +func validateRequestedRole(role clientRole) error { + switch role { + case roleController, roleStandby, roleObserver: + return nil + default: + return fmt.Errorf("unsupported requested role %q", role) + } +} + +type roleAction string + +const ( + actionRequestControl roleAction = "request_control" + actionTransferControl roleAction = "transfer_control" +) + +type roleCommand struct { + Action roleAction `json:"action"` +} + +func (command roleCommand) validate() error { + switch command.Action { + case actionRequestControl, actionTransferControl: + return nil + default: + return errors.New("invalid role action") + } +} + +type roleEvent struct { + Type string `json:"type"` + ClientID string `json:"client_id"` + Role clientRole `json:"role"` + Generation uint64 `json:"generation"` + Reason string `json:"reason"` +} + +func defaultClientHello(tty bool, termHint, colorHint string) clientHello { + return clientHello{ + TTY: tty, + TermHint: boundedClientHint(termHint), + ColorHint: boundedClientHint(colorHint), + Capabilities: []clientCapability{ + capabilityFramedOutput, + capabilityRoleEvents, + capabilityLocalMouseFilter, + capabilityOwnedScreen, + }, + } +} + +func boundedClientHint(hint string) string { + if len(hint) <= maxClientHintLen { + return hint + } + hint = hint[:maxClientHintLen] + for !utf8.ValidString(hint) { + hint = hint[:len(hint)-1] + } + return hint +} diff --git a/internal/session/attach_input_flush_darwin.go b/internal/session/attach_input_flush_darwin.go new file mode 100644 index 0000000..f9091bc --- /dev/null +++ b/internal/session/attach_input_flush_darwin.go @@ -0,0 +1,9 @@ +//go:build darwin + +package session + +import "golang.org/x/sys/unix" + +func flushTerminalInput(fd int) error { + return unix.IoctlSetPointerInt(fd, unix.TIOCFLUSH, unix.TCIFLUSH) +} diff --git a/internal/session/attach_input_flush_linux.go b/internal/session/attach_input_flush_linux.go new file mode 100644 index 0000000..0a3d78c --- /dev/null +++ b/internal/session/attach_input_flush_linux.go @@ -0,0 +1,9 @@ +//go:build linux + +package session + +import "golang.org/x/sys/unix" + +func flushTerminalInput(fd int) error { + return unix.IoctlSetInt(fd, unix.TCFLSH, unix.TCIFLUSH) +} diff --git a/internal/session/attach_mouse_toggle_test.go b/internal/session/attach_mouse_toggle_test.go new file mode 100644 index 0000000..badc813 --- /dev/null +++ b/internal/session/attach_mouse_toggle_test.go @@ -0,0 +1,27 @@ +package session + +import ( + "bytes" + "testing" +) + +func TestAttachToggleMouseDisablesActiveTerminalMouseModes(t *testing.T) { + // Given + var output bytes.Buffer + runtime := newAttachRuntime(attachRuntimeConfig{output: &output, mouseEnabled: true}) + frames := newAttachFrameWriter(&bytes.Buffer{}, protocolV2, "client-1", 1) + + // When + err := runtime.runCommand(commandToggleMouse, frames) + + // Then + if err != nil { + t.Fatal(err) + } + if !bytes.Contains(output.Bytes(), []byte(mouseReset)) { + t.Fatalf("toggle output = %q, want active mouse modes disabled", output.Bytes()) + } + if runtime.mouseEnabled() { + t.Fatal("mouse passthrough remained enabled after the toggle") + } +} diff --git a/internal/session/attach_policy.go b/internal/session/attach_policy.go new file mode 100644 index 0000000..d6bc2e9 --- /dev/null +++ b/internal/session/attach_policy.go @@ -0,0 +1,91 @@ +package session + +import "fmt" + +const ( + AttachPrefixEnv = "UAM_ATTACH_PREFIX" + AttachBackDetachEnv = "UAM_ATTACH_BACK_DETACH" + + AttachPolicyMouseEnv = "UAM_ATTACH_POLICY_MOUSE" + AttachPolicyPrefixEnv = "UAM_ATTACH_POLICY_PREFIX" + AttachPolicyBackDetachEnv = "UAM_ATTACH_POLICY_BACK_DETACH" +) + +type attachPolicySnapshot struct { + mouse string + controlPrefix string + backDetach bool + backDetachSet bool +} + +type resolvedAttachPolicy struct { + mouseEnabled bool + controlPrefix byte + backDetach bool +} + +func attachPolicyFromEnv(getenv func(string) string) attachPolicySnapshot { + backDetach := getenv(AttachPolicyBackDetachEnv) + return attachPolicySnapshot{ + mouse: getenv(AttachPolicyMouseEnv), + controlPrefix: getenv(AttachPolicyPrefixEnv), + backDetach: backDetach != "0", + backDetachSet: backDetach != "", + } +} + +func resolveAttachPolicy(snapshot attachPolicySnapshot, getenv func(string) string) (resolvedAttachPolicy, error) { + mouse, err := parseProfileMousePolicy(snapshot.mouse) + if err != nil { + return resolvedAttachPolicy{}, err + } + prefix, err := parseControlPrefix(snapshot.controlPrefix) + if err != nil { + return resolvedAttachPolicy{}, err + } + policy := resolvedAttachPolicy{mouseEnabled: mouse, controlPrefix: prefix, backDetach: true} + if snapshot.backDetachSet { + policy.backDetach = snapshot.backDetach + } + if value := getenv(AttachMouseEnv); value != "" { + policy.mouseEnabled = value != "off" + } + if value := getenv(AttachPrefixEnv); value != "" { + policy.controlPrefix, err = parseControlPrefix(value) + if err != nil { + return resolvedAttachPolicy{}, fmt.Errorf("%s: %w", AttachPrefixEnv, err) + } + } + if value := getenv(AttachBackDetachEnv); value != "" { + policy.backDetach = value != "0" + } + return policy, nil +} + +func parseProfileMousePolicy(value string) (bool, error) { + switch value { + case "", "auto", "on": + return true, nil + case "off": + return false, nil + default: + return false, fmt.Errorf("invalid attach mouse policy %q", value) + } +} + +func parseControlPrefix(value string) (byte, error) { + if value == "" { + value = "C-b" + } + if len(value) != 3 || value[0] != 'C' || value[1] != '-' || value[2] < 'a' || value[2] > 'z' { + return 0, fmt.Errorf("invalid attach control prefix %q", value) + } + return value[2] - 'a' + 1, nil +} + +func controlPrefixName(prefix byte) string { + if prefix < 1 || prefix > 26 { + prefix = detachPrefix + } + return "Ctrl+" + string(rune('A'+prefix-1)) +} diff --git a/internal/session/attach_protocol_integration_test.go b/internal/session/attach_protocol_integration_test.go new file mode 100644 index 0000000..c6aefdf --- /dev/null +++ b/internal/session/attach_protocol_integration_test.go @@ -0,0 +1,238 @@ +package session + +import ( + "bufio" + "bytes" + "context" + "errors" + "io" + "net" + "os" + "strings" + "testing" + "time" +) + +func TestAttachProtocolCompatibilityMatrix(t *testing.T) { + t.Run("v2 client consumes unversioned v1 host output as raw bytes", func(t *testing.T) { + dir := t.TempDir() + if err := EnsureDir(dir); err != nil { + t.Fatal(err) + } + name := "uam-fake-77778888" + ln, err := net.Listen("unix", SocketPath(dir, name)) + if err != nil { + t.Fatal(err) + } + defer func() { _ = ln.Close() }() + payload := []byte{0x00, 0xff, 'v', '1', '-', 'r', 'a', 'w', '\r', '\n'} + serverErr := make(chan error, 1) + go func() { + conn, acceptErr := ln.Accept() + if acceptErr != nil { + serverErr <- acceptErr + return + } + defer func() { _ = conn.Close() }() + var req request + if err := readJSONLine(bufio.NewReader(conn), &req); err != nil { + serverErr <- err + return + } + if req.Version != protocolV2 { + serverErr <- errors.New("client did not request v2") + return + } + if err := writeJSONLine(conn, response{OK: true}); err != nil { + serverErr <- err + return + } + serverErr <- writeAll(conn, payload) + }() + + stdinR, stdinW, err := os.Pipe() + if err != nil { + t.Fatal(err) + } + defer func() { _ = stdinR.Close(); _ = stdinW.Close() }() + stdoutR, stdoutW, err := os.Pipe() + if err != nil { + t.Fatal(err) + } + defer func() { _ = stdoutR.Close(); _ = stdoutW.Close() }() + if err := runAttachWithOptions(dir, name, stdinR, stdoutW, attachOptions{quiet: true}); err != nil { + t.Fatal(err) + } + _ = stdoutW.Close() + got, err := io.ReadAll(stdoutR) + if err != nil { + t.Fatal(err) + } + if !bytes.Equal(got, payload) { + t.Fatalf("fallback output = %x, want %x", got, payload) + } + if err := <-serverErr; err != nil { + t.Fatalf("v1 host fixture: %v", err) + } + }) + + t.Run("v2 client falls back only for an unversioned v1 response", func(t *testing.T) { + var resp response + if err := jsonUnmarshalLine(`{"ok":true}`, &resp); err != nil { + t.Fatal(err) + } + got, err := negotiateAttachResponse(protocolV2, resp) + if err != nil || got != protocolV1 { + t.Fatalf("negotiation = %d, %v; want v1 fallback", got, err) + } + + if err := jsonUnmarshalLine(`{"ok":true,"version":1}`, &resp); err != nil { + t.Fatal(err) + } + if _, err := negotiateAttachResponse(protocolV2, resp); err == nil { + t.Fatal("explicit response downgrade was accepted") + } + }) + + t.Run("v2 host preserves v1 raw and uses v2 server frames", func(t *testing.T) { + client := newTestClient(t) + name := "uam-fake-33334444" + done := startInProcessHost(t, client, name, `printf 'compat-marker'; while IFS= read -r line; do printf 'in:%s\n' "$line"; done`) + cleanupProtocolHost(t, client, name, done) + + v1, err := net.Dial("unix", SocketPath(client.Dir, name)) + if err != nil { + t.Fatal(err) + } + defer func() { _ = v1.Close() }() + if err := writeJSONLine(v1, request{Op: opAttach, Cols: 80, Rows: 24}); err != nil { + t.Fatal(err) + } + v1Reader := bufio.NewReader(v1) + var v1Resp response + if err := readJSONLine(v1Reader, &v1Resp); err != nil { + t.Fatal(err) + } + if !v1Resp.OK || v1Resp.versionPresent { + t.Fatalf("v1 response = %+v, version present = %v", v1Resp, v1Resp.versionPresent) + } + v1Raw := readUntilContains(t, v1, v1Reader, []byte("compat-marker")) + if bytes.HasPrefix(v1Raw, []byte{serverFramePTY}) { + t.Fatalf("v1 output unexpectedly framed: %x", v1Raw) + } + + secondV1, err := net.Dial("unix", SocketPath(client.Dir, name)) + if err != nil { + t.Fatal(err) + } + if err := writeJSONLine(secondV1, request{Op: opAttach, Cols: 80, Rows: 24}); err != nil { + t.Fatal(err) + } + var rejected response + if err := readJSONLine(bufio.NewReader(secondV1), &rejected); err != nil { + t.Fatal(err) + } + _ = secondV1.Close() + if rejected.OK || !strings.Contains(rejected.Err, "legacy attach already controlled") { + t.Fatalf("second v1 response = %+v, want controlled rejection", rejected) + } + if err := writeFrame(v1, frameDetach, nil); err != nil { + t.Fatal(err) + } + if err := v1.SetReadDeadline(time.Now().Add(5 * time.Second)); err != nil { + t.Fatal(err) + } + if _, err := io.Copy(io.Discard, v1Reader); err != nil { + t.Fatalf("wait for v1 detach: %v", err) + } + _ = v1.Close() + + v2, err := net.Dial("unix", SocketPath(client.Dir, name)) + if err != nil { + t.Fatal(err) + } + defer func() { _ = v2.Close() }() + hello := validTestHello() + if err := writeJSONLine(v2, request{Op: opAttach, Version: protocolV2, Cols: 80, Rows: 24, RequestedRole: roleController, Hello: &hello}); err != nil { + t.Fatal(err) + } + v2Reader := bufio.NewReader(v2) + var v2Resp response + if err := readJSONLine(v2Reader, &v2Resp); err != nil { + t.Fatal(err) + } + if !v2Resp.OK || !v2Resp.versionPresent || v2Resp.Version != protocolV2 { + t.Fatalf("v2 response = %+v, version present = %v", v2Resp, v2Resp.versionPresent) + } + kind, payload, err := readFrame(v2Reader) + if err != nil { + t.Fatal(err) + } + if kind != serverFrameControl || !bytes.Contains(payload, []byte(`"role":"controller"`)) { + t.Fatalf("v2 role frame = kind %d payload %x", kind, payload) + } + kind, payload, err = readFrame(v2Reader) + if err != nil { + t.Fatal(err) + } + if kind != serverFramePTY || !bytes.Contains(payload, []byte("compat-marker")) { + t.Fatalf("v2 first frame = kind %d payload %x", kind, payload) + } + if err := writeFrame(v2, frameDetach, nil); err != nil { + t.Fatal(err) + } + }) + + t.Run("unsupported explicit versions are rejected", func(t *testing.T) { + var req request + if err := jsonUnmarshalLine(`{"op":"attach","version":99}`, &req); err != nil { + t.Fatal(err) + } + if _, err := negotiateAttachRequest(req); err == nil { + t.Fatal("unsupported request version was accepted") + } + }) +} + +func cleanupProtocolHost(t *testing.T, client *Client, name string, done <-chan error) { + t.Helper() + t.Cleanup(func() { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + if client.HasSession(ctx, name) { + if err := client.Kill(ctx, name); err != nil { + t.Errorf("kill protocol host: %v", err) + } + } + select { + case err := <-done: + if err != nil { + t.Errorf("protocol host: %v", err) + } + case <-time.After(5 * time.Second): + t.Error("protocol host cleanup timed out") + } + }) +} + +func readUntilContains(t *testing.T, conn net.Conn, reader *bufio.Reader, marker []byte) []byte { + t.Helper() + if err := conn.SetReadDeadline(time.Now().Add(5 * time.Second)); err != nil { + t.Fatal(err) + } + var got []byte + buf := make([]byte, 4096) + for !bytes.Contains(got, marker) { + n, err := reader.Read(buf) + if n > 0 { + got = append(got, buf[:n]...) + } + if err != nil { + t.Fatalf("read output marker: %v", err) + } + } + if err := conn.SetReadDeadline(time.Time{}); err != nil { + t.Fatal(err) + } + return got +} diff --git a/internal/session/attach_protocol_manual_test.go b/internal/session/attach_protocol_manual_test.go new file mode 100644 index 0000000..29ba389 --- /dev/null +++ b/internal/session/attach_protocol_manual_test.go @@ -0,0 +1,154 @@ +package session + +import ( + "bufio" + "bytes" + "context" + "encoding/binary" + "encoding/json" + "net" + "os" + "path/filepath" + "testing" + "time" +) + +func TestAttachProtocolRealPTYFixture(t *testing.T) { + client := newTestClient(t) + ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) + defer cancel() + name := "uam-fake-a1b2c3d4" + command := `stty raw -echo; printf 'TASK1-READY'; cat` + if err := client.CreateSession(ctx, name, t.TempDir(), nil, []string{"/bin/sh", "-c", command}); err != nil { + t.Fatal(err) + } + waitFor(t, "task 1 PTY fixture readiness", func() bool { + out, err := client.Capture(ctx, name, 20) + return err == nil && bytes.Contains([]byte(out), []byte("TASK1-READY")) + }) + + v1Conn, err := net.Dial("unix", SocketPath(client.Dir, name)) + if err != nil { + t.Fatal(err) + } + if err := writeJSONLine(v1Conn, request{Op: opAttach, Cols: 80, Rows: 24}); err != nil { + t.Fatal(err) + } + v1Reader := bufio.NewReader(v1Conn) + var v1Resp response + if err := readBoundedJSONLine(v1Reader, &v1Resp); err != nil || !v1Resp.OK || v1Resp.versionPresent { + t.Fatalf("v1 handshake = %+v, %v", v1Resp, err) + } + v1Payload := []byte{0x00, 0xff, 'V', '1', '\r', '\n'} + if err := writeFrame(v1Conn, frameStdin, v1Payload); err != nil { + t.Fatal(err) + } + v1Bytes := readUntilContains(t, v1Conn, v1Reader, v1Payload) + if !bytes.Contains(v1Bytes, v1Payload) { + t.Fatalf("v1 payload missing from raw stream: %x", v1Bytes) + } + if err := writeFrame(v1Conn, frameDetach, nil); err != nil { + t.Fatal(err) + } + _ = v1Conn.Close() + + v2Conn, err := net.Dial("unix", SocketPath(client.Dir, name)) + if err != nil { + t.Fatal(err) + } + defer func() { _ = v2Conn.Close() }() + hello := validTestHello() + if err := writeJSONLine(v2Conn, request{Op: opAttach, Version: protocolV2, Cols: 80, Rows: 24, RequestedRole: roleController, Hello: &hello}); err != nil { + t.Fatal(err) + } + v2Reader := bufio.NewReader(v2Conn) + var v2Resp response + if err := readBoundedJSONLine(v2Reader, &v2Resp); err != nil || !v2Resp.OK || v2Resp.Version != protocolV2 { + t.Fatalf("v2 handshake = %+v, %v", v2Resp, err) + } + v2Payload := []byte{0x00, 0xff, 'V', '2', '\r', '\n'} + if err := writeFrame(v2Conn, frameStdin, ownedFramePayload(v2Resp.Generation, v2Payload)); err != nil { + t.Fatal(err) + } + if err := v2Conn.SetReadDeadline(time.Now().Add(5 * time.Second)); err != nil { + t.Fatal(err) + } + var v2PTY []byte + var v2Controls [][]byte + for !bytes.Contains(v2PTY, v2Payload) { + kind, payload, err := readFrame(v2Reader) + if err != nil { + t.Fatal(err) + } + switch kind { + case serverFramePTY: + v2PTY = append(v2PTY, payload...) + case serverFrameControl: + v2Controls = append(v2Controls, append([]byte{}, payload...)) + default: + t.Fatalf("unexpected server frame type %d", kind) + } + } + if !bytes.Contains(v2PTY, v2Payload) { + t.Fatalf("v2 payload missing from PTY frames: %x", v2PTY) + } + for _, control := range v2Controls { + if bytes.Contains(v2PTY, control) { + t.Fatalf("control payload leaked into PTY bytes: %x", control) + } + } + if err := writeFrame(v2Conn, frameDetach, nil); err != nil { + t.Fatal(err) + } + _ = v2Conn.Close() + + if err := client.Kill(ctx, name); err != nil { + t.Fatal(err) + } + waitFor(t, "task 1 PTY fixture cleanup", func() bool { + _, err := os.Stat(SocketPath(client.Dir, name)) + return os.IsNotExist(err) + }) + writeTask1ManualEvidence(t, v1Bytes, v2PTY, v2Controls) +} + +func writeTask1ManualEvidence(t *testing.T, v1, v2PTY []byte, controls [][]byte) { + t.Helper() + dir := os.Getenv("UAM_TASK1_EVIDENCE_DIR") + if dir == "" { + return + } + var transcript bytes.Buffer + transcript.WriteString("UAM-TASK1\x00V1\x00") + writeEvidenceChunk(&transcript, v1) + transcript.WriteString("V2-PTY\x00") + writeEvidenceChunk(&transcript, v2PTY) + transcript.WriteString("V2-CONTROL\x00") + _ = binary.Write(&transcript, binary.BigEndian, uint32(len(controls))) + for _, control := range controls { + writeEvidenceChunk(&transcript, control) + } + if err := os.WriteFile(filepath.Join(dir, "task-1-protocol.bin"), transcript.Bytes(), 0o600); err != nil { + t.Fatal(err) + } + assertions := struct { + V1PayloadExact bool `json:"v1_payload_exact"` + V2PayloadExact bool `json:"v2_payload_exact"` + ControlFrames int `json:"control_frames"` + ControlInPTY bool `json:"control_in_pty"` + SocketRemovedOnCleanup bool `json:"socket_removed_on_cleanup"` + }{true, true, len(controls), false, true} + data, err := json.MarshalIndent(assertions, "", " ") + if err != nil { + t.Fatal(err) + } + data = append(data, '\n') + if err := os.WriteFile(filepath.Join(dir, "task-1-pty-assertions.json"), data, 0o600); err != nil { + t.Fatal(err) + } +} + +func writeEvidenceChunk(dst *bytes.Buffer, data []byte) { + _ = binary.Write(dst, binary.BigEndian, uint32(len(data))) + dst.Write(data) +} diff --git a/internal/session/attach_protocol_test.go b/internal/session/attach_protocol_test.go new file mode 100644 index 0000000..e4006a1 --- /dev/null +++ b/internal/session/attach_protocol_test.go @@ -0,0 +1,232 @@ +package session + +import ( + "bufio" + "bytes" + "encoding/binary" + "errors" + "io" + "net" + "reflect" + "strings" + "testing" + "time" + + "github.com/charmbracelet/x/term" + "github.com/creack/pty" +) + +func TestAttachHandshakeTimeout(t *testing.T) { + dir := t.TempDir() + if err := EnsureDir(dir); err != nil { + t.Fatal(err) + } + name := "uam-fake-11112222" + ln, err := net.Listen("unix", SocketPath(dir, name)) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = ln.Close() }) + peerDone := make(chan struct{}) + go func() { + defer close(peerDone) + conn, acceptErr := ln.Accept() + if acceptErr != nil { + return + } + defer func() { _ = conn.Close() }() + var req request + _ = readJSONLine(bufio.NewReader(conn), &req) + _, _ = io.Copy(io.Discard, conn) + }() + + ptmx, tty, err := pty.Open() + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = ptmx.Close(); _ = tty.Close() }) + before, err := term.GetState(tty.Fd()) + if err != nil { + t.Fatal(err) + } + started := time.Now() + err = runAttachWithOptions(dir, name, tty, tty, attachOptions{quiet: true}) + elapsed := time.Since(started) + if err == nil || !strings.Contains(err.Error(), "i/o timeout") { + t.Fatalf("attach error = %v, want handshake timeout", err) + } + if elapsed < 1500*time.Millisecond || elapsed > 3*time.Second { + t.Fatalf("handshake elapsed = %s, want approximately 2s", elapsed) + } + after, err := term.GetState(tty.Fd()) + if err != nil { + t.Fatal(err) + } + if !reflect.DeepEqual(after, before) { + t.Fatal("terminal state changed before negotiation completed") + } + select { + case <-peerDone: + case <-time.After(time.Second): + t.Fatal("silent peer did not exit after handshake timeout") + } +} + +func TestAttachHandshakeOversizedLine(t *testing.T) { + valid := []byte(`{"op":"attach"}`) + exact := append(append([]byte{}, valid...), bytes.Repeat([]byte{' '}, maxControlLine-len(valid))...) + exact = append(exact, '\n') + var exactReq request + if err := readBoundedJSONLine(bufio.NewReader(bytes.NewReader(exact)), &exactReq); err != nil { + t.Fatalf("exact-limit line rejected: %v", err) + } + + line := append(bytes.Repeat([]byte{'x'}, maxControlLine+1), '\n') + var req request + err := readBoundedJSONLine(bufio.NewReader(bytes.NewReader(line)), &req) + if !errors.Is(err, errControlLineTooLarge) { + t.Fatalf("oversized line error = %v, want %v", err, errControlLineTooLarge) + } +} + +func TestAttachServerFrameLimit(t *testing.T) { + maximum := bytes.Repeat([]byte{'m'}, maxFrameLen) + var maximumWire bytes.Buffer + if err := writeFrame(&maximumWire, serverFramePTY, maximum); err != nil { + t.Fatalf("maximum frame write: %v", err) + } + _, maximumRead, err := readFrame(&maximumWire) + if err != nil || !bytes.Equal(maximumRead, maximum) { + t.Fatalf("maximum frame round trip: len=%d err=%v", len(maximumRead), err) + } + + payload := bytes.Repeat([]byte{'x'}, maxFrameLen+1) + if err := writeFrame(io.Discard, serverFramePTY, payload); !errors.Is(err, errFrameTooLarge) { + t.Fatalf("oversized write error = %v, want %v", err, errFrameTooLarge) + } + + var header [5]byte + header[0] = serverFramePTY + binary.BigEndian.PutUint32(header[1:], uint32(maxFrameLen+1)) + if _, _, err := readFrame(bytes.NewReader(header[:])); !errors.Is(err, errFrameTooLarge) { + t.Fatalf("oversized read error = %v, want %v", err, errFrameTooLarge) + } +} + +func TestAttachV2OutputIsByteExact(t *testing.T) { + first := []byte{0x00, 0xff, 'p', 't', 'y', '\r', '\n'} + second := []byte{serverFramePTY, 0, 0, 0, 3, 'e', 'n', 'd'} + var wire bytes.Buffer + if err := writeFrame(&wire, serverFramePTY, first); err != nil { + t.Fatal(err) + } + if err := writeFrame(&wire, serverFrameControl, []byte(`{"type":"role","role":"observer"}`)); err != nil { + t.Fatal(err) + } + if err := writeFrame(&wire, serverFramePTY, second); err != nil { + t.Fatal(err) + } + + var terminal bytes.Buffer + if err := copyAttachOutput(&terminal, bufio.NewReader(&wire), protocolV2, true); err != nil { + t.Fatalf("copy v2 output: %v", err) + } + want := append(append([]byte{}, first...), second...) + if !bytes.Equal(terminal.Bytes(), want) { + t.Fatalf("terminal bytes = %x, want %x", terminal.Bytes(), want) + } +} + +func TestAttachMalformedExplicitVersionsNeverDowngrade(t *testing.T) { + for _, raw := range []string{ + `{"op":"attach","version":null}`, + `{"op":"attach","version":"2"}`, + `{"op":"attach","version":2.5}`, + `{"ok":true,"version":null}`, + `{"ok":true,"version":"2"}`, + } { + t.Run(raw, func(t *testing.T) { + if strings.Contains(raw, `"op"`) { + var req request + if err := jsonUnmarshalLine(raw, &req); err == nil { + t.Fatal("malformed request version was accepted") + } + return + } + var resp response + if err := jsonUnmarshalLine(raw, &resp); err == nil { + t.Fatal("malformed response version was accepted") + } + }) + } +} + +func TestAttachExplicitVersionWireRejection(t *testing.T) { + for _, raw := range []string{ + `{"op":"attach","version":99}`, + `{"op":"attach","version":null}`, + `{"op":"attach","version":"2"}`, + } { + t.Run(raw, func(t *testing.T) { + client, server := net.Pipe() + t.Cleanup(func() { _ = client.Close(); _ = server.Close() }) + done := make(chan struct{}) + go func() { + defer close(done) + (&host{}).handleConn(server) + }() + if err := client.SetReadDeadline(time.Now().Add(time.Second)); err != nil { + t.Fatal(err) + } + if err := writeAll(client, []byte(raw+"\n")); err != nil { + t.Fatal(err) + } + var resp response + if err := readBoundedJSONLine(bufio.NewReader(client), &resp); err != nil { + t.Fatal(err) + } + if resp.OK || resp.Err == "" { + t.Fatalf("explicit invalid version response = %+v", resp) + } + select { + case <-done: + case <-time.After(time.Second): + t.Fatal("rejecting host did not close") + } + }) + } +} + +func TestAttachHostHandshakeTimeout(t *testing.T) { + client, server := net.Pipe() + t.Cleanup(func() { _ = client.Close(); _ = server.Close() }) + done := make(chan struct{}) + started := time.Now() + go func() { + defer close(done) + (&host{}).handleConn(server) + }() + if err := client.SetReadDeadline(time.Now().Add(3 * time.Second)); err != nil { + t.Fatal(err) + } + var resp response + if err := readBoundedJSONLine(bufio.NewReader(client), &resp); err != nil { + t.Fatalf("read host timeout response: %v", err) + } + if resp.OK || !strings.Contains(resp.Err, "i/o timeout") { + t.Fatalf("host timeout response = %+v", resp) + } + select { + case <-done: + elapsed := time.Since(started) + if elapsed < 1500*time.Millisecond || elapsed > 3*time.Second { + t.Fatalf("host handshake elapsed = %s, want approximately 2s", elapsed) + } + case <-time.After(3 * time.Second): + t.Fatal("host did not time out a silent handshake") + } +} + +func jsonUnmarshalLine(raw string, dst any) error { + return readBoundedJSONLine(bufio.NewReader(strings.NewReader(raw+"\n")), dst) +} diff --git a/internal/session/attach_signal_test.go b/internal/session/attach_signal_test.go new file mode 100644 index 0000000..5647760 --- /dev/null +++ b/internal/session/attach_signal_test.go @@ -0,0 +1,101 @@ +package session + +import ( + "bufio" + "fmt" + "net" + "reflect" + "strings" + "syscall" + "testing" + "time" + + "github.com/charmbracelet/x/term" +) + +func testTodo6SignalCleanup(t *testing.T) { + for _, assignedRole := range []clientRole{roleController, roleStandby, roleObserver} { + t.Run(string(assignedRole), func(t *testing.T) { + // Given + dir := t.TempDir() + if err := EnsureDir(dir); err != nil { + t.Fatal(err) + } + name := "uam-fake-67676767" + listener, err := net.Listen("unix", SocketPath(dir, name)) + if err != nil { + t.Fatal(err) + } + defer func() { _ = listener.Close() }() + serverErr := make(chan error, 1) + go serveTodo6SignalAttach(listener, assignedRole, serverErr) + ptmx, tty := openProtocolPTY(t) + before, err := term.GetState(tty.Fd()) + if err != nil { + t.Fatal(err) + } + snapshot := capturePTYOutput(ptmx) + done := make(chan error, 1) + go func() { done <- runAttachWithOptions(dir, name, tty, tty, attachOptions{quiet: true}) }() + waitFor(t, "signal fixture readiness", func() bool { return strings.Contains(snapshot(), "signal-ready") }) + + // When + if err := syscall.Kill(syscall.Getpid(), syscall.SIGTERM); err != nil { + t.Fatal(err) + } + + // Then + select { + case err := <-done: + if err != nil { + t.Fatal(err) + } + case <-time.After(5 * time.Second): + t.Fatal("signal did not stop attachment") + } + if err := <-serverErr; err != nil { + t.Fatal(err) + } + after, err := term.GetState(tty.Fd()) + if err != nil { + t.Fatal(err) + } + if !reflect.DeepEqual(before, after) { + t.Fatalf("signal cleanup changed terminal state for role %q", assignedRole) + } + }) + } +} + +func serveTodo6SignalAttach(listener net.Listener, assignedRole clientRole, result chan<- error) { + conn, err := listener.Accept() + if err != nil { + result <- err + return + } + defer func() { _ = conn.Close() }() + reader := bufio.NewReader(conn) + var req request + if err := readJSONLine(reader, &req); err != nil { + result <- err + return + } + if err := writeJSONLine(conn, response{OK: true, Version: protocolV2, ClientID: "signal-client", AssignedRole: assignedRole, Generation: 1}); err != nil { + result <- err + return + } + if err := writeFrame(conn, serverFrameControl, []byte(`{"type":"role","client_id":"signal-client","role":"`+assignedRole+`","generation":1,"reason":"assigned"}`)); err != nil { + result <- err + return + } + if err := writeFrame(conn, serverFramePTY, []byte("signal-ready")); err != nil { + result <- err + return + } + kind, _, err := readFrame(reader) + if err == nil && kind != frameDetach { + result <- fmt.Errorf("signal cleanup frame = %d, want %d", kind, frameDetach) + return + } + result <- err +} diff --git a/internal/session/attach_todo6_evidence_test.go b/internal/session/attach_todo6_evidence_test.go new file mode 100644 index 0000000..5cc85be --- /dev/null +++ b/internal/session/attach_todo6_evidence_test.go @@ -0,0 +1,256 @@ +package session + +import ( + "bytes" + "encoding/json" + "fmt" + "os" + "path/filepath" + "strconv" + "strings" + "testing" + + "github.com/charmbracelet/x/term" +) + +type todo6Evidence struct { + controller *todo6PTYAttach + standby *todo6PTYAttach + observer *todo6PTYAttach + providerOutput string + termiosAfter map[string][]byte + termiosEqual map[string]bool + cleanupOrder map[string]todo6CleanupOrder + cleanup todo6CleanupObservation +} + +type todo6ControlEvent struct { + Client string `json:"client"` + Message string `json:"message"` +} + +type todo6CleanupOrder struct { + ResetOffset int `json:"reset_offset"` + ExitOffset int `json:"exit_offset"` + Valid bool `json:"valid"` +} + +type todo6CleanupObservation struct { + hostPID int + hostAlive bool + childPID int + childAlive bool + socketPath string + socketPresent bool + runtimeEntries []string +} + +type todo6CleanupReceipt struct { + HostPID int `json:"host_pid"` + HostAlive bool `json:"host_alive"` + ChildPID int `json:"child_pid"` + ChildAlive bool `json:"child_alive"` + SocketPath string `json:"socket_path"` + SocketPresent bool `json:"socket_present"` + RuntimeEntries []string `json:"runtime_entries"` +} + +type todo6Assertions struct { + ProviderByteValues []int `json:"provider_byte_values"` + RejectedByteValuesPresent []int `json:"rejected_byte_values_present"` + ControlEventCount int `json:"control_event_count"` + TermiosEqual map[string]bool `json:"termios_equal"` + CleanupOrder map[string]todo6CleanupOrder `json:"cleanup_order"` +} + +func TestTodo6EvidenceDirectoryRejectsRelativePath(t *testing.T) { + // Given + getenv := func(string) string { return ".omo/evidence/persistent-agent-multiplexer/task-6-attach" } + + // When + _, err := todo6EvidenceDirectory(getenv) + + // Then + if err == nil { + t.Fatal("relative Todo 6 evidence directory was accepted") + } +} + +func (observation todo6CleanupObservation) validate() error { + switch { + case observation.hostAlive: + return fmt.Errorf("host process %d remains alive", observation.hostPID) + case observation.childAlive: + return fmt.Errorf("child process %d remains alive", observation.childPID) + case observation.socketPresent: + return fmt.Errorf("session socket remains at %s", observation.socketPath) + case len(observation.runtimeEntries) != 0: + return fmt.Errorf("runtime entries remain: %v", observation.runtimeEntries) + default: + return nil + } +} + +func (observation todo6CleanupObservation) receipt() todo6CleanupReceipt { + return todo6CleanupReceipt{ + HostPID: observation.hostPID, HostAlive: observation.hostAlive, + ChildPID: observation.childPID, ChildAlive: observation.childAlive, + SocketPath: observation.socketPath, SocketPresent: observation.socketPresent, + RuntimeEntries: observation.runtimeEntries, + } +} + +func todo6TermStateBytes(state *term.State) []byte { + return fmt.Appendf(nil, "%#v", state) +} + +func observeTodo6CleanupOrder(output string) todo6CleanupOrder { + resetOffset := strings.LastIndex(output, screenReset) + exitOffset := strings.LastIndex(output, screenExit) + return todo6CleanupOrder{ResetOffset: resetOffset, ExitOffset: exitOffset, Valid: resetOffset >= 0 && resetOffset <= exitOffset} +} + +func todo6EvidenceDirectory(getenv func(string) string) (string, error) { + dir := getenv("UAM_TASK6_EVIDENCE_DIR") + if dir == "" { + return "", nil + } + if !filepath.IsAbs(dir) { + return "", fmt.Errorf("UAM_TASK6_EVIDENCE_DIR must be absolute: %q", dir) + } + return filepath.Clean(dir), nil +} + +func writeTodo6Evidence(t *testing.T, evidence todo6Evidence) { + t.Helper() + dir, err := todo6EvidenceDirectory(os.Getenv) + if err != nil { + t.Fatal(err) + } + if dir == "" { + return + } + if err := os.MkdirAll(dir, 0o700); err != nil { + t.Fatal(err) + } + transcript := bytes.Join([][]byte{ + []byte(evidence.controller.snapshot()), []byte(evidence.standby.snapshot()), + []byte(evidence.observer.snapshot()), []byte(evidence.providerOutput), + }, []byte("\n---CLIENT---\n")) + if err := os.WriteFile(filepath.Join(dir, "pty-transcript.bin"), transcript, 0o600); err != nil { + t.Fatal(err) + } + events := append(parseTodo6ControlEvents("controller", evidence.controller.snapshot()), parseTodo6ControlEvents("standby", evidence.standby.snapshot())...) + events = append(events, parseTodo6ControlEvents("observer", evidence.observer.snapshot())...) + requireTodo6ControlEvents(t, events) + writeTodo6ControlEvents(t, filepath.Join(dir, "control-events.jsonl"), events) + providerBytes, err := parseTodo6ProviderBytes(evidence.providerOutput) + if err != nil { + t.Fatal(err) + } + rejectedPresent := presentTodo6ProviderBytes(providerBytes, []int{79, 83, 112}) + if len(rejectedPresent) != 0 { + t.Fatalf("rejected provider bytes observed: %v", rejectedPresent) + } + before := map[string][]byte{ + "controller": todo6TermStateBytes(evidence.controller.before), + "standby": todo6TermStateBytes(evidence.standby.before), + "observer": todo6TermStateBytes(evidence.observer.before), + } + writeTodo6JSON(t, filepath.Join(dir, "termios-before.json"), before) + writeTodo6JSON(t, filepath.Join(dir, "termios-after.json"), evidence.termiosAfter) + writeTodo6JSON(t, filepath.Join(dir, "assertions.json"), todo6Assertions{ + ProviderByteValues: providerBytes, RejectedByteValuesPresent: rejectedPresent, + ControlEventCount: len(events), TermiosEqual: evidence.termiosEqual, CleanupOrder: evidence.cleanupOrder, + }) + writeTodo6JSON(t, filepath.Join(dir, "cleanup-receipt.json"), evidence.cleanup.receipt()) +} + +func parseTodo6ControlEvents(client, snapshot string) []todo6ControlEvent { + const prefix = "[uam: " + events := make([]todo6ControlEvent, 0) + for { + start := strings.Index(snapshot, prefix) + if start < 0 { + return events + } + snapshot = snapshot[start+len(prefix):] + end := strings.IndexByte(snapshot, ']') + if end < 0 { + return events + } + events = append(events, todo6ControlEvent{Client: client, Message: snapshot[:end]}) + snapshot = snapshot[end+1:] + } +} + +func requireTodo6ControlEvents(t *testing.T, events []todo6ControlEvent) { + t.Helper() + encoded, err := json.Marshal(events) + if err != nil { + t.Fatal(err) + } + raw := string(encoded) + for _, expected := range []string{ + "control requested", "control transfer requested", "selected profile focused", "effective profile focused+session", "mouse passthrough false", + `"client":"controller"`, `"client":"standby"`, `"client":"observer"`, + } { + if !strings.Contains(raw, expected) { + t.Fatalf("captured control events lack %q: %s", expected, raw) + } + } +} + +func writeTodo6ControlEvents(t *testing.T, path string, events []todo6ControlEvent) { + t.Helper() + var output bytes.Buffer + encoder := json.NewEncoder(&output) + for _, event := range events { + if err := encoder.Encode(event); err != nil { + t.Fatal(err) + } + } + if err := os.WriteFile(path, output.Bytes(), 0o600); err != nil { + t.Fatal(err) + } +} + +func parseTodo6ProviderBytes(output string) ([]int, error) { + values := make([]int, 0) + for _, line := range strings.Split(output, "\n") { + raw, found := strings.CutPrefix(strings.TrimSpace(line), "BYTE:") + if !found { + continue + } + value, err := strconv.Atoi(raw) + if err != nil { + return nil, fmt.Errorf("parse provider byte %q: %w", raw, err) + } + values = append(values, value) + } + return values, nil +} + +func presentTodo6ProviderBytes(observed, candidates []int) []int { + present := make([]int, 0) + for _, candidate := range candidates { + for _, value := range observed { + if value == candidate { + present = append(present, candidate) + break + } + } + } + return present +} + +func writeTodo6JSON[T any](t *testing.T, path string, value T) { + t.Helper() + data, err := json.MarshalIndent(value, "", " ") + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(path, append(data, '\n'), 0o600); err != nil { + t.Fatal(err) + } +} diff --git a/internal/session/attach_todo6_manual_test.go b/internal/session/attach_todo6_manual_test.go new file mode 100644 index 0000000..af24ecb --- /dev/null +++ b/internal/session/attach_todo6_manual_test.go @@ -0,0 +1,209 @@ +package session + +import ( + "context" + "errors" + "os" + "reflect" + "strings" + "testing" + "time" + + "github.com/charmbracelet/x/term" + "github.com/creack/pty" +) + +type todo6PTYAttach struct { + master *os.File + slave *os.File + done chan error + snapshot func() string + before *term.State +} + +type todo6PTYAttachConfig struct { + dir string + name string + requestedRole clientRole + profile attachProfileSnapshot +} + +func TestTodo6AttachControlModesRealPTY(t *testing.T) { + // Given + client := newTestClient(t) + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + name := "uam-fake-66666666" + command := `stty raw -echo; printf READY; while :; do n=$(dd bs=1 count=1 2>/dev/null | od -An -tu1 | tr -d ' \n'); [ -n "$n" ] || exit; printf '\r\nBYTE:%s\r\n' "$n"; done` + if err := client.CreateSession(ctx, name, t.TempDir(), nil, []string{"/bin/sh", "-c", command}); err != nil { + t.Fatal(err) + } + state, err := readState(client.Dir, name) + if err != nil { + t.Fatal(err) + } + profile := attachProfileSnapshot{selected: "focused", effective: "focused+session"} + controller := startTodo6PTYAttach(t, todo6PTYAttachConfig{dir: client.Dir, name: name, requestedRole: roleController, profile: profile}) + waitFor(t, "Todo 6 controller assignment", func() bool { return strings.Contains(controller.snapshot(), "role controller;") }) + standby := startTodo6PTYAttach(t, todo6PTYAttachConfig{dir: client.Dir, name: name, requestedRole: roleController, profile: profile}) + waitFor(t, "Todo 6 standby assignment", func() bool { return strings.Contains(standby.snapshot(), "role standby;") }) + observer := startTodo6PTYAttach(t, todo6PTYAttachConfig{dir: client.Dir, name: name, requestedRole: roleObserver, profile: profile}) + waitFor(t, "Todo 6 observer assignment", func() bool { return strings.Contains(observer.snapshot(), "role observer;") }) + for _, attached := range []*todo6PTYAttach{controller, standby, observer} { + waitFor(t, "Todo 6 PTY replay", func() bool { return strings.Contains(attached.snapshot(), "READY") }) + } + + // When + writeTodo6PTY(t, standby, []byte{detachPrefix, detachPrefix, detachPrefix, 'c', detachPrefix, 'r', 'S', detachPrefix, 'i', detachPrefix, 'm'}) + writeTodo6PTY(t, observer, []byte("O\x1b[6n\x1b[200~prompt-like\x1b[201~")) + writeTodo6PTY(t, observer, []byte{detachPrefix, detachPrefix, detachPrefix, 'c', detachPrefix, 'r', detachPrefix, 'o', detachPrefix, 'i', detachPrefix, 'm'}) + writeTodo6PTY(t, controller, []byte{'A', detachPrefix, detachPrefix, detachPrefix, 'c', detachPrefix, 'r', detachPrefix, 'i', detachPrefix, 'm', detachPrefix, 'o'}) + waitFor(t, "standby promotion", func() bool { return strings.Count(standby.snapshot(), "role controller") >= 1 }) + writeTodo6PTY(t, standby, []byte{'B', detachPrefix, 'o'}) + waitFor(t, "controller return", func() bool { return strings.Contains(controller.snapshot(), "role controller (transferred)") }) + writeTodo6PTY(t, controller, []byte{'C'}) + providerOutput := "" + providerTimer := time.NewTimer(20 * time.Second) + providerTicker := time.NewTicker(20 * time.Millisecond) + defer providerTimer.Stop() + defer providerTicker.Stop() + providerReady := false + for !providerReady { + output, captureErr := client.Capture(ctx, name, 100) + if captureErr == nil { + providerOutput = output + providerReady = strings.Contains(output, "BYTE:65") && strings.Contains(output, "BYTE:2") && strings.Contains(output, "BYTE:3") && strings.Contains(output, "BYTE:66") && strings.Contains(output, "BYTE:67") + } + if providerReady { + break + } + select { + case <-providerTimer.C: + t.Fatalf("timed out waiting for controller command bytes; capture=%q standby=%q", providerOutput, standby.snapshot()) + case <-providerTicker.C: + } + } + writeTodo6PTY(t, observer, []byte{detachPrefix, 'd'}) + waitTodo6Detach(t, observer) + writeTodo6PTY(t, standby, []byte{detachPrefix, 'd'}) + waitTodo6Detach(t, standby) + writeTodo6PTY(t, controller, []byte{detachPrefix, 'd'}) + waitTodo6Detach(t, controller) + + // Then + termiosAfter := make(map[string][]byte, 3) + termiosEqual := make(map[string]bool, 3) + attachments := map[string]*todo6PTYAttach{"controller": controller, "standby": standby, "observer": observer} + for role, attached := range attachments { + after, stateErr := term.GetState(attached.slave.Fd()) + if stateErr != nil { + t.Fatal(stateErr) + } + termiosEqual[role] = reflect.DeepEqual(attached.before, after) + termiosAfter[role] = todo6TermStateBytes(after) + } + providerOutput, err = client.Capture(ctx, name, 200) + if err != nil { + t.Fatal(err) + } + for _, rejected := range []string{"BYTE:79", "BYTE:83", "BYTE:112"} { + if strings.Contains(providerOutput, rejected) { + t.Fatalf("non-controller input reached provider: %s", rejected) + } + } + for role, equal := range termiosEqual { + if !equal { + t.Fatalf("%s attachment changed terminal state", role) + } + } + cleanupOrder := make(map[string]todo6CleanupOrder, 3) + for role, attached := range attachments { + cleanupOrder[role] = observeTodo6CleanupOrder(attached.snapshot()) + if !cleanupOrder[role].Valid { + t.Fatalf("%s attach output was written after screen restoration", role) + } + } + for _, attached := range []*todo6PTYAttach{controller, standby, observer} { + _ = attached.master.Close() + _ = attached.slave.Close() + } + if err := client.Kill(ctx, name); err != nil { + t.Fatal(err) + } + waitFor(t, "Todo 6 runtime cleanup", func() bool { + entries, readErr := os.ReadDir(client.Dir) + return readErr == nil && len(entries) == 0 + }) + waitFor(t, "Todo 6 process cleanup", func() bool { return !state.hostAlive() && !state.childAlive() }) + entries, err := os.ReadDir(client.Dir) + if err != nil { + t.Fatal(err) + } + runtimeEntries := make([]string, 0, len(entries)) + for _, entry := range entries { + runtimeEntries = append(runtimeEntries, entry.Name()) + } + _, socketErr := os.Lstat(SocketPath(client.Dir, name)) + if socketErr != nil && !errors.Is(socketErr, os.ErrNotExist) { + t.Fatal(socketErr) + } + cleanup := todo6CleanupObservation{ + hostPID: state.HostPID, hostAlive: state.hostAlive(), childPID: state.ChildPID, childAlive: state.childAlive(), + socketPath: SocketPath(client.Dir, name), socketPresent: socketErr == nil, runtimeEntries: runtimeEntries, + } + if err := cleanup.validate(); err != nil { + t.Fatal(err) + } + configDir := os.Getenv("UAM_CONFIG_DIR") + if err := os.Remove(client.Dir); err != nil { + t.Fatal(err) + } + if err := os.RemoveAll(configDir); err != nil { + t.Fatal(err) + } + writeTodo6Evidence(t, todo6Evidence{ + controller: controller, standby: standby, observer: observer, providerOutput: providerOutput, + termiosAfter: termiosAfter, termiosEqual: termiosEqual, cleanupOrder: cleanupOrder, cleanup: cleanup, + }) +} + +func startTodo6PTYAttach(t *testing.T, config todo6PTYAttachConfig) *todo6PTYAttach { + t.Helper() + master, slave, err := pty.Open() + if err != nil { + t.Fatal(err) + } + if err := pty.Setsize(master, &pty.Winsize{Cols: 80, Rows: 24}); err != nil { + t.Fatal(err) + } + before, err := term.GetState(slave.Fd()) + if err != nil { + t.Fatal(err) + } + done := make(chan error, 1) + go func() { + done <- runAttachWithOptions(config.dir, config.name, slave, slave, attachOptions{ + quiet: true, requestedRole: config.requestedRole, profile: config.profile, + }) + }() + return &todo6PTYAttach{master: master, slave: slave, done: done, snapshot: capturePTYOutput(master), before: before} +} + +func writeTodo6PTY(t *testing.T, attached *todo6PTYAttach, payload []byte) { + t.Helper() + if _, err := attached.master.Write(payload); err != nil { + t.Fatal(err) + } +} + +func waitTodo6Detach(t *testing.T, attached *todo6PTYAttach) { + t.Helper() + select { + case err := <-attached.done: + if err != nil { + t.Fatal(err) + } + case <-time.After(5 * time.Second): + t.Fatal("Todo 6 attachment did not detach") + } +} diff --git a/internal/session/attach_todo6_pin_test.go b/internal/session/attach_todo6_pin_test.go new file mode 100644 index 0000000..1da2820 --- /dev/null +++ b/internal/session/attach_todo6_pin_test.go @@ -0,0 +1,106 @@ +package session + +import ( + "bufio" + "bytes" + "fmt" + "net" + "reflect" + "testing" + "time" + + "github.com/charmbracelet/x/term" +) + +func TestTodo6PINPreservesV1CodexAndCleanupContracts(t *testing.T) { + // Given: an unversioned v1 host behind a legacy Codex session name. + dir := t.TempDir() + if err := EnsureDir(dir); err != nil { + t.Fatal(err) + } + name := "uam-codex-60606060" + if err := writeState(dir, State{Name: name}); err != nil { + t.Fatal(err) + } + listener, err := net.Listen("unix", SocketPath(dir, name)) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = listener.Close() }) + serverDone := make(chan error, 1) + go func() { + conn, acceptErr := listener.Accept() + if acceptErr != nil { + serverDone <- acceptErr + return + } + defer func() { _ = conn.Close() }() + reader := bufio.NewReader(conn) + var req request + if readErr := readJSONLine(reader, &req); readErr != nil { + serverDone <- readErr + return + } + if writeErr := writeJSONLine(conn, response{OK: true}); writeErr != nil { + serverDone <- writeErr + return + } + if writeErr := writeAll(conn, []byte("todo6-v1-pin")); writeErr != nil { + serverDone <- writeErr + return + } + kind, _, readErr := readFrame(reader) + if readErr == nil && kind != frameDetach { + readErr = fmt.Errorf("attach frame = %d, want %d", kind, frameDetach) + } + serverDone <- readErr + }() + + ptmx, tty := openProtocolPTY(t) + before, err := term.GetState(tty.Fd()) + if err != nil { + t.Fatal(err) + } + snapshot := capturePTYOutput(ptmx) + attachDone := make(chan error, 1) + + // When: the v2-capable client falls back to v1 and detaches twice-safe. + go func() { + attachDone <- runAttachWithOptions(dir, name, tty, tty, attachOptions{quiet: true}) + }() + waitFor(t, "v1 characterization output", func() bool { + return bytes.Contains([]byte(snapshot()), []byte("todo6-v1-pin")) + }) + if _, err := ptmx.Write([]byte{detachPrefix, 'd'}); err != nil { + t.Fatal(err) + } + select { + case err := <-attachDone: + if err != nil { + t.Fatal(err) + } + case <-time.After(5 * time.Second): + t.Fatal("characterization attach did not detach") + } + + // Then: v1 output stayed raw, Codex stayed on the primary screen, and termios returned exactly. + after, err := term.GetState(tty.Fd()) + if err != nil { + t.Fatal(err) + } + if !reflect.DeepEqual(before, after) { + t.Fatal("attach cleanup did not restore the original terminal state") + } + output := snapshot() + if bytes.Contains([]byte(output), []byte(screenEnter)) || bytes.Contains([]byte(output), []byte(screenExit)) { + t.Fatalf("legacy Codex attach changed screen ownership: %q", output) + } + select { + case err := <-serverDone: + if err != nil { + t.Fatal(err) + } + case <-time.After(time.Second): + t.Fatal("v1 characterization server did not exit") + } +} diff --git a/internal/session/client.go b/internal/session/client.go index dd7e242..aa2c549 100644 --- a/internal/session/client.go +++ b/internal/session/client.go @@ -40,6 +40,15 @@ type Client struct { Exe string } +type CreateSpec struct { + Name string + Cwd string + ProviderIdentity string + ScrollbackLines int + Env map[string]string + Command []string +} + func NewClient() *Client { return &Client{Dir: DefaultDir()} } @@ -64,12 +73,23 @@ func (c *Client) exePath() (string, error) { // once the host reports the agent started (or with the host's startup error), // mirroring the synchronous contract of `tmux new-session -d`. func (c *Client) CreateSession(ctx context.Context, name, cwd string, env map[string]string, command []string) error { - if err := ValidateName(name); err != nil { + return c.CreateProviderSession(ctx, CreateSpec{Name: name, Cwd: cwd, Env: env, Command: command}) +} + +func (c *Client) CreateProviderSession(ctx context.Context, spec CreateSpec) error { + if err := ValidateName(spec.Name); err != nil { + return fmt.Errorf("refusing to create session: %w", err) + } + if err := validateProviderIdentity(spec.ProviderIdentity); err != nil { return fmt.Errorf("refusing to create session: %w", err) } - if len(command) == 0 { + if len(spec.Command) == 0 { return errors.New("create session: empty command") } + scrollbackLines, err := validatedScrollbackLines(spec.ScrollbackLines) + if err != nil { + return fmt.Errorf("create session: %w", err) + } exe, err := c.exePath() if err != nil { return err @@ -77,15 +97,19 @@ func (c *Client) CreateSession(ctx context.Context, name, cwd string, env map[st if err := EnsureDir(c.Dir); err != nil { return err } - args := []string{"__host", "--dir", c.Dir, "--name", name} - if cwd != "" { - args = append(args, "--cwd", cwd) + args := []string{"__host", "--dir", c.Dir, "--name", spec.Name} + args = append(args, "--scrollback", fmt.Sprintf("%d", scrollbackLines)) + if spec.Cwd != "" { + args = append(args, "--cwd", spec.Cwd) } - for _, k := range sortedKeys(env) { - args = append(args, "--env", k+"="+env[k]) + if spec.ProviderIdentity != "" { + args = append(args, "--provider", spec.ProviderIdentity) + } + for _, k := range sortedKeys(spec.Env) { + args = append(args, "--env", k+"="+spec.Env[k]) } args = append(args, "--") - args = append(args, command...) + args = append(args, spec.Command...) r, w, err := os.Pipe() if err != nil { @@ -106,7 +130,7 @@ func (c *Client) CreateSession(ctx context.Context, name, cwd string, env map[st // Reap the host whenever it eventually exits so it never lingers as a // zombie under a long-lived TUI process. go func() { _ = cmd.Wait() }() - return waitReady(ctx, r, name, cmd.Process.Pid) + return waitReady(ctx, r, spec.Name, cmd.Process.Pid) } func waitReady(ctx context.Context, r *os.File, name string, hostPID int) error { @@ -367,6 +391,9 @@ func (c *Client) roundTrip(ctx context.Context, name string, req request) (respo return response{}, fmt.Errorf("session %s: read %s response: %w", name, req.Op, err) } if !resp.OK { + if resp.ErrorCode == errorCodeBusy { + return resp, fmt.Errorf("session %s: %w", name, &SessionBusyError{Operation: req.Op}) + } return resp, fmt.Errorf("session %s: %s failed: %s", name, req.Op, resp.Err) } return resp, nil diff --git a/internal/session/client_registry.go b/internal/session/client_registry.go new file mode 100644 index 0000000..7441de7 --- /dev/null +++ b/internal/session/client_registry.go @@ -0,0 +1,236 @@ +package session + +import ( + "errors" + "fmt" + "net" + "sync" +) + +var errLegacyAttachBusy = errors.New("legacy attach already controlled") + +type terminalSize struct { + cols int + rows int +} + +func (size terminalSize) valid() bool { + return validSize(size.cols, size.rows) +} + +type serverMessage struct { + kind byte + payload []byte +} + +type attachClient struct { + conn net.Conn + out chan serverMessage + done chan struct{} + version protocolVersion + id string + requestedRole clientRole + assignedRole clientRole + order uint64 + generation uint64 + latestSize terminalSize + hello clientHello + ready bool + fallback bool + once sync.Once +} + +func (client *attachClient) drop() { + client.once.Do(func() { + close(client.done) + if client.conn != nil { + _ = client.conn.Close() + } + }) +} + +type clientRegistration struct { + requestedRole clientRole + hello clientHello + size terminalSize +} + +type roleChange struct { + client *attachClient + clientID string + role clientRole + generation uint64 + reason string +} + +type clientRegistry struct { + clients map[*attachClient]struct{} + controller *attachClient + standbys []*attachClient + nextID uint64 + nextOrder uint64 + generation uint64 +} + +func newClientRegistry() *clientRegistry { + return &clientRegistry{clients: make(map[*attachClient]struct{})} +} + +func (registry *clientRegistry) register(client *attachClient, registration clientRegistration) error { + if err := validateRequestedRole(registration.requestedRole); err != nil { + return err + } + if client.version == protocolV2 { + if err := validateClientHello(registration.hello); err != nil { + return fmt.Errorf("invalid client hello: %w", err) + } + } else if registry.controller != nil { + return errLegacyAttachBusy + } + + registry.nextID++ + registry.nextOrder++ + client.id = fmt.Sprintf("client-%d", registry.nextID) + client.order = registry.nextOrder + client.requestedRole = registration.requestedRole + client.hello = registration.hello + if registration.size.valid() { + client.latestSize = registration.size + } + registry.clients[client] = struct{}{} + + if registration.requestedRole == roleObserver { + client.assignedRole = roleObserver + client.generation = registry.generation + return nil + } + if registry.controller == nil { + registry.assignController(client) + return nil + } + client.assignedRole = roleStandby + client.generation = registry.generation + registry.standbys = append(registry.standbys, client) + return nil +} + +func (registry *clientRegistry) assignController(client *attachClient) { + registry.generation++ + client.assignedRole = roleController + registry.controller = client + registry.syncGeneration() +} + +func (registry *clientRegistry) syncGeneration() { + for client := range registry.clients { + client.generation = registry.generation + } +} + +func (registry *clientRegistry) acceptsControl(client *attachClient, generation uint64) bool { + _, registered := registry.clients[client] + return registered && registry.controller == client && client.assignedRole == roleController && client.generation == generation +} + +func (registry *clientRegistry) updateSize(client *attachClient, generation uint64, size terminalSize) bool { + switch registry.resizeReason(client, generation, size) { + case "accepted": + client.latestSize = size + return true + case "not_controller": + client.latestSize = size + } + return false +} + +func (registry *clientRegistry) resizeReason(client *attachClient, generation uint64, size terminalSize) string { + if !size.valid() { + return "invalid_size" + } + if _, registered := registry.clients[client]; !registered { + return "unknown_client" + } + if client.generation != generation { + return "stale_generation" + } + if client.assignedRole == roleObserver { + return "observer" + } + if !registry.acceptsControl(client, generation) { + return "not_controller" + } + return "accepted" +} + +func (registry *clientRegistry) transfer(client *attachClient) []roleChange { + if registry.controller != client || len(registry.standbys) == 0 { + return nil + } + next := registry.standbys[0] + registry.standbys = registry.standbys[1:] + client.assignedRole = roleStandby + registry.standbys = append(registry.standbys, client) + registry.assignController(next) + return []roleChange{ + registry.roleChange(client, "transferred"), + registry.roleChange(next, "transferred"), + } +} + +func (registry *clientRegistry) remove(client *attachClient) []roleChange { + if _, registered := registry.clients[client]; !registered { + return nil + } + delete(registry.clients, client) + registry.removeStandby(client) + if registry.controller != client { + return nil + } + registry.controller = nil + if len(registry.standbys) == 0 { + registry.generation++ + registry.syncGeneration() + return nil + } + next := registry.standbys[0] + registry.standbys = registry.standbys[1:] + registry.assignController(next) + return []roleChange{registry.roleChange(next, "promoted")} +} + +func (registry *clientRegistry) roleChange(client *attachClient, reason string) roleChange { + return roleChange{ + client: client, clientID: client.id, role: client.assignedRole, generation: client.generation, reason: reason, + } +} + +func (registry *clientRegistry) removeStandby(client *attachClient) { + for index, standby := range registry.standbys { + if standby == client { + registry.standbys = append(registry.standbys[:index], registry.standbys[index+1:]...) + return + } + } +} + +func (registry *clientRegistry) readyClients() []*attachClient { + clients := make([]*attachClient, 0, len(registry.clients)) + for client := range registry.clients { + if client.ready { + clients = append(clients, client) + } + } + return clients +} + +func (registry *clientRegistry) drain() []*attachClient { + clients := make([]*attachClient, 0, len(registry.clients)) + for client := range registry.clients { + clients = append(clients, client) + } + registry.clients = make(map[*attachClient]struct{}) + registry.controller = nil + registry.standbys = nil + registry.generation++ + return clients +} diff --git a/internal/session/client_registry_manual_test.go b/internal/session/client_registry_manual_test.go new file mode 100644 index 0000000..94bd950 --- /dev/null +++ b/internal/session/client_registry_manual_test.go @@ -0,0 +1,266 @@ +package session + +import ( + "bufio" + "bytes" + "context" + "encoding/json" + "fmt" + "net" + "os" + "path/filepath" + "sync" + "testing" + "time" +) + +type monitoredAttach struct { + conn net.Conn + reader *bufio.Reader + mu sync.Mutex + roles []roleEvent + generation uint64 + record map[string]bool + carry []byte + done chan struct{} +} + +func openMonitoredAttach(t *testing.T, client *Client, name string, requested clientRole, monitor bool) (*monitoredAttach, response) { + t.Helper() + conn, err := net.Dial("unix", SocketPath(client.Dir, name)) + if err != nil { + t.Fatal(err) + } + hello := validTestHello() + if err := writeJSONLine(conn, request{ + Op: opAttach, Version: protocolV2, Cols: 80, Rows: 24, RequestedRole: requested, Hello: &hello, + }); err != nil { + t.Fatal(err) + } + reader := bufio.NewReader(conn) + var response response + if err := readBoundedJSONLine(reader, &response); err != nil || !response.OK { + t.Fatalf("attach response = %+v, %v", response, err) + } + attached := &monitoredAttach{conn: conn, reader: reader, generation: response.Generation, record: make(map[string]bool), done: make(chan struct{})} + for range 2 { + kind, payload, err := readFrame(reader) + if err != nil { + t.Fatal(err) + } + attached.consume(kind, payload) + } + if monitor { + go attached.readFrames(reader) + } + return attached, response +} + +func (attached *monitoredAttach) readFrames(reader *bufio.Reader) { + defer close(attached.done) + for { + kind, payload, err := readFrame(reader) + if err != nil { + return + } + attached.consume(kind, payload) + } +} + +func (attached *monitoredAttach) consume(kind byte, payload []byte) { + attached.mu.Lock() + defer attached.mu.Unlock() + if kind == serverFrameControl { + var event roleEvent + if json.Unmarshal(payload, &event) == nil && event.Type == "role" { + attached.roles = append(attached.roles, event) + attached.generation = event.Generation + } + return + } + data := append(attached.carry, payload...) + for _, marker := range []string{ + "REC:65:81:25", "REC:66:101:31", "REC:67:111:33", "REC:68:121:35", + "REC:72:", "REC:79:", "REC:83:", "REC:90:", + } { + if bytes.Contains(data, []byte(marker)) { + attached.record[marker] = true + } + } + if len(data) > 128 { + data = data[len(data)-128:] + } + attached.carry = append(attached.carry[:0], data...) +} + +func (attached *monitoredAttach) writeControlFrame(kind byte, payload []byte) error { + attached.mu.Lock() + generation := attached.generation + attached.mu.Unlock() + return writeFrame(attached.conn, kind, ownedFramePayload(generation, payload)) +} + +func (attached *monitoredAttach) setGeneration(generation uint64) { + attached.mu.Lock() + attached.generation = generation + attached.mu.Unlock() +} + +func (attached *monitoredAttach) sawRole(role clientRole) bool { + attached.mu.Lock() + defer attached.mu.Unlock() + for _, event := range attached.roles { + if event.Role == role { + return true + } + } + return false +} + +func (attached *monitoredAttach) sawRecord(marker string) bool { + attached.mu.Lock() + defer attached.mu.Unlock() + return attached.record[marker] +} + +func (attached *monitoredAttach) roleEvents() []roleEvent { + attached.mu.Lock() + defer attached.mu.Unlock() + return append([]roleEvent(nil), attached.roles...) +} + +func (attached *monitoredAttach) detach(t *testing.T) { + t.Helper() + if err := writeFrame(attached.conn, frameDetach, nil); err != nil { + t.Fatal(err) + } + select { + case <-attached.done: + case <-time.After(5 * time.Second): + t.Fatal("attach did not close after detach") + } +} + +func TestControllerRegistryRealPTYFixture(t *testing.T) { + evidenceDir := os.Getenv("UAM_TASK4_EVIDENCE_DIR") + if evidenceDir == "" { + t.Skip("manual Todo 4 PTY fixture") + } + client := newTestClient(t) + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + name := "uam-fake-a4b4c4d4" + command := `stty raw -echo; printf READY; while :; do n=$(dd bs=1 count=1 2>/dev/null | od -An -tu1 | tr -d ' \n'); [ -n "$n" ] || exit; set -- $(stty size); printf '\r\nREC:%s:%s:%s\r\n' "$n" "$2" "$1"; if [ "$n" = 72 ]; then yes X | head -c 8388608; fi; done` + if err := client.CreateSession(ctx, name, t.TempDir(), nil, []string{"/bin/sh", "-c", command}); err != nil { + t.Fatal(err) + } + waitFor(t, "manual provider readiness", func() bool { + output, err := client.Capture(ctx, name, 20) + return err == nil && bytes.Contains([]byte(output), []byte("READY")) + }) + + controller, controllerResponse := openMonitoredAttach(t, client, name, roleController, false) + standby, standbyResponse := openMonitoredAttach(t, client, name, roleController, true) + observer, observerResponse := openMonitoredAttach(t, client, name, roleObserver, false) + if controllerResponse.AssignedRole != roleController || standbyResponse.AssignedRole != roleStandby || observerResponse.AssignedRole != roleObserver { + t.Fatalf("initial roles = %q/%q/%q", controllerResponse.AssignedRole, standbyResponse.AssignedRole, observerResponse.AssignedRole) + } + _ = observer.writeControlFrame(frameResize, resizePayload(120, 40)) + _ = observer.writeControlFrame(frameStdin, []byte("O")) + _ = standby.writeControlFrame(frameResize, resizePayload(100, 30)) + _ = standby.writeControlFrame(frameStdin, []byte("S")) + _ = controller.writeControlFrame(frameResize, resizePayload(81, 25)) + _ = controller.writeControlFrame(frameStdin, []byte("A")) + waitFor(t, "first controller input", func() bool { return standby.sawRecord("REC:65:81:25") }) + + transfer, err := json.Marshal(roleCommand{Action: actionTransferControl}) + if err != nil { + t.Fatal(err) + } + if err := writeFrame(controller.conn, frameRole, transfer); err != nil { + t.Fatal(err) + } + waitFor(t, "standby transfer", func() bool { return standby.sawRole(roleController) }) + _ = controller.writeControlFrame(frameResize, resizePayload(82, 26)) + _ = controller.writeControlFrame(frameStdin, []byte("Z")) + _ = standby.writeControlFrame(frameResize, resizePayload(101, 31)) + _ = standby.writeControlFrame(frameStdin, []byte("B")) + waitFor(t, "transferred controller input", func() bool { return standby.sawRecord("REC:66:101:31") }) + + standby.detach(t) + failover, failoverResponse := openMonitoredAttach(t, client, name, roleController, true) + if failoverResponse.AssignedRole != roleStandby { + t.Fatalf("failover role = %q", failoverResponse.AssignedRole) + } + controller.setGeneration(failoverResponse.Generation) + if err := controller.writeControlFrame(frameStdin, []byte("H")); err != nil { + t.Fatal(err) + } + waitFor(t, "slow controller eviction", func() bool { return failover.sawRole(roleController) }) + waitFor(t, "slow controller provider input", func() bool { return failover.sawRecord("REC:72:") }) + _ = failover.writeControlFrame(frameResize, resizePayload(111, 33)) + _ = failover.writeControlFrame(frameStdin, []byte("C")) + waitFor(t, "post-backpressure controller input", func() bool { return failover.sawRecord("REC:67:111:33") }) + + failover.detach(t) + _ = observer.writeControlFrame(frameStdin, []byte("O")) + _ = writeFrame(observer.conn, frameDetach, nil) + if err := observer.conn.SetReadDeadline(time.Now().Add(5 * time.Second)); err != nil { + t.Fatal(err) + } + if _, err := observer.reader.WriteTo(bytes.NewBuffer(nil)); err != nil { + t.Fatalf("wait for observer detach: %v", err) + } + output, err := client.Capture(ctx, name, 50) + if err != nil { + t.Fatal(err) + } + if bytes.Contains([]byte(output), []byte("REC:79:")) { + t.Fatal("observer input reached provider in no-controller state") + } + replacement, replacementResponse := openMonitoredAttach(t, client, name, roleController, true) + if replacementResponse.AssignedRole != roleController { + t.Fatalf("replacement after vacancy = %q", replacementResponse.AssignedRole) + } + _ = replacement.writeControlFrame(frameResize, resizePayload(121, 35)) + _ = replacement.writeControlFrame(frameStdin, []byte("D")) + waitFor(t, "replacement controller input", func() bool { return replacement.sawRecord("REC:68:121:35") }) + for _, rejected := range []string{"REC:79:", "REC:83:", "REC:90:"} { + if standby.sawRecord(rejected) || failover.sawRecord(rejected) || replacement.sawRecord(rejected) { + t.Fatalf("non-owner provider record observed: %s", rejected) + } + } + + eventEvidence := struct { + Initial []response `json:"initial"` + StandbyEvents []roleEvent `json:"standby_events"` + FailoverEvents []roleEvent `json:"failover_events"` + }{ + Initial: []response{controllerResponse, standbyResponse, observerResponse, failoverResponse, replacementResponse}, + StandbyEvents: standby.roleEvents(), + FailoverEvents: failover.roleEvents(), + } + events, err := json.MarshalIndent(eventEvidence, "", " ") + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(evidenceDir, "task-4-events.json"), append(events, '\n'), 0o600); err != nil { + t.Fatal(err) + } + trace := fmt.Appendf(nil, "accepted=A@81x25,B@101x31,H,C@111x33,D@121x35\nrejected=O,S,Z\nslow_controller_promoted=%t\n", failover.sawRole(roleController)) + if err := os.WriteFile(filepath.Join(evidenceDir, "task-4-pty-trace.bin"), trace, 0o600); err != nil { + t.Fatal(err) + } + _ = replacement.conn.Close() + if err := client.Kill(ctx, name); err != nil { + t.Fatal(err) + } + waitFor(t, "manual host cleanup", func() bool { + entries, err := os.ReadDir(client.Dir) + return err == nil && len(entries) == 0 + }) + cleanup := []byte("{\"runtime_entries\":0,\"socket_removed\":true}\n") + if err := os.WriteFile(filepath.Join(evidenceDir, "task-4-cleanup.json"), cleanup, 0o600); err != nil { + t.Fatal(err) + } +} diff --git a/internal/session/client_registry_test.go b/internal/session/client_registry_test.go new file mode 100644 index 0000000..00ccea1 --- /dev/null +++ b/internal/session/client_registry_test.go @@ -0,0 +1,230 @@ +package session + +import ( + "net" + "strings" + "testing" + "unicode/utf8" +) + +func testAttachClient(version protocolVersion) *attachClient { + return &attachClient{version: version, out: make(chan serverMessage, 1), done: make(chan struct{})} +} + +func validTestHello() clientHello { + return clientHello{ + TTY: true, + TermHint: "xterm-256color", + ColorHint: "truecolor", + Capabilities: []clientCapability{ + capabilityFramedOutput, + capabilityRoleEvents, + capabilityLocalMouseFilter, + capabilityOwnedScreen, + }, + } +} + +func registerTestClient(t *testing.T, registry *clientRegistry, role clientRole, size terminalSize) *attachClient { + t.Helper() + client := testAttachClient(protocolV2) + if err := registry.register(client, clientRegistration{requestedRole: role, hello: validTestHello(), size: size}); err != nil { + t.Fatalf("register client: %v", err) + } + return client +} + +func TestControllerAssignmentSingleWriter(t *testing.T) { + registry := newClientRegistry() + controller := registerTestClient(t, registry, roleController, terminalSize{cols: 80, rows: 24}) + standby := registerTestClient(t, registry, roleController, terminalSize{cols: 100, rows: 30}) + observer := registerTestClient(t, registry, roleObserver, terminalSize{cols: 120, rows: 40}) + + if controller.id != "client-1" || controller.order != 1 || controller.assignedRole != roleController { + t.Fatalf("first client = id %q order %d role %q", controller.id, controller.order, controller.assignedRole) + } + if standby.id != "client-2" || standby.order != 2 || standby.assignedRole != roleStandby { + t.Fatalf("second client = id %q order %d role %q", standby.id, standby.order, standby.assignedRole) + } + if observer.id != "client-3" || observer.order != 3 || observer.assignedRole != roleObserver { + t.Fatalf("observer = id %q order %d role %q", observer.id, observer.order, observer.assignedRole) + } + if !registry.acceptsControl(controller, controller.generation) || registry.acceptsControl(standby, standby.generation) || registry.acceptsControl(observer, observer.generation) { + t.Fatal("exactly the assigned controller must own input and resize") + } +} + +func TestControllerTransferAtomic(t *testing.T) { + registry := newClientRegistry() + first := registerTestClient(t, registry, roleController, terminalSize{cols: 80, rows: 24}) + second := registerTestClient(t, registry, roleController, terminalSize{cols: 100, rows: 30}) + staleGeneration := first.generation + + changes := registry.transfer(first) + + if len(changes) != 2 || first.assignedRole != roleStandby || second.assignedRole != roleController { + t.Fatalf("transfer changes = %#v, roles = %q/%q", changes, first.assignedRole, second.assignedRole) + } + if registry.acceptsControl(first, staleGeneration) || !registry.acceptsControl(second, second.generation) { + t.Fatal("transfer accepted a stale owner generation or rejected the new owner") + } +} + +func TestControllerDisconnectPromotesOldestEligible(t *testing.T) { + registry := newClientRegistry() + first := registerTestClient(t, registry, roleController, terminalSize{cols: 80, rows: 24}) + second := registerTestClient(t, registry, roleController, terminalSize{cols: 90, rows: 25}) + third := registerTestClient(t, registry, roleStandby, terminalSize{cols: 100, rows: 30}) + + registry.remove(first) + if registry.controller != second { + t.Fatalf("first promotion = %v, want oldest standby", registry.controller) + } + registry.remove(second) + if registry.controller != third { + t.Fatalf("second promotion = %v, want next standby", registry.controller) + } + registry.remove(third) + if registry.controller != nil { + t.Fatalf("controller after final drop = %v, want vacancy", registry.controller) + } +} + +func TestExplicitObserverNeverPromotes(t *testing.T) { + registry := newClientRegistry() + controller := registerTestClient(t, registry, roleController, terminalSize{cols: 80, rows: 24}) + observer := registerTestClient(t, registry, roleObserver, terminalSize{cols: 120, rows: 40}) + + registry.remove(controller) + + if registry.controller != nil || observer.assignedRole != roleObserver { + t.Fatalf("observer promoted: controller=%v role=%q", registry.controller, observer.assignedRole) + } +} + +func TestObserverInputAndTerminalRepliesAreIgnored(t *testing.T) { + registry := newClientRegistry() + registerTestClient(t, registry, roleController, terminalSize{cols: 80, rows: 24}) + observer := registerTestClient(t, registry, roleObserver, terminalSize{cols: 120, rows: 40}) + + if registry.acceptsControl(observer, observer.generation) { + t.Fatal("observer stdin or terminal replies were accepted") + } +} + +func TestControllerResizeOwnership(t *testing.T) { + registry := newClientRegistry() + controller := registerTestClient(t, registry, roleController, terminalSize{cols: 80, rows: 24}) + standby := registerTestClient(t, registry, roleController, terminalSize{cols: 100, rows: 30}) + + if !registry.updateSize(controller, controller.generation, terminalSize{cols: 81, rows: 25}) { + t.Fatal("controller resize rejected") + } + if registry.updateSize(standby, standby.generation, terminalSize{cols: 101, rows: 31}) { + t.Fatal("standby resize accepted") + } + if standby.latestSize != (terminalSize{cols: 101, rows: 31}) { + t.Fatalf("standby latest valid size = %+v", standby.latestSize) + } + observer := registerTestClient(t, registry, roleObserver, terminalSize{cols: 120, rows: 40}) + if registry.updateSize(observer, observer.generation, terminalSize{cols: 121, rows: 41}) { + t.Fatal("observer resize accepted") + } + if observer.latestSize != (terminalSize{cols: 120, rows: 40}) { + t.Fatalf("observer resize was not discarded: %+v", observer.latestSize) + } +} + +func TestResizeDuringTransfer(t *testing.T) { + registry := newClientRegistry() + first := registerTestClient(t, registry, roleController, terminalSize{cols: 80, rows: 24}) + second := registerTestClient(t, registry, roleController, terminalSize{cols: 100, rows: 30}) + staleGeneration := first.generation + registry.transfer(first) + + if registry.updateSize(first, staleGeneration, terminalSize{cols: 81, rows: 25}) { + t.Fatal("stale controller resize crossed transfer generation") + } + if first.latestSize != (terminalSize{cols: 80, rows: 24}) { + t.Fatalf("stale resize changed standby size: %+v", first.latestSize) + } + if !registry.updateSize(second, second.generation, terminalSize{cols: 101, rows: 31}) { + t.Fatal("new controller resize rejected") + } +} + +func TestSlowControllerDropTriggersFailover(t *testing.T) { + registry := newClientRegistry() + controller := registerTestClient(t, registry, roleController, terminalSize{cols: 80, rows: 24}) + standby := registerTestClient(t, registry, roleController, terminalSize{cols: 100, rows: 30}) + host := &host{registry: registry} + controller.out <- serverMessage{kind: serverFramePTY, payload: []byte("blocked")} + + if host.enqueueClient(controller, serverMessage{kind: serverFramePTY, payload: []byte("overflow")}) { + t.Fatal("full controller queue accepted output") + } + if registry.controller != standby { + t.Fatalf("controller after backpressure = %v, want standby", registry.controller) + } +} + +func TestClientHelloValidation(t *testing.T) { + valid := validTestHello() + if err := validateClientHello(valid); err != nil { + t.Fatalf("valid hello: %v", err) + } + tests := []clientHello{ + {TTY: true, Capabilities: []clientCapability{"unknown"}}, + {TTY: true, Capabilities: []clientCapability{capabilityRoleEvents, capabilityRoleEvents}}, + {TTY: true, TermHint: string(make([]byte, maxClientHintLen+1))}, + {TTY: true, ColorHint: string(make([]byte, maxClientHintLen+1))}, + } + for _, hello := range tests { + if err := validateClientHello(hello); err == nil { + t.Fatalf("invalid hello accepted: %#v", hello) + } + } +} + +func TestBoundedClientHintPreservesUTF8(t *testing.T) { + hint := strings.Repeat("a", maxClientHintLen-1) + "é" + bounded := boundedClientHint(hint) + if len(bounded) > maxClientHintLen || !utf8.ValidString(bounded) { + t.Fatalf("bounded hint is invalid: %q", bounded) + } +} + +func TestLegacyAttachFirstControllerAndExtraBusy(t *testing.T) { + registry := newClientRegistry() + first := testAttachClient(protocolV1) + if err := registry.register(first, clientRegistration{requestedRole: roleController, size: terminalSize{cols: 80, rows: 24}}); err != nil { + t.Fatalf("first v1 register: %v", err) + } + second := testAttachClient(protocolV1) + if err := registry.register(second, clientRegistration{requestedRole: roleController}); err == nil { + t.Fatal("extra v1 controller was not busy") + } +} + +func TestClientRegistryRejectsMalformedRoleAndSize(t *testing.T) { + registry := newClientRegistry() + if err := registry.register(testAttachClient(protocolV2), clientRegistration{requestedRole: clientRole("invalid"), hello: validTestHello()}); err == nil { + t.Fatal("malformed role accepted") + } + client := testAttachClient(protocolV2) + if err := registry.register(client, clientRegistration{requestedRole: roleController, hello: validTestHello(), size: terminalSize{cols: 1001, rows: 24}}); err != nil { + t.Fatalf("invalid diagnostic size rejected attach: %v", err) + } + if client.latestSize.valid() { + t.Fatalf("invalid size retained: %+v", client.latestSize) + } +} + +func TestDroppedClientConnectionClosesOnce(t *testing.T) { + left, right := net.Pipe() + client := testAttachClient(protocolV2) + client.conn = left + client.drop() + client.drop() + _ = right.Close() +} diff --git a/internal/session/diagnostic.go b/internal/session/diagnostic.go new file mode 100644 index 0000000..21fb838 --- /dev/null +++ b/internal/session/diagnostic.go @@ -0,0 +1,72 @@ +package session + +import ( + "context" + "errors" + "fmt" + "os" + "strings" +) + +type RuntimeDiagnostic struct { + Protocols []int `json:"protocols"` + Controller int `json:"controller"` + Standby int `json:"standby"` + Observer int `json:"observer"` +} + +func (h *host) runtimeDiagnostic() RuntimeDiagnostic { + h.mu.Lock() + defer h.mu.Unlock() + report := RuntimeDiagnostic{Protocols: []int{int(protocolV1), int(protocolV2)}} + for client := range h.registry.clients { + switch client.assignedRole { + case roleController: + report.Controller++ + case roleStandby: + report.Standby++ + case roleObserver: + report.Observer++ + } + } + return report +} + +func (c *Client) Doctor(ctx context.Context, name string) (RuntimeDiagnostic, error) { + resp, err := c.roundTrip(ctx, name, request{Op: opDoctor}) + if err != nil { + return RuntimeDiagnostic{}, err + } + if resp.Diagnostic == nil { + return RuntimeDiagnostic{}, fmt.Errorf("session %s: missing diagnostic response", name) + } + return *resp.Diagnostic, nil +} + +func (c *Client) RuntimeCount(_ context.Context) (int, error) { + if err := VerifyDir(c.Dir); err != nil { + if errors.Is(err, os.ErrNotExist) { + return 0, nil + } + return 0, err + } + entries, err := os.ReadDir(c.Dir) + if err != nil { + if errors.Is(err, os.ErrNotExist) { + return 0, nil + } + return 0, err + } + count := 0 + for _, entry := range entries { + name, stateFile := strings.CutSuffix(entry.Name(), ".json") + if !stateFile || ValidateName(name) != nil { + continue + } + state, readErr := readState(c.Dir, name) + if readErr == nil && (state.hostAlive() || state.childAlive()) { + count++ + } + } + return count, nil +} diff --git a/internal/session/diagnostics_test.go b/internal/session/diagnostics_test.go new file mode 100644 index 0000000..64bae4c --- /dev/null +++ b/internal/session/diagnostics_test.go @@ -0,0 +1,87 @@ +package session + +import ( + "bufio" + "bytes" + "encoding/json" + "log/slog" + "strings" + "testing" + + uamlog "github.com/RandomCodeSpace/unified-agent-manager/internal/log" +) + +func TestAttachLifecycleStructuredLogs(t *testing.T) { + // Given: a host registry with a controller and standby using protocol v2. + var output bytes.Buffer + previous := uamlog.SetLogger(slog.New(slog.NewJSONHandler(&output, nil))) + t.Cleanup(func() { uamlog.SetLogger(previous) }) + h := &host{name: "uam-codex-a1", registry: newClientRegistry()} + controller := &attachClient{version: protocolV2, done: make(chan struct{}), out: make(chan serverMessage, 8)} + standby := &attachClient{version: protocolV2, done: make(chan struct{}), out: make(chan serverMessage, 8)} + + // When: clients attach, control transfers, an invalid resize is ignored, and + // the controller drops. + if _, err := h.registerAttachClient(controller, clientRegistration{requestedRole: roleController, hello: validDiagnosticHello()}); err != nil { + t.Fatal(err) + } + if _, err := h.registerAttachClient(standby, clientRegistration{requestedRole: roleController, hello: validDiagnosticHello()}); err != nil { + t.Fatal(err) + } + h.handleRoleCommand(controller, mustRoleCommand(t, actionTransferControl)) + h.resizeClient(controller, controller.generation, terminalSize{}) + h.dropClient(standby) + fallbackHost := &host{name: "uam-codex-a2", registry: newClientRegistry()} + fallbackClient := &attachClient{ + version: protocolV1, fallback: true, done: make(chan struct{}), out: make(chan serverMessage, 1), + } + if _, err := fallbackHost.registerAttachClient(fallbackClient, clientRegistration{requestedRole: roleController}); err != nil { + t.Fatal(err) + } + uamlog.Diagnostic(uamlog.DiagnosticEvent{Event: "attach.negotiation", Session: h.name, Reason: "rejected"}) + uamlog.Diagnostic(uamlog.DiagnosticEvent{Event: "attach.negotiation", Session: h.name, Reason: "timeout"}) + + // Then: retained events contain only stable lifecycle fields. + logs := output.String() + for _, event := range []string{"attach.negotiation", "attach.lifecycle", "role.assignment", "role.transfer", "resize.ignored", "role.promotion"} { + if !strings.Contains(logs, `"event":"`+event+`"`) { + t.Fatalf("missing %s in logs:\n%s", event, logs) + } + } + scanner := bufio.NewScanner(strings.NewReader(logs)) + for scanner.Scan() { + var event map[string]any + if err := json.Unmarshal(scanner.Bytes(), &event); err != nil { + t.Fatal(err) + } + for _, field := range []string{"session", "client_id", "protocol", "role", "reason", "provider", "profile"} { + if _, exists := event[field]; !exists { + t.Fatalf("diagnostic field %q missing from %s", field, scanner.Text()) + } + } + } + if err := scanner.Err(); err != nil { + t.Fatal(err) + } +} + +func validDiagnosticHello() clientHello { + return clientHello{ + TTY: true, + Capabilities: []clientCapability{ + capabilityFramedOutput, + capabilityRoleEvents, + capabilityLocalMouseFilter, + capabilityOwnedScreen, + }, + } +} + +func mustRoleCommand(t *testing.T, action roleAction) []byte { + t.Helper() + payload, err := json.Marshal(roleCommand{Action: action}) + if err != nil { + t.Fatal(err) + } + return payload +} diff --git a/internal/session/host.go b/internal/session/host.go index 9de3a00..e19eb21 100644 --- a/internal/session/host.go +++ b/internal/session/host.go @@ -41,6 +41,11 @@ const ( // here (plain runes, no attributes) and gives peek deeper context. const historyLines = 4000 +const ( + minHistoryLines = 100 + maxHistoryLines = 100000 +) + // killGrace phases the kill escalation: SIGHUP immediately (what tmux // kill-session delivered), SIGTERM after one grace period, SIGKILL after two. const killGrace = 1500 * time.Millisecond @@ -68,6 +73,8 @@ func RunHost(args []string) error { name := fs.String("name", "", "session name") cwd := fs.String("cwd", "", "working directory for the agent") label := fs.String("label", "", "user-facing session label") + providerIdentity := fs.String("provider", "", "managed-session provider identity") + scrollbackLines := fs.Int("scrollback", historyLines, "terminal scrollback lines") var envs stringList fs.Var(&envs, "env", "KEY=VALUE environment entry (repeatable)") if err := fs.Parse(args); err != nil { @@ -75,7 +82,9 @@ func RunHost(args []string) error { } command := fs.Args() ready := readyPipe() - err := runHost(*dir, *name, *cwd, *label, envs, command, ready) + err := runHost(*dir, hostLaunchSpec{ + name: *name, cwd: *cwd, label: *label, providerIdentity: *providerIdentity, scrollbackLines: *scrollbackLines, envs: envs, command: command, + }, ready) if err != nil && ready != nil { // Surface the startup failure to the waiting parent before exiting. _, _ = fmt.Fprintf(ready, "error: %v\n", err) @@ -98,16 +107,27 @@ type stringList []string func (s *stringList) String() string { return strings.Join(*s, ",") } func (s *stringList) Set(v string) error { *s = append(*s, v); return nil } +type hostLaunchSpec struct { + name string + cwd string + label string + providerIdentity string + scrollbackLines int + envs []string + command []string +} + type host struct { dir, name string providerIdentityFile string - mu sync.Mutex - term *vterm.Terminal - ptmx *os.File - label string - state State - clients map[*attachClient]struct{} + mu sync.Mutex + controlMu sync.Mutex + term *vterm.Terminal + ptmx *os.File + label string + state State + registry *clientRegistry child *exec.Cmd // exited is closed once the agent process has been reaped; the kill @@ -119,26 +139,21 @@ type host struct { uamStopping atomic.Bool } -type attachClient struct { - conn net.Conn - out chan []byte - once sync.Once -} - -func (c *attachClient) drop() { - c.once.Do(func() { - close(c.out) - _ = c.conn.Close() - }) -} - -func runHost(dir, name, cwd, label string, envs, command []string, ready *os.File) error { +func runHost(dir string, spec hostLaunchSpec, ready *os.File) error { + name := spec.name if err := ValidateName(name); err != nil { return err } - if len(command) == 0 { + if err := validateProviderIdentity(spec.providerIdentity); err != nil { + return err + } + if len(spec.command) == 0 { return errors.New("host requires a command") } + scrollbackLines, err := validatedScrollbackLines(spec.scrollbackLines) + if err != nil { + return err + } if err := EnsureDir(dir); err != nil { return err } @@ -159,33 +174,34 @@ func runHost(dir, name, cwd, label string, envs, command []string, ready *os.Fil h := &host{ dir: dir, name: name, - providerIdentityFile: envValue(envs, ProviderIdentityFileEnv), - label: label, - term: vterm.New(defaultCols, defaultRows, historyLines), - clients: map[*attachClient]struct{}{}, + providerIdentityFile: envValue(spec.envs, ProviderIdentityFileEnv), + label: spec.label, + term: vterm.New(defaultCols, defaultRows, scrollbackLines), + registry: newClientRegistry(), exited: make(chan struct{}), cleaned: make(chan struct{}), } - cmd := exec.Command(command[0], command[1:]...) // #nosec G204 -- argv comes from the trusted uam client that spawned this host; no shell is involved. - cmd.Dir = cwd + cmd := exec.Command(spec.command[0], spec.command[1:]...) // #nosec G204 -- argv comes from the trusted uam client that spawned this host; no shell is involved. + cmd.Dir = spec.cwd cmd.Env = append(os.Environ(), "TERM=xterm-256color") - cmd.Env = append(cmd.Env, envs...) + cmd.Env = append(cmd.Env, spec.envs...) ptmx, err := pty.StartWithSize(cmd, &pty.Winsize{Cols: defaultCols, Rows: defaultRows}) if err != nil { - return fmt.Errorf("start %s: %w", command[0], err) + return fmt.Errorf("start %s: %w", spec.command[0], err) } h.ptmx = ptmx h.child = cmd h.state = State{ - Name: name, - HostPID: os.Getpid(), - HostStart: procStartTime(os.Getpid()), - ChildPID: cmd.Process.Pid, - ChildStart: procStartTime(cmd.Process.Pid), - CreatedUnix: time.Now().Unix(), - Cwd: cwd, - Label: label, - Command: command, + Name: name, + HostPID: os.Getpid(), + HostStart: procStartTime(os.Getpid()), + ChildPID: cmd.Process.Pid, + ChildStart: procStartTime(cmd.Process.Pid), + CreatedUnix: time.Now().Unix(), + Cwd: spec.cwd, + Label: spec.label, + ProviderIdentity: spec.providerIdentity, + Command: spec.command, } if err := writeState(dir, h.state); err != nil { h.signalChild(syscall.SIGKILL) @@ -238,17 +254,11 @@ func (h *host) pumpPTY() { copy(data, buf[:n]) h.mu.Lock() _, _ = h.term.Write(data) - for cl := range h.clients { - select { - case cl.out <- data: - default: - // Client stopped draining; cut it loose instead of - // blocking the whole session on one wedged terminal. - delete(h.clients, cl) - cl.drop() - } - } + clients := h.registry.readyClients() h.mu.Unlock() + for _, client := range clients { + h.enqueueClient(client, serverMessage{kind: serverFramePTY, payload: data}) + } } if err != nil { return @@ -303,9 +313,24 @@ func (h *host) acceptLoop(ln net.Listener) { } func (h *host) handleConn(conn net.Conn) { + if err := conn.SetReadDeadline(time.Now().Add(attachHandshakeTimeout)); err != nil { + _ = conn.Close() + return + } br := bufio.NewReader(conn) var req request - if err := readJSONLine(br, &req); err != nil { + if err := readBoundedJSONLine(br, &req); err != nil { + reason := "rejected" + var timeout net.Error + if errors.As(err, &timeout) && timeout.Timeout() { + reason = "timeout" + } + log.Diagnostic(log.DiagnosticEvent{Event: "attach.negotiation", Session: h.name, Reason: reason}) + _ = writeJSONLine(conn, response{Err: fmt.Sprintf("invalid request: %v", err)}) + _ = conn.Close() + return + } + if err := conn.SetWriteDeadline(time.Now().Add(attachHandshakeTimeout)); err != nil { _ = conn.Close() return } @@ -317,14 +342,12 @@ func (h *host) handleConn(conn net.Conn) { _ = writeJSONLine(conn, response{OK: true, Data: data}) _ = conn.Close() case opSend: - h.mu.Lock() - _, err := h.ptmx.Write([]byte(req.Text)) - h.mu.Unlock() + err := h.writeOutOfBandInput([]byte(req.Text)) _ = writeJSONLine(conn, errResponse(err)) _ = conn.Close() case opResize: - h.applyResize(req.Cols, req.Rows) - _ = writeJSONLine(conn, response{OK: true}) + err := h.resizeOutOfBand(terminalSize{cols: req.Cols, rows: req.Rows}) + _ = writeJSONLine(conn, errResponse(err)) _ = conn.Close() case opLabel: h.setLabel(req.Label) @@ -342,6 +365,10 @@ func (h *host) handleConn(conn net.Conn) { _ = conn.Close() case opAttach: h.handleAttach(conn, br, req) + case opDoctor: + report := h.runtimeDiagnostic() + _ = writeJSONLine(conn, response{OK: true, Diagnostic: &report}) + _ = conn.Close() default: _ = writeJSONLine(conn, response{Err: fmt.Sprintf("unknown op %q", req.Op)}) _ = conn.Close() @@ -350,6 +377,9 @@ func (h *host) handleConn(conn net.Conn) { func errResponse(err error) response { if err != nil { + if errors.Is(err, ErrSessionBusy) { + return response{Err: err.Error(), ErrorCode: errorCodeBusy} + } return response{Err: err.Error()} } return response{OK: true} @@ -361,13 +391,11 @@ func (h *host) setLabel(label string) { h.state.Label = label st := h.state title := []byte(titleSequence(label)) - for cl := range h.clients { - select { - case cl.out <- title: - default: - } - } + clients := h.registry.readyClients() h.mu.Unlock() + for _, client := range clients { + h.enqueueClient(client, serverMessage{kind: serverFramePTY, payload: title}) + } if err := writeState(h.dir, st); err != nil { log.Warn("persist session label failed", "session", h.name, "error", err) } @@ -411,56 +439,83 @@ func (h *host) applyPTYSizeLocked(cols, rows int) { _ = pty.Setsize(h.ptmx, &pty.Winsize{Cols: uint16(cols), Rows: uint16(rows)}) // #nosec G115 -- bounds checked above } -func (h *host) applyResize(cols, rows int) { - h.mu.Lock() - defer h.mu.Unlock() - h.applyResizeLocked(cols, rows) -} - func (h *host) handleAttach(conn net.Conn, br *bufio.Reader, req request) { - cl := &attachClient{conn: conn, out: make(chan []byte, attachBufFrames)} - h.mu.Lock() - label := h.label - h.mu.Unlock() - // The client is not registered yet, so this connection has no concurrent - // writer: the response line is safe to write directly. - if err := writeJSONLine(conn, response{OK: true, Data: label}); err != nil { + version, err := negotiateAttachRequest(req) + if err != nil { + log.Diagnostic(log.DiagnosticEvent{Event: "attach.negotiation", Session: h.name, Reason: "rejected"}) + _ = writeJSONLine(conn, response{Err: err.Error()}) _ = conn.Close() return } - // Apply the attaching geometry, then register and queue the screen replay - // atomically so no live broadcast can interleave ahead of it. - h.mu.Lock() - if validSize(req.Cols, req.Rows) { - curCols, curRows := h.term.Size() - if req.Cols == curCols && req.Rows == curRows { - // Same geometry produces no SIGWINCH; nudge the size so the - // agent's TUI still repaints for the new viewer without truncating - // the replay buffer before Redraw. - if cols, rows, ok := resizeNudge(req.Cols, req.Rows); ok { - h.applyPTYSizeLocked(cols, rows) + registration, err := attachRegistration(req, version) + if err != nil { + log.Diagnostic(log.DiagnosticEvent{ + Event: "attach.negotiation", Session: h.name, Protocol: int(version), Reason: "rejected", + }) + _ = writeJSONLine(conn, response{Err: err.Error()}) + _ = conn.Close() + return + } + client := &attachClient{ + conn: conn, out: make(chan serverMessage, attachBufFrames), done: make(chan struct{}), version: version, + fallback: version == protocolV1 && !req.versionPresent, + } + attachResponse, err := h.registerAttachClient(client, registration) + if err != nil { + _ = writeJSONLine(conn, response{Err: err.Error()}) + _ = conn.Close() + return + } + if err := writeAttachResponse(conn, attachResponse); err != nil { + h.dropClientReason(client, "handshake_write") + return + } + if err := conn.SetDeadline(time.Time{}); err != nil { + h.dropClientReason(client, "deadline_reset") + return + } + h.initializeAttachClient(client, registration, attachResponse.Data) + go h.attachWriter(client) + h.attachReader(client, br) +} + +func (h *host) attachWriter(client *attachClient) { + for { + select { + case <-client.done: + return + case message := <-client.out: + if err := h.writeServerMessage(client, message); err != nil { + h.dropClientReason(client, "connection_write") + return } } - h.applyResizeLocked(req.Cols, req.Rows) } - cl.out <- append([]byte(titleSequence(label)), h.term.Redraw()...) - h.clients[cl] = struct{}{} - h.mu.Unlock() - go h.attachWriter(cl) - h.attachReader(cl, br) } -func (h *host) attachWriter(cl *attachClient) { - for data := range cl.out { - if _, err := cl.conn.Write(data); err != nil { - h.dropClient(cl) - return +func (h *host) writeServerMessage(client *attachClient, message serverMessage) error { + if client.version == protocolV1 { + if message.kind != serverFramePTY { + return nil } + return writeAll(client.conn, message.payload) } + if len(message.payload) == 0 { + return writeFrame(client.conn, message.kind, nil) + } + for len(message.payload) > 0 { + size := min(len(message.payload), maxFrameLen) + if err := writeFrame(client.conn, message.kind, message.payload[:size]); err != nil { + return err + } + message.payload = message.payload[size:] + } + return nil } -func (h *host) attachReader(cl *attachClient, br *bufio.Reader) { - defer h.dropClient(cl) +func (h *host) attachReader(client *attachClient, br *bufio.Reader) { + reason := "connection_drop" + defer func() { h.dropClientReason(client, reason) }() for { kind, payload, err := readFrame(br) if err != nil { @@ -468,29 +523,86 @@ func (h *host) attachReader(cl *attachClient, br *bufio.Reader) { } switch kind { case frameStdin: - h.mu.Lock() - _, werr := h.ptmx.Write(payload) - h.mu.Unlock() - if werr != nil { + if !h.handleStdinFrame(client, payload) { + reason = "malformed_frame" return } case frameResize: - if len(payload) == 4 { - cols := int(payload[0])<<8 | int(payload[1]) - rows := int(payload[2])<<8 | int(payload[3]) - h.applyResize(cols, rows) + if !h.handleResizeFrame(client, payload) { + reason = "malformed_frame" + return + } + case frameRole: + if !h.handleRoleCommand(client, payload) { + reason = "malformed_frame" + return } case frameDetach: + reason = "detached" + return + default: + reason = "malformed_frame" return } } } -func (h *host) dropClient(cl *attachClient) { +func (h *host) dropClient(client *attachClient) { + h.dropClientReason(client, "dropped") +} + +func (h *host) dropClientReason(client *attachClient, reason string) { + h.controlMu.Lock() h.mu.Lock() - delete(h.clients, cl) + _, registered := h.registry.clients[client] + wasController := h.registry.controller == client + changes := h.registry.remove(client) + var promotedSize terminalSize + var promotedClient *attachClient + var promotedReplay []byte + if len(changes) > 0 && h.registry.controller != nil { + promotedClient = h.registry.controller + promotedSize = promotedClient.latestSize + if promotedSize.valid() && h.term != nil { + h.term.Resize(promotedSize.cols, promotedSize.rows) + } + if promotedClient.ready && h.term != nil { + promotedReplay = h.term.Redraw() + } + } h.mu.Unlock() - cl.drop() + if promotedSize.valid() { + h.applyPTYSize(promotedSize) + } + h.controlMu.Unlock() + client.drop() + if !registered { + return + } + log.Diagnostic(log.DiagnosticEvent{ + Event: "attach.lifecycle", Session: h.name, ClientID: client.id, + Protocol: int(client.version), Role: string(client.assignedRole), Reason: reason, + }) + h.enqueueRoleChanges(changes) + if wasController && len(changes) == 0 && promotedClient == nil { + log.Diagnostic(log.DiagnosticEvent{ + Event: "role.vacancy", Session: h.name, ClientID: client.id, + Protocol: int(client.version), Role: string(client.assignedRole), Reason: "no_controller", + }) + } + for _, change := range changes { + log.Diagnostic(log.DiagnosticEvent{ + Event: "role.promotion", Session: h.name, ClientID: change.clientID, + Protocol: int(change.client.version), Role: string(change.role), Reason: change.reason, + }) + log.Diagnostic(log.DiagnosticEvent{ + Event: "controller.failover", Session: h.name, ClientID: change.clientID, + Protocol: int(change.client.version), Role: string(change.role), Reason: reason, + }) + } + if promotedClient != nil && len(promotedReplay) > 0 { + h.enqueueClient(promotedClient, serverMessage{kind: serverFramePTY, payload: promotedReplay}) + } } // terminateChild escalates HUP → TERM → KILL against the agent's process @@ -526,11 +638,15 @@ func (h *host) signalChild(sig syscall.Signal) { // attached clients, and remove the runtime files. func (h *host) shutdown(exitCode int) { h.mu.Lock() - for cl := range h.clients { - delete(h.clients, cl) - cl.drop() - } + clients := h.registry.drain() h.mu.Unlock() + for _, client := range clients { + log.Diagnostic(log.DiagnosticEvent{ + Event: "attach.lifecycle", Session: h.name, ClientID: client.id, + Protocol: int(client.version), Role: string(client.assignedRole), Reason: "host_shutdown", + }) + client.drop() + } providerID := readProviderIdentityHandoff(h.dir, h.name, h.providerIdentityFile) if err := removeSessionFiles(h.dir, h.name); err != nil { log.Warn("remove session files failed", "session", h.name, "error", err) diff --git a/internal/session/host_attach.go b/internal/session/host_attach.go new file mode 100644 index 0000000..bb81f65 --- /dev/null +++ b/internal/session/host_attach.go @@ -0,0 +1,102 @@ +package session + +import ( + "errors" + "net" + + "github.com/RandomCodeSpace/unified-agent-manager/internal/log" +) + +func attachRegistration(req request, version protocolVersion) (clientRegistration, error) { + registration := clientRegistration{ + requestedRole: roleController, + size: terminalSize{cols: req.Cols, rows: req.Rows}, + } + if version == protocolV1 { + return registration, nil + } + if req.Hello == nil { + return clientRegistration{}, errors.New("invalid client hello: missing hello") + } + registration.requestedRole = req.RequestedRole + registration.hello = *req.Hello + return registration, nil +} + +func (h *host) registerAttachClient(client *attachClient, registration clientRegistration) (response, error) { + h.mu.Lock() + if err := h.registry.register(client, registration); err != nil { + h.mu.Unlock() + return response{}, err + } + attachResponse := response{OK: true, Data: h.label} + if client.version == protocolV2 { + attachResponse.Version = protocolV2 + attachResponse.ClientID = client.id + attachResponse.AssignedRole = client.assignedRole + attachResponse.Generation = client.generation + } + clientID := client.id + protocol := int(client.version) + role := string(client.assignedRole) + fallback := client.fallback + termHint := client.hello.TermHint + h.mu.Unlock() + reason := "selected" + if fallback { + reason = "fallback" + } + log.Diagnostic(log.DiagnosticEvent{ + Event: "attach.negotiation", Session: h.name, ClientID: clientID, + Protocol: protocol, Role: role, Reason: reason, TermHint: termHint, + }) + log.Diagnostic(log.DiagnosticEvent{ + Event: "attach.lifecycle", Session: h.name, ClientID: clientID, + Protocol: protocol, Role: role, Reason: "attached", + }) + log.Diagnostic(log.DiagnosticEvent{ + Event: "role.assignment", Session: h.name, ClientID: clientID, + Protocol: protocol, Role: role, Reason: "assigned", + }) + return attachResponse, nil +} + +func writeAttachResponse(conn net.Conn, attachResponse response) error { + return writeJSONLine(conn, attachResponse) +} + +func (h *host) initializeAttachClient(client *attachClient, registration clientRegistration, label string) { + h.controlMu.Lock() + defer h.controlMu.Unlock() + h.mu.Lock() + controls := h.registry.acceptsControl(client, client.generation) + nudgeSize := h.prepareInitialGeometry(registration.size, controls) + if client.version == protocolV2 { + client.out <- h.roleMessage(client, "assigned") + } + client.out <- serverMessage{kind: serverFramePTY, payload: append([]byte(titleSequence(label)), h.term.Redraw()...)} + client.ready = true + h.mu.Unlock() + if !controls || !registration.size.valid() { + return + } + if nudgeSize.valid() { + h.applyPTYSize(nudgeSize) + } + h.applyPTYSize(registration.size) +} + +func (h *host) prepareInitialGeometry(size terminalSize, controls bool) terminalSize { + if !controls || !size.valid() { + return terminalSize{} + } + currentCols, currentRows := h.term.Size() + var nudgeSize terminalSize + if size.cols == currentCols && size.rows == currentRows { + if cols, rows, ok := resizeNudge(size.cols, size.rows); ok { + nudgeSize = terminalSize{cols: cols, rows: rows} + } + } + h.term.Resize(size.cols, size.rows) + return nudgeSize +} diff --git a/internal/session/host_control_frame.go b/internal/session/host_control_frame.go new file mode 100644 index 0000000..bdafcad --- /dev/null +++ b/internal/session/host_control_frame.go @@ -0,0 +1,40 @@ +package session + +func (h *host) parseControlFrame(client *attachClient, payload []byte) (uint64, []byte, bool) { + if client.version == protocolV1 { + h.mu.Lock() + generation := client.generation + h.mu.Unlock() + return generation, payload, true + } + generation, input, err := parseOwnedFramePayload(payload) + return generation, input, err == nil +} + +func (h *host) parseResizeFrame(client *attachClient, payload []byte) (uint64, terminalSize, bool) { + generation, sizePayload, valid := h.parseControlFrame(client, payload) + if !valid || len(sizePayload) != 4 { + return 0, terminalSize{}, false + } + return generation, terminalSize{ + cols: int(sizePayload[0])<<8 | int(sizePayload[1]), + rows: int(sizePayload[2])<<8 | int(sizePayload[3]), + }, true +} + +func (h *host) handleStdinFrame(client *attachClient, payload []byte) bool { + generation, input, valid := h.parseControlFrame(client, payload) + if !valid { + return false + } + return h.writeControllerInput(client, generation, input) == nil +} + +func (h *host) handleResizeFrame(client *attachClient, payload []byte) bool { + generation, size, valid := h.parseResizeFrame(client, payload) + if !valid { + return false + } + h.resizeClient(client, generation, size) + return true +} diff --git a/internal/session/host_control_frame_test.go b/internal/session/host_control_frame_test.go new file mode 100644 index 0000000..65626ce --- /dev/null +++ b/internal/session/host_control_frame_test.go @@ -0,0 +1,27 @@ +package session + +import "testing" + +func TestV2ControlFramesRequireOwnershipEpoch(t *testing.T) { + host := &host{} + client := testAttachClient(protocolV2) + if _, _, valid := host.parseControlFrame(client, []byte("stdin")); valid { + t.Fatal("v2 stdin without an ownership epoch was accepted") + } + if _, _, valid := host.parseResizeFrame(client, resizePayload(80, 24)); valid { + t.Fatal("v2 resize without an ownership epoch was accepted") + } + if _, _, valid := host.parseResizeFrame(client, ownedFramePayload(1, []byte{0, 80, 0})); valid { + t.Fatal("v2 resize with an invalid payload was accepted") + } +} + +func TestV1ControlFramesRemainUnwrapped(t *testing.T) { + client := testAttachClient(protocolV1) + client.generation = 3 + host := &host{} + generation, payload, valid := host.parseControlFrame(client, []byte("legacy")) + if !valid || generation != 3 || string(payload) != "legacy" { + t.Fatalf("v1 control = (%d, %q, %t), want raw payload", generation, payload, valid) + } +} diff --git a/internal/session/host_controller.go b/internal/session/host_controller.go new file mode 100644 index 0000000..5496717 --- /dev/null +++ b/internal/session/host_controller.go @@ -0,0 +1,166 @@ +package session + +import ( + "encoding/json" + "fmt" + + "github.com/creack/pty" + + "github.com/RandomCodeSpace/unified-agent-manager/internal/log" +) + +func (h *host) enqueueClient(client *attachClient, message serverMessage) bool { + select { + case <-client.done: + return false + default: + } + select { + case client.out <- message: + return true + default: + log.Diagnostic(log.DiagnosticEvent{ + Event: "slow_client.eviction", Session: h.name, ClientID: client.id, + Protocol: int(client.version), Role: string(client.assignedRole), Reason: "output_backpressure", + }) + h.dropClientReason(client, "slow_client") + return false + } +} + +func (h *host) roleMessage(client *attachClient, reason string) serverMessage { + return roleMessageFor(client.id, client.assignedRole, client.generation, reason) +} + +func roleMessageFor(clientID string, role clientRole, generation uint64, reason string) serverMessage { + payload, err := json.Marshal(roleEvent{ + Type: "role", ClientID: clientID, Role: role, Generation: generation, Reason: reason, + }) + if err != nil { + return serverMessage{kind: serverFrameControl} + } + return serverMessage{kind: serverFrameControl, payload: payload} +} + +func (h *host) enqueueRoleChanges(changes []roleChange) { + for _, change := range changes { + if change.client.version == protocolV2 { + h.enqueueClient(change.client, roleMessageFor(change.clientID, change.role, change.generation, change.reason)) + } + } +} + +func (h *host) writeControllerInput(client *attachClient, generation uint64, payload []byte) error { + h.controlMu.Lock() + defer h.controlMu.Unlock() + h.mu.Lock() + accepted := h.registry.acceptsControl(client, generation) + h.mu.Unlock() + if !accepted { + return nil + } + if _, err := h.ptmx.Write(payload); err != nil { + return fmt.Errorf("write controller input: %w", err) + } + return nil +} + +func (h *host) writeOutOfBandInput(payload []byte) error { + h.controlMu.Lock() + defer h.controlMu.Unlock() + h.mu.Lock() + busy := h.registry.controller != nil + h.mu.Unlock() + if busy { + return &SessionBusyError{Operation: opSend} + } + if _, err := h.ptmx.Write(payload); err != nil { + return fmt.Errorf("write out-of-band input: %w", err) + } + return nil +} + +func (h *host) resizeOutOfBand(size terminalSize) error { + h.controlMu.Lock() + defer h.controlMu.Unlock() + h.mu.Lock() + defer h.mu.Unlock() + if h.registry.controller != nil { + return &SessionBusyError{Operation: opResize} + } + h.applyResizeLocked(size.cols, size.rows) + return nil +} + +func (h *host) resizeClient(client *attachClient, generation uint64, size terminalSize) { + h.controlMu.Lock() + defer h.controlMu.Unlock() + h.mu.Lock() + reason := h.registry.resizeReason(client, generation, size) + accepted := h.registry.updateSize(client, generation, size) + if accepted && h.term != nil { + h.term.Resize(size.cols, size.rows) + } + h.mu.Unlock() + event := "resize.ignored" + if accepted { + event = "resize.accepted" + } + log.Diagnostic(log.DiagnosticEvent{ + Event: event, Session: h.name, ClientID: client.id, Protocol: int(client.version), + Role: string(client.assignedRole), Reason: reason, + }) + if accepted { + h.applyPTYSize(size) + } +} + +func (h *host) handleRoleCommand(client *attachClient, payload []byte) bool { + var command roleCommand + if err := json.Unmarshal(payload, &command); err != nil || command.validate() != nil { + return false + } + if command.Action == actionRequestControl { + return true + } + + h.controlMu.Lock() + h.mu.Lock() + changes := h.registry.transfer(client) + var controllerSize terminalSize + var controller *attachClient + var repaint []byte + if len(changes) > 0 && h.registry.controller != nil { + controller = h.registry.controller + controllerSize = controller.latestSize + if controllerSize.valid() && h.term != nil { + h.term.Resize(controllerSize.cols, controllerSize.rows) + } + if controller.ready && h.term != nil { + repaint = h.term.Redraw() + } + } + h.mu.Unlock() + if len(changes) > 0 && controllerSize.valid() { + h.applyPTYSize(controllerSize) + } + h.controlMu.Unlock() + h.enqueueRoleChanges(changes) + for _, change := range changes { + log.Diagnostic(log.DiagnosticEvent{ + Event: "role.transfer", Session: h.name, ClientID: change.clientID, + Protocol: int(change.client.version), Role: string(change.role), Reason: change.reason, + }) + } + if controller != nil && len(repaint) > 0 { + h.enqueueClient(controller, serverMessage{kind: serverFramePTY, payload: repaint}) + } + return true +} + +func (h *host) applyPTYSize(size terminalSize) { + if h.ptmx == nil || !size.valid() { + return + } + _ = pty.Setsize(h.ptmx, &pty.Winsize{Cols: uint16(size.cols), Rows: uint16(size.rows)}) // #nosec G115 -- bounds checked above +} diff --git a/internal/session/host_controller_epoch_test.go b/internal/session/host_controller_epoch_test.go new file mode 100644 index 0000000..3e8e875 --- /dev/null +++ b/internal/session/host_controller_epoch_test.go @@ -0,0 +1,155 @@ +package session + +import ( + "bufio" + "bytes" + "io" + "os" + "sync" + "testing" + "time" + + "github.com/RandomCodeSpace/unified-agent-manager/internal/vterm" +) + +type queuedFrameReader struct { + reader *bytes.Reader + drained chan struct{} + release <-chan struct{} + once sync.Once +} + +func (reader *queuedFrameReader) Read(data []byte) (int, error) { + if reader.reader.Len() > 0 { + return reader.reader.Read(data) + } + reader.once.Do(func() { close(reader.drained) }) + <-reader.release + return 0, io.EOF +} + +func TestQueuedStandbyFramesCannotCrossPromotion(t *testing.T) { + tests := []struct { + name string + kind byte + payload func(uint64) []byte + assert func(*testing.T, *host, *os.File) + }{ + { + name: "stdin", + kind: frameStdin, + payload: func(generation uint64) []byte { + return ownedFramePayload(generation, []byte("stale-input")) + }, + assert: func(t *testing.T, _ *host, ptyOutput *os.File) { + t.Helper() + if err := ptyOutput.SetReadDeadline(time.Now().Add(100 * time.Millisecond)); err != nil { + t.Fatal(err) + } + buffer := make([]byte, len("stale-input")) + n, err := ptyOutput.Read(buffer) + if n > 0 || err == nil { + t.Fatalf("queued standby stdin reached PTY: %q, %v", buffer[:n], err) + } + }, + }, + { + name: "resize", + kind: frameResize, + payload: func(generation uint64) []byte { + return ownedFramePayload(generation, resizePayload(111, 33)) + }, + assert: func(t *testing.T, host *host, _ *os.File) { + t.Helper() + host.mu.Lock() + cols, rows := host.term.Size() + host.mu.Unlock() + if cols != 90 || rows != 25 { + t.Fatalf("queued standby resize changed controller size to %dx%d, want 90x25", cols, rows) + } + }, + }, + { + name: "future stdin", + kind: frameStdin, + payload: func(generation uint64) []byte { + return ownedFramePayload(generation+2, []byte("future-input")) + }, + assert: func(t *testing.T, _ *host, ptyOutput *os.File) { + t.Helper() + if err := ptyOutput.SetReadDeadline(time.Now().Add(100 * time.Millisecond)); err != nil { + t.Fatal(err) + } + buffer := make([]byte, len("future-input")) + n, err := ptyOutput.Read(buffer) + if n > 0 || err == nil { + t.Fatalf("future ownership epoch reached PTY: %q, %v", buffer[:n], err) + } + }, + }, + { + name: "future resize", + kind: frameResize, + payload: func(generation uint64) []byte { + return ownedFramePayload(generation+2, resizePayload(112, 34)) + }, + assert: func(t *testing.T, host *host, _ *os.File) { + t.Helper() + host.mu.Lock() + cols, rows := host.term.Size() + host.mu.Unlock() + if cols != 90 || rows != 25 { + t.Fatalf("future ownership epoch changed controller size to %dx%d, want 90x25", cols, rows) + } + }, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + registry := newClientRegistry() + controller := registerTestClient(t, registry, roleController, terminalSize{cols: 80, rows: 24}) + standby := registerTestClient(t, registry, roleController, terminalSize{cols: 90, rows: 25}) + staleGeneration := standby.generation + var frame bytes.Buffer + if err := writeFrame(&frame, test.kind, test.payload(staleGeneration)); err != nil { + t.Fatal(err) + } + if changes := registry.transfer(controller); len(changes) != 2 { + t.Fatalf("transfer changes = %d, want 2", len(changes)) + } + if standby.generation == staleGeneration { + t.Fatal("promotion did not advance the ownership generation") + } + + ptyOutput, ptyInput, err := os.Pipe() + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { + _ = ptyOutput.Close() + _ = ptyInput.Close() + }) + host := &host{registry: registry, term: vterm.New(80, 24, historyLines), ptmx: ptyInput} + host.term.Resize(90, 25) + release := make(chan struct{}) + queued := &queuedFrameReader{reader: bytes.NewReader(frame.Bytes()), drained: make(chan struct{}), release: release} + done := make(chan struct{}) + go func() { + host.attachReader(standby, bufio.NewReader(queued)) + close(done) + }() + t.Cleanup(func() { + close(release) + <-done + }) + + select { + case <-queued.drained: + case <-time.After(time.Second): + t.Fatal("queued frame was not processed") + } + test.assert(t, host, ptyOutput) + }) + } +} diff --git a/internal/session/host_inprocess_test.go b/internal/session/host_inprocess_test.go index 3d824d4..724d6ec 100644 --- a/internal/session/host_inprocess_test.go +++ b/internal/session/host_inprocess_test.go @@ -25,7 +25,9 @@ func startInProcessHost(t *testing.T, c *Client, name, command string) chan erro done := make(chan error, 1) go func() { defer func() { _ = w.Close() }() - done <- runHost(c.Dir, name, cwd, "label0", []string{"UAM_T=1"}, []string{"/bin/sh", "-c", command}, w) + done <- runHost(c.Dir, hostLaunchSpec{ + name: name, cwd: cwd, label: "label0", envs: []string{"UAM_T=1"}, command: []string{"/bin/sh", "-c", command}, + }, w) }() type readyResult struct { line string diff --git a/internal/session/ownership_error.go b/internal/session/ownership_error.go new file mode 100644 index 0000000..5f37b5c --- /dev/null +++ b/internal/session/ownership_error.go @@ -0,0 +1,24 @@ +package session + +import ( + "errors" + "fmt" +) + +const errorCodeBusy = "busy" + +// ErrSessionBusy identifies operations rejected while a controller owns the PTY. +var ErrSessionBusy = errors.New("session controller is attached") + +// SessionBusyError preserves the rejected operation across the host protocol. +type SessionBusyError struct { + Operation string +} + +func (err *SessionBusyError) Error() string { + return fmt.Sprintf("%s: %s", err.Operation, ErrSessionBusy) +} + +func (err *SessionBusyError) Is(target error) bool { + return target == ErrSessionBusy +} diff --git a/internal/session/proto.go b/internal/session/proto.go index 080a7c7..11ebf50 100644 --- a/internal/session/proto.go +++ b/internal/session/proto.go @@ -2,35 +2,132 @@ package session import ( "bufio" + "bytes" "encoding/binary" "encoding/json" + "errors" "fmt" "io" "sync" + "time" ) // Wire protocol between the client (uam TUI/CLI) and a session host. // // Control ops are one JSON request line answered by one JSON response line on // a fresh connection. The "attach" op upgrades the connection after its -// response: the host then streams raw PTY output bytes to the client, and the -// client sends framed messages (stdin bytes, resizes, detach) to the host. -// Framing is only needed client→host, where three message kinds share the -// stream; host→client carries exactly one kind of data so it stays raw. +// response. Version 1 then streams raw PTY output to the client while client +// input remains framed. Version 2 frames both PTY output and server controls. + +type protocolVersion int + +const ( + protocolV1 protocolVersion = 1 + protocolV2 protocolVersion = 2 +) + +const ( + attachHandshakeTimeout = 2 * time.Second + maxControlLine = 64 << 10 +) + +var ( + errControlLineTooLarge = errors.New("attach control line too large") + errFrameTooLarge = errors.New("attach frame too large") + errOwnershipEpoch = errors.New("invalid attach ownership epoch") +) type request struct { - Op string `json:"op"` - Text string `json:"text,omitempty"` - Lines int `json:"lines,omitempty"` - Cols int `json:"cols,omitempty"` - Rows int `json:"rows,omitempty"` - Label string `json:"label,omitempty"` + Op string `json:"op"` + Text string `json:"text,omitempty"` + Lines int `json:"lines,omitempty"` + Cols int `json:"cols,omitempty"` + Rows int `json:"rows,omitempty"` + Label string `json:"label,omitempty"` + Version protocolVersion `json:"version,omitempty"` + RequestedRole clientRole `json:"requested_role,omitempty"` + Hello *clientHello `json:"hello,omitempty"` + versionPresent bool } type response struct { - OK bool `json:"ok"` - Err string `json:"err,omitempty"` - Data string `json:"data,omitempty"` + OK bool `json:"ok"` + Err string `json:"err,omitempty"` + ErrorCode string `json:"error_code,omitempty"` + Data string `json:"data,omitempty"` + Version protocolVersion `json:"version,omitempty"` + ClientID string `json:"client_id,omitempty"` + AssignedRole clientRole `json:"assigned_role,omitempty"` + Generation uint64 `json:"generation,omitempty"` + Diagnostic *RuntimeDiagnostic `json:"diagnostic,omitempty"` + versionPresent bool +} + +func (r *request) UnmarshalJSON(data []byte) error { + type wireRequest request + var decoded wireRequest + if err := json.Unmarshal(data, &decoded); err != nil { + return err + } + present, err := explicitVersionPresent(data) + if err != nil { + return err + } + *r = request(decoded) + r.versionPresent = present + return nil +} + +func (r *response) UnmarshalJSON(data []byte) error { + type wireResponse response + var decoded wireResponse + if err := json.Unmarshal(data, &decoded); err != nil { + return err + } + present, err := explicitVersionPresent(data) + if err != nil { + return err + } + *r = response(decoded) + r.versionPresent = present + return nil +} + +func explicitVersionPresent(data []byte) (bool, error) { + var fields map[string]json.RawMessage + if err := json.Unmarshal(data, &fields); err != nil { + return false, err + } + raw, present := fields["version"] + if !present { + return false, nil + } + if bytes.Equal(bytes.TrimSpace(raw), []byte("null")) { + return false, errors.New("attach protocol version cannot be null") + } + return true, nil +} + +func negotiateAttachRequest(req request) (protocolVersion, error) { + if !req.versionPresent && req.Version == 0 { + return protocolV1, nil + } + switch req.Version { + case protocolV1, protocolV2: + return req.Version, nil + default: + return 0, fmt.Errorf("unsupported attach protocol version %d", req.Version) + } +} + +func negotiateAttachResponse(requested protocolVersion, resp response) (protocolVersion, error) { + if !resp.versionPresent && resp.Version == 0 { + return protocolV1, nil + } + if resp.Version != requested { + return 0, fmt.Errorf("attach protocol response version %d does not match requested version %d", resp.Version, requested) + } + return requested, nil } const ( @@ -40,6 +137,7 @@ const ( opLabel = "label" opResize = "resize" opAttach = "attach" + opDoctor = "doctor" ) // Attach stream frame types (client → host). @@ -47,19 +145,41 @@ const ( frameStdin byte = 0 frameResize byte = 1 frameDetach byte = 2 + frameRole byte = 3 +) + +// Attach stream frame types (host → v2 client). +const ( + serverFramePTY byte = 0 + serverFrameControl byte = 1 ) // maxFrameLen bounds a single client→host frame so a corrupt or hostile // length prefix cannot make the host allocate unbounded memory. const maxFrameLen = 1 << 20 +const ownershipEpochLen = 8 + +func ownedFramePayload(generation uint64, payload []byte) []byte { + framed := make([]byte, ownershipEpochLen+len(payload)) + binary.BigEndian.PutUint64(framed, generation) + copy(framed[ownershipEpochLen:], payload) + return framed +} + +func parseOwnedFramePayload(payload []byte) (uint64, []byte, error) { + if len(payload) < ownershipEpochLen { + return 0, nil, errOwnershipEpoch + } + return binary.BigEndian.Uint64(payload[:ownershipEpochLen]), payload[ownershipEpochLen:], nil +} + func writeJSONLine(w io.Writer, v any) error { data, err := json.Marshal(v) if err != nil { return err } - _, err = w.Write(append(data, '\n')) - return err + return writeAll(w, append(data, '\n')) } func readJSONLine(r *bufio.Reader, v any) error { @@ -70,7 +190,33 @@ func readJSONLine(r *bufio.Reader, v any) error { return json.Unmarshal(line, v) } +func readBoundedJSONLine(r *bufio.Reader, v any) error { + line := make([]byte, 0, min(maxControlLine, r.Size())) + for { + part, err := r.ReadSlice('\n') + contentLength := len(line) + len(part) + if err == nil && len(part) > 0 && part[len(part)-1] == '\n' { + contentLength-- + } + if contentLength > maxControlLine { + return fmt.Errorf("%w: limit is %d bytes", errControlLineTooLarge, maxControlLine) + } + line = append(line, part...) + switch { + case err == nil: + return json.Unmarshal(line, v) + case errors.Is(err, bufio.ErrBufferFull): + continue + default: + return err + } + } +} + func writeFrame(w io.Writer, kind byte, payload []byte) error { + if len(payload) > maxFrameLen { + return fmt.Errorf("%w: %d bytes", errFrameTooLarge, len(payload)) + } hdr := [5]byte{kind} binary.BigEndian.PutUint32(hdr[1:], uint32(len(payload))) // #nosec G115 -- payload length is bounded by callers if err := writeAll(w, hdr[:]); err != nil { @@ -102,15 +248,98 @@ func writeAll(w io.Writer, data []byte) error { // writes (header then payload), so protecting individual net.Conn.Write calls // is insufficient when stdin, resize, and detach goroutines share a socket. type frameWriter struct { - mu sync.Mutex - w io.Writer + mu sync.Mutex + w io.Writer + version protocolVersion + clientID string + role clientRole + generation uint64 } func newFrameWriter(w io.Writer) *frameWriter { return &frameWriter{w: w} } +func newAttachFrameWriter(w io.Writer, version protocolVersion, clientID string, generation uint64) *frameWriter { + return &frameWriter{w: w, version: version, clientID: clientID, generation: generation} +} + +func (w *frameWriter) SetAssignedRole(role clientRole) { + w.mu.Lock() + w.role = role + w.mu.Unlock() +} + +func (w *frameWriter) AssignedRole() clientRole { + w.mu.Lock() + defer w.mu.Unlock() + return w.role +} + +func (w *frameWriter) Generation() uint64 { + w.mu.Lock() + defer w.mu.Unlock() + return w.generation +} + +func (w *frameWriter) ClientID() string { + return w.clientID +} + +func (w *frameWriter) HasControl() bool { + return w.version == protocolV1 || w.AssignedRole() == roleController +} + +func (w *frameWriter) ObserveControl(payload []byte) error { + _, _, err := w.observeRoleEvent(payload, nil) + return err +} + +func (w *frameWriter) observeRoleEvent(payload []byte, beforePromotion func() error) (roleEvent, bool, error) { + var event roleEvent + if err := json.Unmarshal(payload, &event); err != nil { + return roleEvent{}, false, fmt.Errorf("decode attach control event: %w", err) + } + if event.Type != "role" { + return roleEvent{}, false, fmt.Errorf("unsupported attach control event %q", event.Type) + } + if event.Role != "" { + if err := validateRequestedRole(event.Role); err != nil { + return roleEvent{}, false, fmt.Errorf("invalid attach role event: %w", err) + } + } + if event.ClientID != w.clientID { + return event, false, nil + } + w.mu.Lock() + defer w.mu.Unlock() + changed := event.Role != "" && event.Role != w.role + if changed && event.Role == roleController && beforePromotion != nil { + if err := beforePromotion(); err != nil { + return roleEvent{}, false, err + } + } + if event.Role != "" { + w.role = event.Role + } + if event.Generation > w.generation { + w.generation = event.Generation + } + return event, changed, nil +} + +func (w *frameWriter) WriteRoleCommand(action roleAction) error { + payload, err := json.Marshal(roleCommand{Action: action}) + if err != nil { + return fmt.Errorf("encode attach role command: %w", err) + } + return w.WriteFrame(frameRole, payload) +} + func (w *frameWriter) WriteFrame(kind byte, payload []byte) error { w.mu.Lock() defer w.mu.Unlock() + if w.version == protocolV2 && (kind == frameStdin || kind == frameResize) { + payload = ownedFramePayload(w.generation, payload) + } return writeFrame(w.w, kind, payload) } @@ -121,7 +350,7 @@ func readFrame(r io.Reader) (byte, []byte, error) { } n := binary.BigEndian.Uint32(hdr[1:]) if n > maxFrameLen { - return 0, nil, fmt.Errorf("attach frame too large: %d bytes", n) + return 0, nil, fmt.Errorf("%w: %d bytes", errFrameTooLarge, n) } payload := make([]byte, n) if _, err := io.ReadFull(r, payload); err != nil { diff --git a/internal/session/proto_test.go b/internal/session/proto_test.go index 24f9882..6c170a9 100644 --- a/internal/session/proto_test.go +++ b/internal/session/proto_test.go @@ -1,13 +1,68 @@ package session import ( + "bufio" "bytes" "errors" "io" + "net" "sync" "testing" ) +func TestAttachV1CharacterizationHandshakeAndRawOutput(t *testing.T) { + client, server := net.Pipe() + t.Cleanup(func() { + _ = client.Close() + _ = server.Close() + }) + payload := []byte{'v', '1', ':', 0x00, 0xff, '\r', '\n'} + serverErr := make(chan error, 1) + go func() { + br := bufio.NewReader(server) + var req request + if err := readJSONLine(br, &req); err != nil { + serverErr <- err + return + } + if req.Op != opAttach || req.Cols != 80 || req.Rows != 24 { + serverErr <- errors.New("unexpected v1 attach request") + return + } + if err := writeJSONLine(server, response{OK: true, Data: "v1-label"}); err != nil { + serverErr <- err + return + } + if err := writeAll(server, payload); err != nil { + serverErr <- err + return + } + serverErr <- server.Close() + }() + + if err := writeJSONLine(client, request{Op: opAttach, Cols: 80, Rows: 24}); err != nil { + t.Fatalf("write v1 handshake: %v", err) + } + br := bufio.NewReader(client) + var resp response + if err := readJSONLine(br, &resp); err != nil { + t.Fatalf("read v1 handshake: %v", err) + } + if !resp.OK || resp.Data != "v1-label" { + t.Fatalf("v1 response = %+v", resp) + } + got, err := io.ReadAll(br) + if err != nil { + t.Fatalf("read v1 raw output: %v", err) + } + if !bytes.Equal(got, payload) { + t.Fatalf("v1 raw output = %x, want %x", got, payload) + } + if err := <-serverErr; err != nil { + t.Fatalf("v1 fixture: %v", err) + } +} + type shortWriter struct { mu sync.Mutex buf bytes.Buffer @@ -85,6 +140,95 @@ func TestFrameWriterSerializesConcurrentFrames(t *testing.T) { } } +func TestAttachFrameWriterStampsV2OwnershipGeneration(t *testing.T) { + var wire bytes.Buffer + writer := newAttachFrameWriter(&wire, protocolV2, "client-1", 7) + if err := writer.WriteFrame(frameStdin, []byte("before")); err != nil { + t.Fatal(err) + } + if err := writer.ObserveControl([]byte(`{"type":"role","client_id":"client-1","generation":8}`)); err != nil { + t.Fatalf("observe control: %v", err) + } + if err := writer.WriteFrame(frameResize, []byte{0, 100, 0, 30}); err != nil { + t.Fatal(err) + } + if err := writer.ObserveControl([]byte(`{"type":"role","client_id":"client-2","generation":9}`)); err != nil { + t.Fatalf("observe control: %v", err) + } + if err := writer.WriteFrame(frameStdin, []byte("after")); err != nil { + t.Fatal(err) + } + + want := []struct { + kind byte + generation uint64 + payload []byte + }{ + {kind: frameStdin, generation: 7, payload: []byte("before")}, + {kind: frameResize, generation: 8, payload: []byte{0, 100, 0, 30}}, + {kind: frameStdin, generation: 8, payload: []byte("after")}, + } + for _, expected := range want { + kind, payload, err := readFrame(&wire) + if err != nil { + t.Fatal(err) + } + generation, control, err := parseOwnedFramePayload(payload) + if err != nil { + t.Fatal(err) + } + if kind != expected.kind || generation != expected.generation || !bytes.Equal(control, expected.payload) { + t.Fatalf("frame = (%d, %d, %q), want (%d, %d, %q)", kind, generation, control, expected.kind, expected.generation, expected.payload) + } + } +} + +func TestAttachFrameWriterDoesNotRegressMatchingClientGeneration(t *testing.T) { + var wire bytes.Buffer + writer := newAttachFrameWriter(&wire, protocolV2, "client-1", 1) + if err := writer.ObserveControl([]byte(`{"type":"role","client_id":"client-1","generation":3}`)); err != nil { + t.Fatalf("observe control: %v", err) + } + if err := writer.ObserveControl([]byte(`{"type":"role","client_id":"client-1","generation":2}`)); err != nil { + t.Fatalf("observe control: %v", err) + } + if err := writer.WriteFrame(frameStdin, []byte("stdin")); err != nil { + t.Fatal(err) + } + if err := writer.WriteFrame(frameResize, []byte{0, 100, 0, 30}); err != nil { + t.Fatal(err) + } + + for _, expectedKind := range []byte{frameStdin, frameResize} { + kind, payload, err := readFrame(&wire) + if err != nil { + t.Fatal(err) + } + generation, _, err := parseOwnedFramePayload(payload) + if err != nil { + t.Fatal(err) + } + if kind != expectedKind || generation != 3 { + t.Fatalf("frame = (%d, generation %d), want (%d, generation 3)", kind, generation, expectedKind) + } + } +} + +func TestAttachFrameWriterPreservesV1ControlPayloads(t *testing.T) { + var wire bytes.Buffer + writer := newAttachFrameWriter(&wire, protocolV1, "", 7) + if err := writer.WriteFrame(frameStdin, []byte("legacy")); err != nil { + t.Fatal(err) + } + kind, payload, err := readFrame(&wire) + if err != nil { + t.Fatal(err) + } + if kind != frameStdin || !bytes.Equal(payload, []byte("legacy")) { + t.Fatalf("v1 frame = (%d, %q), want raw stdin", kind, payload) + } +} + func FuzzFrameDecoding(f *testing.F) { for _, seed := range [][]byte{ {frameDetach, 0, 0, 0, 0}, diff --git a/internal/session/provider_terminal_policy.go b/internal/session/provider_terminal_policy.go new file mode 100644 index 0000000..6e3986c --- /dev/null +++ b/internal/session/provider_terminal_policy.go @@ -0,0 +1,27 @@ +package session + +import ( + "fmt" + "regexp" + "strings" +) + +var providerIdentityRE = regexp.MustCompile(`^[a-z0-9]+$`) + +func validateProviderIdentity(identity string) error { + if identity == "" || providerIdentityRE.MatchString(identity) { + return nil + } + return fmt.Errorf("invalid provider identity %q", identity) +} + +func attachOwnsOuterScreen(dir, name string) bool { + state, err := readState(dir, name) + if err != nil { + return true + } + if state.ProviderIdentity == "" { + return !strings.HasPrefix(name, "uam-codex-") + } + return state.ProviderIdentity != "codex" +} diff --git a/internal/session/provider_terminal_policy_manual_test.go b/internal/session/provider_terminal_policy_manual_test.go new file mode 100644 index 0000000..b6b7043 --- /dev/null +++ b/internal/session/provider_terminal_policy_manual_test.go @@ -0,0 +1,149 @@ +package session + +import ( + "bytes" + "context" + "encoding/binary" + "encoding/json" + "os" + "path/filepath" + "strings" + "testing" + "time" +) + +type providerTerminalAssertion struct { + Name string `json:"name"` + SessionName string `json:"session_name"` + ProviderIdentity string `json:"provider_identity,omitempty"` + Argv []string `json:"argv"` + ScreenEnter bool `json:"screen_enter"` + ScreenExit bool `json:"screen_exit"` + NoAltScreenCount int `json:"no_alt_screen_count"` +} + +func TestProviderTerminalPolicyRealPTYFixture(t *testing.T) { + client := newTestClient(t) + provider := filepath.Join(t.TempDir(), "fake-provider") + script := "#!/bin/sh\nprintf 'TASK3-ARGV'\nfor arg in \"$@\"; do printf '<%s>' \"$arg\"; done\nprintf '\\n'\nsleep 60\n" + if err := os.WriteFile(provider, []byte(script), 0o700); err != nil { + t.Fatal(err) + } + tests := []struct { + name string + sessionName string + providerIdentity string + args []string + wantOuterScreen bool + }{ + {name: "explicit generic", sessionName: "uam-codex-d1d1d1d1", providerIdentity: "claude", args: []string{"--generic"}, wantOuterScreen: true}, + {name: "explicit Codex", sessionName: "uam-fake-e2e2e2e2", providerIdentity: "codex", args: []string{"--no-alt-screen", "resume", "--last"}}, + {name: "legacy Codex fallback", sessionName: "uam-codex-f3f3f3f3", args: []string{"--no-alt-screen"}}, + } + + assertions := make([]providerTerminalAssertion, 0, len(tests)) + var transcript bytes.Buffer + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + command := append([]string{provider}, test.args...) + spec := CreateSpec{Name: test.sessionName, Cwd: t.TempDir(), ProviderIdentity: test.providerIdentity, Command: command} + if test.providerIdentity == "" { + if err := client.CreateSession(context.Background(), spec.Name, spec.Cwd, nil, spec.Command); err != nil { + t.Fatalf("CreateSession: %v", err) + } + } else if err := client.CreateProviderSession(context.Background(), spec); err != nil { + t.Fatalf("CreateProviderSession: %v", err) + } + state, err := readState(client.Dir, test.sessionName) + if err != nil { + t.Fatalf("readState: %v", err) + } + if strings.Join(state.Command, "\x00") != strings.Join(command, "\x00") { + t.Fatalf("provider argv = %#v, want %#v", state.Command, command) + } + + attached := startQuietAttach(t, client.Dir, test.sessionName, 80, 24) + waitFor(t, "task 3 provider argv", func() bool { return strings.Contains(attached.Snapshot(), "TASK3-ARGV") }) + attached.Detach(t) + waitFor(t, "task 3 terminal cleanup", func() bool { return strings.Contains(attached.Snapshot(), screenReset) }) + output := []byte(attached.Snapshot()) + enter := bytes.Contains(output, []byte(screenEnter)) + exit := bytes.Contains(output, []byte(screenExit)) + if enter != test.wantOuterScreen || exit != test.wantOuterScreen { + t.Fatalf("screen ownership enter=%v exit=%v, want %v", enter, exit, test.wantOuterScreen) + } + noAltScreenCount := 0 + for _, arg := range state.Command { + if arg == "--no-alt-screen" { + noAltScreenCount++ + } + } + assertions = append(assertions, providerTerminalAssertion{ + Name: test.name, SessionName: test.sessionName, ProviderIdentity: test.providerIdentity, + Argv: state.Command, ScreenEnter: enter, ScreenExit: exit, + NoAltScreenCount: noAltScreenCount, + }) + writeTask3TranscriptChunk(t, &transcript, test.name, output) + }) + } + + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + if err := client.KillAll(ctx); err != nil { + t.Fatalf("KillAll: %v", err) + } + entries, err := os.ReadDir(client.Dir) + if err != nil { + t.Fatal(err) + } + if len(entries) != 0 { + t.Fatalf("runtime directory not empty after cleanup: %+v", entries) + } + writeTask3ManualEvidence(t, transcript.Bytes(), assertions) +} + +func writeTask3TranscriptChunk(t *testing.T, transcript *bytes.Buffer, name string, output []byte) { + t.Helper() + if err := binary.Write(transcript, binary.BigEndian, uint32(len(name))); err != nil { + t.Fatal(err) + } + transcript.WriteString(name) + if err := binary.Write(transcript, binary.BigEndian, uint32(len(output))); err != nil { + t.Fatal(err) + } + transcript.Write(output) +} + +func writeTask3ManualEvidence(t *testing.T, transcript []byte, assertions []providerTerminalAssertion) { + t.Helper() + dir := os.Getenv("UAM_TASK3_EVIDENCE_DIR") + if dir == "" { + return + } + if err := os.WriteFile(filepath.Join(dir, "task-3-pty-transcript.bin"), transcript, 0o600); err != nil { + t.Fatal(err) + } + data, err := json.MarshalIndent(struct { + Assertions []providerTerminalAssertion `json:"assertions"` + RuntimeClean bool `json:"runtime_clean"` + }{Assertions: assertions, RuntimeClean: true}, "", " ") + if err != nil { + t.Fatal(err) + } + data = append(data, '\n') + if err := os.WriteFile(filepath.Join(dir, "task-3-manual-assertions.json"), data, 0o600); err != nil { + t.Fatal(err) + } + cleanup, err := json.MarshalIndent(struct { + KillAllSucceeded bool `json:"kill_all_succeeded"` + RuntimeEntries int `json:"runtime_entries"` + SocketsRemaining int `json:"sockets_remaining"` + }{KillAllSucceeded: true}, "", " ") + if err != nil { + t.Fatal(err) + } + cleanup = append(cleanup, '\n') + if err := os.WriteFile(filepath.Join(dir, "task-3-cleanup.json"), cleanup, 0o600); err != nil { + t.Fatal(err) + } +} diff --git a/internal/session/provider_terminal_policy_test.go b/internal/session/provider_terminal_policy_test.go new file mode 100644 index 0000000..4b5a3e1 --- /dev/null +++ b/internal/session/provider_terminal_policy_test.go @@ -0,0 +1,119 @@ +package session + +import ( + "context" + "os" + "path/filepath" + "strings" + "testing" +) + +func TestCreateProviderSessionPersistsExplicitIdentity(t *testing.T) { + client := newTestClient(t) + name := "uam-fake-a1b2c3d4" + if err := client.CreateProviderSession(context.Background(), CreateSpec{ + Name: name, Cwd: t.TempDir(), ProviderIdentity: "codex", + Command: []string{"/bin/sh", "-c", "sleep 60"}, + }); err != nil { + t.Fatalf("CreateProviderSession: %v", err) + } + + state, err := readState(client.Dir, name) + if err != nil { + t.Fatalf("readState: %v", err) + } + if state.ProviderIdentity != "codex" { + t.Fatalf("state provider identity = %q, want codex", state.ProviderIdentity) + } +} + +func TestCreateProviderSessionRejectsMalformedIdentity(t *testing.T) { + client := newTestClient(t) + err := client.CreateProviderSession(context.Background(), CreateSpec{ + Name: "uam-fake-b1c2d3e4", Cwd: t.TempDir(), ProviderIdentity: "../codex", + Command: []string{"/bin/sh", "-c", "sleep 60"}, + }) + if err == nil || !strings.Contains(err.Error(), "invalid provider identity") { + t.Fatalf("CreateProviderSession error = %v, want malformed identity rejection", err) + } +} + +func TestExplicitProviderIdentityControlsAttachScreenOwnership(t *testing.T) { + tests := []struct { + name string + sessionName string + providerIdentity string + wantOuterScreen bool + }{ + {name: "generic provider with misleading Codex name", sessionName: "uam-codex-a1a1a1a1", providerIdentity: "claude", wantOuterScreen: true}, + {name: "Codex provider with generic name", sessionName: "uam-fake-b2b2b2b2", providerIdentity: "codex", wantOuterScreen: false}, + {name: "unknown provider", sessionName: "uam-fake-c3c3c3c3", providerIdentity: "futureagent", wantOuterScreen: true}, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + client := newTestClient(t) + marker := "identity-" + test.providerIdentity + if err := client.CreateProviderSession(context.Background(), CreateSpec{ + Name: test.sessionName, Cwd: t.TempDir(), ProviderIdentity: test.providerIdentity, + Command: []string{"/bin/sh", "-c", "echo " + marker + "; sleep 60"}, + }); err != nil { + t.Fatalf("CreateProviderSession: %v", err) + } + attached := startQuietAttach(t, client.Dir, test.sessionName, 80, 24) + waitFor(t, "provider identity replay", func() bool { return strings.Contains(attached.Snapshot(), marker) }) + if got := strings.Contains(attached.Snapshot(), screenEnter); got != test.wantOuterScreen { + t.Fatalf("screen enter present = %v, want %v: %q", got, test.wantOuterScreen, attached.Snapshot()) + } + attached.Detach(t) + waitFor(t, "screen cleanup", func() bool { + return strings.Contains(attached.Snapshot(), screenExit) == test.wantOuterScreen + }) + }) + } +} + +func TestAttachScreenOwnershipUsesExplicitProviderIdentity(t *testing.T) { + tests := []struct { + name string + sessionName string + providerIdentity string + want bool + }{ + {name: "explicit generic overrides misleading Codex name", sessionName: "uam-codex-11111111", providerIdentity: "claude", want: true}, + {name: "explicit Codex overrides generic name", sessionName: "uam-fake-22222222", providerIdentity: "codex", want: false}, + {name: "unknown provider is safely generic", sessionName: "uam-fake-33333333", providerIdentity: "futureagent", want: true}, + {name: "legacy Codex name keeps primary screen", sessionName: "uam-codex-44444444", want: false}, + {name: "legacy generic name owns outer screen", sessionName: "uam-fake-55555555", want: true}, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + dir := t.TempDir() + if err := os.Chmod(dir, 0o700); err != nil { + t.Fatal(err) + } + if err := writeState(dir, State{Name: test.sessionName, ProviderIdentity: test.providerIdentity}); err != nil { + t.Fatal(err) + } + if got := attachOwnsOuterScreen(dir, test.sessionName); got != test.want { + t.Fatalf("attachOwnsOuterScreen() = %v, want %v", got, test.want) + } + }) + } +} + +func TestAttachScreenOwnershipMalformedMetadataFallsBackToGeneric(t *testing.T) { + dir := t.TempDir() + if err := os.Chmod(dir, 0o700); err != nil { + t.Fatal(err) + } + name := "uam-codex-66666666" + path := filepath.Join(dir, name+".json") + if err := os.WriteFile(path, []byte(`{"name":"uam-codex-66666666","provider_identity":"../codex"}`), 0o600); err != nil { + t.Fatal(err) + } + if !attachOwnsOuterScreen(dir, name) { + t.Fatal("malformed host metadata must use safe generic screen ownership") + } +} diff --git a/internal/session/scrollback_policy.go b/internal/session/scrollback_policy.go new file mode 100644 index 0000000..92340bd --- /dev/null +++ b/internal/session/scrollback_policy.go @@ -0,0 +1,13 @@ +package session + +import "fmt" + +func validatedScrollbackLines(lines int) (int, error) { + if lines == 0 { + return historyLines, nil + } + if lines < minHistoryLines || lines > maxHistoryLines { + return 0, fmt.Errorf("scrollback lines %d outside %d..%d", lines, minHistoryLines, maxHistoryLines) + } + return lines, nil +} diff --git a/internal/session/session.go b/internal/session/session.go index bb115cc..6c6dfe3 100644 --- a/internal/session/session.go +++ b/internal/session/session.go @@ -119,13 +119,14 @@ type State struct { // a recycled PID from the original // process, so a stale state file can never make uam treat — or worse, // signal — an unrelated process as a session. - HostStart int64 `json:"host_start,omitempty"` - ChildPID int `json:"child_pid"` - ChildStart int64 `json:"child_start,omitempty"` - CreatedUnix int64 `json:"created_unix"` - Cwd string `json:"cwd"` - Label string `json:"label,omitempty"` - Command []string `json:"command"` + HostStart int64 `json:"host_start,omitempty"` + ChildPID int `json:"child_pid"` + ChildStart int64 `json:"child_start,omitempty"` + CreatedUnix int64 `json:"created_unix"` + Cwd string `json:"cwd"` + Label string `json:"label,omitempty"` + ProviderIdentity string `json:"provider_identity,omitempty"` + Command []string `json:"command"` } // hostAlive / childAlive are the start-time-verified liveness probes for a @@ -199,6 +200,9 @@ func readState(dir, name string) (State, error) { if st.Name != name { return State{}, fmt.Errorf("session state name %q does not match file name %q", st.Name, name) } + if err := validateProviderIdentity(st.ProviderIdentity); err != nil { + return State{}, err + } return st, nil } diff --git a/internal/session/session_test.go b/internal/session/session_test.go index a0c4d6d..e3b70fc 100644 --- a/internal/session/session_test.go +++ b/internal/session/session_test.go @@ -779,7 +779,7 @@ func TestAttachOwnsTerminalStateOnTTY(t *testing.T) { } } -func TestCodexAttachPreservesPrimaryScreenScrollback(t *testing.T) { +func TestLegacyCodexNamePrefixPreservesPrimaryScreenScrollback(t *testing.T) { c := newTestClient(t) ctx := context.Background() name := "uam-codex-aaaa9999" diff --git a/internal/session/todo11_compat_test.go b/internal/session/todo11_compat_test.go new file mode 100644 index 0000000..7782321 --- /dev/null +++ b/internal/session/todo11_compat_test.go @@ -0,0 +1,154 @@ +package session + +import ( + "bytes" + "crypto/sha256" + "encoding/json" + "fmt" + "os" + "path/filepath" + "runtime" + "sort" + "strings" + "sync/atomic" + "testing" +) + +var todo11Run atomic.Int32 + +type todo11Report struct { + RunsIdentical bool `json:"runs_identical"` + Matrix map[string]string `json:"matrix"` + Terms []string `json:"terms"` + Scenarios []string `json:"scenarios"` +} + +func TestTodo11CompatibilityRealPTYMatrix(t *testing.T) { + // Given: an explicitly scoped absolute artifact directory and checked-in binary fixtures. + evidenceDir := os.Getenv("UAM_TASK11_EVIDENCE_DIR") + if evidenceDir == "" { + t.Skip("UAM_TASK11_EVIDENCE_DIR is required for artifact collection") + } + if !filepath.IsAbs(evidenceDir) { + t.Fatalf("UAM_TASK11_EVIDENCE_DIR must be absolute: %q", evidenceDir) + } + if filepath.Base(evidenceDir) != "task-11-compat" { + t.Fatalf("refusing unexpected evidence directory: %q", evidenceDir) + } + for _, child := range []string{"transcripts", "normalized"} { + if err := os.MkdirAll(filepath.Join(evidenceDir, child), 0o755); err != nil { + t.Fatal(err) + } + } + fixture := todo11LoadFixture(t) + terms := []string{ + "xterm-256color", "screen-256color", "tmux-256color", "xterm-kitty", + "alacritty", "wezterm", "ghostty", + } + run := int(todo11Run.Add(1)) + hashes := make(map[string]string, len(terms)) + goroutinesBefore := runtime.NumGoroutine() + cleanupObserved := true + + // When: every TERM metadata input drives the same real-PTY logical replay. + for index, termName := range terms { + normalized, transcript := todo11ExerciseHost(t, termName, fixture, run*100+index) + data, err := json.MarshalIndent(normalized, "", " ") + if err != nil { + t.Fatal(err) + } + data = append(data, '\n') + hashes[termName] = fmt.Sprintf("%x", sha256.Sum256(data)) + name := todo11SafeName(termName) + if err := os.WriteFile(filepath.Join(evidenceDir, "transcripts", name+".bin"), transcript, 0o644); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(evidenceDir, "normalized", name+".json"), data, 0o644); err != nil { + t.Fatal(err) + } + cleanupObserved = cleanupObserved && normalized.SocketRemoved && normalized.RuntimeEntries == 0 + if err := todo11ValidateNegotiatedTermHint( + todo11ExpectedDiagnosticTermHint(termName), + normalized.NegotiatedTermHint, + ); err != nil { + t.Fatalf("%s: %v", termName, err) + } + if normalized.CapabilityInferred || !normalized.ObserverSuppressed || + !normalized.DisconnectReattached || !normalized.MalformedDropped || + !normalized.TruncatedDropped || !normalized.WINCHObserved || + !normalized.ReplayObserved || !normalized.ReplayModesObserved { + t.Fatalf("%s behavioral assertions failed", termName) + } + } + unsupported, unsupportedTranscript := todo11ExerciseHost(t, "unsupported-uam-term", fixture, run*100+99) + if unsupported.NegotiatedTermHint != "redacted" { + t.Fatalf("unsupported negotiated TERM = %q", unsupported.NegotiatedTermHint) + } + cleanupObserved = cleanupObserved && unsupported.SocketRemoved && unsupported.RuntimeEntries == 0 + unsupportedData, err := json.MarshalIndent(unsupported, "", " ") + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(evidenceDir, "transcripts", "unsupported_term.bin"), unsupportedTranscript, 0o644); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(evidenceDir, "normalized", "unsupported_term.json"), append(unsupportedData, '\n'), 0o644); err != nil { + t.Fatal(err) + } + hashPath := filepath.Join(evidenceDir, fmt.Sprintf("hashes-run-%d.txt", run)) + if err := os.WriteFile(hashPath, todo11HashLines(hashes), 0o644); err != nil { + t.Fatal(err) + } + + // Then: the second repetition must be byte-identical to the first. + if run == 2 { + first, err := os.ReadFile(filepath.Join(evidenceDir, "hashes-run-1.txt")) + if err != nil { + t.Fatal(err) + } + second, err := os.ReadFile(hashPath) + if err != nil { + t.Fatal(err) + } + identical := bytes.Equal(first, second) + diff := []byte("no normalized hash differences\n") + if !identical { + diff = []byte("normalized hash lists differ\n") + } + if err := os.WriteFile(filepath.Join(evidenceDir, "hash-diff.txt"), diff, 0o644); err != nil { + t.Fatal(err) + } + report := todo11Report{RunsIdentical: identical, Matrix: hashes, Terms: terms, Scenarios: []string{ + "printable_utf8", "arrows_modifiers", "control_prefix", "sgr_mouse", + "bracketed_paste", "focus", "alternate_screen", "resize_burst", + "disconnect_reconnect", "observer", "provider_replay", "malformed_escape_frame", + "large_paste", "unsupported_term_metadata", + }} + todo11WriteJSON(t, filepath.Join(evidenceDir, "report.json"), report) + goroutinesAfter := runtime.NumGoroutine() + cleanup := map[string]any{ + "hosts_cleaned": cleanupObserved, "connections_closed": unsupported.DisconnectReattached, + "goroutines_bounded": goroutinesAfter <= goroutinesBefore+1, + "runtime_entries_remaining": unsupported.RuntimeEntries, + "socket_removed": unsupported.SocketRemoved, + "go_routines_before": goroutinesBefore, "go_routines_after": goroutinesAfter, + } + todo11WriteJSON(t, filepath.Join(evidenceDir, "cleanup-receipt.json"), cleanup) + if !identical { + t.Fatal("normalized hashes differ across repetitions") + } + } +} + +func todo11HashLines(hashes map[string]string) []byte { + terms := make([]string, 0, len(hashes)) + for termName := range hashes { + terms = append(terms, termName) + } + sort.Strings(terms) + var result strings.Builder + for _, termName := range terms { + fmt.Fprintf(&result, "%s %s\n", hashes[termName], termName) + } + return []byte(result.String()) +} diff --git a/internal/session/todo11_diagnostic_race_test.go b/internal/session/todo11_diagnostic_race_test.go new file mode 100644 index 0000000..105da3b --- /dev/null +++ b/internal/session/todo11_diagnostic_race_test.go @@ -0,0 +1,53 @@ +package session + +import ( + "runtime" + "sync" + "testing" +) + +func TestTodo11AttachDiagnosticSnapshotsMutableClientState(t *testing.T) { + // Given: client state changing under the host mutex during attach diagnostics. + for range 100 { + h := &host{name: "uam-fake-abcdef12", registry: newClientRegistry()} + client := &attachClient{ + version: protocolV2, + done: make(chan struct{}), + out: make(chan serverMessage, 8), + } + stop := make(chan struct{}) + started := make(chan struct{}) + var mutator sync.WaitGroup + mutator.Add(1) + go func() { + defer mutator.Done() + close(started) + for { + select { + case <-stop: + return + default: + } + h.mu.Lock() + client.assignedRole = roleStandby + client.hello.TermHint = "screen-256color" + h.mu.Unlock() + runtime.Gosched() + } + }() + <-started + + // When: registration snapshots fields and emits attach diagnostics. + _, err := h.registerAttachClient(client, clientRegistration{ + requestedRole: roleController, + hello: validTestHello(), + }) + close(stop) + mutator.Wait() + + // Then: registration succeeds without an unsynchronized diagnostic read. + if err != nil { + t.Fatal(err) + } + } +} diff --git a/internal/session/todo11_fake_provider_test.go b/internal/session/todo11_fake_provider_test.go new file mode 100644 index 0000000..f0078ab --- /dev/null +++ b/internal/session/todo11_fake_provider_test.go @@ -0,0 +1,186 @@ +package session + +import ( + "bytes" + "encoding/base64" + "encoding/hex" + "encoding/json" + "log/slog" + "os" + "os/signal" + "sync" + "syscall" + "testing" + + "github.com/charmbracelet/x/term" + + uamlog "github.com/RandomCodeSpace/unified-agent-manager/internal/log" +) + +const todo11InputDelimiter = byte(0x1e) + +var todo11ProviderExit = []byte("todo11-provider-exit") + +type todo11ProviderEvent struct { + Type string `json:"type"` + TERM string `json:"term,omitempty"` + Data string `json:"data,omitempty"` + Signal string `json:"signal,omitempty"` + Cols int `json:"cols,omitempty"` + Rows int `json:"rows,omitempty"` + Modes []string `json:"modes,omitempty"` +} + +type todo11EventWriter struct { + mu sync.Mutex + file *os.File +} + +func (writer *todo11EventWriter) write(event todo11ProviderEvent) error { + data, err := json.Marshal(event) + if err != nil { + return err + } + writer.mu.Lock() + defer writer.mu.Unlock() + _, err = writer.file.Write(append(data, '\n')) + return err +} + +func TestTodo11FakeProviderProcess(t *testing.T) { + if os.Getenv("UAM_TODO11_FAKE_PROVIDER") != "1" { + t.Skip("helper process") + } + report, err := os.OpenFile(os.Getenv("UAM_TODO11_PROVIDER_REPORT"), os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0o600) + if err != nil { + t.Fatal(err) + } + defer func() { _ = report.Close() }() + writer := &todo11EventWriter{file: report} + output, err := hex.DecodeString(os.Getenv("UAM_TODO11_PROVIDER_OUTPUT_HEX")) + if err != nil { + t.Fatal(err) + } + oldState, err := term.MakeRaw(os.Stdin.Fd()) + if err != nil { + t.Fatal(err) + } + defer func() { _ = term.Restore(os.Stdin.Fd(), oldState) }() + cols, rows, err := term.GetSize(os.Stdin.Fd()) + if err != nil { + t.Fatal(err) + } + if err := writer.write(todo11ProviderEvent{ + Type: "startup", TERM: os.Getenv("TERM"), Cols: cols, Rows: rows, + }); err != nil { + t.Fatal(err) + } + if _, err := os.Stdout.Write(output); err != nil { + t.Fatal(err) + } + if err := writer.write(todo11ProviderEvent{Type: "modes", Modes: todo11ProviderModes(output)}); err != nil { + t.Fatal(err) + } + + winch := make(chan os.Signal, 8) + stop := make(chan struct{}) + var signals sync.WaitGroup + signals.Add(1) + signal.Notify(winch, syscall.SIGWINCH) + go func() { + defer signals.Done() + for { + select { + case <-stop: + return + case <-winch: + width, height, sizeErr := term.GetSize(os.Stdin.Fd()) + if sizeErr == nil { + _ = writer.write(todo11ProviderEvent{ + Type: "signal", Signal: "SIGWINCH", Cols: width, Rows: height, + }) + } + } + } + }() + defer func() { + signal.Stop(winch) + close(stop) + signals.Wait() + }() + + var pending []byte + buffer := make([]byte, 32<<10) + for { + n, readErr := os.Stdin.Read(buffer) + for _, value := range buffer[:n] { + if value == todo11InputDelimiter { + record := append([]byte(nil), pending...) + if err := writer.write(todo11ProviderEvent{ + Type: "input", Data: base64.StdEncoding.EncodeToString(record), + }); err != nil { + t.Fatal(err) + } + pending = pending[:0] + if bytes.Equal(record, todo11ProviderExit) { + return + } + continue + } + pending = append(pending, value) + } + if readErr != nil { + return + } + } +} + +func TestTodo11HostProcess(t *testing.T) { + if os.Getenv("UAM_TODO11_HOST_HELPER") != "1" { + t.Skip("helper process") + } + diagnostics, err := os.OpenFile( + os.Getenv("UAM_TODO11_HOST_DIAGNOSTICS"), + os.O_CREATE|os.O_APPEND|os.O_WRONLY, + 0o600, + ) + if err != nil { + t.Fatal(err) + } + defer func() { _ = diagnostics.Close() }() + previousLogger := uamlog.SetLogger(slog.New(slog.NewJSONHandler(diagnostics, nil))) + defer uamlog.SetLogger(previousLogger) + spec := hostLaunchSpec{ + name: os.Getenv("UAM_TODO11_HOST_NAME"), + cwd: os.Getenv("UAM_TODO11_HOST_CWD"), + label: "todo11", + envs: []string{ + "UAM_TODO11_FAKE_PROVIDER=1", + "UAM_TODO11_PROVIDER_REPORT=" + os.Getenv("UAM_TODO11_PROVIDER_REPORT"), + "UAM_TODO11_PROVIDER_OUTPUT_HEX=" + os.Getenv("UAM_TODO11_PROVIDER_OUTPUT_HEX"), + }, + command: []string{os.Getenv("UAM_TODO11_TEST_BINARY"), "-test.run=^TestTodo11FakeProviderProcess$"}, + } + if err := runHost(os.Getenv("UAM_TODO11_RUNTIME_DIR"), spec, readyPipe()); err != nil { + t.Fatal(err) + } +} + +func todo11ProviderModes(output []byte) []string { + sequences := []struct { + name string + data []byte + }{ + {name: "alternate_screen_enter", data: []byte("\x1b[?1049h")}, + {name: "alternate_screen_exit", data: []byte("\x1b[?1049l")}, + {name: "bracketed_paste_enable", data: []byte("\x1b[?2004h")}, + {name: "focus_enable", data: []byte("\x1b[?1004h")}, + } + modes := make([]string, 0, len(sequences)) + for _, sequence := range sequences { + if bytes.Contains(output, sequence.data) { + modes = append(modes, sequence.name) + } + } + return modes +} diff --git a/internal/session/todo11_harness_test.go b/internal/session/todo11_harness_test.go new file mode 100644 index 0000000..27b7527 --- /dev/null +++ b/internal/session/todo11_harness_test.go @@ -0,0 +1,80 @@ +package session + +import ( + "encoding/hex" + "encoding/json" + "os" + "path/filepath" + "strings" + "testing" +) + +type todo11Fixture struct { + Input []struct { + Name string `json:"name"` + Hex string `json:"hex"` + } `json:"input"` + ProviderOutputHex string `json:"provider_output_hex"` + MalformedEscapeHex string `json:"malformed_escape_hex"` +} + +type todo11Normalized struct { + TermHint string `json:"term_hint"` + NegotiatedTermHint string `json:"negotiated_term_hint"` + ProviderTERM string `json:"provider_term"` + Protocol int `json:"protocol"` + ControllerRole string `json:"controller_role"` + ReconnectRole string `json:"reconnect_role"` + InputHex map[string]string `json:"input_hex"` + ProviderModes []string `json:"provider_modes"` + InitialSize [2]int `json:"initial_size"` + FinalSize [2]int `json:"final_size"` + WINCHObserved bool `json:"winch_observed"` + ReplayObserved bool `json:"replay_observed"` + ReplayModesObserved bool `json:"replay_modes_observed"` + ObserverSuppressed bool `json:"observer_suppressed"` + DisconnectReattached bool `json:"disconnect_reattached"` + MalformedDropped bool `json:"malformed_dropped"` + TruncatedDropped bool `json:"truncated_dropped"` + LargePasteBytes int `json:"large_paste_bytes"` + CapabilityInferred bool `json:"capability_inferred"` + SocketRemoved bool `json:"socket_removed"` + RuntimeEntries int `json:"runtime_entries"` +} + +func todo11LoadFixture(t *testing.T) todo11Fixture { + t.Helper() + data, err := os.ReadFile(filepath.Join("..", "..", "testdata", "todo11", "fixtures.json")) + if err != nil { + t.Fatal(err) + } + var fixture todo11Fixture + if err := json.Unmarshal(data, &fixture); err != nil { + t.Fatal(err) + } + return fixture +} + +func todo11Decode(t *testing.T, value string) []byte { + t.Helper() + data, err := hex.DecodeString(value) + if err != nil { + t.Fatal(err) + } + return data +} + +func todo11SafeName(termName string) string { + return strings.NewReplacer("-", "_", ".", "_").Replace(termName) +} + +func todo11WriteJSON(t *testing.T, path string, value any) { + t.Helper() + data, err := json.MarshalIndent(value, "", " ") + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(path, append(data, '\n'), 0o644); err != nil { + t.Fatal(err) + } +} diff --git a/internal/session/todo11_matrix_scenario_test.go b/internal/session/todo11_matrix_scenario_test.go new file mode 100644 index 0000000..eab9405 --- /dev/null +++ b/internal/session/todo11_matrix_scenario_test.go @@ -0,0 +1,127 @@ +package session + +import ( + "bytes" + "testing" +) + +func todo11ExerciseHost( + t *testing.T, + termHint string, + fixture todo11Fixture, + index int, +) (todo11Normalized, []byte) { + t.Helper() + harness := todo11StartHost(t, fixture, index) + controller := harness.attach(t, termHint, roleController) + negotiatedTermHint := harness.negotiatedTermHint(t, controller.clientID) + replayModes := bytes.Contains(controller.replay, []byte("\x1b[?2004h")) && + bytes.Contains(controller.replay, []byte("\x1b[?1004h")) + + inputNames := make([]string, 0, len(fixture.Input)+3) + for _, item := range fixture.Input { + input := todo11Decode(t, item.Hex) + filtered, detached := (&stdinFilter{}).filter(input) + if detached { + t.Fatalf("%s detached unexpectedly", item.Name) + } + todo11WriteInput(t, controller, filtered) + inputNames = append(inputNames, item.Name) + harness.waitInputCount(t, len(inputNames)) + } + malformedEscape := todo11Decode(t, fixture.MalformedEscapeHex) + todo11WriteInput(t, controller, malformedEscape) + inputNames = append(inputNames, "malformed_escape") + harness.waitInputCount(t, len(inputNames)) + + largePaste := append(append([]byte{}, pasteBegin...), bytes.Repeat([]byte("p"), 128<<10)...) + largePaste = append(largePaste, pasteEnd...) + filteredPaste, detached := (&stdinFilter{}).filter(largePaste) + if detached || !bytes.Equal(filteredPaste, largePaste) { + t.Fatal("large bracketed paste changed before protocol framing") + } + todo11WriteInput(t, controller, filteredPaste) + inputNames = append(inputNames, "large_paste") + harness.waitInputCount(t, len(inputNames)) + + observer := harness.attach(t, termHint, roleObserver) + todo11WriteInput(t, observer, []byte("observer-must-not-arrive")) + todo11WriteInput(t, controller, []byte("controller-ack")) + inputNames = append(inputNames, "controller_ack") + harness.waitInputCount(t, len(inputNames)) + + resizes := [][2]int{{80, 24}, {132, 43}, {1, 1}, {110, 32}} + for _, size := range resizes { + if err := controller.write(frameResize, resizePayload(size[0], size[1])); err != nil { + t.Fatal(err) + } + } + var finalWINCH bool + waitFor(t, "provider final resize signal", func() bool { + for _, event := range harness.events(t) { + if event.Type == "signal" && event.Signal == "SIGWINCH" && + event.Cols == 110 && event.Rows == 32 { + finalWINCH = true + } + } + return finalWINCH + }) + + controller.close() + reconnected := harness.attach(t, termHint, roleController) + reconnected.awaitController(t, &harness.transcript) + disconnectReattached := reconnected.role == roleController + todo11DropMalformed(t, reconnected, false) + afterMalformed := harness.attach(t, termHint, roleController) + afterMalformed.awaitController(t, &harness.transcript) + malformedDropped := afterMalformed.role == roleController + todo11DropMalformed(t, afterMalformed, true) + afterTruncated := harness.attach(t, termHint, roleController) + afterTruncated.awaitController(t, &harness.transcript) + truncatedDropped := afterTruncated.role == roleController + todo11WriteInput(t, afterTruncated, todo11ProviderExit) + harness.waitInputCount(t, len(inputNames)+1) + + events := harness.events(t) + startup := events[0] + var modes []string + for _, event := range events { + if event.Type == "modes" { + modes = append([]string{}, event.Modes...) + } + } + socketRemoved, runtimeEntries := harness.cleanup(t) + report := todo11ReadAllReport(t, harness) + harness.transcript.Write(report) + events = harness.events(t) + var inputEvents []todo11ProviderEvent + observerSuppressed := true + for _, event := range events { + if event.Type != "input" { + continue + } + if bytes.Equal(todo11EventInput(t, event), []byte("observer-must-not-arrive")) { + observerSuppressed = false + continue + } + inputEvents = append(inputEvents, event) + } + observedInputs := todo11ObservedInputs(t, inputEvents, inputNames) + + normalized := todo11Normalized{ + TermHint: termHint, NegotiatedTermHint: negotiatedTermHint, + ProviderTERM: startup.TERM, Protocol: int(protocolV2), + ControllerRole: string(roleController), ReconnectRole: string(reconnected.role), + InputHex: observedInputs, ProviderModes: modes, + InitialSize: [2]int{startup.Cols, startup.Rows}, FinalSize: [2]int{110, 32}, + WINCHObserved: finalWINCH, ReplayObserved: len(controller.replay) > 0 && len(reconnected.replay) > 0, + ReplayModesObserved: replayModes, ObserverSuppressed: observerSuppressed, + DisconnectReattached: disconnectReattached, MalformedDropped: malformedDropped, + TruncatedDropped: truncatedDropped, LargePasteBytes: len(filteredPaste), + CapabilityInferred: false, SocketRemoved: socketRemoved, RuntimeEntries: runtimeEntries, + } + if startup.TERM != "xterm-256color" { + t.Fatalf("provider TERM = %q", startup.TERM) + } + return normalized, harness.transcript.Bytes() +} diff --git a/internal/session/todo11_protocol_client_test.go b/internal/session/todo11_protocol_client_test.go new file mode 100644 index 0000000..465ab82 --- /dev/null +++ b/internal/session/todo11_protocol_client_test.go @@ -0,0 +1,161 @@ +package session + +import ( + "bufio" + "bytes" + "encoding/binary" + "encoding/json" + "net" + "testing" + "time" +) + +type todo11ProtocolAttach struct { + conn net.Conn + reader *bufio.Reader + clientID string + generation uint64 + role clientRole + replay []byte +} + +func (harness *todo11HostHarness) attach(t *testing.T, termHint string, role clientRole) *todo11ProtocolAttach { + t.Helper() + conn, err := net.Dial("unix", SocketPath(harness.runtimeDir, harness.name)) + if err != nil { + t.Fatal(err) + } + hello := validTestHello() + hello.TermHint = termHint + req := request{ + Op: opAttach, Version: protocolV2, Cols: 110, Rows: 32, + RequestedRole: role, Hello: &hello, + } + requestData, err := json.Marshal(req) + if err != nil { + t.Fatal(err) + } + writeEvidenceChunk(&harness.transcript, requestData) + if err := writeJSONLine(conn, req); err != nil { + t.Fatal(err) + } + reader := bufio.NewReader(conn) + var response response + if err := readBoundedJSONLine(reader, &response); err != nil { + t.Fatal(err) + } + responseData, err := json.Marshal(response) + if err != nil { + t.Fatal(err) + } + writeEvidenceChunk(&harness.transcript, responseData) + roleAccepted := response.AssignedRole == role || + (role == roleController && response.AssignedRole == roleStandby) + if !response.OK || response.Version != protocolV2 || !roleAccepted { + t.Fatalf("attach response = %+v, want v2 %s", response, role) + } + attached := &todo11ProtocolAttach{ + conn: conn, reader: reader, clientID: response.ClientID, + generation: response.Generation, role: response.AssignedRole, + } + if err := conn.SetReadDeadline(time.Now().Add(3 * time.Second)); err != nil { + t.Fatal(err) + } + roleFrameObserved := false + for attempts := 0; attempts < 8 && (!roleFrameObserved || len(attached.replay) == 0); attempts++ { + kind, payload, frameErr := readFrame(reader) + if frameErr != nil { + t.Fatal(frameErr) + } + harness.transcript.WriteByte(kind) + writeEvidenceChunk(&harness.transcript, payload) + switch kind { + case serverFramePTY: + attached.replay = append(attached.replay, payload...) + case serverFrameControl: + roleFrameObserved = attached.observeRole(t, payload) || roleFrameObserved + } + } + if err := conn.SetReadDeadline(time.Time{}); err != nil { + t.Fatal(err) + } + if len(attached.replay) == 0 { + t.Fatal("attach replay was empty") + } + if !roleFrameObserved { + t.Fatal("attach role frame was not observed") + } + return attached +} + +func (attached *todo11ProtocolAttach) observeRole(t *testing.T, payload []byte) bool { + t.Helper() + var event roleEvent + if err := json.Unmarshal(payload, &event); err != nil || event.Type != "role" { + return false + } + attached.role = event.Role + attached.generation = event.Generation + return true +} + +func (attached *todo11ProtocolAttach) awaitController(t *testing.T, transcript *bytes.Buffer) { + t.Helper() + if attached.role == roleController { + return + } + if err := attached.conn.SetReadDeadline(time.Now().Add(3 * time.Second)); err != nil { + t.Fatal(err) + } + defer func() { + if err := attached.conn.SetReadDeadline(time.Time{}); err != nil { + t.Fatal(err) + } + }() + for attached.role != roleController { + kind, payload, err := readFrame(attached.reader) + if err != nil { + t.Fatalf("wait for controller promotion: %v", err) + } + transcript.WriteByte(kind) + writeEvidenceChunk(transcript, payload) + switch kind { + case serverFramePTY: + attached.replay = append(attached.replay, payload...) + case serverFrameControl: + attached.observeRole(t, payload) + } + } +} + +func (attached *todo11ProtocolAttach) write(kind byte, payload []byte) error { + return writeFrame(attached.conn, kind, ownedFramePayload(attached.generation, payload)) +} + +func (attached *todo11ProtocolAttach) close() { + _ = attached.conn.Close() +} + +func todo11WriteInput(t *testing.T, attached *todo11ProtocolAttach, data []byte) { + t.Helper() + framed := append(append([]byte{}, data...), todo11InputDelimiter) + if err := attached.write(frameStdin, framed); err != nil { + t.Fatal(err) + } +} + +func todo11DropMalformed(t *testing.T, attached *todo11ProtocolAttach, truncated bool) { + t.Helper() + if truncated { + header := [5]byte{frameStdin} + binary.BigEndian.PutUint32(header[1:], 4) + _, _ = attached.conn.Write(append(header[:], 'x')) + attached.close() + return + } + _, err := attached.conn.Write([]byte{frameStdin, 0xff, 0xff, 0xff, 0xff}) + if err != nil { + t.Fatal(err) + } + attached.close() +} diff --git a/internal/session/todo11_real_host_test.go b/internal/session/todo11_real_host_test.go new file mode 100644 index 0000000..3e79d48 --- /dev/null +++ b/internal/session/todo11_real_host_test.go @@ -0,0 +1,208 @@ +package session + +import ( + "bufio" + "bytes" + "encoding/base64" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "os" + "os/exec" + "path/filepath" + "testing" + "time" +) + +type todo11HostHarness struct { + client *Client + name string + reportPath string + diagnosticsPath string + runtimeDir string + done <-chan error + hostStderr *bytes.Buffer + transcript bytes.Buffer +} + +type todo11HostDiagnostic struct { + Event string `json:"event"` + ClientID string `json:"client_id"` + TermHint string `json:"term_hint"` +} + +func todo11StartHost(t *testing.T, fixture todo11Fixture, index int) *todo11HostHarness { + t.Helper() + executable, err := os.Executable() + if err != nil { + t.Fatal(err) + } + runtimeDir := t.TempDir() + reportPath := filepath.Join(t.TempDir(), "provider.jsonl") + diagnosticsPath := filepath.Join(t.TempDir(), "host-diagnostics.jsonl") + cwd := t.TempDir() + name := fmt.Sprintf("uam-fake-%08d", index) + hostCommand := exec.Command(executable, "-test.run=^TestTodo11HostProcess$") // #nosec G204 -- current test binary. + hostCommand.Env = append(os.Environ(), + "UAM_TODO11_HOST_HELPER=1", + "UAM_HOST_READY_FD=3", + "UAM_TODO11_HOST_NAME="+name, + "UAM_TODO11_HOST_CWD="+cwd, + "UAM_TODO11_RUNTIME_DIR="+runtimeDir, + "UAM_TODO11_TEST_BINARY="+executable, + "UAM_TODO11_PROVIDER_REPORT="+reportPath, + "UAM_TODO11_HOST_DIAGNOSTICS="+diagnosticsPath, + "UAM_TODO11_PROVIDER_OUTPUT_HEX="+fixture.ProviderOutputHex, + ) + readyReader, readyWriter, err := os.Pipe() + if err != nil { + t.Fatal(err) + } + hostCommand.ExtraFiles = []*os.File{readyWriter} + hostStderr := &bytes.Buffer{} + hostCommand.Stderr = hostStderr + if err := hostCommand.Start(); err != nil { + _ = readyReader.Close() + _ = readyWriter.Close() + t.Fatal(err) + } + _ = readyWriter.Close() + done := make(chan error, 1) + go func() { + done <- hostCommand.Wait() + }() + line, err := bufio.NewReader(readyReader).ReadString('\n') + _ = readyReader.Close() + if err != nil || line != "ok\n" { + t.Fatalf("host readiness = %q, %v; stderr=%s", line, err, hostStderr.String()) + } + harness := &todo11HostHarness{ + client: &Client{Dir: runtimeDir, Exe: executable}, name: name, + reportPath: reportPath, diagnosticsPath: diagnosticsPath, + runtimeDir: runtimeDir, done: done, hostStderr: hostStderr, + } + waitFor(t, "fake provider startup", func() bool { + events := harness.events(t) + return len(events) > 0 && events[0].Type == "startup" + }) + waitFor(t, "fake provider replay readiness", func() bool { + capture, captureErr := harness.client.Capture(t.Context(), name, 50) + return captureErr == nil && bytes.Contains([]byte(capture), []byte("TODO11-READY")) + }) + return harness +} + +func (harness *todo11HostHarness) negotiatedTermHint(t *testing.T, clientID string) string { + t.Helper() + var observed string + waitFor(t, "host TERM diagnostic", func() bool { + data, err := os.ReadFile(harness.diagnosticsPath) + if err != nil { + return false + } + for _, line := range bytes.Split(data, []byte{'\n'}) { + if len(line) == 0 { + continue + } + var event todo11HostDiagnostic + if json.Unmarshal(line, &event) != nil { + continue + } + if event.Event == "attach.negotiation" && event.ClientID == clientID { + observed = event.TermHint + return true + } + } + return false + }) + return observed +} + +func (harness *todo11HostHarness) events(t *testing.T) []todo11ProviderEvent { + t.Helper() + data, err := os.ReadFile(harness.reportPath) + if err != nil { + if errors.Is(err, os.ErrNotExist) { + return nil + } + t.Fatal(err) + } + var events []todo11ProviderEvent + scanner := bufio.NewScanner(bytes.NewReader(data)) + scanner.Buffer(make([]byte, 64<<10), 1<<20) + for scanner.Scan() { + var event todo11ProviderEvent + if err := json.Unmarshal(scanner.Bytes(), &event); err != nil { + t.Fatal(err) + } + events = append(events, event) + } + if err := scanner.Err(); err != nil { + t.Fatal(err) + } + return events +} + +func (harness *todo11HostHarness) waitInputCount(t *testing.T, count int) []todo11ProviderEvent { + t.Helper() + var inputs []todo11ProviderEvent + waitFor(t, fmt.Sprintf("provider input count %d", count), func() bool { + inputs = inputs[:0] + for _, event := range harness.events(t) { + if event.Type == "input" { + inputs = append(inputs, event) + } + } + return len(inputs) >= count + }) + return inputs +} + +func todo11EventInput(t *testing.T, event todo11ProviderEvent) []byte { + t.Helper() + data, err := base64.StdEncoding.DecodeString(event.Data) + if err != nil { + t.Fatal(err) + } + return data +} + +func (harness *todo11HostHarness) cleanup(t *testing.T) (bool, int) { + t.Helper() + select { + case err := <-harness.done: + if err != nil { + t.Fatalf("host process: %v; stderr=%s", err, harness.hostStderr.String()) + } + case <-time.After(10 * time.Second): + t.Fatal("host did not exit after provider shutdown") + } + _, socketErr := os.Stat(SocketPath(harness.runtimeDir, harness.name)) + entries, err := os.ReadDir(harness.runtimeDir) + if err != nil { + t.Fatal(err) + } + return errors.Is(socketErr, os.ErrNotExist), len(entries) +} + +func todo11ObservedInputs(t *testing.T, events []todo11ProviderEvent, names []string) map[string]string { + t.Helper() + if len(events) < len(names) { + t.Fatalf("provider inputs = %d, want at least %d", len(events), len(names)) + } + result := make(map[string]string, len(names)) + for index, name := range names { + result[name] = hex.EncodeToString(todo11EventInput(t, events[index])) + } + return result +} + +func todo11ReadAllReport(t *testing.T, harness *todo11HostHarness) []byte { + t.Helper() + data, err := os.ReadFile(harness.reportPath) + if err != nil { + t.Fatal(err) + } + return data +} diff --git a/internal/session/todo11_term_negotiation_test.go b/internal/session/todo11_term_negotiation_test.go new file mode 100644 index 0000000..cdb3d9a --- /dev/null +++ b/internal/session/todo11_term_negotiation_test.go @@ -0,0 +1,81 @@ +package session + +import ( + "bytes" + "fmt" + "os" + "testing" +) + +func todo11ExpectedDiagnosticTermHint(wireTermHint string) string { + switch wireTermHint { + case "alacritty", "ghostty", "screen-256color", "tmux-256color", + "wezterm", "xterm-256color", "xterm-kitty": + return wireTermHint + default: + return "redacted" + } +} + +func todo11ValidateNegotiatedTermHint(expected, observed string) error { + if observed != expected { + return fmt.Errorf("host negotiated TERM = %q, want %q", observed, expected) + } + return nil +} + +func TestTodo11TermHintMutationIsDetected(t *testing.T) { + // Given: an expected TERM distinct from the value placed on the real v2 wire. + const expected = "xterm-256color" + const mutated = "screen-256color" + fixture := todo11LoadFixture(t) + harness := todo11StartHost(t, fixture, 9001) + + // When: the production runHost attach path consumes and diagnoses the mutation. + attached := harness.attach(t, mutated, roleController) + observed := harness.negotiatedTermHint(t, attached.clientID) + err := todo11ValidateNegotiatedTermHint(expected, observed) + todo11WriteInput(t, attached, todo11ProviderExit) + harness.waitInputCount(t, 1) + socketRemoved, runtimeEntries := harness.cleanup(t) + + // Then: the diagnostic carries the mutated wire value and validation rejects it. + if observed != mutated { + t.Fatalf("host negotiated TERM = %q, want mutated wire value %q", observed, mutated) + } + if err == nil { + t.Fatal("TERM mutation was not detected") + } + if !socketRemoved || runtimeEntries != 0 { + t.Fatalf("mutation host cleanup = socket removed %t, runtime entries %d", socketRemoved, runtimeEntries) + } +} + +func TestTodo11UnsupportedTermHintIsRedacted(t *testing.T) { + // Given: an unsupported TERM containing diagnostic-sensitive text. + const unsupported = "secret-token-terminal" + fixture := todo11LoadFixture(t) + harness := todo11StartHost(t, fixture, 9002) + + // When: the production runHost attach path diagnoses the unsupported hint. + attached := harness.attach(t, unsupported, roleController) + observed := harness.negotiatedTermHint(t, attached.clientID) + + // Then: only the bounded redaction classification is emitted. + if observed != "redacted" { + t.Fatalf("unsupported host TERM diagnostic = %q, want redacted", observed) + } + diagnostics, err := os.ReadFile(harness.diagnosticsPath) + if err != nil { + t.Fatal(err) + } + todo11WriteInput(t, attached, todo11ProviderExit) + harness.waitInputCount(t, 1) + socketRemoved, runtimeEntries := harness.cleanup(t) + if bytes.Contains(diagnostics, []byte(unsupported)) { + t.Fatal("unsupported raw TERM leaked into diagnostics") + } + if !socketRemoved || runtimeEntries != 0 { + t.Fatalf("redaction host cleanup = socket removed %t, runtime entries %d", socketRemoved, runtimeEntries) + } +} diff --git a/internal/session/todo8_terminal_policy_manual_test.go b/internal/session/todo8_terminal_policy_manual_test.go new file mode 100644 index 0000000..bf8eaad --- /dev/null +++ b/internal/session/todo8_terminal_policy_manual_test.go @@ -0,0 +1,199 @@ +package session + +import ( + "bytes" + "context" + "encoding/binary" + "encoding/json" + "fmt" + "os" + "path/filepath" + "testing" + "time" + + "github.com/creack/pty" +) + +type todo8PTYAssertion struct { + SharedProviderMarker bool `json:"shared_provider_marker"` + FirstPreservedMouse bool `json:"first_preserved_mouse"` + SecondFilteredMouse bool `json:"second_filtered_mouse"` + ProviderInputExact bool `json:"provider_input_exact"` + RuntimeClean bool `json:"runtime_clean"` +} + +func TestTodo8TerminalPolicyRealPTYFixture(t *testing.T) { + client := newTestClient(t) + providerInputPath := filepath.Join(t.TempDir(), "provider-input.bin") + command := "stty raw -echo; printf '\033[?1;1000;1004;1006;2004hTASK8-READY'; cat >\"$1\"" + name := "uam-fake-80808080" + if err := client.CreateProviderSession(t.Context(), CreateSpec{ + Name: name, Cwd: t.TempDir(), ProviderIdentity: "claude", ScrollbackLines: 123, + Command: []string{"/bin/sh", "-c", command, "todo8-provider", providerInputPath}, + }); err != nil { + t.Fatal(err) + } + t.Cleanup(func() { + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + _ = client.KillAll(ctx) + }) + + first := startTodo8Attach(t, client.Dir, name, attachPolicySnapshot{ + mouse: "on", controlPrefix: "C-a", backDetach: false, backDetachSet: true, + }) + waitFor(t, "first Todo 8 PTY replay", func() bool { return bytes.Contains(first.bytes(), []byte("TASK8-READY")) }) + second := startTodo8Attach(t, client.Dir, name, attachPolicySnapshot{ + mouse: "off", controlPrefix: "C-z", backDetach: true, backDetachSet: true, + }) + waitFor(t, "second Todo 8 PTY replay", func() bool { return bytes.Contains(second.bytes(), []byte("TASK8-READY")) }) + + payload := []byte("\x1b[200~uam-like:\x01d:\x02d\x1b[201~\x1b[I\x1b[O\x1b[1;5A\x1b[>1u") + if _, err := first.ptmx.Write(payload); err != nil { + t.Fatal(err) + } + waitFor(t, "byte-exact provider input", func() bool { + got, err := os.ReadFile(providerInputPath) + return err == nil && bytes.Equal(got, payload) + }) + providerInput, err := os.ReadFile(providerInputPath) + if err != nil { + t.Fatal(err) + } + firstLiveOutput, secondLiveOutput := first.bytes(), second.bytes() + + first.detach(t, []byte{0x01, 'd'}) + waitFor(t, "second Todo 8 client promotion", func() bool { + return bytes.Contains(second.bytes(), []byte("role controller")) + }) + second.detach(t, []byte{0x1a, 'd'}) + ctx, cancel := context.WithTimeout(t.Context(), 10*time.Second) + defer cancel() + if err := client.KillAll(ctx); err != nil { + t.Fatal(err) + } + entries, err := os.ReadDir(client.Dir) + if err != nil { + t.Fatal(err) + } + if len(entries) != 0 { + t.Fatalf("runtime residue after Todo 8 fixture: %+v", entries) + } + + firstOutput, secondOutput := first.bytes(), second.bytes() + assertion := todo8PTYAssertion{ + SharedProviderMarker: bytes.Contains(firstLiveOutput, []byte("TASK8-READY")) && bytes.Contains(secondLiveOutput, []byte("TASK8-READY")), + FirstPreservedMouse: containsEnabledDECMode(firstLiveOutput, "1000") && containsEnabledDECMode(firstLiveOutput, "1006"), + SecondFilteredMouse: !containsEnabledDECMode(secondLiveOutput, "1000") && !containsEnabledDECMode(secondLiveOutput, "1006"), + ProviderInputExact: bytes.Equal(providerInput, payload), + RuntimeClean: true, + } + if !assertion.SharedProviderMarker || !assertion.FirstPreservedMouse || !assertion.SecondFilteredMouse || !assertion.ProviderInputExact { + t.Fatalf("Todo 8 PTY assertion failed: %+v", assertion) + } + writeTodo8Evidence(t, firstOutput, secondOutput, providerInput, assertion) +} + +func containsEnabledDECMode(data []byte, mode string) bool { + for len(data) > 0 { + start := bytes.Index(data, []byte("\x1b[?")) + if start < 0 { + return false + } + data = data[start+3:] + final := bytes.IndexByte(data, 'h') + if final < 0 { + continue + } + params := data[:final] + valid := len(params) > 0 + for _, value := range params { + if value != ';' && (value < '0' || value > '9') { + valid = false + break + } + } + if !valid { + continue + } + for _, param := range bytes.Split(params, []byte{';'}) { + if string(param) == mode { + return true + } + } + data = data[final+1:] + } + return false +} + +type todo8Attach struct { + ptmx *os.File + done <-chan error + snapshot func() string +} + +func startTodo8Attach(t *testing.T, dir, name string, policy attachPolicySnapshot) todo8Attach { + t.Helper() + ptmx, tty, err := pty.Open() + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = ptmx.Close(); _ = tty.Close() }) + if err := pty.Setsize(ptmx, &pty.Winsize{Cols: 80, Rows: 24}); err != nil { + t.Fatal(err) + } + done := make(chan error, 1) + go func() { + done <- runAttachWithOptions(dir, name, tty, tty, attachOptions{quiet: true, policy: policy}) + }() + return todo8Attach{ptmx: ptmx, done: done, snapshot: capturePTYOutput(ptmx)} +} + +func (attach todo8Attach) bytes() []byte { return []byte(attach.snapshot()) } + +func (attach todo8Attach) detach(t *testing.T, chord []byte) { + t.Helper() + if _, err := attach.ptmx.Write(chord); err != nil { + t.Fatal(err) + } + select { + case err := <-attach.done: + if err != nil { + t.Fatal(err) + } + case <-time.After(10 * time.Second): + t.Fatalf("attach did not detach with chord %x", chord) + } +} + +func writeTodo8Evidence(t *testing.T, first, second, providerInput []byte, assertion todo8PTYAssertion) { + t.Helper() + dir := os.Getenv("UAM_TASK8_EVIDENCE_DIR") + if dir == "" { + return + } + if !filepath.IsAbs(dir) { + t.Fatalf("UAM_TASK8_EVIDENCE_DIR must be absolute: %q", dir) + } + var transcript bytes.Buffer + for _, chunk := range [][]byte{first, second} { + if err := binary.Write(&transcript, binary.BigEndian, uint32(len(chunk))); err != nil { + t.Fatal(err) + } + transcript.Write(chunk) + } + artifacts := map[string][]byte{ + "dual-client-transcript.bin": transcript.Bytes(), + "provider-input.bin": providerInput, + } + data, err := json.MarshalIndent(assertion, "", " ") + if err != nil { + t.Fatal(err) + } + artifacts["assertions.json"] = append(data, '\n') + for name, content := range artifacts { + if err := os.WriteFile(filepath.Join(dir, name), content, 0o600); err != nil { + t.Fatal(fmt.Errorf("write %s: %w", name, err)) + } + } +} diff --git a/internal/session/todo8_terminal_policy_test.go b/internal/session/todo8_terminal_policy_test.go new file mode 100644 index 0000000..51e5ad4 --- /dev/null +++ b/internal/session/todo8_terminal_policy_test.go @@ -0,0 +1,163 @@ +package session + +import ( + "bytes" + "testing" +) + +func TestProfileMousePolicyMatrix(t *testing.T) { + tests := []struct { + name string + profile string + want bool + wantErr bool + }{ + {name: "auto preserves provider modes", profile: "auto", want: true}, + {name: "on preserves provider modes", profile: "on", want: true}, + {name: "off filters provider modes", profile: "off", want: false}, + {name: "invalid policy is rejected", profile: "sometimes", wantErr: true}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + policy, err := resolveAttachPolicy(attachPolicySnapshot{mouse: test.profile, controlPrefix: "C-b", backDetach: true}, func(string) string { return "" }) + if test.wantErr { + if err == nil { + t.Fatal("resolveAttachPolicy accepted an invalid mouse policy") + } + return + } + if err != nil { + t.Fatalf("resolveAttachPolicy: %v", err) + } + if policy.mouseEnabled != test.want { + t.Fatalf("mouse enabled = %v, want %v", policy.mouseEnabled, test.want) + } + }) + } +} + +func TestTemporaryMouseBypassIsClientLocal(t *testing.T) { + var first, second bytes.Buffer + firstRuntime := newAttachRuntime(attachRuntimeConfig{output: &first, mouseEnabled: true}) + secondRuntime := newAttachRuntime(attachRuntimeConfig{output: &second, mouseEnabled: true}) + firstRuntime.toggleMouse() + + providerReplay := []byte("shared\x1b[?1000;1006hstate") + firstFilter := newAttachOutputFilterWithMouse(&first, firstRuntime.mouseEnabled) + secondFilter := newAttachOutputFilterWithMouse(&second, secondRuntime.mouseEnabled) + if _, err := firstFilter.Write(providerReplay); err != nil { + t.Fatal(err) + } + if _, err := secondFilter.Write(providerReplay); err != nil { + t.Fatal(err) + } + + if bytes.Contains(first.Bytes(), []byte("\x1b[?1000;1006h")) { + t.Fatalf("temporarily bypassed client retained provider mouse modes: %q", first.Bytes()) + } + if !bytes.Contains(second.Bytes(), []byte("\x1b[?1000;1006h")) { + t.Fatalf("second client lost shared provider modes: %q", second.Bytes()) + } + if !secondRuntime.mouseEnabled() { + t.Fatal("first client's temporary toggle changed second client") + } +} + +func TestProfileControlPrefix(t *testing.T) { + policy, err := resolveAttachPolicy(attachPolicySnapshot{mouse: "auto", controlPrefix: "C-a", backDetach: true}, func(string) string { return "" }) + if err != nil { + t.Fatal(err) + } + filter := stdinFilter{prefix: policy.controlPrefix, backDetach: policy.backDetach, role: roleController} + got, detached := filter.filter([]byte{0x01, 0x01, 0x02, 'd'}) + if detached { + t.Fatal("legacy Ctrl+B detached a C-a policy attachment") + } + want := []byte{0x01, 0x02, 'd'} + if !bytes.Equal(got, want) { + t.Fatalf("provider bytes = %q, want %q", got, want) + } + if _, detached := filter.filter([]byte{0x01, 'd'}); !detached { + t.Fatal("configured C-a prefix did not detach") + } + for _, invalid := range []string{"C-A", "C-aa", "M-a", "C-{"} { + if _, err := resolveAttachPolicy(attachPolicySnapshot{mouse: "auto", controlPrefix: invalid}, func(string) string { return "" }); err == nil { + t.Fatalf("resolveAttachPolicy accepted invalid prefix %q", invalid) + } + } +} + +func TestProfileBackDetach(t *testing.T) { + left := []byte("\x1b[D") + for _, test := range []struct { + name string + backDetach bool + wantDetach bool + }{ + {name: "enabled", backDetach: true, wantDetach: true}, + {name: "disabled", backDetach: false, wantDetach: false}, + } { + t.Run(test.name, func(t *testing.T) { + filter := stdinFilter{prefix: detachPrefix, backDetach: test.backDetach, role: roleController} + got, detached := filter.filter(left) + if detached != test.wantDetach { + t.Fatalf("detached = %v, want %v", detached, test.wantDetach) + } + if !test.wantDetach && !bytes.Equal(got, left) { + t.Fatalf("left arrow = %q, want byte-exact %q", got, left) + } + }) + } +} + +func TestProfileScrollbackBound(t *testing.T) { + for _, test := range []struct { + lines int + want int + wantErr bool + }{ + {lines: 0, want: historyLines}, + {lines: 100, want: 100}, + {lines: 100000, want: 100000}, + {lines: 99, wantErr: true}, + {lines: 100001, wantErr: true}, + } { + got, err := validatedScrollbackLines(test.lines) + if test.wantErr { + if err == nil { + t.Fatalf("validatedScrollbackLines(%d) accepted invalid input", test.lines) + } + continue + } + if err != nil || got != test.want { + t.Fatalf("validatedScrollbackLines(%d) = %d, %v; want %d", test.lines, got, err, test.want) + } + } +} + +func TestEnvironmentOverridePrecedence(t *testing.T) { + env := map[string]string{ + AttachMouseEnv: "off", + AttachPrefixEnv: "C-z", + AttachBackDetachEnv: "0", + } + policy, err := resolveAttachPolicy(attachPolicySnapshot{mouse: "on", controlPrefix: "C-a", backDetach: true}, func(key string) string { return env[key] }) + if err != nil { + t.Fatal(err) + } + if policy.mouseEnabled || policy.controlPrefix != 0x1a || policy.backDetach { + t.Fatalf("environment did not win: %+v", policy) + } +} + +func TestPasteFocusAndKeyboardRemainByteExact(t *testing.T) { + filter := stdinFilter{prefix: 0x01, backDetach: true, role: roleController} + want := []byte("\x1b[200~pasted\x01d\x02d\x1b[201~\x1b[I\x1b[O\x1b[1;5A\x1b[>1u") + got, detached := filter.filter(want) + if detached { + t.Fatal("UAM-like pasted bytes detached the client") + } + if !bytes.Equal(got, want) { + t.Fatalf("provider input changed:\n got %x\nwant %x", got, want) + } +} diff --git a/internal/session/todo9_evidence_helpers_test.go b/internal/session/todo9_evidence_helpers_test.go new file mode 100644 index 0000000..47f35c9 --- /dev/null +++ b/internal/session/todo9_evidence_helpers_test.go @@ -0,0 +1,129 @@ +package session + +import ( + "bytes" + "encoding/json" + "errors" + "net" + "os" + "path/filepath" + "testing" + "time" +) + +type todo9ObservedEvent struct { + Source string `json:"source"` + Event string `json:"event"` + ClientID string `json:"client_id,omitempty"` + Role clientRole `json:"role,omitempty"` + Generation uint64 `json:"generation,omitempty"` + Reason string `json:"reason,omitempty"` + Bytes int `json:"bytes,omitempty"` + Accepted bool `json:"accepted,omitempty"` + Busy bool `json:"busy,omitempty"` +} + +type todo9Assertions struct { + DetachedReply bool `json:"detached_reply"` + AttachedBusy bool `json:"attached_busy"` + ByteOrder bool `json:"byte_order"` + MixedWriterBytes bool `json:"mixed_writer_bytes"` + TransferRoleEvents int `json:"transfer_role_events"` + TransferRepaints int `json:"transfer_repaints"` + DropPromotionEvents int `json:"drop_promotion_events"` + DropPromotionRepaints int `json:"drop_promotion_repaints"` + PTYCols int `json:"pty_cols"` + PTYRows int `json:"pty_rows"` +} + +type todo9CleanupObservation struct { + AttachedClients int `json:"attached_clients"` + SocketRemoved bool `json:"socket_removed"` + RuntimeRemoved bool `json:"runtime_removed"` + PTYClosed bool `json:"pty_closed"` + NestedOMOFound bool `json:"nested_omo_found"` + InProcessHostOnly bool `json:"in_process_host_only"` +} + +func todo9ReadServerFrame(t *testing.T, attached *monitoredAttach) (byte, []byte) { + t.Helper() + if err := attached.conn.SetReadDeadline(time.Now().Add(2 * time.Second)); err != nil { + t.Fatal(err) + } + kind, payload, err := readFrame(attached.reader) + if err != nil { + t.Fatal(err) + } + if err := attached.conn.SetReadDeadline(time.Time{}); err != nil { + t.Fatal(err) + } + return kind, payload +} + +func todo9ReadRoleEvent(t *testing.T, attached *monitoredAttach) roleEvent { + t.Helper() + kind, payload := todo9ReadServerFrame(t, attached) + if kind != serverFrameControl { + t.Fatalf("frame kind = %d, want control", kind) + } + var event roleEvent + if err := json.Unmarshal(payload, &event); err != nil { + t.Fatal(err) + } + attached.setGeneration(event.Generation) + return event +} + +func todo9ReadRepaint(t *testing.T, attached *monitoredAttach) int { + t.Helper() + kind, payload := todo9ReadServerFrame(t, attached) + if kind != serverFramePTY || len(payload) == 0 { + t.Fatalf("repaint frame = kind %d bytes %d", kind, len(payload)) + } + return len(payload) +} + +func todo9AssertNoAdditionalFrame(t *testing.T, attached *monitoredAttach) { + t.Helper() + if err := attached.conn.SetReadDeadline(time.Now().Add(25 * time.Millisecond)); err != nil { + t.Fatal(err) + } + _, _, err := readFrame(attached.reader) + var netErr net.Error + if !errors.As(err, &netErr) || !netErr.Timeout() { + t.Fatalf("additional server frame observed: %v", err) + } + if err := attached.conn.SetReadDeadline(time.Time{}); err != nil { + t.Fatal(err) + } +} + +func todo9WaitForClientCount(t *testing.T, h *host, want int) { + t.Helper() + waitFor(t, "attached client count", func() bool { + h.mu.Lock() + defer h.mu.Unlock() + return len(h.registry.clients) == want + }) +} + +func todo9WriteObservedArtifacts(t *testing.T, evidenceDir string, events []todo9ObservedEvent, assertions todo9Assertions) { + t.Helper() + var eventData bytes.Buffer + encoder := json.NewEncoder(&eventData) + for _, event := range events { + if err := encoder.Encode(event); err != nil { + t.Fatal(err) + } + } + if err := os.WriteFile(filepath.Join(evidenceDir, "ownership-events.jsonl"), eventData.Bytes(), 0o644); err != nil { + t.Fatal(err) + } + assertionData, err := json.Marshal(assertions) + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(evidenceDir, "assertions.json"), append(assertionData, '\n'), 0o644); err != nil { + t.Fatal(err) + } +} diff --git a/internal/session/todo9_ownership_realpty_test.go b/internal/session/todo9_ownership_realpty_test.go new file mode 100644 index 0000000..f888845 --- /dev/null +++ b/internal/session/todo9_ownership_realpty_test.go @@ -0,0 +1,201 @@ +package session + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "os" + "path/filepath" + "testing" + + "github.com/RandomCodeSpace/unified-agent-manager/internal/vterm" + "github.com/charmbracelet/x/term" + "github.com/creack/pty" +) + +func TestTodo9OwnershipRealPTYFixture(t *testing.T) { + evidenceDir := os.Getenv("UAM_TASK9_EVIDENCE_DIR") + if evidenceDir == "" { + t.Skip("UAM_TASK9_EVIDENCE_DIR is required for artifact collection") + } + if !filepath.IsAbs(evidenceDir) { + t.Fatalf("UAM_TASK9_EVIDENCE_DIR must be absolute: %q", evidenceDir) + } + if filepath.Base(evidenceDir) != "task-9-ownership" { + t.Fatalf("refusing unexpected evidence directory: %q", evidenceDir) + } + if err := os.MkdirAll(evidenceDir, 0o755); err != nil { + t.Fatal(err) + } + + ptmx, tty, err := pty.Open() + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { + _ = ptmx.Close() + _ = tty.Close() + }) + oldState, err := term.MakeRaw(tty.Fd()) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = term.Restore(tty.Fd(), oldState) }) + h := &host{registry: newClientRegistry(), term: vterm.New(80, 24, historyLines), ptmx: ptmx} + server := todo9StartServer(t, h) + + detachedErr := server.client.SendLine(context.Background(), todo9SessionName, "detached") + if detachedErr != nil { + t.Fatal(detachedErr) + } + controller, controllerResponse := openMonitoredAttach(t, server.client, todo9SessionName, roleController, false) + standby, standbyResponse := openMonitoredAttach(t, server.client, todo9SessionName, roleController, false) + if controllerResponse.AssignedRole != roleController || standbyResponse.AssignedRole != roleStandby { + t.Fatalf("initial roles = %q/%q", controllerResponse.AssignedRole, standbyResponse.AssignedRole) + } + attachedErr := server.client.SendLine(context.Background(), todo9SessionName, "busy") + attachedBusy := errors.Is(attachedErr, ErrSessionBusy) + if !attachedBusy { + t.Fatalf("attached reply = %v", attachedErr) + } + if err := controller.writeControlFrame(frameStdin, []byte("controller")); err != nil { + t.Fatal(err) + } + if err := standby.writeControlFrame(frameResize, resizePayload(100, 35)); err != nil { + t.Fatal(err) + } + waitFor(t, "standby resize observation", func() bool { + h.mu.Lock() + defer h.mu.Unlock() + for client := range h.registry.clients { + if client.id == standbyResponse.ClientID { + return client.latestSize == (terminalSize{cols: 100, rows: 35}) + } + } + return false + }) + command, err := json.Marshal(roleCommand{Action: actionTransferControl}) + if err != nil { + t.Fatal(err) + } + if err := writeFrame(controller.conn, frameRole, command); err != nil { + t.Fatal(err) + } + controllerTransfer := todo9ReadRoleEvent(t, controller) + standbyTransfer := todo9ReadRoleEvent(t, standby) + transferRepaintBytes := todo9ReadRepaint(t, standby) + todo9AssertNoAdditionalFrame(t, standby) + if controllerTransfer.Role != roleStandby || standbyTransfer.Role != roleController || standbyTransfer.Reason != "transferred" { + t.Fatalf("transfer events = %+v / %+v", controllerTransfer, standbyTransfer) + } + if err := standby.writeControlFrame(frameStdin, []byte("promoted")); err != nil { + t.Fatal(err) + } + want := []byte("detached\rcontrollerpromoted") + got := todo9ReadExact(t, tty, len(want)) + byteOrder := bytes.Equal(got, want) + if !byteOrder { + t.Fatalf("PTY byte order = %q, want %q", got, want) + } + size, err := pty.GetsizeFull(ptmx) + if err != nil { + t.Fatal(err) + } + if int(size.Cols) != 100 || int(size.Rows) != 35 { + t.Fatalf("PTY size = %dx%d", size.Cols, size.Rows) + } + + if err := writeFrame(controller.conn, frameDetach, nil); err != nil { + t.Fatal(err) + } + todo9WaitForClientCount(t, h, 1) + failover, failoverResponse := openMonitoredAttach(t, server.client, todo9SessionName, roleController, false) + if failoverResponse.AssignedRole != roleStandby { + t.Fatalf("failover role = %q", failoverResponse.AssignedRole) + } + if err := writeFrame(standby.conn, frameDetach, nil); err != nil { + t.Fatal(err) + } + failoverPromotion := todo9ReadRoleEvent(t, failover) + promotionRepaintBytes := todo9ReadRepaint(t, failover) + todo9AssertNoAdditionalFrame(t, failover) + if failoverPromotion.Role != roleController || failoverPromotion.Reason != "promoted" { + t.Fatalf("failover event = %+v", failoverPromotion) + } + + events := []todo9ObservedEvent{ + {Source: "round_trip", Event: "reply_detached", Accepted: detachedErr == nil}, + {Source: "round_trip", Event: "reply_attached", Busy: attachedBusy}, + {Source: "server_control_frame", Event: "role", ClientID: controllerTransfer.ClientID, Role: controllerTransfer.Role, Generation: controllerTransfer.Generation, Reason: controllerTransfer.Reason}, + {Source: "server_control_frame", Event: "role", ClientID: standbyTransfer.ClientID, Role: standbyTransfer.Role, Generation: standbyTransfer.Generation, Reason: standbyTransfer.Reason}, + {Source: "server_pty_frame", Event: "transfer_repaint", Bytes: transferRepaintBytes}, + {Source: "server_control_frame", Event: "role", ClientID: failoverPromotion.ClientID, Role: failoverPromotion.Role, Generation: failoverPromotion.Generation, Reason: failoverPromotion.Reason}, + {Source: "server_pty_frame", Event: "promotion_repaint", Bytes: promotionRepaintBytes}, + } + assertions := todo9Assertions{ + DetachedReply: detachedErr == nil, + AttachedBusy: attachedBusy, + ByteOrder: byteOrder, + MixedWriterBytes: !byteOrder, + TransferRoleEvents: 2, + TransferRepaints: 1, + DropPromotionEvents: 1, + DropPromotionRepaints: 1, + PTYCols: int(size.Cols), + PTYRows: int(size.Rows), + } + if err := os.WriteFile(filepath.Join(evidenceDir, "pty-byte-order.bin"), got, 0o644); err != nil { + t.Fatal(err) + } + todo9WriteObservedArtifacts(t, evidenceDir, events, assertions) + + if err := writeFrame(failover.conn, frameDetach, nil); err != nil { + t.Fatal(err) + } + todo9WaitForClientCount(t, h, 0) + _ = controller.conn.Close() + _ = standby.conn.Close() + _ = failover.conn.Close() + if err := server.listener.Close(); err != nil { + t.Fatal(err) + } + _, socketErr := os.Stat(SocketPath(server.client.Dir, todo9SessionName)) + socketRemoved := errors.Is(socketErr, os.ErrNotExist) + ptyClosed := ptmx.Close() == nil && tty.Close() == nil + if err := os.RemoveAll(server.client.Dir); err != nil { + t.Fatal(err) + } + _, runtimeErr := os.Stat(server.client.Dir) + runtimeRemoved := errors.Is(runtimeErr, os.ErrNotExist) + nestedOMOFound := false + if err := filepath.WalkDir(evidenceDir, func(path string, entry os.DirEntry, walkErr error) error { + if walkErr != nil { + return walkErr + } + if entry.IsDir() && entry.Name() == ".omo" { + nestedOMOFound = true + } + return nil + }); err != nil { + t.Fatal(err) + } + cleanup := todo9CleanupObservation{ + AttachedClients: 0, + SocketRemoved: socketRemoved, + RuntimeRemoved: runtimeRemoved, + PTYClosed: ptyClosed, + NestedOMOFound: nestedOMOFound, + InProcessHostOnly: true, + } + if !cleanup.SocketRemoved || !cleanup.RuntimeRemoved || !cleanup.PTYClosed || cleanup.NestedOMOFound { + t.Fatalf("cleanup observation = %+v", cleanup) + } + cleanupData, err := json.Marshal(cleanup) + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(evidenceDir, "cleanup-receipt.json"), append(cleanupData, '\n'), 0o644); err != nil { + t.Fatal(err) + } +} diff --git a/internal/session/todo9_ownership_test.go b/internal/session/todo9_ownership_test.go new file mode 100644 index 0000000..d4b252f --- /dev/null +++ b/internal/session/todo9_ownership_test.go @@ -0,0 +1,280 @@ +package session + +import ( + "bufio" + "bytes" + "encoding/json" + "errors" + "io" + "net" + "os" + "sync" + "testing" + "time" + + "github.com/RandomCodeSpace/unified-agent-manager/internal/vterm" +) + +const todo9SessionName = "uam-fake-abcdef12" + +type todo9Server struct { + client *Client + listener net.Listener +} + +func todo9StartServer(t *testing.T, h *host) *todo9Server { + t.Helper() + dir := t.TempDir() + if err := EnsureDir(dir); err != nil { + t.Fatal(err) + } + listener, err := net.Listen("unix", SocketPath(dir, todo9SessionName)) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = listener.Close() }) + go func() { + for { + conn, err := listener.Accept() + if err != nil { + return + } + go h.handleConn(conn) + } + }() + return &todo9Server{client: &Client{Dir: dir}, listener: listener} +} + +func todo9ControlClient(t *testing.T, h *host) *Client { + t.Helper() + return todo9StartServer(t, h).client +} + +func todo9PipeHost(t *testing.T) (*host, *os.File) { + t.Helper() + readEnd, writeEnd, err := os.Pipe() + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { + _ = readEnd.Close() + _ = writeEnd.Close() + }) + return &host{registry: newClientRegistry(), term: vterm.New(80, 24, historyLines), ptmx: writeEnd}, readEnd +} + +func todo9ReadExact(t *testing.T, reader *os.File, size int) []byte { + t.Helper() + if err := reader.SetReadDeadline(time.Now().Add(time.Second)); err != nil { + t.Fatal(err) + } + got := make([]byte, size) + if _, err := io.ReadFull(reader, got); err != nil { + t.Fatal(err) + } + return got +} + +func todo9AssertNoPTYBytes(t *testing.T, reader *os.File) { + t.Helper() + if err := reader.SetReadDeadline(time.Now().Add(25 * time.Millisecond)); err != nil { + t.Fatal(err) + } + var one [1]byte + if n, err := reader.Read(one[:]); n != 0 || err == nil { + t.Fatalf("unexpected PTY bytes: %q, err=%v", one[:n], err) + } +} + +func TestReplyWithoutControllerSucceeds(t *testing.T) { + h, provider := todo9PipeHost(t) + client := todo9ControlClient(t, h) + + if err := client.SendLine(t.Context(), todo9SessionName, "detached"); err != nil { + t.Fatalf("detached reply: %v", err) + } + + if got := todo9ReadExact(t, provider, len("detached\r")); !bytes.Equal(got, []byte("detached\r")) { + t.Fatalf("provider bytes = %q", got) + } +} + +func TestReplyWithControllerReturnsBusy(t *testing.T) { + h, provider := todo9PipeHost(t) + registerTestClient(t, h.registry, roleController, terminalSize{cols: 80, rows: 24}) + client := todo9ControlClient(t, h) + + err := client.SendLine(t.Context(), todo9SessionName, "must-not-mix") + + if !errors.Is(err, ErrSessionBusy) { + t.Fatalf("reply error = %v, want errors.Is ErrSessionBusy", err) + } + var busy *SessionBusyError + if !errors.As(err, &busy) || busy.Operation != opSend { + t.Fatalf("reply error = %#v, want typed send busy error", err) + } + todo9AssertNoPTYBytes(t, provider) +} + +func TestOutOfBandResizeRejected(t *testing.T) { + h, _ := todo9PipeHost(t) + registerTestClient(t, h.registry, roleController, terminalSize{cols: 80, rows: 24}) + client := todo9ControlClient(t, h) + + _, err := client.roundTrip(t.Context(), todo9SessionName, request{Op: opResize, Cols: 120, Rows: 40}) + + if !errors.Is(err, ErrSessionBusy) { + t.Fatalf("resize error = %v, want errors.Is ErrSessionBusy", err) + } + cols, rows := h.term.Size() + if cols != 80 || rows != 24 { + t.Fatalf("terminal resized out of band to %dx%d", cols, rows) + } +} + +func TestEveryDropPathPromotesOnce(t *testing.T) { + tests := []struct { + name string + drop func(*host, *attachClient) + }{ + {name: "detach", drop: func(h *host, client *attachClient) { + var frame bytes.Buffer + if err := writeFrame(&frame, frameDetach, nil); err != nil { + t.Fatal(err) + } + h.attachReader(client, bufio.NewReader(&frame)) + }}, + {name: "socket failure", drop: func(h *host, client *attachClient) { + h.attachReader(client, bufio.NewReader(bytes.NewReader(nil))) + }}, + {name: "malformed frame", drop: func(h *host, client *attachClient) { + var frame bytes.Buffer + if err := writeFrame(&frame, 0xff, nil); err != nil { + t.Fatal(err) + } + h.attachReader(client, bufio.NewReader(&frame)) + }}, + {name: "slow client eviction", drop: func(h *host, client *attachClient) { + client.out <- serverMessage{kind: serverFramePTY, payload: []byte("blocked")} + h.enqueueClient(client, serverMessage{kind: serverFramePTY, payload: []byte("overflow")}) + }}, + {name: "concurrent repeated drop", drop: func(h *host, client *attachClient) { + var drops sync.WaitGroup + for range 32 { + drops.Add(1) + go func() { + defer drops.Done() + h.dropClient(client) + }() + } + drops.Wait() + }}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + h, _ := todo9PipeHost(t) + controller := registerTestClient(t, h.registry, roleController, terminalSize{cols: 80, rows: 24}) + standby := registerTestClient(t, h.registry, roleController, terminalSize{cols: 90, rows: 30}) + + test.drop(h, controller) + + if h.registry.controller != standby { + t.Fatal("oldest standby was not promoted") + } + promotions := 0 + for len(standby.out) > 0 { + message := <-standby.out + var event roleEvent + if message.kind == serverFrameControl && json.Unmarshal(message.payload, &event) == nil && event.Reason == "promoted" { + promotions++ + } + } + if promotions != 1 { + t.Fatalf("promotion events = %d, want 1", promotions) + } + }) + } + + t.Run("terminal host shutdown does not promote", func(t *testing.T) { + registry := newClientRegistry() + controller := registerTestClient(t, registry, roleController, terminalSize{cols: 80, rows: 24}) + standby := registerTestClient(t, registry, roleController, terminalSize{cols: 90, rows: 30}) + clients := registry.drain() + if len(clients) != 2 || registry.controller != nil { + t.Fatal("shutdown did not drain registry") + } + if controller.assignedRole != roleController || standby.assignedRole != roleStandby || len(standby.out) != 0 { + t.Fatal("terminal shutdown promoted a standby") + } + }) +} + +func TestPromotionAppliesLatestValidSizeOnce(t *testing.T) { + h, _ := todo9PipeHost(t) + controller := registerTestClient(t, h.registry, roleController, terminalSize{cols: 80, rows: 24}) + standby := registerTestClient(t, h.registry, roleController, terminalSize{cols: 90, rows: 30}) + standby.ready = true + standby.out = make(chan serverMessage, 4) + if h.registry.updateSize(standby, standby.generation, terminalSize{cols: 100, rows: 35}) { + t.Fatal("standby resize reached PTY before promotion") + } + + h.dropClient(controller) + h.dropClient(controller) + + cols, rows := h.term.Size() + if cols != 100 || rows != 35 { + t.Fatalf("promoted size = %dx%d, want latest 100x35", cols, rows) + } + promotions := 0 + repaints := 0 + for len(standby.out) > 0 { + message := <-standby.out + var event roleEvent + if message.kind == serverFrameControl && json.Unmarshal(message.payload, &event) == nil && event.Reason == "promoted" { + promotions++ + } + if message.kind == serverFramePTY { + repaints++ + } + } + if promotions != 1 { + t.Fatalf("promotion applications = %d, want 1", promotions) + } + if repaints != 1 { + t.Fatalf("promotion repaints = %d, want 1", repaints) + } +} + +func TestControllerTransferRepaintsOnce(t *testing.T) { + h, _ := todo9PipeHost(t) + controller := registerTestClient(t, h.registry, roleController, terminalSize{cols: 80, rows: 24}) + standby := registerTestClient(t, h.registry, roleController, terminalSize{cols: 100, rows: 35}) + standby.ready = true + standby.out = make(chan serverMessage, 4) + command, err := json.Marshal(roleCommand{Action: actionTransferControl}) + if err != nil { + t.Fatal(err) + } + + if !h.handleRoleCommand(controller, command) { + t.Fatal("valid transfer command was rejected") + } + if !h.handleRoleCommand(controller, command) { + t.Fatal("repeated transfer command was rejected") + } + + repaints := 0 + for len(standby.out) > 0 { + if message := <-standby.out; message.kind == serverFramePTY { + repaints++ + } + } + if repaints != 1 { + t.Fatalf("transfer repaints = %d, want 1", repaints) + } + cols, rows := h.term.Size() + if cols != 100 || rows != 35 { + t.Fatalf("transferred size = %dx%d, want 100x35", cols, rows) + } +} diff --git a/internal/session/todo9_resize_generation_test.go b/internal/session/todo9_resize_generation_test.go new file mode 100644 index 0000000..1584b74 --- /dev/null +++ b/internal/session/todo9_resize_generation_test.go @@ -0,0 +1,48 @@ +package session + +import ( + "fmt" + "testing" +) + +func TestResizeGenerationRejectsStaleFrames(t *testing.T) { + h, _ := todo9PipeHost(t) + controller := registerTestClient(t, h.registry, roleController, terminalSize{cols: 80, rows: 24}) + stale := controller.generation + standby := registerTestClient(t, h.registry, roleController, terminalSize{cols: 90, rows: 30}) + h.registry.transfer(controller) + + frames := [][]byte{ + resizePayload(120, 40), + ownedFramePayload(stale, resizePayload(121, 41)), + ownedFramePayload(standby.generation+1, resizePayload(122, 42)), + ownedFramePayload(standby.generation, resizePayload(0, 0)), + ownedFramePayload(standby.generation, resizePayload(1001, 24)), + } + for _, frame := range frames { + h.handleResizeFrame(standby, frame) + } + + cols, rows := h.term.Size() + if cols != 80 || rows != 24 { + t.Fatalf("rejected resize reached terminal: %dx%d", cols, rows) + } +} + +func TestInvalidPromotedSizeRetainsPrevious(t *testing.T) { + for _, invalid := range []terminalSize{{}, {cols: 1001, rows: 24}, {cols: 80, rows: 1001}} { + t.Run(fmt.Sprintf("%dx%d", invalid.cols, invalid.rows), func(t *testing.T) { + h, _ := todo9PipeHost(t) + controller := registerTestClient(t, h.registry, roleController, terminalSize{cols: 80, rows: 24}) + standby := registerTestClient(t, h.registry, roleController, terminalSize{}) + h.registry.updateSize(standby, standby.generation, invalid) + + h.dropClient(controller) + + cols, rows := h.term.Size() + if cols != 80 || rows != 24 { + t.Fatalf("invalid promoted size changed terminal to %dx%d", cols, rows) + } + }) + } +} diff --git a/internal/session/todo9_service_integration_test.go b/internal/session/todo9_service_integration_test.go new file mode 100644 index 0000000..8c63d7e --- /dev/null +++ b/internal/session/todo9_service_integration_test.go @@ -0,0 +1,65 @@ +package session_test + +import ( + "context" + "encoding/json" + "errors" + "io" + "net" + "os" + "path/filepath" + "testing" + "time" + + "github.com/RandomCodeSpace/unified-agent-manager/internal/adapter" + "github.com/RandomCodeSpace/unified-agent-manager/internal/app" + "github.com/RandomCodeSpace/unified-agent-manager/internal/session" +) + +func TestServiceReplyRealHostOwnershipIntegration(t *testing.T) { + runtimeDir := t.TempDir() + t.Setenv("UAM_CONFIG_DIR", filepath.Join(t.TempDir(), "config")) + executable, err := os.Executable() + if err != nil { + t.Fatal(err) + } + backend := &session.Client{Dir: runtimeDir, Exe: executable} + ctx, cancel := context.WithTimeout(t.Context(), 15*time.Second) + defer cancel() + t.Cleanup(func() { _ = backend.KillAll(context.Background()) }) + const name = "uam-fake-abcdef12" + if err := backend.CreateSession(ctx, name, t.TempDir(), nil, []string{"/bin/sh", "-c", "stty raw -echo; cat"}); err != nil { + t.Fatal(err) + } + agent := adapter.NewAgent("fake", "Fake", []adapter.CommandCandidate{{Display: "sh", Args: []string{"/bin/sh"}}}, nil, backend) + service := app.NewService(nil, adapter.NewRegistryWithBackend(backend, []adapter.AgentAdapter{agent})) + + if err := service.Reply(ctx, "abcdef12", "detached"); err != nil { + t.Fatalf("detached service reply: %v", err) + } + + conn, err := net.Dial("unix", session.SocketPath(runtimeDir, name)) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = conn.Close() }) + const attachRequest = `{"op":"attach","version":2,"cols":80,"rows":24,"requested_role":"controller","hello":{"tty":true,"term_hint":"xterm-256color","color_hint":"truecolor","capabilities":["framed_output","role_events","local_mouse_filter","owned_screen"]}}` + "\n" + if _, err := io.WriteString(conn, attachRequest); err != nil { + t.Fatal(err) + } + var response struct { + OK bool `json:"ok"` + AssignedRole string `json:"assigned_role"` + } + if err := json.NewDecoder(conn).Decode(&response); err != nil { + t.Fatal(err) + } + if !response.OK || response.AssignedRole != "controller" { + t.Fatalf("attach response = %+v", response) + } + + err = service.Reply(ctx, "abcdef12", "attached") + if !errors.Is(err, session.ErrSessionBusy) { + t.Fatalf("attached service reply = %v, want ErrSessionBusy", err) + } +} diff --git a/internal/store/profile_migration_test.go b/internal/store/profile_migration_test.go new file mode 100644 index 0000000..77a0603 --- /dev/null +++ b/internal/store/profile_migration_test.go @@ -0,0 +1,340 @@ +package store + +import ( + "bytes" + "encoding/json" + "errors" + "log/slog" + "os" + "path/filepath" + "strings" + "testing" + + uamlog "github.com/RandomCodeSpace/unified-agent-manager/internal/log" +) + +func copyV3Fixture(t *testing.T) (string, []byte) { + t.Helper() + fixture, err := os.ReadFile(filepath.Join("testdata", "schema-v3-full.json")) + if err != nil { + t.Fatal(err) + } + path := filepath.Join(t.TempDir(), "sessions.json") + if err := os.WriteFile(path, fixture, 0o600); err != nil { + t.Fatal(err) + } + return path, fixture +} + +func TestMigrateV3ProfilesPreservesSessions(t *testing.T) { + // Given + path, original := copyV3Fixture(t) + store, err := Open(path) + if err != nil { + t.Fatal(err) + } + + // When + cfg, err := store.Load() + if err != nil { + t.Fatal(err) + } + + // Then + if cfg.SchemaVersion != 4 || cfg.DefaultProfile != "" || len(cfg.Profiles) != 0 { + t.Fatalf("migrated profile config = %+v", cfg) + } + record, ok := cfg.Sessions["claude:12345678"] + if !ok { + t.Fatal("schema-v3 session was dropped") + } + if record.Profile != "" || record.ProfileOverrides != nil { + t.Fatalf("v3 session selected unexpected profile: %+v", record) + } + if record.ID != "12345678-1234-4234-9234-123456789abc" || record.Agent != "claude" || record.ProviderSessionID != "provider_session_12345678" { + t.Fatalf("identity changed: %+v", record) + } + if record.CommandAlias != "claude-local" || record.Name != "preserve every field" || record.Prompt != "characterize schema v3" || record.Mode != ModeSafe || record.Workdir != "/tmp/schema-v3-worktree" || record.SessionName != "uam-claude-12345678" || record.Status != StatusActive { + t.Fatalf("session metadata changed: %+v", record) + } + if record.CreatedAt.Format("2006-01-02T15:04:05Z") != "2025-01-02T03:04:05Z" || record.LastSeenAt.Format("2006-01-02T15:04:05Z") != "2025-02-03T04:05:06Z" { + t.Fatalf("timestamps changed: %+v", record) + } + if record.LastExitCode == nil || *record.LastExitCode != 23 || !record.Pinned || record.Group != "migration" || record.SortIndex != 17 || record.PR == nil || record.PR.Number != 42 { + t.Fatalf("exit/order/UI/PR fields changed: %+v", record) + } + backups, err := filepath.Glob(path + ".bak.*") + if err != nil || len(backups) != 1 { + t.Fatalf("migration backups=%v err=%v", backups, err) + } + backup, err := os.ReadFile(backups[0]) + if err != nil { + t.Fatal(err) + } + if !bytes.Equal(backup, original) { + t.Fatalf("backup differs from v3 input: got=%s want=%s", backup, original) + } + after, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + if !hasJSONPath(t, after, "top_level_extension") || !hasJSONPath(t, after, "sessions", "claude:12345678", "session_extension") { + t.Fatalf("migration dropped unknown fields: %s", after) + } +} + +func TestMigrateV3ClearsUnversionedProfileSelection(t *testing.T) { + // Given + path := filepath.Join(t.TempDir(), "sessions.json") + doc := []byte(`{"schema_version":3,"default_profile":"preview","profiles":{"preview":{"mouse":"off"}},"sessions":{"claude:abc12345":{"id":"abc12345","agent":"claude","tmux_session":"uam-claude-abc12345","profile":"preview","profile_overrides":{"back_detach":false},"extension":"keep"}}}`) + if err := os.WriteFile(path, doc, 0o600); err != nil { + t.Fatal(err) + } + store, err := Open(path) + if err != nil { + t.Fatal(err) + } + + // When + cfg, err := store.Load() + + // Then + if err != nil { + t.Fatal(err) + } + record, ok := cfg.Sessions["claude:abc12345"] + if !ok { + t.Fatal("v3 session was dropped") + } + if cfg.DefaultProfile != "" || record.Profile != "" || record.ProfileOverrides != nil { + t.Fatalf("v3 profile state became active: default=%q record=%+v", cfg.DefaultProfile, record) + } + after, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + if !hasJSONPath(t, after, "profiles", "preview") || !hasJSONPath(t, after, "sessions", "claude:abc12345", "extension") { + t.Fatalf("migration dropped preserved data: %s", after) + } +} + +func TestMissingProfileFallsBackToLegacyDefaults(t *testing.T) { + tests := []struct { + name string + profiles string + profile string + }{ + {name: "missing profile", profiles: `{}`, profile: "deleted"}, + {name: "missing stable profile", profiles: `{}`, profile: "stable"}, + {name: "missing claude profile", profiles: `{}`, profile: "claude"}, + {name: "invalid profile", profiles: `{"broken":{"mode":"turbo"}}`, profile: "broken"}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + // Given + path := filepath.Join(t.TempDir(), "sessions.json") + doc := `{"schema_version":4,"default_profile":"` + test.profile + `","profiles":` + test.profiles + `,"sessions":{"claude:abc12345":{"id":"abc12345","agent":"claude","mode":"safe","tmux_session":"uam-claude-abc12345","profile":"` + test.profile + `"}}}` + if err := os.WriteFile(path, []byte(doc), 0o600); err != nil { + t.Fatal(err) + } + var diagnostics bytes.Buffer + previous := uamlog.SetLogger(slog.New(slog.NewTextHandler(&diagnostics, nil))) + t.Cleanup(func() { uamlog.SetLogger(previous) }) + store, err := Open(path) + if err != nil { + t.Fatal(err) + } + + // When + cfg, err := store.Load() + if err != nil { + t.Fatal(err) + } + + // Then + record, ok := cfg.Sessions["claude:abc12345"] + if !ok { + t.Fatal("session with unusable profile was dropped") + } + if cfg.DefaultProfile != "" || record.Profile != "" || record.Mode != ModeSafe { + t.Fatalf("legacy fallback changed record: %+v", record) + } + if !strings.Contains(diagnostics.String(), "profile.resolution") || + !strings.Contains(diagnostics.String(), "profile_fallback") || + !strings.Contains(diagnostics.String(), test.profile) { + t.Fatalf("missing fallback diagnostic: %s", diagnostics.String()) + } + }) + } +} + +func TestProfileFallbackDiagnosticRedactsPersistedIdentifiers(t *testing.T) { + // Given + path := filepath.Join(t.TempDir(), "sessions.json") + const ( + defaultProfile = "secret-input-7f3a" + sessionKey = "TOKEN=private-value" + sessionProfile = "provider-output-91bc" + ) + doc := `{"schema_version":4,"default_profile":"` + defaultProfile + `","profiles":{},"sessions":{"` + sessionKey + `":{"id":"abc12345","agent":"claude","mode":"safe","tmux_session":"uam-claude-abc12345","profile":"` + sessionProfile + `"}}}` + if err := os.WriteFile(path, []byte(doc), 0o600); err != nil { + t.Fatal(err) + } + var diagnostics bytes.Buffer + previous := uamlog.SetLogger(slog.New(slog.NewJSONHandler(&diagnostics, nil))) + t.Cleanup(func() { uamlog.SetLogger(previous) }) + store, err := Open(path) + if err != nil { + t.Fatal(err) + } + + // When + if _, err := store.Load(); err != nil { + t.Fatal(err) + } + + // Then + output := diagnostics.String() + for _, sentinel := range []string{defaultProfile, sessionKey, sessionProfile} { + if strings.Contains(output, sentinel) { + t.Fatalf("fallback diagnostic leaked persisted identifier %q: %s", sentinel, output) + } + } + if count := strings.Count(output, `"event":"profile.resolution"`); count != 2 { + t.Fatalf("fallback diagnostic count=%d, want 2: %s", count, output) + } + if count := strings.Count(output, `"profile":"redacted"`); count != 2 { + t.Fatalf("redacted profile count=%d, want 2: %s", count, output) + } + if !strings.Contains(output, `"policy":"default"`) || !strings.Contains(output, `"policy":"session"`) { + t.Fatalf("fallback diagnostic lost scope: %s", output) + } +} + +func TestProfileMigrationFailurePreservesOriginal(t *testing.T) { + // Given + path, original := copyV3Fixture(t) + store, err := Open(path) + if err != nil { + t.Fatal(err) + } + injected := errors.New("injected migration write failure") + store.migrationWrite = func(Config) error { + backups, globErr := filepath.Glob(path + ".bak.*") + if globErr != nil || len(backups) != 1 { + t.Fatalf("backup must exist before write: backups=%v err=%v", backups, globErr) + } + return injected + } + + // When + _, loadErr := store.Load() + + // Then + if !errors.Is(loadErr, injected) { + t.Fatalf("Load error=%v, want injected failure", loadErr) + } + after, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + if !bytes.Equal(after, original) { + t.Fatalf("failed migration changed original: got=%s want=%s", after, original) + } +} + +func TestProfileMigrationBackupFailurePreservesOriginal(t *testing.T) { + // Given + path, original := copyV3Fixture(t) + store, err := Open(path) + if err != nil { + t.Fatal(err) + } + injected := errors.New("injected migration backup failure") + store.migrationBackup = func() error { return injected } + store.migrationWrite = func(Config) error { + t.Fatal("migration write ran after backup failure") + return nil + } + + // When + _, loadErr := store.Load() + + // Then + if !errors.Is(loadErr, injected) { + t.Fatalf("Load error=%v, want injected backup failure", loadErr) + } + after, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + if !bytes.Equal(after, original) { + t.Fatalf("failed backup changed original: got=%s want=%s", after, original) + } +} + +func TestProfileMigrationRetryIgnoresInterruptedTempState(t *testing.T) { + // Given + path, original := copyV3Fixture(t) + staleTemp := path + ".tmp.interrupted" + if err := os.WriteFile(staleTemp, []byte("partial migration"), 0o600); err != nil { + t.Fatal(err) + } + first, err := Open(path) + if err != nil { + t.Fatal(err) + } + first.migrationWrite = func(Config) error { return errors.New("interrupted") } + if _, err := first.Load(); err == nil { + t.Fatal("injected interruption unexpectedly succeeded") + } + failedBytes, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + if !bytes.Equal(failedBytes, original) { + t.Fatal("interrupted attempt changed the original") + } + second, err := Open(path) + if err != nil { + t.Fatal(err) + } + + // When + cfg, err := second.Load() + + // Then + if err != nil { + t.Fatal(err) + } + if cfg.SchemaVersion != CurrentSchemaVersion || len(cfg.Sessions) != 1 { + t.Fatalf("retry did not complete migration: %+v", cfg) + } + stale, err := os.ReadFile(staleTemp) + if err != nil || string(stale) != "partial migration" { + t.Fatalf("stale temp was reused: data=%q err=%v", stale, err) + } +} + +func TestV3BinaryContractTreatsV4ReadOnly(t *testing.T) { + // Given: this local contract models only the v3 schema guard, not an old binary. + fixture := []byte(`{"schema_version":4,"profiles":{"future":{"mouse":"off"}},"sessions":{}}`) + var header struct { + SchemaVersion int `json:"schema_version"` + } + + // When + err := json.Unmarshal(fixture, &header) + readOnly := header.SchemaVersion > 3 + + // Then + if err != nil { + t.Fatal(err) + } + if !readOnly { + t.Fatalf("v3 contract accepted schema %d as writable", header.SchemaVersion) + } + if !bytes.Contains(fixture, []byte(`"profiles"`)) { + t.Fatal("v4 fixture does not exercise a field unknown to schema v3") + } +} diff --git a/internal/store/profile_roundtrip_test.go b/internal/store/profile_roundtrip_test.go new file mode 100644 index 0000000..321bdd1 --- /dev/null +++ b/internal/store/profile_roundtrip_test.go @@ -0,0 +1,251 @@ +package store + +import ( + "encoding/json" + "os" + "path/filepath" + "testing" +) + +func boolPointer(value bool) *bool { return &value } + +func runtimeOnlyConfigFields() []string { + return []string{ + "client_id", + "client_ids", + "client_role", + "controller", + "controller_id", + "controller_client_id", + "controller_role", + "role", + "requested_role", + "assigned_role", + "terminal_width", + "terminal_height", + "terminal_columns", + "terminal_rows", + "terminal_dimensions", + "terminal_size", + "capability", + "capabilities", + "capability_response", + "capability_responses", + "protocol_version", + "negotiated_protocol_version", + } +} + +func TestProfileExplicitFalseRoundTrip(t *testing.T) { + // Given + store, err := Open(filepath.Join(t.TempDir(), "sessions.json")) + if err != nil { + t.Fatal(err) + } + mode := ModeSafe + mouse := MousePolicyOff + provider := "claude" + alias := "claude-local" + prefix := "C-a" + scrollback := 8000 + cfg := DefaultConfig() + cfg.DefaultProfile = "focused" + cfg.Profiles["focused"] = Profile{ + Provider: &provider, + Mode: &mode, + CommandAlias: &alias, + Mouse: &mouse, + ControlPrefix: &prefix, + BackDetach: boolPointer(false), + ScrollbackLines: &scrollback, + } + cfg.Sessions["claude:abc12345"] = SessionRecord{ + ID: "abc12345", Agent: "claude", SessionName: "uam-claude-abc12345", Profile: "focused", + ProfileOverrides: &SessionProfileOverrides{BackDetach: boolPointer(false)}, + } + + // When + if err := store.Save(cfg); err != nil { + t.Fatal(err) + } + loaded, err := store.Load() + if err != nil { + t.Fatal(err) + } + + // Then + profile := loaded.Profiles["focused"] + if profile.BackDetach == nil || *profile.BackDetach || profile.Provider == nil || *profile.Provider != "claude" || profile.Mode == nil || *profile.Mode != ModeSafe || profile.CommandAlias == nil || *profile.CommandAlias != "claude-local" { + t.Fatalf("profile optional values changed: %+v", profile) + } + overrides := loaded.Sessions["claude:abc12345"].ProfileOverrides + if overrides == nil || overrides.BackDetach == nil || *overrides.BackDetach { + t.Fatalf("session explicit false changed: %+v", overrides) + } + raw, err := os.ReadFile(store.Path()) + if err != nil { + t.Fatal(err) + } + if !json.Valid(raw) || !containsJSONFalse(t, raw, "profiles", "focused", "back_detach") { + t.Fatalf("explicit false missing from JSON: %s", raw) + } +} + +func containsJSONFalse(t *testing.T, data []byte, keys ...string) bool { + t.Helper() + var value any + if err := json.Unmarshal(data, &value); err != nil { + t.Fatal(err) + } + current := value + for _, key := range keys { + object, ok := current.(map[string]any) + if !ok { + return false + } + current, ok = object[key] + if !ok { + return false + } + } + boolean, ok := current.(bool) + return ok && !boolean +} + +func TestNestedUnknownFieldsRoundTrip(t *testing.T) { + // Given + path := filepath.Join(t.TempDir(), "sessions.json") + doc := []byte(`{"schema_version":4,"profiles":{"future":{"mouse":"off","profile_extension":{"value":7}}},"sessions":{"claude:abc12345":{"id":"abc12345","agent":"claude","tmux_session":"uam-claude-abc12345","profile":"future","session_extension":{"value":9}}}}`) + if err := os.WriteFile(path, doc, 0o600); err != nil { + t.Fatal(err) + } + store, err := Open(path) + if err != nil { + t.Fatal(err) + } + + // When + cfg, err := store.Load() + if err != nil { + t.Fatal(err) + } + if err := store.Save(cfg); err != nil { + t.Fatal(err) + } + + // Then + after, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + if !hasJSONPath(t, after, "profiles", "future", "profile_extension") { + t.Fatalf("profile unknown field dropped: %s", after) + } + if !hasJSONPath(t, after, "sessions", "claude:abc12345", "session_extension") { + t.Fatalf("session unknown field dropped: %s", after) + } +} + +func hasJSONPath(t *testing.T, data []byte, keys ...string) bool { + t.Helper() + var current any + if err := json.Unmarshal(data, ¤t); err != nil { + t.Fatal(err) + } + for _, key := range keys { + object, ok := current.(map[string]any) + if !ok { + return false + } + current, ok = object[key] + if !ok { + return false + } + } + return true +} + +func TestRuntimeClientStateIsNotPersisted(t *testing.T) { + // Given + path := filepath.Join(t.TempDir(), "sessions.json") + doc := []byte(`{"schema_version":4,"profiles":{},"sessions":{"claude:abc12345":{"id":"abc12345","agent":"claude","tmux_session":"uam-claude-abc12345","session_extension":"keep","client_id":"client-1","controller_id":"client-1","requested_role":"controller","assigned_role":"controller","terminal_width":120,"terminal_height":40,"capabilities":["framed_output"],"negotiated_protocol_version":2}}}`) + if err := os.WriteFile(path, doc, 0o600); err != nil { + t.Fatal(err) + } + store, err := Open(path) + if err != nil { + t.Fatal(err) + } + + // When + cfg, err := store.Load() + if err != nil { + t.Fatal(err) + } + if err := store.Save(cfg); err != nil { + t.Fatal(err) + } + + // Then + after, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + for _, field := range []string{"client_id", "controller_id", "requested_role", "assigned_role", "terminal_width", "terminal_height", "capabilities", "negotiated_protocol_version"} { + if hasJSONPath(t, after, "sessions", "claude:abc12345", field) { + t.Fatalf("runtime field %q persisted: %s", field, after) + } + } + if !hasJSONPath(t, after, "sessions", "claude:abc12345", "session_extension") { + t.Fatalf("legitimate unknown field was dropped with runtime state: %s", after) + } +} + +func TestRuntimeClientStateAtConfigRootIsNotPersisted(t *testing.T) { + // Given + path := filepath.Join(t.TempDir(), "sessions.json") + runtimeFields := runtimeOnlyConfigFields() + doc := map[string]any{ + "schema_version": 4, + "profiles": map[string]any{}, + "sessions": map[string]any{}, + "root_extension": map[string]any{"preserve": true}, + } + for _, field := range runtimeFields { + doc[field] = "forbidden-runtime-state" + } + data, err := json.Marshal(doc) + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(path, data, 0o600); err != nil { + t.Fatal(err) + } + store, err := Open(path) + if err != nil { + t.Fatal(err) + } + + // When + cfg, err := store.Load() + if err != nil { + t.Fatal(err) + } + if err := store.Save(cfg); err != nil { + t.Fatal(err) + } + + // Then + after, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + for _, field := range runtimeFields { + if hasJSONPath(t, after, field) { + t.Errorf("root runtime field %q persisted: %s", field, after) + } + } + if !hasJSONPath(t, after, "root_extension") { + t.Fatalf("legitimate root unknown field was dropped: %s", after) + } +} diff --git a/internal/store/profile_validation.go b/internal/store/profile_validation.go new file mode 100644 index 0000000..53409a6 --- /dev/null +++ b/internal/store/profile_validation.go @@ -0,0 +1,132 @@ +package store + +import ( + "encoding/json" + "fmt" + "regexp" + "strings" +) + +var providerNameRE = regexp.MustCompile(`^[a-z0-9]+$`) +var profileNameRE = regexp.MustCompile(`^[a-z0-9][a-z0-9._-]{0,63}$`) + +var prohibitedProfileFields = map[string]struct{}{ + "allow_heuristic_resume": {}, + "allow_latest": {}, + "allow_unsafe_resume": {}, + "argv": {}, + "command": {}, + "env": {}, + "environment": {}, + "heuristic_resume": {}, + "provider_session_id": {}, + "resume": {}, + "resume_args": {}, + "resume_kind": {}, + "resume_safety": {}, + "term": {}, +} + +var knownSessionOverrideFields = map[string]struct{}{ + "mode": {}, + "command_alias": {}, + "mouse": {}, + "control_prefix": {}, + "back_detach": {}, + "scrollback_lines": {}, + "client_id": {}, + "controller_id": {}, + "requested_role": {}, + "assigned_role": {}, + "terminal_width": {}, + "terminal_height": {}, + "capabilities": {}, + "negotiated_protocol_version": {}, +} + +type sessionProfileOverridesAlias SessionProfileOverrides + +func ValidateProfileName(name string) error { + if !profileNameRE.MatchString(name) || name == "none" { + return fmt.Errorf("invalid profile name %q", name) + } + return nil +} + +func (o SessionProfileOverrides) MarshalJSON() ([]byte, error) { + base, err := json.Marshal(sessionProfileOverridesAlias(o)) + if err != nil { + return nil, err + } + return mergeUnknownJSON(base, o.unknown, knownSessionOverrideFields) +} + +func (o *SessionProfileOverrides) UnmarshalJSON(data []byte) error { + var alias sessionProfileOverridesAlias + if err := json.Unmarshal(data, &alias); err != nil { + return err + } + *o = SessionProfileOverrides(alias) + unknown, err := decodeUnknownJSON(data, knownSessionOverrideFields) + if err != nil { + return err + } + o.unknown = unknown + return nil +} + +func ValidateProfile(profile Profile) error { + for field := range profile.unknown { + if _, prohibited := prohibitedProfileFields[strings.ToLower(field)]; prohibited { + return fmt.Errorf("profile field %q is prohibited", field) + } + } + if profile.Provider != nil && !providerNameRE.MatchString(*profile.Provider) { + return fmt.Errorf("invalid profile provider %q", *profile.Provider) + } + if profile.Mode != nil && *profile.Mode != ModeYolo && *profile.Mode != ModeSafe { + return fmt.Errorf("invalid profile mode %q", *profile.Mode) + } + if profile.CommandAlias != nil && !isSafeCommandAlias(*profile.CommandAlias) { + return fmt.Errorf("invalid profile command alias %q", *profile.CommandAlias) + } + if profile.Mouse != nil && !validMousePolicy(*profile.Mouse) { + return fmt.Errorf("invalid profile mouse policy %q", *profile.Mouse) + } + if profile.ControlPrefix != nil && !validControlPrefix(*profile.ControlPrefix) { + return fmt.Errorf("invalid profile control prefix %q", *profile.ControlPrefix) + } + if profile.ScrollbackLines != nil && !validScrollbackLines(*profile.ScrollbackLines) { + return fmt.Errorf("invalid profile scrollback lines %d", *profile.ScrollbackLines) + } + return nil +} + +func ValidateSessionProfileOverrides(overrides SessionProfileOverrides) error { + for field := range overrides.unknown { + if strings.EqualFold(field, "provider") { + return fmt.Errorf("session profile override field %q is prohibited", field) + } + if _, prohibited := prohibitedProfileFields[strings.ToLower(field)]; prohibited { + return fmt.Errorf("session profile override field %q is prohibited", field) + } + } + return ValidateProfile(Profile{ + Mode: overrides.Mode, + CommandAlias: overrides.CommandAlias, + Mouse: overrides.Mouse, + ControlPrefix: overrides.ControlPrefix, + BackDetach: overrides.BackDetach, + ScrollbackLines: overrides.ScrollbackLines, + }) +} + +func validMousePolicy(policy MousePolicy) bool { + return policy == MousePolicyAuto || policy == MousePolicyOn || policy == MousePolicyOff +} + +func validControlPrefix(prefix string) bool { + return len(prefix) == 3 && prefix[0] == 'C' && prefix[1] == '-' && prefix[2] >= 'a' && prefix[2] <= 'z' +} + +func validScrollbackLines(lines int) bool { return lines >= 100 && lines <= 100000 } diff --git a/internal/store/schema_v3_characterization_test.go b/internal/store/schema_v3_characterization_test.go new file mode 100644 index 0000000..117f48d --- /dev/null +++ b/internal/store/schema_v3_characterization_test.go @@ -0,0 +1,125 @@ +package store + +import ( + "bytes" + "encoding/json" + "os" + "path/filepath" + "testing" +) + +func TestSchemaV3BaselineLoadsCurrentRecordsAndTopLevelUnknown(t *testing.T) { + // Given + path := filepath.Join(t.TempDir(), "sessions.json") + fixture, err := os.ReadFile(filepath.Join("testdata", "schema-v3-full.json")) + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(path, fixture, 0o600); err != nil { + t.Fatal(err) + } + store, err := Open(path) + if err != nil { + t.Fatal(err) + } + + // When + cfg, err := store.Load() + if err != nil { + t.Fatal(err) + } + if err := store.Save(cfg); err != nil { + t.Fatal(err) + } + + // Then + record, ok := cfg.Sessions["claude:12345678"] + if !ok { + t.Fatal("schema-v3 session was dropped") + } + if record.ID != "12345678-1234-4234-9234-123456789abc" || record.ProviderSessionID != "provider_session_12345678" { + t.Fatalf("schema-v3 identity changed: %+v", record) + } + if record.LastExitCode == nil || *record.LastExitCode != 23 || record.PR == nil || record.PR.Number != 42 { + t.Fatalf("schema-v3 exit or PR fields changed: %+v", record) + } + after, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + var raw map[string]json.RawMessage + if err := json.Unmarshal(after, &raw); err != nil { + t.Fatal(err) + } + if _, ok := raw["top_level_extension"]; !ok { + t.Fatalf("top-level unknown field was dropped: %s", after) + } +} + +func TestSchemaV3BaselineCreatesBackupBeforeOlderMigration(t *testing.T) { + // Given + dir := t.TempDir() + path := filepath.Join(dir, "sessions.json") + original := []byte(`{"schema_version":2,"sessions":{}}`) + if err := os.WriteFile(path, original, 0o600); err != nil { + t.Fatal(err) + } + store, err := Open(path) + if err != nil { + t.Fatal(err) + } + + // When + if _, err := store.Load(); err != nil { + t.Fatal(err) + } + + // Then + backups, err := filepath.Glob(path + ".bak.*") + if err != nil || len(backups) != 1 { + t.Fatalf("migration backups=%v err=%v", backups, err) + } + backup, err := os.ReadFile(backups[0]) + if err != nil { + t.Fatal(err) + } + if !bytes.Equal(backup, original) { + t.Fatalf("backup differs from original: got=%s want=%s", backup, original) + } +} + +func TestSchemaV3BaselineQuarantinesMalformedJSON(t *testing.T) { + // Given + dir := t.TempDir() + path := filepath.Join(dir, "sessions.json") + original := []byte("{malformed") + if err := os.WriteFile(path, original, 0o600); err != nil { + t.Fatal(err) + } + store, err := Open(path) + if err != nil { + t.Fatal(err) + } + + // When + cfg, err := store.Load() + if err != nil { + t.Fatal(err) + } + + // Then + if len(cfg.Sessions) != 0 { + t.Fatalf("quarantine returned sessions: %+v", cfg.Sessions) + } + backups, err := filepath.Glob(path + ".bak.*") + if err != nil || len(backups) != 1 { + t.Fatalf("quarantine backups=%v err=%v", backups, err) + } + quarantined, err := os.ReadFile(backups[0]) + if err != nil { + t.Fatal(err) + } + if !bytes.Equal(quarantined, original) { + t.Fatalf("quarantine changed malformed bytes: got=%q want=%q", quarantined, original) + } +} diff --git a/internal/store/store.go b/internal/store/store.go index 1158215..6a17496 100644 --- a/internal/store/store.go +++ b/internal/store/store.go @@ -16,7 +16,7 @@ import ( "github.com/RandomCodeSpace/unified-agent-manager/internal/log" ) -const CurrentSchemaVersion = 3 +const CurrentSchemaVersion = 4 const configFileName = "sessions.json" @@ -42,6 +42,14 @@ const ( ModeSafe Mode = "safe" ) +type MousePolicy string + +const ( + MousePolicyAuto MousePolicy = "auto" + MousePolicyOn MousePolicy = "on" + MousePolicyOff MousePolicy = "off" +) + // Status distinguishes records that should keep behaving as live sessions // (StatusActive — recoverable on attach) from records the user deliberately // retired (StatusClosedByUser). @@ -53,10 +61,12 @@ const ( ) type Config struct { - SchemaVersion int `json:"schema_version"` - DefaultAgent string `json:"default_agent"` - Sessions map[string]SessionRecord `json:"sessions"` - UI UISettings `json:"ui"` + SchemaVersion int `json:"schema_version"` + DefaultAgent string `json:"default_agent"` + DefaultProfile string `json:"default_profile"` + Profiles map[string]Profile `json:"profiles"` + Sessions map[string]SessionRecord `json:"sessions"` + UI UISettings `json:"ui"` // unknown captures any top-level JSON fields written by a newer binary so // they round-trip untouched instead of being silently dropped (F33). It is @@ -78,45 +88,60 @@ var ErrReadOnly = errors.New("store: config loaded from a newer schema is read-o // in-memory-only fields) so (Un)MarshalJSON can delegate to the stdlib encoder // without recursing. type configAlias struct { - SchemaVersion int `json:"schema_version"` - DefaultAgent string `json:"default_agent"` - Sessions map[string]SessionRecord `json:"sessions"` - UI UISettings `json:"ui"` + SchemaVersion int `json:"schema_version"` + DefaultAgent string `json:"default_agent"` + DefaultProfile string `json:"default_profile"` + Profiles map[string]Profile `json:"profiles"` + Sessions map[string]SessionRecord `json:"sessions"` + UI UISettings `json:"ui"` } -// knownConfigFields lists the JSON keys Config models directly; everything else -// is preserved verbatim via the unknown overflow. +// knownConfigFields lists modeled keys and runtime-only keys that must never +// enter the persistent unknown overflow. var knownConfigFields = map[string]struct{}{ - "schema_version": {}, - "default_agent": {}, - "sessions": {}, - "ui": {}, + "schema_version": {}, + "default_agent": {}, + "default_profile": {}, + "profiles": {}, + "sessions": {}, + "ui": {}, + "client_id": {}, + "client_ids": {}, + "client_role": {}, + "controller": {}, + "controller_id": {}, + "controller_client_id": {}, + "controller_role": {}, + "role": {}, + "requested_role": {}, + "assigned_role": {}, + "terminal_width": {}, + "terminal_height": {}, + "terminal_columns": {}, + "terminal_rows": {}, + "terminal_dimensions": {}, + "terminal_size": {}, + "capability": {}, + "capabilities": {}, + "capability_response": {}, + "capability_responses": {}, + "protocol_version": {}, + "negotiated_protocol_version": {}, } func (c Config) MarshalJSON() ([]byte, error) { base, err := json.Marshal(configAlias{ - SchemaVersion: c.SchemaVersion, - DefaultAgent: c.DefaultAgent, - Sessions: c.Sessions, - UI: c.UI, + SchemaVersion: c.SchemaVersion, + DefaultAgent: c.DefaultAgent, + DefaultProfile: c.DefaultProfile, + Profiles: c.Profiles, + Sessions: c.Sessions, + UI: c.UI, }) if err != nil { return nil, err } - if len(c.unknown) == 0 { - return base, nil - } - var merged map[string]json.RawMessage - if err := json.Unmarshal(base, &merged); err != nil { - return nil, err - } - for k, v := range c.unknown { - if _, known := knownConfigFields[k]; known { - continue - } - merged[k] = v - } - return json.Marshal(merged) + return mergeUnknownJSON(base, c.unknown, knownConfigFields) } func (c *Config) UnmarshalJSON(data []byte) error { @@ -126,24 +151,48 @@ func (c *Config) UnmarshalJSON(data []byte) error { } c.SchemaVersion = alias.SchemaVersion c.DefaultAgent = alias.DefaultAgent + c.DefaultProfile = alias.DefaultProfile + c.Profiles = alias.Profiles c.Sessions = alias.Sessions c.UI = alias.UI + unknown, err := decodeUnknownJSON(data, knownConfigFields) + if err != nil { + return err + } + c.unknown = unknown + return nil +} +func mergeUnknownJSON(base []byte, unknown map[string]json.RawMessage, known map[string]struct{}) ([]byte, error) { + if len(unknown) == 0 { + return base, nil + } + var merged map[string]json.RawMessage + if err := json.Unmarshal(base, &merged); err != nil { + return nil, err + } + for key, value := range unknown { + if _, exists := known[key]; !exists { + merged[key] = value + } + } + return json.Marshal(merged) +} + +func decodeUnknownJSON(data []byte, known map[string]struct{}) (map[string]json.RawMessage, error) { var raw map[string]json.RawMessage if err := json.Unmarshal(data, &raw); err != nil { - return err + return nil, err } - for k := range raw { - if _, known := knownConfigFields[k]; known { - delete(raw, k) + for key := range raw { + if _, exists := known[key]; exists { + delete(raw, key) } } if len(raw) == 0 { - c.unknown = nil - } else { - c.unknown = raw + return nil, nil } - return nil + return raw, nil } type UISettings struct { @@ -154,6 +203,71 @@ type UISettings struct { PeekWidth int `json:"peek_width"` } +type Profile struct { + Provider *string `json:"provider,omitempty"` + Mode *Mode `json:"mode,omitempty"` + CommandAlias *string `json:"command_alias,omitempty"` + Mouse *MousePolicy `json:"mouse,omitempty"` + ControlPrefix *string `json:"control_prefix,omitempty"` + BackDetach *bool `json:"back_detach,omitempty"` + ScrollbackLines *int `json:"scrollback_lines,omitempty"` + + unknown map[string]json.RawMessage +} + +type profileAlias Profile + +var knownProfileFields = map[string]struct{}{ + "provider": {}, + "mode": {}, + "command_alias": {}, + "mouse": {}, + "control_prefix": {}, + "back_detach": {}, + "scrollback_lines": {}, + "client_id": {}, + "controller_id": {}, + "requested_role": {}, + "assigned_role": {}, + "terminal_width": {}, + "terminal_height": {}, + "capabilities": {}, + "negotiated_protocol_version": {}, +} + +func (p Profile) MarshalJSON() ([]byte, error) { + base, err := json.Marshal(profileAlias(p)) + if err != nil { + return nil, err + } + return mergeUnknownJSON(base, p.unknown, knownProfileFields) +} + +func (p *Profile) UnmarshalJSON(data []byte) error { + var alias profileAlias + if err := json.Unmarshal(data, &alias); err != nil { + return err + } + *p = Profile(alias) + unknown, err := decodeUnknownJSON(data, knownProfileFields) + if err != nil { + return err + } + p.unknown = unknown + return nil +} + +type SessionProfileOverrides struct { + Mode *Mode `json:"mode,omitempty"` + CommandAlias *string `json:"command_alias,omitempty"` + Mouse *MousePolicy `json:"mouse,omitempty"` + ControlPrefix *string `json:"control_prefix,omitempty"` + BackDetach *bool `json:"back_detach,omitempty"` + ScrollbackLines *int `json:"scrollback_lines,omitempty"` + + unknown map[string]json.RawMessage +} + type SessionRecord struct { ID string `json:"id"` Agent string `json:"agent"` @@ -180,8 +294,66 @@ type SessionRecord struct { // LastExitCode records the agent process's exit status from the most // recent close (-1 when it died on a signal). Pointer so records from // older schemas stay distinguishable from a real exit 0. - LastExitCode *int `json:"last_exit_code,omitempty"` - PR *PRRecord `json:"pr,omitempty"` + LastExitCode *int `json:"last_exit_code,omitempty"` + PR *PRRecord `json:"pr,omitempty"` + Profile string `json:"profile,omitempty"` + ProfileOverrides *SessionProfileOverrides `json:"profile_overrides,omitempty"` + + unknown map[string]json.RawMessage +} + +type sessionRecordAlias SessionRecord + +var knownSessionRecordFields = map[string]struct{}{ + "id": {}, + "agent": {}, + "command_alias": {}, + "name": {}, + "prompt": {}, + "mode": {}, + "workdir": {}, + "tmux_session": {}, + "created_at": {}, + "last_seen_at": {}, + "pinned": {}, + "group": {}, + "sort_index": {}, + "status": {}, + "provider_session_id": {}, + "last_exit_code": {}, + "pr": {}, + "profile": {}, + "profile_overrides": {}, + "client_id": {}, + "controller_id": {}, + "requested_role": {}, + "assigned_role": {}, + "terminal_width": {}, + "terminal_height": {}, + "capabilities": {}, + "negotiated_protocol_version": {}, +} + +func (r SessionRecord) MarshalJSON() ([]byte, error) { + base, err := json.Marshal(sessionRecordAlias(r)) + if err != nil { + return nil, err + } + return mergeUnknownJSON(base, r.unknown, knownSessionRecordFields) +} + +func (r *SessionRecord) UnmarshalJSON(data []byte) error { + var alias sessionRecordAlias + if err := json.Unmarshal(data, &alias); err != nil { + return err + } + *r = SessionRecord(alias) + unknown, err := decodeUnknownJSON(data, knownSessionRecordFields) + if err != nil { + return err + } + r.unknown = unknown + return nil } // SessionExit describes how a provider process left its native session host. @@ -207,7 +379,9 @@ type Store struct { // still live. It is injected by the caller (the store stays backend-free) // and is used only to reclassify Statusless v1 records during migration // (F07). - sessionExists func(string) bool + sessionExists func(string) bool + migrationBackup func() error + migrationWrite func(Config) error } func Open(path string) (*Store, error) { @@ -245,6 +419,7 @@ func DefaultConfig() Config { return Config{ SchemaVersion: CurrentSchemaVersion, DefaultAgent: DefaultAgentName, + Profiles: map[string]Profile{}, Sessions: map[string]SessionRecord{}, UI: UISettings{Sort: "state", PeekWidth: 60}, } @@ -350,15 +525,23 @@ func (s *Store) loadNoLock() (Config, error) { } dropInvalidRecords(&cfg) if cfg.SchemaVersion < CurrentSchemaVersion { - if err := s.copyBackup(); err != nil { + backupMigration := s.copyBackup + if s.migrationBackup != nil { + backupMigration = s.migrationBackup + } + if err := backupMigration(); err != nil { return Config{}, err } // Reclassify Statusless v1 records BEFORE normalize backfills them to // Active: a dead-pane record was a user-stopped session and must not // auto-resume on attach (F07). normalize stays unchanged. reclassifyV1Closed(&cfg, s.sessionExists) - cfg = migrate(normalize(cfg)) - if err := s.saveNoLock(cfg); err != nil { + cfg = migrate(cfg) + writeMigration := s.saveNoLock + if s.migrationWrite != nil { + writeMigration = s.migrationWrite + } + if err := writeMigration(cfg); err != nil { return Config{}, err } } @@ -500,6 +683,9 @@ func normalize(cfg Config) Config { if cfg.Sessions == nil { cfg.Sessions = map[string]SessionRecord{} } + if cfg.Profiles == nil { + cfg.Profiles = map[string]Profile{} + } if _, ok := knownSorts[cfg.UI.Sort]; !ok { cfg.UI.Sort = defaultSort } @@ -509,9 +695,36 @@ func normalize(cfg Config) Config { cfg.Sessions[k] = rec } } + normalizeProfileReferences(&cfg) return cfg } +func normalizeProfileReferences(cfg *Config) { + if cfg.DefaultProfile != "" { + profile, exists := cfg.Profiles[cfg.DefaultProfile] + if !exists || !validProfile(profile) { + log.ProfileFallback("default", cfg.DefaultProfile) + cfg.DefaultProfile = "" + } + } + for key, record := range cfg.Sessions { + if record.Profile == "" { + continue + } + profile, exists := cfg.Profiles[record.Profile] + if exists && validProfile(profile) { + continue + } + log.ProfileFallback("session", record.Profile) + record.Profile = "" + cfg.Sessions[key] = record + } +} + +func validProfile(profile Profile) bool { + return ValidateProfile(profile) == nil +} + // clampPeekWidth coerces an out-of-range peek width back into bounds. A // non-positive value (unset or corrupt) falls back to the default; otherwise it // is clamped into [minPeekWidth, maxPeekWidth]. @@ -558,6 +771,14 @@ func migrate(cfg Config) Config { } } } + if cfg.SchemaVersion < 4 { + cfg.DefaultProfile = "" + for key, record := range cfg.Sessions { + record.Profile = "" + record.ProfileOverrides = nil + cfg.Sessions[key] = record + } + } cfg.SchemaVersion = CurrentSchemaVersion return normalize(cfg) } @@ -592,6 +813,7 @@ func (s *Store) saveNoLock(cfg Config) error { return err } if err := os.Rename(tmp, s.path); err != nil { + _ = os.Remove(tmp) return err } return syncDir(filepath.Dir(s.path)) @@ -643,7 +865,14 @@ func (s *Store) copyBackup() error { _ = out.Close() return err } - return out.Close() + if err := out.Sync(); err != nil { + _ = out.Close() + return err + } + if err := out.Close(); err != nil { + return err + } + return syncDir(filepath.Dir(s.path)) } func (s *Store) moveAside() error { return os.Rename(s.path, s.backupPath()) } diff --git a/internal/store/task2_manual_qa_test.go b/internal/store/task2_manual_qa_test.go new file mode 100644 index 0000000..6b482fe --- /dev/null +++ b/internal/store/task2_manual_qa_test.go @@ -0,0 +1,151 @@ +package store + +import ( + "bytes" + "encoding/json" + "errors" + "fmt" + "os" + "path/filepath" + "strings" + "testing" +) + +func writeTask2Artifact(t *testing.T, path string, data []byte) { + t.Helper() + if err := os.WriteFile(path, data, 0o600); err != nil { + t.Fatal(err) + } +} + +func TestTodo2ManualDataSurfaceQA(t *testing.T) { + // Given + evidenceDir := os.Getenv("UAM_TASK2_EVIDENCE_DIR") + configDir := os.Getenv("UAM_CONFIG_DIR") + if configDir == "" { + configDir = t.TempDir() + t.Setenv("UAM_CONFIG_DIR", configDir) + } + if evidenceDir == "" { + evidenceDir = t.TempDir() + } + if err := os.MkdirAll(configDir, 0o700); err != nil { + t.Fatal(err) + } + if err := os.MkdirAll(evidenceDir, 0o700); err != nil { + t.Fatal(err) + } + fixture, err := os.ReadFile(filepath.Join("testdata", "schema-v3-full.json")) + if err != nil { + t.Fatal(err) + } + var inputDocument map[string]any + if err := json.Unmarshal(fixture, &inputDocument); err != nil { + t.Fatal(err) + } + for _, field := range runtimeOnlyConfigFields() { + inputDocument[field] = "forbidden-runtime-state" + } + input, err := json.MarshalIndent(inputDocument, "", " ") + if err != nil { + t.Fatal(err) + } + path := DefaultPath() + writeTask2Artifact(t, path, input) + writeTask2Artifact(t, filepath.Join(evidenceDir, "before.json"), input) + store, err := Open("") + if err != nil { + t.Fatal(err) + } + + // When + cfg, err := store.Load() + if err != nil { + t.Fatal(err) + } + after, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + backups, err := filepath.Glob(path + ".bak.*") + if err != nil || len(backups) != 1 { + t.Fatalf("backups=%v err=%v", backups, err) + } + backup, err := os.ReadFile(backups[0]) + if err != nil { + t.Fatal(err) + } + + // Then + if cfg.SchemaVersion != 4 || len(cfg.Sessions) != 1 || cfg.Sessions["claude:12345678"].ProviderSessionID != "provider_session_12345678" { + t.Fatalf("semantic migration failed: %+v", cfg) + } + if !bytes.Equal(backup, input) { + t.Fatal("backup is not byte-identical to v3 input") + } + var parsed map[string]json.RawMessage + if err := json.Unmarshal(after, &parsed); err != nil { + t.Fatal(err) + } + if _, ok := parsed["profiles"]; !ok { + t.Fatal("v4 profiles object missing") + } + if !hasJSONPath(t, after, "top_level_extension") || !hasJSONPath(t, after, "sessions", "claude:12345678", "session_extension") { + t.Fatal("unknown fields were not preserved") + } + for _, runtimeField := range runtimeOnlyConfigFields() { + if bytes.Contains(after, []byte(`"`+runtimeField+`"`)) { + t.Fatalf("runtime field %q serialized", runtimeField) + } + } + writeTask2Artifact(t, filepath.Join(evidenceDir, "after.json"), after) + writeTask2Artifact(t, filepath.Join(evidenceDir, "backup.json"), backup) + semanticDiff := []byte("schema_version: 3 -> 4\ndefault_profile: absent -> empty\nprofiles: absent -> {}\nsessions: 1 -> 1\nruntime_only_root_fields: present -> removed\nidentities_timestamps_exit_order_ui_pr_unknowns: preserved\n") + writeTask2Artifact(t, filepath.Join(evidenceDir, "diff.txt"), semanticDiff) + + failureDir := filepath.Join(configDir, "write-failure") + if err := os.MkdirAll(failureDir, 0o700); err != nil { + t.Fatal(err) + } + failurePath := filepath.Join(failureDir, configFileName) + writeTask2Artifact(t, failurePath, input) + failureStore, err := Open(failurePath) + if err != nil { + t.Fatal(err) + } + injected := errors.New("manual injected migration write failure") + failureStore.migrationWrite = func(Config) error { return injected } + if _, err := failureStore.Load(); !errors.Is(err, injected) { + t.Fatalf("failure injection error=%v", err) + } + failureAfter, err := os.ReadFile(failurePath) + if err != nil { + t.Fatal(err) + } + if !bytes.Equal(failureAfter, input) { + t.Fatal("migration write failure changed original bytes") + } + failureBackups, err := filepath.Glob(failurePath + ".bak.*") + if err != nil || len(failureBackups) != 1 { + t.Fatalf("failure backups=%v err=%v", failureBackups, err) + } + writeTask2Artifact(t, filepath.Join(evidenceDir, "failure-before.json"), input) + writeTask2Artifact(t, filepath.Join(evidenceDir, "failure-after.json"), failureAfter) + + assertions := fmt.Sprintf("PASS schema=%d sessions=%d backup_exact=true unknowns_preserved=true runtime_state_absent_at_root_and_nested=true\nPASS injected_write_failure_preserved_original=true backup_created_before_write=true\n", cfg.SchemaVersion, len(cfg.Sessions)) + writeTask2Artifact(t, filepath.Join(evidenceDir, "assertions.txt"), []byte(assertions)) + + if os.Getenv("UAM_TASK2_EVIDENCE_DIR") != "" { + tempRoot := filepath.Clean(os.TempDir()) + string(os.PathSeparator) + if !strings.HasPrefix(filepath.Clean(configDir)+string(os.PathSeparator), tempRoot) { + t.Fatalf("refusing cleanup outside temp root: %s", configDir) + } + if err := os.RemoveAll(configDir); err != nil { + t.Fatal(err) + } + if _, err := os.Stat(configDir); !errors.Is(err, os.ErrNotExist) { + t.Fatalf("config fixture still exists: %v", err) + } + writeTask2Artifact(t, filepath.Join(evidenceDir, "cleanup-receipt.txt"), []byte("removed isolated UAM_CONFIG_DIR: "+configDir+"\n")) + } +} diff --git a/internal/store/testdata/schema-v3-full.json b/internal/store/testdata/schema-v3-full.json new file mode 100644 index 0000000..44ec9ac --- /dev/null +++ b/internal/store/testdata/schema-v3-full.json @@ -0,0 +1,41 @@ +{ + "schema_version": 3, + "default_agent": "claude", + "sessions": { + "claude:12345678": { + "id": "12345678-1234-4234-9234-123456789abc", + "agent": "claude", + "command_alias": "claude-local", + "name": "preserve every field", + "prompt": "characterize schema v3", + "mode": "safe", + "workdir": "/tmp/schema-v3-worktree", + "tmux_session": "uam-claude-12345678", + "created_at": "2025-01-02T03:04:05Z", + "last_seen_at": "2025-02-03T04:05:06Z", + "pinned": true, + "group": "migration", + "sort_index": 17, + "status": "active", + "provider_session_id": "provider_session_12345678", + "last_exit_code": 23, + "pr": { + "url": "https://github.com/owner/repo/pull/42", + "number": 42, + "last_status": "open", + "last_checked": "2025-03-04T05:06:07Z" + }, + "session_extension": { + "preserve": true + } + } + }, + "ui": { + "group_by_dir": true, + "sort": "state", + "peek_width": 91 + }, + "top_level_extension": { + "preserve": true + } +} diff --git a/internal/vterm/todo8_terminal_policy_test.go b/internal/vterm/todo8_terminal_policy_test.go new file mode 100644 index 0000000..7d31e0d --- /dev/null +++ b/internal/vterm/todo8_terminal_policy_test.go @@ -0,0 +1,23 @@ +package vterm + +import ( + "bytes" + "testing" +) + +func TestReplayPreservesProviderModes(t *testing.T) { + term := New(40, 4, 4000) + providerModes := []byte("\x1b[?1h\x1b[?1000;1004;1006;2004h\x1b=provider") + if _, err := term.Write(providerModes); err != nil { + t.Fatal(err) + } + replay := term.Redraw() + for _, want := range [][]byte{ + []byte("\x1b[?1h"), []byte("\x1b[?1000h"), []byte("\x1b[?1004h"), + []byte("\x1b[?1006h"), []byte("\x1b[?2004h"), []byte("\x1b="), + } { + if !bytes.Contains(replay, want) { + t.Fatalf("replay omitted provider mode %x: %x", want, replay) + } + } +} diff --git a/script/qa/docs-contract-smoke.sh b/script/qa/docs-contract-smoke.sh new file mode 100755 index 0000000..ee54aba --- /dev/null +++ b/script/qa/docs-contract-smoke.sh @@ -0,0 +1,210 @@ +#!/usr/bin/env bash +set -euo pipefail + +usage() { + printf '%s\n' 'usage: script/qa/docs-contract-smoke.sh --uam --evidence-dir ' >&2 + exit 2 +} + +uam='' +evidence_dir='' +while [ "$#" -gt 0 ]; do + case "$1" in + --uam) uam=${2:-}; shift 2 ;; + --evidence-dir) evidence_dir=${2:-}; shift 2 ;; + *) usage ;; + esac +done + +[ -n "$uam" ] && [ -x "$uam" ] || { printf '%s\n' 'docs smoke: --uam must name an executable' >&2; exit 2; } +[ -n "$evidence_dir" ] && [ "${evidence_dir#/}" != "$evidence_dir" ] || { printf '%s\n' 'docs smoke: --evidence-dir must be absolute' >&2; exit 2; } +uam=$(cd "$(dirname "$uam")" && printf '%s/%s' "$(pwd -P)" "$(basename "$uam")") +mkdir -p "$evidence_dir" +evidence_dir=$(cd "$evidence_dir" && pwd -P) + +repo_root=$(cd "$(dirname "$0")/../.." && pwd -P) +cd "$repo_root" +real_config=/home/dev/.config/uam +scratch=$(mktemp -d) +config_dir="$scratch/config" +session_dir="$scratch/session" +xdg_config_home="$scratch/xdg-config" +cache_dir="$scratch/cache" +transcript="$evidence_dir/command-transcript.txt" +help_file="$evidence_dir/help.txt" +link_report="$evidence_dir/link-report.txt" +reader_status="$evidence_dir/reader-executable-status.tsv" +terminal_smoke="$evidence_dir/terminal-smoke-real.json" + +case "$config_dir" in + /home/dev/.config/uam|/home/dev/.config/uam/*) + printf '%s\n' 'docs smoke: refusing real UAM config path' >&2 + exit 2 + ;; +esac + +fingerprint() { + if [ -e "$real_config/sessions.json" ]; then + sha256sum "$real_config/sessions.json" | awk '{print $1}' + else + printf '%s' absent + fi +} + +real_before=$(fingerprint) +mkdir -p "$config_dir" "$session_dir" "$xdg_config_home" "$cache_dir" +export UAM_CONFIG_DIR="$config_dir" +export UAM_SESSION_DIR="$session_dir" +export XDG_CONFIG_HOME="$xdg_config_home" +export UAM_CACHE_DIR="$cache_dir" + +cleanup() { + status=$? + real_after=$(fingerprint) + removed=false + rm -rf "$scratch" + [ ! -e "$scratch" ] && removed=true + printf '{\n "isolated_config": %s,\n "real_config_path": "%s",\n "real_config_unchanged": %s,\n "scratch_removed": %s,\n "exit_status": %s\n}\n' \ + 'true' "$real_config" "$( [ "$real_before" = "$real_after" ] && printf true || printf false )" "$removed" "$status" \ + > "$evidence_dir/cleanup-receipt.json" + [ "$real_before" = "$real_after" ] || { printf '%s\n' 'docs smoke: real UAM config changed' >&2; exit 1; } +} +trap cleanup EXIT + +: > "$transcript" +run() { + label=$1 + shift + { + printf '$' + printf ' %q' "$@" + printf '\n' + "$@" + printf '[exit 0]\n\n' + } >> "$transcript" 2>&1 +} + +run_expect_fail() { + label=$1 + shift + { + printf '$' + printf ' %q' "$@" + printf '\n' + } >> "$transcript" + if "$@" >> "$transcript" 2>&1; then + printf '%s\n' "docs smoke: expected failure succeeded: $label" >&2 + exit 1 + fi + printf '[expected nonzero]\n\n' >> "$transcript" +} + +run_reader() { + case_id=$1 + shift + { + printf '$' + printf ' %q' "$@" + printf '\n' + } >> "$transcript" + if "$@" >> "$transcript" 2>&1; then + printf '[exit 0]\n\n' >> "$transcript" + printf '%s\t0\n' "$case_id" >> "$reader_status" + return 0 + fi + status=$? + printf '[exit %s]\n\n' "$status" >> "$transcript" + printf '%s\t%s\n' "$case_id" "$status" >> "$reader_status" + return "$status" +} + +run 'help' "$uam" --help +cp "$transcript" "$help_file" +grep -F 'uam profile effective [--json]' "$help_file" >/dev/null +grep -F 'uam doctor [] [--json]' "$help_file" >/dev/null + +run 'profile set focused' "$uam" profile set focused --provider claude --mode safe --mouse off --prefix C-a --back-detach off --scrollback 8000 +run 'profile default focused' "$uam" profile default focused +run 'profile show focused json' "$uam" profile show focused --json +run 'profile ls json' "$uam" profile ls --json +run 'doctor global json' "$uam" doctor --json +run 'profile default none' "$uam" profile default none +run 'profile remove focused' "$uam" profile rm focused + +cp "$repo_root/internal/store/testdata/schema-v3-full.json" "$config_dir/sessions.json" +run 'trigger schema migration' "$uam" profile set migrated --provider claude --mode safe --mouse off --prefix C-a --back-detach off --scrollback 8000 +backup=$(find "$config_dir" -maxdepth 1 -type f -name 'sessions.json.bak.*' -print -quit) +[ -n "$backup" ] && cmp "$repo_root/internal/store/testdata/schema-v3-full.json" "$backup" +grep -Eq '"schema_version"[[:space:]]*:[[:space:]]*4' "$config_dir/sessions.json" +grep -F '"top_level_extension"' "$config_dir/sessions.json" >/dev/null +grep -F '"session_extension"' "$config_dir/sessions.json" >/dev/null + +session_id=12345678-1234-4234-9234-123456789abc +run 'profile assign' "$uam" profile assign "$session_id" migrated +run 'profile override' "$uam" profile override "$session_id" --mouse on +run 'profile effective json' "$uam" profile effective "$session_id" --json +run 'doctor session json' "$uam" doctor "$session_id" --json +run_expect_fail 'referenced profile delete' "$uam" profile rm migrated +run 'profile assign none' "$uam" profile assign "$session_id" none +run 'profile remove migrated' "$uam" profile rm migrated +run 'cross terminal collector' "$repo_root/scripts/terminal-smoke-real" --terminal kitty --output "$terminal_smoke" --non-interactive +grep -F '"terminal": "kitty"' "$terminal_smoke" >/dev/null + +: > "$link_report" +link_fail=0 +while IFS= read -r source; do + while IFS= read -r target; do + case "$target" in + ''|http://*|https://*|mailto:*|\#*) continue ;; + esac + path=${target%%#*} + case "$path" in + /*) resolved="$repo_root$path" ;; + *) resolved="$(dirname "$source")/$path" ;; + esac + if [ -e "$resolved" ]; then + printf 'PASS %s -> %s\n' "$source" "$target" >> "$link_report" + else + printf 'FAIL %s -> %s\n' "$source" "$target" >> "$link_report" + link_fail=1 + fi + done < <(sed -nE 's/.*\]\(([^)#]+).*/\1/p' "$source") +done < <(find "$repo_root/docs" -type f -name '*.md' -print; printf '%s\n' "$repo_root/README.md") +[ "$link_fail" -eq 0 ] + +: > "$reader_status" +run_reader two_clients go test ./internal/session -run '^(TestControllerAssignmentSingleWriter|TestAttachProtocolCompatibilityMatrix)$' -count=1 +run_reader deleted_profile go test ./internal/cli -run '^TestReferencedProfileDeleteRejected$' -count=1 +run_reader old_host_and_unsupported_term go test ./internal/session -run '^(TestAttachProtocolCompatibilityMatrix|TestTodo11UnsupportedTermHintIsRedacted)$' -count=1 +run_reader migration_and_runtime_persistence go test ./internal/store -run '^(TestMigrateV3ProfilesPreservesSessions|TestRuntimeClientStateIsNotPersisted)$' -count=1 +awk -F '\t' ' +BEGIN { + print "{" + print " \"executable_cases\": [" +} +{ + if (NR > 1) print "," + printf " {\"id\":\"%s\",\"exit_status\":%s,\"evidence\":\"command-transcript.txt\"}", $1, $2 +} +END { + print "" + print " ]," + print " \"semantic_cases\": [" + print " {\"id\":\"reboot\",\"status\":\"independent_review_required\",\"source\":\"F1/F3 reader review\",\"evidence\":\"README resuming sessions; ADR 0003 persistence and recovery\"}," + print " {\"id\":\"sigkill\",\"status\":\"independent_review_required\",\"source\":\"F1/F3 reader review\",\"evidence\":\"ADR 0002 consequences; ADR 0003 persistence and recovery\"}" + print " ]" + print "}" +}' "$reader_status" > "$evidence_dir/reader-checklist.json" +printf '%s\n' '{' \ + ' "purpose": "Independent reader-review input; this file records questions and evidence, not verdicts.",' \ + ' "questions": [' \ + ' {"id":"two_clients","question":"Which attached client may write stdin, resize, or reply?","evidence":"command-transcript.txt; ADR 0003 roles"},' \ + ' {"id":"deleted_profile","question":"What happens when removing a profile selected by a session?","evidence":"command-transcript.txt; README profiles"},' \ + ' {"id":"old_host","question":"How does a v2 client behave with a v1 host?","evidence":"command-transcript.txt; ADR 0003 protocol matrix"},' \ + ' {"id":"unsupported_term","question":"Does a TERM hint prove capability or appear unredacted in diagnostics?","evidence":"command-transcript.txt; ADR 0002 and ADR 0003"},' \ + ' {"id":"reboot","question":"What survives, and what recovery action is available after reboot?","evidence":"README resuming sessions; ADR 0003 persistence and recovery"},' \ + ' {"id":"sigkill","question":"What cleanup guarantee remains after SIGKILL?","evidence":"ADR 0002 consequences; ADR 0003 persistence and recovery"}' \ + ' ]' \ + '}' > "$evidence_dir/reader-review-input.json" + +printf '{\n "current_schema": 4,\n "migration_fixture": "schema-v3-full.json",\n "migration_backup_exact": true,\n "unknown_fields_preserved": true,\n "help_profile_commands_present": true,\n "help_doctor_command_present": true\n}\n' > "$evidence_dir/schema-cli-match.json" diff --git a/scripts/terminal-smoke-real b/scripts/terminal-smoke-real new file mode 100755 index 0000000..6f07c11 --- /dev/null +++ b/scripts/terminal-smoke-real @@ -0,0 +1,52 @@ +#!/usr/bin/env bash +set -euo pipefail + +target="all" +output="" +while (($#)); do + case "$1" in + --terminal) target="$2"; shift 2 ;; + --output) output="$2"; shift 2 ;; + --non-interactive) shift ;; + *) echo "unknown argument: $1" >&2; exit 2 ;; + esac +done + +node - "$target" "$output" <<'NODE' +const { execFileSync } = require("node:child_process"); +const fs = require("node:fs"); +const targets = { + kitty: "kitty", + wezterm: "wezterm", + alacritty: "alacritty", + ghostty: "ghostty" +}; +const requested = process.argv[2]; +const names = requested === "all" + ? ["kitty", "wezterm", "alacritty", "ghostty", "iterm2-ssh", "windows-terminal-ssh"] + : [requested]; +const results = []; +for (const name of names) { + const binary = targets[name]; + if (!binary) { + results.push({ terminal: name, status: "unavailable", reason: "remote SSH target not configured" }); + continue; + } + try { + const version = execFileSync(binary, ["--version"], { encoding: "utf8", timeout: 5000 }).trim(); + const display = Boolean(process.env.DISPLAY || process.env.WAYLAND_DISPLAY); + results.push({ + terminal: name, + status: display ? "available_manual" : "unavailable_runtime", + reason: display ? "binary detected; manual visual run required" : "binary detected but no graphical display", + version + }); + } catch (error) { + results.push({ terminal: name, status: "unavailable", reason: error.code === "ENOENT" ? "binary not installed" : String(error.message) }); + } +} +const report = { nonInteractive: true, mandatoryCI: false, results }; +const json = JSON.stringify(report, null, 2) + "\n"; +if (process.argv[3]) fs.writeFileSync(process.argv[3], json); +process.stdout.write(json); +NODE diff --git a/scripts/terminal-smoke-todo7-xterm b/scripts/terminal-smoke-todo7-xterm new file mode 100755 index 0000000..5a97cdc --- /dev/null +++ b/scripts/terminal-smoke-todo7-xterm @@ -0,0 +1,103 @@ +#!/usr/bin/env bash +set -euo pipefail + +repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +todo7_evidence="$repo_root/.omo/evidence/persistent-agent-multiplexer/task-7-profile-cli" +todo11_evidence="$repo_root/.omo/evidence/persistent-agent-multiplexer/task-11-compat" +isolation_root="$(mktemp -d)" +config_dir="$isolation_root/uam-config" +session_dir="$isolation_root/uam-session" +cache_dir="$isolation_root/uam-cache" +xdg_config_home="$isolation_root/xdg-config" + +cleanup() { + case "$isolation_root" in + /tmp/tmp.*) find "$isolation_root" -depth -delete ;; + *) exit 70 ;; + esac +} +trap cleanup EXIT + +for path in "$config_dir" "$session_dir" "$cache_dir" "$xdg_config_home"; do + [[ "$path" = /* && "$path" == "$isolation_root"/* ]] || exit 70 +done +real_config="$(realpath -m "$HOME/.config/uam")" +[[ "$(realpath -m "$config_dir")" != "$real_config" ]] || { + echo "refusing real UAM config path" >&2 + exit 70 +} +mkdir -p "$config_dir" "$session_dir" "$cache_dir" "$xdg_config_home" "$todo7_evidence" "$todo11_evidence" + +node -e ' + const fs = require("node:fs"); + const path = require("node:path"); + const [out, root, config, session, cache, xdg, home] = process.argv.slice(1); + fs.writeFileSync(out, JSON.stringify({ + isolationRoot: root, + UAM_CONFIG_DIR: config, + UAM_SESSION_DIR: session, + UAM_CACHE_DIR: cache, + XDG_CONFIG_HOME: xdg, + absolute: [config, session, cache, xdg].every((value) => value.startsWith("/")), + configWithinIsolationRoot: path.resolve(config).startsWith(`${path.resolve(root)}${path.sep}`), + configDiffersFromHomeConfig: path.resolve(config) !== path.resolve(home, ".config", "uam") + }, null, 2) + "\n"); +' "$todo11_evidence/profile-isolation.json" "$isolation_root" "$config_dir" "$session_dir" "$cache_dir" "$xdg_config_home" "$HOME" + +( + cd "$repo_root" + timeout 120s env \ + UAM_CONFIG_DIR="$config_dir" \ + UAM_SESSION_DIR="$session_dir" \ + UAM_CACHE_DIR="$cache_dir" \ + XDG_CONFIG_HOME="$xdg_config_home" \ + UAM_TASK7_EVIDENCE_DIR="$todo7_evidence" \ + go test ./internal/cli -run '^TestTodo7ProfileCLIRealSurface$' -count=1 -v +) | tee "$todo11_evidence/todo7-real-surface.txt" +find "$todo7_evidence" -maxdepth 1 -type f -name 'uam-task7' -delete + +( + cd "$repo_root" + go test ./internal/app -run '^(TestTodo7CharacterizationDispatchKeepsExplicitLaunchInputs|TestTodo7CharacterizationFindExactRejectsIDPrefixes|TestWizardProfileSelection|TestSessionDetailsShowEffectiveProfile)$' -count=1 -v +) | tee "$todo11_evidence/profile-regression.txt" + +"$repo_root/scripts/terminal-smoke-xterm" \ + --non-interactive \ + --ansi "$todo7_evidence/wizard-details-pty.ansi" \ + --evidence-dir "$todo7_evidence" \ + --title "UAM profile wizard and effective profile" +"$repo_root/scripts/terminal-smoke-xterm" \ + --non-interactive \ + --ansi "$todo7_evidence/wizard-details-pty.ansi" \ + --evidence-dir "$todo11_evidence" \ + --title "UAM Todo 11 xterm compatibility" + +cleanup +trap - EXIT +node -e ' + const fs = require("node:fs"); + const path = require("node:path"); + const [todo7, todo11, isolation] = process.argv.slice(1); + const required = ["terminal.png", "terminal.txt", "metadata.json"]; + const observed = Object.fromEntries(required.map((name) => [ + name, fs.statSync(path.join(todo11, name)).size + ])); + const remaining = fs.readdirSync("/proc", { withFileTypes: true }) + .filter((entry) => entry.isDirectory() && /^\d+$/u.test(entry.name) && Number(entry.name) !== process.pid) + .map((entry) => { + try { return fs.readFileSync(`/proc/${entry.name}/cmdline`, "utf8"); } + catch { return ""; } + }) + .filter((command) => command.includes(isolation)); + const receipt = { + isolatedProcessesRemaining: remaining.length, + isolationRootRemoved: !fs.existsSync(isolation), + npmCacheRemoved: !fs.existsSync(isolation), + nodeModulesRemoved: !fs.existsSync(path.join(process.cwd(), "scripts", "xterm-harness", "node_modules")), + visualArtifactsNonEmpty: Object.values(observed).every((size) => size > 0), + artifactBytes: observed + }; + const data = JSON.stringify(receipt, null, 2) + "\n"; + fs.writeFileSync(path.join(todo11, "cleanup-receipt.json"), data); + fs.writeFileSync(path.join(todo7, "cleanup-receipt.json"), data); +' "$todo7_evidence" "$todo11_evidence" "$isolation_root" diff --git a/scripts/terminal-smoke-xterm b/scripts/terminal-smoke-xterm new file mode 100755 index 0000000..ff34da3 --- /dev/null +++ b/scripts/terminal-smoke-xterm @@ -0,0 +1,62 @@ +#!/usr/bin/env bash +set -euo pipefail + +repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +harness_dir="$repo_root/scripts/xterm-harness" +ansi="" +evidence_dir="" +title="UAM xterm visual QA" + +while (($#)); do + case "$1" in + --ansi) ansi="$2"; shift 2 ;; + --evidence-dir) evidence_dir="$2"; shift 2 ;; + --title) title="$2"; shift 2 ;; + --non-interactive) shift ;; + *) echo "unknown argument: $1" >&2; exit 2 ;; + esac +done + +if [[ -z "$ansi" || -z "$evidence_dir" || "$evidence_dir" != /* ]]; then + echo "--ansi and absolute --evidence-dir are required" >&2 + exit 2 +fi +mkdir -p "$evidence_dir" +scratch="$(mktemp -d)" +cleanup() { + case "$scratch" in + /tmp/tmp.*) + if [[ -d "$harness_dir/node_modules" ]]; then + find "$harness_dir/node_modules" -depth -delete + fi + find "$scratch" -depth -delete + ;; + *) exit 70 ;; + esac +} +trap cleanup EXIT + +npm ci --prefix "$harness_dir" --ignore-scripts --no-audit --cache "$scratch/npm-cache" +UAM_CHROME_PROFILE="$scratch/chrome-profile" \ + node "$harness_dir/render.mjs" --ansi "$ansi" --evidence-dir "$evidence_dir" --title "$title" +cleanup +trap - EXIT +node -e ' + const fs = require("node:fs"); + const path = require("node:path"); + const [dir, scratch, harness] = process.argv.slice(1); + const chrome = fs.readdirSync("/proc", { withFileTypes: true }) + .filter((entry) => entry.isDirectory() && /^\d+$/u.test(entry.name) && Number(entry.name) !== process.pid) + .map((entry) => { + try { return fs.readFileSync(`/proc/${entry.name}/cmdline`, "utf8"); } + catch { return ""; } + }) + .filter((command) => command.includes(scratch) && command.includes("chrome")); + const receipt = { + browser_processes_remaining: chrome.length, + chrome_profile_removed: !fs.existsSync(scratch), + npm_cache_removed: !fs.existsSync(scratch), + node_modules_removed: !fs.existsSync(path.join(harness, "node_modules")) + }; + fs.writeFileSync(path.join(dir, "xterm-cleanup-receipt.json"), JSON.stringify(receipt, null, 2) + "\n"); +' "$evidence_dir" "$scratch" "$harness_dir" diff --git a/scripts/xterm-harness/index.html b/scripts/xterm-harness/index.html new file mode 100644 index 0000000..7488b34 --- /dev/null +++ b/scripts/xterm-harness/index.html @@ -0,0 +1,46 @@ + + + + + UAM terminal visual harness + + + + + +
+ + + diff --git a/scripts/xterm-harness/package-lock.json b/scripts/xterm-harness/package-lock.json new file mode 100644 index 0000000..0cffdcb --- /dev/null +++ b/scripts/xterm-harness/package-lock.json @@ -0,0 +1,25 @@ +{ + "name": "uam-xterm-visual-harness", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "uam-xterm-visual-harness", + "version": "1.0.0", + "license": "MIT", + "dependencies": { + "@xterm/xterm": "6.0.0" + } + }, + "node_modules/@xterm/xterm": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/@xterm/xterm/-/xterm-6.0.0.tgz", + "integrity": "sha512-TQwDdQGtwwDt+2cgKDLn0IRaSxYu1tSUjgKarSDkUM0ZNiSRXFpjxEsvc/Zgc5kq5omJ+V0a8/kIM2WD3sMOYg==", + "license": "MIT", + "workspaces": [ + "addons/*" + ] + } + } +} diff --git a/scripts/xterm-harness/package.json b/scripts/xterm-harness/package.json new file mode 100644 index 0000000..bdcb5b2 --- /dev/null +++ b/scripts/xterm-harness/package.json @@ -0,0 +1,10 @@ +{ + "name": "uam-xterm-visual-harness", + "version": "1.0.0", + "private": true, + "type": "module", + "license": "MIT", + "dependencies": { + "@xterm/xterm": "6.0.0" + } +} diff --git a/scripts/xterm-harness/render.mjs b/scripts/xterm-harness/render.mjs new file mode 100644 index 0000000..3b0e455 --- /dev/null +++ b/scripts/xterm-harness/render.mjs @@ -0,0 +1,208 @@ +import { createHash } from "node:crypto"; +import { spawn } from "node:child_process"; +import { readFile, writeFile } from "node:fs/promises"; +import { pathToFileURL } from "node:url"; +import path from "node:path"; +import process from "node:process"; + +const args = new Map(); +for (let index = 2; index < process.argv.length; index += 2) { + args.set(process.argv[index], process.argv[index + 1]); +} +const source = args.get("--ansi"); +const outputDir = args.get("--evidence-dir"); +const title = args.get("--title") ?? "UAM xterm visual QA"; +if (!source || !outputDir || !path.isAbsolute(outputDir)) { + throw new Error("--ansi and absolute --evidence-dir are required"); +} + +const harnessDir = path.dirname(new URL(import.meta.url).pathname); +const htmlURL = pathToFileURL(path.join(harnessDir, "index.html")).href; +const chrome = process.env.UAM_CHROME_BIN ?? "google-chrome"; +const profileDir = process.env.UAM_CHROME_PROFILE; +if (!profileDir || !path.isAbsolute(profileDir)) { + throw new Error("absolute UAM_CHROME_PROFILE is required"); +} + +class CDP { + constructor(url) { + this.nextID = 1; + this.pending = new Map(); + this.socket = new WebSocket(url); + this.socket.onmessage = (event) => { + const message = JSON.parse(event.data); + if (message.id && this.pending.has(message.id)) { + const { resolve, reject } = this.pending.get(message.id); + this.pending.delete(message.id); + if (message.error) reject(new Error(JSON.stringify(message.error))); + else resolve(message.result); + } + }; + } + + async ready() { + if (this.socket.readyState === WebSocket.OPEN) return; + await new Promise((resolve, reject) => { + this.socket.onopen = resolve; + this.socket.onerror = reject; + }); + } + + async send(method, params = {}) { + await this.ready(); + const id = this.nextID++; + const result = new Promise((resolve, reject) => this.pending.set(id, { resolve, reject })); + this.socket.send(JSON.stringify({ id, method, params })); + return result; + } + + close() { + this.socket.close(); + } +} + +function browserEndpoint(child) { + return new Promise((resolve, reject) => { + let stderr = ""; + const timer = setTimeout(() => reject(new Error(`Chrome endpoint timeout: ${stderr}`)), 10000); + child.stderr.on("data", (chunk) => { + stderr += chunk; + const match = stderr.match(/DevTools listening on (ws:\/\/\S+)/u); + if (match) { + clearTimeout(timer); + resolve(match[1]); + } + }); + child.once("exit", (code) => { + clearTimeout(timer); + reject(new Error(`Chrome exited before endpoint, code=${code}: ${stderr}`)); + }); + }); +} + +async function waitForHarness(cdp) { + for (let attempt = 0; attempt < 100; attempt += 1) { + const state = await cdp.send("Runtime.evaluate", { + expression: "document.readyState === 'complete' && typeof window.renderAnsi === 'function'", + returnByValue: true + }); + if (state.result.value === true) return; + await new Promise((resolve) => setTimeout(resolve, 50)); + } + throw new Error("xterm harness did not become ready"); +} + +async function renderANSI(cdp, ansi, title) { + const render = await cdp.send("Runtime.evaluate", { + expression: `window.renderAnsi(${JSON.stringify(ansi.toString("base64"))}, ${JSON.stringify(title)})`, + awaitPromise: true, + returnByValue: true + }); + if (render.exceptionDetails) throw new Error(JSON.stringify(render.exceptionDetails)); + const text = await cdp.send("Runtime.evaluate", { + expression: "window.__terminalText", + returnByValue: true + }); + const capture = await cdp.send("Page.captureScreenshot", { + format: "png", + captureBeyondViewport: false + }); + return { + terminal: render.result.value, + text: text.result.value, + png: Buffer.from(capture.data, "base64") + }; +} + +const child = spawn(chrome, [ + "--headless=new", + "--no-sandbox", + "--disable-dev-shm-usage", + "--disable-background-networking", + "--hide-scrollbars", + "--remote-allow-origins=*", + "--remote-debugging-port=0", + `--user-data-dir=${profileDir}`, + "--window-size=1280,900", + "about:blank" +], { stdio: ["ignore", "ignore", "pipe"] }); +let browser; +let page; +try { + const endpoint = await browserEndpoint(child); + browser = new CDP(endpoint); + const version = await browser.send("Browser.getVersion"); + const target = await browser.send("Target.createTarget", { url: htmlURL }); + const listURL = new URL(endpoint); + listURL.protocol = "http:"; + listURL.pathname = "/json/list"; + let pageInfo; + for (let attempt = 0; attempt < 100 && !pageInfo; attempt += 1) { + const targets = await fetch(listURL).then((response) => response.json()); + pageInfo = targets.find((item) => item.id === target.targetId); + if (!pageInfo) await new Promise((resolve) => setTimeout(resolve, 50)); + } + if (!pageInfo) throw new Error("Chrome page target not found"); + page = new CDP(pageInfo.webSocketDebuggerUrl); + await page.send("Page.enable"); + await page.send("Runtime.enable"); + await page.send("Emulation.setDeviceMetricsOverride", { + width: 1280, + height: 900, + deviceScaleFactor: 1, + mobile: false + }); + await waitForHarness(page); + const layout = await page.send("Page.getLayoutMetrics"); + const viewport = { + width: layout.cssLayoutViewport.clientWidth, + height: layout.cssLayoutViewport.clientHeight + }; + if (viewport.width !== 1280 || viewport.height !== 900) { + throw new Error(`unexpected capture viewport ${viewport.width}x${viewport.height}`); + } + + const ansi = await readFile(source); + const selection = await renderANSI(page, ansi, `${title} - selection`); + const detailsMarker = Buffer.from("effective: focused"); + const restoreMarker = Buffer.from("\u001b[32;H\u001b[H"); + const detailsAt = ansi.indexOf(detailsMarker); + const restoreAt = ansi.indexOf(restoreMarker, detailsAt); + if (detailsAt < 0 || restoreAt < 0) { + throw new Error("real PTY stream lacks details/effective-profile state boundaries"); + } + await page.send("Runtime.evaluate", { expression: "window.resetTerminal()" }); + const details = await renderANSI(page, ansi.subarray(0, restoreAt), `${title} - details`); + const xtermPackage = JSON.parse(await readFile(path.join(harnessDir, "node_modules", "@xterm", "xterm", "package.json"), "utf8")); + const metadata = { + renderer: "@xterm/xterm", + xtermVersion: xtermPackage.version, + browser: version.product, + protocol: version.protocolVersion, + viewport, + terminal: details.terminal, + states: { + selection: { png: "terminal-selection.png", text: "terminal-selection.txt" }, + details: { png: "terminal.png", text: "terminal.txt" } + }, + source: path.resolve(source), + ansiSHA256: createHash("sha256").update(ansi).digest("hex"), + pngSHA256: createHash("sha256").update(details.png).digest("hex"), + textSHA256: createHash("sha256").update(details.text).digest("hex"), + selectionPNG_SHA256: createHash("sha256").update(selection.png).digest("hex") + }; + await writeFile(path.join(outputDir, "terminal-selection.png"), selection.png); + await writeFile(path.join(outputDir, "terminal-selection.txt"), selection.text); + await writeFile(path.join(outputDir, "terminal.png"), details.png); + await writeFile(path.join(outputDir, "terminal.txt"), details.text); + await writeFile(path.join(outputDir, "terminal-ansi.txt"), ansi); + await writeFile(path.join(outputDir, "metadata.json"), `${JSON.stringify(metadata, null, 2)}\n`); +} finally { + if (page) page.close(); + if (browser) browser.close(); + child.kill("SIGTERM"); + await new Promise((resolve) => { + if (child.exitCode !== null) resolve(); + else child.once("exit", resolve); + }); +} diff --git a/testdata/todo11/fixtures.json b/testdata/todo11/fixtures.json new file mode 100644 index 0000000..343d033 --- /dev/null +++ b/testdata/todo11/fixtures.json @@ -0,0 +1,12 @@ +{ + "input": [ + {"name": "printable_utf8", "hex": "68656c6c6f20e4b896e7958c"}, + {"name": "arrows_modifiers", "hex": "1b5b411b5b421b5b313b35431b5b313b3244"}, + {"name": "control_prefix_literal", "hex": "0202"}, + {"name": "sgr_mouse", "hex": "1b5b3c303b31323b374d1b5b3c303b31323b376d"}, + {"name": "bracketed_paste", "hex": "1b5b3230307e706173746520e697a5e69cace8aa9e1b5b3230317e"}, + {"name": "focus", "hex": "1b5b491b5b4f"} + ], + "provider_output_hex": "6d61696e2d73637265656e0d0a1b5b3f31303439681b5b33316d616c742d73637265656e1b5b306d1b5b3f313034396c726573746f7265640d0a1b5b3f32303034681b5b3f3130303468544f444f31312d5245414459", + "malformed_escape_hex": "1b5b3f313a323b31303439681b5b31321b5b3f313034396cff00" +} From ebea2015882b1d1e4d6fcb6e2b73a45363edc53f Mon Sep 17 00:00:00 2001 From: Amit Kumar Date: Fri, 24 Jul 2026 04:09:05 +0000 Subject: [PATCH 2/2] fix: harden framing and Darwin socket tests --- .../session/attach_cleanup_acceptance_test.go | 2 +- internal/session/attach_handshake_test.go | 4 +-- .../attach_protocol_integration_test.go | 2 +- .../session/attach_protocol_manual_test.go | 2 +- internal/session/attach_protocol_test.go | 2 +- internal/session/attach_signal_test.go | 2 +- internal/session/attach_todo6_pin_test.go | 2 +- .../session/client_registry_manual_test.go | 6 +++- internal/session/host_control_frame_test.go | 2 +- .../session/host_controller_epoch_test.go | 8 ++--- internal/session/proto.go | 13 +++++++-- internal/session/proto_test.go | 29 +++++++++++++++++++ internal/session/socket_test.go | 20 +++++++++++++ .../session/todo11_protocol_client_test.go | 6 +++- internal/session/todo11_real_host_test.go | 11 +++++-- internal/session/todo9_ownership_test.go | 2 +- .../session/todo9_resize_generation_test.go | 8 ++--- .../session/todo9_service_integration_test.go | 10 ++++++- 18 files changed, 105 insertions(+), 26 deletions(-) create mode 100644 internal/session/socket_test.go diff --git a/internal/session/attach_cleanup_acceptance_test.go b/internal/session/attach_cleanup_acceptance_test.go index 155b1b7..8116132 100644 --- a/internal/session/attach_cleanup_acceptance_test.go +++ b/internal/session/attach_cleanup_acceptance_test.go @@ -185,7 +185,7 @@ func testTodo6CodexScrollback(t *testing.T) { func todo6AttachListener(t *testing.T, name string) (string, string, net.Listener) { t.Helper() - dir := t.TempDir() + dir := socketTestDir(t) if err := EnsureDir(dir); err != nil { t.Fatal(err) } diff --git a/internal/session/attach_handshake_test.go b/internal/session/attach_handshake_test.go index 7615a51..a5f3790 100644 --- a/internal/session/attach_handshake_test.go +++ b/internal/session/attach_handshake_test.go @@ -14,7 +14,7 @@ import ( ) func TestAttachHandshakeInterruptionLeavesTerminalUntouched(t *testing.T) { - dir := t.TempDir() + dir := socketTestDir(t) if err := EnsureDir(dir); err != nil { t.Fatal(err) } @@ -64,7 +64,7 @@ func TestAttachHandshakeInterruptionLeavesTerminalUntouched(t *testing.T) { } func TestAttachNegotiatesBeforeScreenOwnership(t *testing.T) { - dir := t.TempDir() + dir := socketTestDir(t) if err := EnsureDir(dir); err != nil { t.Fatal(err) } diff --git a/internal/session/attach_protocol_integration_test.go b/internal/session/attach_protocol_integration_test.go index c6aefdf..6bc2001 100644 --- a/internal/session/attach_protocol_integration_test.go +++ b/internal/session/attach_protocol_integration_test.go @@ -15,7 +15,7 @@ import ( func TestAttachProtocolCompatibilityMatrix(t *testing.T) { t.Run("v2 client consumes unversioned v1 host output as raw bytes", func(t *testing.T) { - dir := t.TempDir() + dir := socketTestDir(t) if err := EnsureDir(dir); err != nil { t.Fatal(err) } diff --git a/internal/session/attach_protocol_manual_test.go b/internal/session/attach_protocol_manual_test.go index 29ba389..4baf075 100644 --- a/internal/session/attach_protocol_manual_test.go +++ b/internal/session/attach_protocol_manual_test.go @@ -67,7 +67,7 @@ func TestAttachProtocolRealPTYFixture(t *testing.T) { t.Fatalf("v2 handshake = %+v, %v", v2Resp, err) } v2Payload := []byte{0x00, 0xff, 'V', '2', '\r', '\n'} - if err := writeFrame(v2Conn, frameStdin, ownedFramePayload(v2Resp.Generation, v2Payload)); err != nil { + if err := writeFrame(v2Conn, frameStdin, mustOwnedFramePayload(t, v2Resp.Generation, v2Payload)); err != nil { t.Fatal(err) } if err := v2Conn.SetReadDeadline(time.Now().Add(5 * time.Second)); err != nil { diff --git a/internal/session/attach_protocol_test.go b/internal/session/attach_protocol_test.go index e4006a1..44aede7 100644 --- a/internal/session/attach_protocol_test.go +++ b/internal/session/attach_protocol_test.go @@ -17,7 +17,7 @@ import ( ) func TestAttachHandshakeTimeout(t *testing.T) { - dir := t.TempDir() + dir := socketTestDir(t) if err := EnsureDir(dir); err != nil { t.Fatal(err) } diff --git a/internal/session/attach_signal_test.go b/internal/session/attach_signal_test.go index 5647760..372c72f 100644 --- a/internal/session/attach_signal_test.go +++ b/internal/session/attach_signal_test.go @@ -17,7 +17,7 @@ func testTodo6SignalCleanup(t *testing.T) { for _, assignedRole := range []clientRole{roleController, roleStandby, roleObserver} { t.Run(string(assignedRole), func(t *testing.T) { // Given - dir := t.TempDir() + dir := socketTestDir(t) if err := EnsureDir(dir); err != nil { t.Fatal(err) } diff --git a/internal/session/attach_todo6_pin_test.go b/internal/session/attach_todo6_pin_test.go index 1da2820..930c974 100644 --- a/internal/session/attach_todo6_pin_test.go +++ b/internal/session/attach_todo6_pin_test.go @@ -14,7 +14,7 @@ import ( func TestTodo6PINPreservesV1CodexAndCleanupContracts(t *testing.T) { // Given: an unversioned v1 host behind a legacy Codex session name. - dir := t.TempDir() + dir := socketTestDir(t) if err := EnsureDir(dir); err != nil { t.Fatal(err) } diff --git a/internal/session/client_registry_manual_test.go b/internal/session/client_registry_manual_test.go index 94bd950..23b8f34 100644 --- a/internal/session/client_registry_manual_test.go +++ b/internal/session/client_registry_manual_test.go @@ -97,7 +97,11 @@ func (attached *monitoredAttach) writeControlFrame(kind byte, payload []byte) er attached.mu.Lock() generation := attached.generation attached.mu.Unlock() - return writeFrame(attached.conn, kind, ownedFramePayload(generation, payload)) + framed, err := ownedFramePayload(generation, payload) + if err != nil { + return err + } + return writeFrame(attached.conn, kind, framed) } func (attached *monitoredAttach) setGeneration(generation uint64) { diff --git a/internal/session/host_control_frame_test.go b/internal/session/host_control_frame_test.go index 65626ce..f525832 100644 --- a/internal/session/host_control_frame_test.go +++ b/internal/session/host_control_frame_test.go @@ -11,7 +11,7 @@ func TestV2ControlFramesRequireOwnershipEpoch(t *testing.T) { if _, _, valid := host.parseResizeFrame(client, resizePayload(80, 24)); valid { t.Fatal("v2 resize without an ownership epoch was accepted") } - if _, _, valid := host.parseResizeFrame(client, ownedFramePayload(1, []byte{0, 80, 0})); valid { + if _, _, valid := host.parseResizeFrame(client, mustOwnedFramePayload(t, 1, []byte{0, 80, 0})); valid { t.Fatal("v2 resize with an invalid payload was accepted") } } diff --git a/internal/session/host_controller_epoch_test.go b/internal/session/host_controller_epoch_test.go index 3e8e875..d686af3 100644 --- a/internal/session/host_controller_epoch_test.go +++ b/internal/session/host_controller_epoch_test.go @@ -39,7 +39,7 @@ func TestQueuedStandbyFramesCannotCrossPromotion(t *testing.T) { name: "stdin", kind: frameStdin, payload: func(generation uint64) []byte { - return ownedFramePayload(generation, []byte("stale-input")) + return mustOwnedFramePayload(t, generation, []byte("stale-input")) }, assert: func(t *testing.T, _ *host, ptyOutput *os.File) { t.Helper() @@ -57,7 +57,7 @@ func TestQueuedStandbyFramesCannotCrossPromotion(t *testing.T) { name: "resize", kind: frameResize, payload: func(generation uint64) []byte { - return ownedFramePayload(generation, resizePayload(111, 33)) + return mustOwnedFramePayload(t, generation, resizePayload(111, 33)) }, assert: func(t *testing.T, host *host, _ *os.File) { t.Helper() @@ -73,7 +73,7 @@ func TestQueuedStandbyFramesCannotCrossPromotion(t *testing.T) { name: "future stdin", kind: frameStdin, payload: func(generation uint64) []byte { - return ownedFramePayload(generation+2, []byte("future-input")) + return mustOwnedFramePayload(t, generation+2, []byte("future-input")) }, assert: func(t *testing.T, _ *host, ptyOutput *os.File) { t.Helper() @@ -91,7 +91,7 @@ func TestQueuedStandbyFramesCannotCrossPromotion(t *testing.T) { name: "future resize", kind: frameResize, payload: func(generation uint64) []byte { - return ownedFramePayload(generation+2, resizePayload(112, 34)) + return mustOwnedFramePayload(t, generation+2, resizePayload(112, 34)) }, assert: func(t *testing.T, host *host, _ *os.File) { t.Helper() diff --git a/internal/session/proto.go b/internal/session/proto.go index 11ebf50..8611a90 100644 --- a/internal/session/proto.go +++ b/internal/session/proto.go @@ -160,11 +160,14 @@ const maxFrameLen = 1 << 20 const ownershipEpochLen = 8 -func ownedFramePayload(generation uint64, payload []byte) []byte { +func ownedFramePayload(generation uint64, payload []byte) ([]byte, error) { + if len(payload) > maxFrameLen-ownershipEpochLen { + return nil, fmt.Errorf("%w: %d bytes with ownership epoch", errFrameTooLarge, len(payload)) + } framed := make([]byte, ownershipEpochLen+len(payload)) binary.BigEndian.PutUint64(framed, generation) copy(framed[ownershipEpochLen:], payload) - return framed + return framed, nil } func parseOwnedFramePayload(payload []byte) (uint64, []byte, error) { @@ -338,7 +341,11 @@ func (w *frameWriter) WriteFrame(kind byte, payload []byte) error { w.mu.Lock() defer w.mu.Unlock() if w.version == protocolV2 && (kind == frameStdin || kind == frameResize) { - payload = ownedFramePayload(w.generation, payload) + var err error + payload, err = ownedFramePayload(w.generation, payload) + if err != nil { + return err + } } return writeFrame(w.w, kind, payload) } diff --git a/internal/session/proto_test.go b/internal/session/proto_test.go index 6c170a9..6d2e46a 100644 --- a/internal/session/proto_test.go +++ b/internal/session/proto_test.go @@ -10,6 +10,35 @@ import ( "testing" ) +func mustOwnedFramePayload(t *testing.T, generation uint64, payload []byte) []byte { + t.Helper() + framed, err := ownedFramePayload(generation, payload) + if err != nil { + t.Fatal(err) + } + return framed +} + +func TestOwnedFramePayloadBoundsAllocation(t *testing.T) { + maximum := make([]byte, maxFrameLen-ownershipEpochLen) + framed, err := ownedFramePayload(1, maximum) + if err != nil { + t.Fatalf("maximum owned payload: %v", err) + } + if len(framed) != maxFrameLen { + t.Fatalf("framed payload length = %d, want %d", len(framed), maxFrameLen) + } + + oversized := make([]byte, maxFrameLen-ownershipEpochLen+1) + if _, err := ownedFramePayload(1, oversized); !errors.Is(err, errFrameTooLarge) { + t.Fatalf("oversized owned payload error = %v, want %v", err, errFrameTooLarge) + } + writer := newAttachFrameWriter(io.Discard, protocolV2, "client-1", 1) + if err := writer.WriteFrame(frameStdin, oversized); !errors.Is(err, errFrameTooLarge) { + t.Fatalf("oversized writer payload error = %v, want %v", err, errFrameTooLarge) + } +} + func TestAttachV1CharacterizationHandshakeAndRawOutput(t *testing.T) { client, server := net.Pipe() t.Cleanup(func() { diff --git a/internal/session/socket_test.go b/internal/session/socket_test.go new file mode 100644 index 0000000..b22a883 --- /dev/null +++ b/internal/session/socket_test.go @@ -0,0 +1,20 @@ +package session + +import ( + "os" + "testing" +) + +func socketTestDir(t *testing.T) string { + t.Helper() + dir, err := os.MkdirTemp("", "uam-sock-") + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { + if err := os.RemoveAll(dir); err != nil { + t.Errorf("remove socket test directory: %v", err) + } + }) + return dir +} diff --git a/internal/session/todo11_protocol_client_test.go b/internal/session/todo11_protocol_client_test.go index 465ab82..03f737a 100644 --- a/internal/session/todo11_protocol_client_test.go +++ b/internal/session/todo11_protocol_client_test.go @@ -129,7 +129,11 @@ func (attached *todo11ProtocolAttach) awaitController(t *testing.T, transcript * } func (attached *todo11ProtocolAttach) write(kind byte, payload []byte) error { - return writeFrame(attached.conn, kind, ownedFramePayload(attached.generation, payload)) + framed, err := ownedFramePayload(attached.generation, payload) + if err != nil { + return err + } + return writeFrame(attached.conn, kind, framed) } func (attached *todo11ProtocolAttach) close() { diff --git a/internal/session/todo11_real_host_test.go b/internal/session/todo11_real_host_test.go index 3e79d48..09839a5 100644 --- a/internal/session/todo11_real_host_test.go +++ b/internal/session/todo11_real_host_test.go @@ -38,7 +38,7 @@ func todo11StartHost(t *testing.T, fixture todo11Fixture, index int) *todo11Host if err != nil { t.Fatal(err) } - runtimeDir := t.TempDir() + runtimeDir := socketTestDir(t) reportPath := filepath.Join(t.TempDir(), "provider.jsonl") diagnosticsPath := filepath.Join(t.TempDir(), "host-diagnostics.jsonl") cwd := t.TempDir() @@ -75,7 +75,14 @@ func todo11StartHost(t *testing.T, fixture todo11Fixture, index int) *todo11Host line, err := bufio.NewReader(readyReader).ReadString('\n') _ = readyReader.Close() if err != nil || line != "ok\n" { - t.Fatalf("host readiness = %q, %v; stderr=%s", line, err, hostStderr.String()) + select { + case waitErr := <-done: + t.Fatalf("host readiness = %q, %v; host wait=%v; stderr=%s", line, err, waitErr, hostStderr.String()) + case <-time.After(10 * time.Second): + _ = hostCommand.Process.Kill() + <-done + t.Fatalf("host readiness = %q, %v; host did not exit; stderr=%s", line, err, hostStderr.String()) + } } harness := &todo11HostHarness{ client: &Client{Dir: runtimeDir, Exe: executable}, name: name, diff --git a/internal/session/todo9_ownership_test.go b/internal/session/todo9_ownership_test.go index d4b252f..aff5979 100644 --- a/internal/session/todo9_ownership_test.go +++ b/internal/session/todo9_ownership_test.go @@ -24,7 +24,7 @@ type todo9Server struct { func todo9StartServer(t *testing.T, h *host) *todo9Server { t.Helper() - dir := t.TempDir() + dir := socketTestDir(t) if err := EnsureDir(dir); err != nil { t.Fatal(err) } diff --git a/internal/session/todo9_resize_generation_test.go b/internal/session/todo9_resize_generation_test.go index 1584b74..4f78382 100644 --- a/internal/session/todo9_resize_generation_test.go +++ b/internal/session/todo9_resize_generation_test.go @@ -14,10 +14,10 @@ func TestResizeGenerationRejectsStaleFrames(t *testing.T) { frames := [][]byte{ resizePayload(120, 40), - ownedFramePayload(stale, resizePayload(121, 41)), - ownedFramePayload(standby.generation+1, resizePayload(122, 42)), - ownedFramePayload(standby.generation, resizePayload(0, 0)), - ownedFramePayload(standby.generation, resizePayload(1001, 24)), + mustOwnedFramePayload(t, stale, resizePayload(121, 41)), + mustOwnedFramePayload(t, standby.generation+1, resizePayload(122, 42)), + mustOwnedFramePayload(t, standby.generation, resizePayload(0, 0)), + mustOwnedFramePayload(t, standby.generation, resizePayload(1001, 24)), } for _, frame := range frames { h.handleResizeFrame(standby, frame) diff --git a/internal/session/todo9_service_integration_test.go b/internal/session/todo9_service_integration_test.go index 8c63d7e..dceff0f 100644 --- a/internal/session/todo9_service_integration_test.go +++ b/internal/session/todo9_service_integration_test.go @@ -17,7 +17,15 @@ import ( ) func TestServiceReplyRealHostOwnershipIntegration(t *testing.T) { - runtimeDir := t.TempDir() + runtimeDir, err := os.MkdirTemp("", "uam-sock-") + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { + if err := os.RemoveAll(runtimeDir); err != nil { + t.Errorf("remove socket test directory: %v", err) + } + }) t.Setenv("UAM_CONFIG_DIR", filepath.Join(t.TempDir(), "config")) executable, err := os.Executable() if err != nil {