diff --git a/.gitignore b/.gitignore index a6b856b..e926419 100644 --- a/.gitignore +++ b/.gitignore @@ -8,6 +8,7 @@ coverage.* .vscode/ /.claude/worktrees/ /.worktrees/ +/.superpowers/ /CLAUDE.md /PLAN.md graphify_out/ diff --git a/README.md b/README.md index 31f1e64..f2bf71c 100644 --- a/README.md +++ b/README.md @@ -39,6 +39,8 @@ Supported providers: - Go 1.25+ to build from source (the pinned toolchain downloads automatically) - Any provider CLI you want to manage already installed and authenticated +- OpenCode 1.18.1 or newer when using the OpenCode provider. If UAM reports an + older version, run `opencode upgrade 1.18.1` before dispatching or resuming. That's it — agents are spawned directly under uam's own session hosts, so there is nothing else to install. @@ -48,8 +50,8 @@ from the dispatch UI instead of failing the whole app. ## Supported platforms -- Linux (Ubuntu), on amd64 and arm64 -- macOS, on Intel and Apple silicon +- Linux (Ubuntu), on AMD64 and ARM64 +- macOS, on AMD64 (Intel) and ARM64 (Apple silicon) - Native Windows is not supported. Windows Terminal and PowerShell can be used only as SSH clients connecting to a Linux or macOS host running `uam`. @@ -205,9 +207,9 @@ Provider behavior: heuristic. - **Copilot**: exact resume — the session is named with the uam id at dispatch (`--name`) and resumed by that exact name (`--resume=`). -- **OpenCode**: UAM learns the current root conversation ID and resumes it - exactly with `--session`. Older records without an identity use guarded - `-c` latest-conversation behavior. +- **OpenCode**: exact resume only. UAM learns the current root conversation ID + and resumes it with `--session`. A stopped legacy record without a valid + exact identity cannot be resumed; dispatch a new Managed Session instead. - **Oh My Pi**: new sessions receive a UAM-ID-specific provider state directory, making `-c` exact. Legacy records without that directory retain guarded latest-conversation behavior. @@ -231,6 +233,32 @@ Managed Session*. It intentionally does not create another UAM row or host. UAM tracks the newly selected root conversation for later exact resume. To get two independently attachable sessions, use `uam new` or `uam dispatch` again. +Each managed OpenCode terminal owns a private authenticated server bound to a +distinct loopback port. UAM uses that server to create or validate the exact +root conversation, attach to that exact ID, and observe `/new` root changes. +Consequently, two OpenCode sessions in the same Workspace retain independent +ports, credentials, terminal hosts, and provider conversation IDs. + +### OpenCode upgrade cleanup + +Current UAM releases do not create, inspect, repair, execute, or delete the +legacy identity plugin at +`$XDG_STATE_HOME/uam/providers/opencode/uam-identity-plugin.mjs` (under +`~/.local/state` when `XDG_STATE_HOME` is unset). A stale file there is inert; +its contents, type, ownership, or permissions cannot block OpenCode launch. + +No automatic cleanup is performed. If no older UAM installation still needs +that generated state, cleanup is optional. First inspect and verify the exact +UAM-generated directory, then remove only that directory: + +```sh +legacy_dir="${XDG_STATE_HOME:-$HOME/.local/state}/uam/providers/opencode" +printf 'Review before removal: %s\n' "$legacy_dir" +ls -la -- "$legacy_dir" +# After verifying the printed path and contents: +rm -rf -- "$legacy_dir" +``` + The terminology and compatibility decision are documented in [Managed Session vs. Provider Conversation](docs/adr/0001-managed-session-vs-provider-conversation.md). @@ -273,10 +301,11 @@ Use `uam dispatch --safe ...` when you want the provider's default approval behavior instead. Safe mode changes provider arguments; it is not an operating- system sandbox and does not reduce the permissions of the `uam` process itself. -OpenCode is capability-probed before launch. In default yolo mode, UAM adds -`--auto` only when the installed OpenCode advertises that flag; older versions -continue without an unsupported argument. This preserves compatibility but does -not turn OpenCode or UAM into a sandbox. +OpenCode keeps the same safety-mode contract as the other providers. Default +yolo mode automatically approves permission requests for the active root +conversation tree. `uam dispatch --safe ...` leaves OpenCode permission prompts +visible for the user and does not auto-reply. Safe mode still is not an +operating-system sandbox. `uam` does not make git checkpoints, stash changes, or modify your repository on its own. It starts and manages agent sessions; the provider remains responsible diff --git a/docs/research/2026-07-15-opencode-session-identity.md b/docs/research/2026-07-15-opencode-session-identity.md new file mode 100644 index 0000000..23faf93 --- /dev/null +++ b/docs/research/2026-07-15-opencode-session-identity.md @@ -0,0 +1,98 @@ +# OpenCode session identity research + +Date: 2026-07-15 +Scope: OpenCode v1.18.1 and UAM's `uam-identity-plugin.mjs` bridge + +## Conclusion + +Keep the generated identity plugin for the current UAM architecture. OpenCode +v1.18.1 has reliable native primitives for resuming an **existing** exact +session, but its supported TUI CLI cannot create a new session with a +caller-selected ID or print the ID of the session it creates. The plugin remains +the smallest supported way for UAM to learn the active root session after TUI +startup and after `/new`. + +There is a plugin-free design available through OpenCode's documented server +and SDK: start `opencode serve`, call `POST /session`, retain the returned ID, +then run `opencode attach --session `. That is not a drop-in change. +UAM would become responsible for a second process, local authentication, port +allocation, readiness, shutdown, and event correlation. `/new` would still +require watching the server event stream; a dedicated OpenCode server per UAM +managed session would make that correlation unambiguous. + +## Upstream facts + +- The latest non-draft, non-prerelease release at the time of research is + [v1.18.1](https://github.com/anomalyco/opencode/releases/tag/v1.18.1), + published 2026-07-14 at 21:37:54 UTC. +- The TUI accepts `--continue`/`-c` for the latest session and + `--session`/`-s` for an exact existing session. `--fork` creates a branch of + the selected session. These are documented in the + [CLI reference](https://github.com/anomalyco/opencode/blob/v1.18.1/packages/web/src/content/docs/cli.mdx) + and defined by the + [TUI command](https://github.com/anomalyco/opencode/blob/v1.18.1/packages/opencode/src/cli/cmd/tui.ts). +- `--session` is not a create-or-resume flag. The TUI validates the ID before + starting, and `opencode run --session` fetches the session and exits with + "Session not found" when it does not exist + ([validation source](https://github.com/anomalyco/opencode/blob/v1.18.1/packages/opencode/src/cli/tui/validate-session.ts), + [run source](https://github.com/anomalyco/opencode/blob/v1.18.1/packages/opencode/src/cli/cmd/run.ts#L456-L533)). +- The supported `POST /session` API creates a session and returns the resulting + `Session`, including its generated ID. Its public request accepts fields such + as `parentID`, `title`, `agent`, `model`, metadata, permissions, and workspace, + but not a caller-provided session ID + ([v1.18.1 OpenAPI](https://github.com/anomalyco/opencode/blob/v1.18.1/packages/sdk/openapi.json#L5404-L5508), + [server docs](https://github.com/anomalyco/opencode/blob/v1.18.1/packages/web/src/content/docs/server.mdx#L146-L167)). +- OpenCode officially supports `opencode serve`, an SDK client, an SSE event + stream at `GET /event`, and `opencode attach --session ` + ([server docs](https://github.com/anomalyco/opencode/blob/v1.18.1/packages/web/src/content/docs/server.mdx), + [SDK docs](https://github.com/anomalyco/opencode/blob/v1.18.1/packages/web/src/content/docs/sdk.mdx), + [attach source](https://github.com/anomalyco/opencode/blob/v1.18.1/packages/opencode/src/cli/cmd/attach.ts)). +- The OpenAPI also contains a newer `/api/session` surface whose create body can + accept an `id`. It should not yet be the UAM compatibility contract: upstream's + own V2 session specification describes the V2 event family as experimental and + unshipped and lists remaining V1 parity work + ([V2 specification](https://github.com/anomalyco/opencode/blob/v1.18.1/specs/v2/session.md), + [OpenAPI preview](https://github.com/anomalyco/opencode/blob/v1.18.1/packages/sdk/openapi.json#L10110-L10188)). +- Plugins and session events are a documented public extension mechanism. + `session.created` and `session.updated` are listed events, and the published + plugin type exposes `Hooks.event({ event })` + ([plugin docs](https://github.com/anomalyco/opencode/blob/v1.18.1/packages/web/src/content/docs/plugins.mdx#L126-L190), + [plugin types](https://github.com/anomalyco/opencode/blob/v1.18.1/packages/plugin/src/index.ts)). + The event schema includes both `properties.sessionID` and the full + `properties.info`, so UAM's current root-ID extraction shape remains valid in + v1.18.1 + ([event schema](https://github.com/anomalyco/opencode/blob/v1.18.1/packages/sdk/openapi.json#L33885-L33935)). + No explicit long-term event ABI guarantee was found, so UAM should keep its + compatibility tests and version probing. + +## Multiple sessions and `/new` + +OpenCode stores multiple root sessions in one project and can resume each by +exact ID. The ambiguous operation is `--continue`, which deliberately selects +the most recent root session. Therefore multiple concurrent UAM sessions in one +workspace are safe only after each UAM record has learned and retained its own +OpenCode ID; the existing `-c` fallback for legacy/unidentified records can +resume the wrong conversation. + +In v1.18.1, `/new` routes the TUI back to its home screen; the next submitted +prompt creates another root session +([TUI command source](https://github.com/anomalyco/opencode/blob/v1.18.1/packages/tui/src/app.tsx#L570-L594)). +UAM's plugin observes that new root `session.created` event and replaces the +identity handoff for that managed UAM session. Consequently, reattaching the UAM +session resumes the conversation created by `/new`; the previous OpenCode +conversation still exists but is no longer the one mapped to that UAM record. +This is expected from the current one-UAM-record-to-one-active-provider-session +model, not evidence that OpenCode lacks multiple sessions. + +## Recommendation + +1. Retain the plugin and add the guided permission bootstrap for UAM-owned + directories (`0700`) and the generated module (`0600`). +2. Add an OpenCode v1.18.1 compatibility test for `session.created` and + `session.updated`, including `/new` replacing only the owning UAM identity. +3. Keep `--session ` as the normal resume path and surface a clear + warning whenever UAM must fall back to `-c` in a workspace with more than one + retained OpenCode session. +4. Re-evaluate a plugin-free server adapter only when UAM is prepared to own the + server lifecycle, or after OpenCode promotes caller-supplied session creation + to its documented stable API/CLI. diff --git a/docs/superpowers/plans/2026-07-16-opencode-native-session.md b/docs/superpowers/plans/2026-07-16-opencode-native-session.md new file mode 100644 index 0000000..5ac5287 --- /dev/null +++ b/docs/superpowers/plans/2026-07-16-opencode-native-session.md @@ -0,0 +1,738 @@ +# Native OpenCode Session Integration Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Replace UAM's generated OpenCode identity plugin with an authenticated per-session OpenCode 1.18.1 server/attach supervisor that creates, resumes, and tracks exact native sessions without regressions. + +**Architecture:** The OpenCode adapter validates the selected OpenCode command, enforces version 1.18.1 or newer, and replaces the provider argv with an internal `uam __opencode` command. That supervisor owns a loopback-only authenticated `opencode serve`, creates or validates one exact root session over HTTP, runs `opencode attach --session`, mirrors OpenCode's auto-permission event response in yolo mode, and atomically hands changing root IDs to the existing native session host through the verified runtime directory. + +**Tech Stack:** Go 1.25.12, standard-library `net/http`, Server-Sent Events, `os/exec`, the existing native PTY/session backend, OpenCode 1.18.1 CLI and HTTP API, Go tests with race detection. + +## Global Constraints + +- Require OpenCode `>= 1.18.1`; reject older, prerelease-at-the-floor, timed-out, and malformed version probes with upgrade guidance. +- Support Linux and macOS on AMD64 and ARM64; Windows remains out of scope. +- Preserve CLI commands and flags, JSON list shape, provider aliases, prompt delivery, session names, store schema v3, unknown-field preservation, default yolo policy, and TUI layout. +- New OpenCode sessions never use `-c`; exact resume requires a validated retained `ProviderSessionID`. +- Never generate, execute, validate, or inject `uam-identity-plugin.mjs`; legacy files remain inert and are not deleted automatically. +- Bind OpenCode servers only to numeric `127.0.0.1` and authenticate HTTP, SSE, and attach with a fresh random password that never enters argv, logs, store data, runtime files, or terminal errors. +- Safe mode leaves OpenCode permission prompts untouched. Yolo mode replies `once` only to emitted `permission.asked` events, matching OpenCode 1.18.1 `--auto` and preserving explicit deny rules. +- Add no dependency and introduce no store migration or schema bump. +- Every production change starts with a test that fails for the intended reason. + +--- + +## Current State + +- `internal/adapter/opencode/opencode.go` injects `OPENCODE_CONFIG_CONTENT`, creates `uam-identity-plugin.mjs`, reads persistent provider state, probes `--help` for `--auto`, and falls back to `opencode -c` when no provider ID exists. +- `internal/adapter/agent.go:Agent.startSession` calls `PrepareLaunch`, appends `SessionArgs`, then launches only the provider command. `LaunchPreparation` cannot replace that command. +- `internal/session/host.go` already reads a provider-neutral identity handoff after child exit, and the process host already owns the PTY/process group needed by a supervisor. +- `internal/session/session.go` verifies a per-UID owner-only runtime directory and cleans only `.json` plus `.sock`. +- `internal/cli/cli.go:runWithoutStore` routes `__host` and `__attach` before store access; it has no OpenCode supervisor route or exact internal exit-code propagation. +- `internal/app/service_test.go:TestProductionProviderResumeKindMatrixThroughAmbiguityGuard` currently characterizes OpenCode-without-provider-ID as heuristic resume. +- The development machine has UAM `0.3.3` installed and OpenCode `1.17.18`; the approved floor is `1.18.1`. + +## Decisions + +1. **Per-UAM-session server, not a shared daemon.** It isolates `/new`, credentials, lifecycle, and permission events. Confidence: 98%. Risk: two OpenCode processes per UAM session; accepted because correctness and cleanup dominate small local overhead. +2. **Stable shipped HTTP surface, not caller-selected experimental V2 IDs.** `POST /session` returns the authoritative ID; `GET /session/:id` validates resume. Confidence: 97%. Rejected: unshipped `/api/session` caller IDs and heuristic `-c`. +3. **Existing PTY prompt path, not HTTP prompt submission.** `Backend.SendLine` queues the initial prompt until attach reads the PTY, preserving current delivery and avoiding a second prompt protocol. Confidence: 96%. Risk: attach-start ordering; covered with delayed-attach tests. +4. **Event reply parity, not blanket allow rules.** OpenCode evaluates deny/allow before emitting `permission.asked`; replying `once` to emitted asks exactly matches its 1.18.1 `--auto` path. Confidence: 99%, verified against tagged upstream source. +5. **Runtime identity file, not persistent provider state.** It reuses the verified `0700` runtime boundary, is cleaned with the session, and keeps schema v3 unchanged. Confidence: 98%. +6. **Small generic command override.** Add only `LaunchPreparation.Command []string`; non-OpenCode providers continue down the current path byte-for-byte. Confidence: 97%. + +## File Structure + +- Modify `internal/adapter/adapter.go`: add the optional launch-command override. +- Modify `internal/adapter/agent.go` and `internal/adapter/agent_test.go`: validate the normal provider command, then use a copied override when present. +- Create `internal/session/provider_identity.go` and `internal/session/provider_identity_test.go`: secure provider-neutral runtime identity read/write/path helpers. +- Modify `internal/session/session.go`, `internal/session/host.go`, and session tests: clean the identity file without losing the exit handoff. +- Replace `internal/adapter/opencode/opencode.go` with adapter wiring only. +- Create `internal/adapter/opencode/command.go` and `command_test.go`: direct-path/shell-alias command construction and minimum-version probe/cache. +- Create `internal/adapter/opencode/client.go` and `client_test.go`: authenticated health/session/permission/SSE client. +- Create `internal/adapter/opencode/supervisor.go` and `supervisor_test.go`: per-session server, attach, event tracking, permission parity, and cleanup. +- Modify `internal/adapter/opencode/opencode_test.go`: remove plugin implementation tests and add adapter-boundary regressions. +- Modify `internal/cli/cli.go` and `internal/cli/cli_test.go`: route `__opencode` before store access and preserve attach exit codes. +- Modify `internal/app/service_test.go`: make missing OpenCode identity unsupported instead of heuristic. +- Modify `README.md`: state the minimum version, exact-session behavior, and non-blocking legacy-file cleanup guidance. + +--- + +### Task 1: Characterize OpenCode 1.18.1 and Add the Generic Command Override + +**Objective:** Prove the local OpenCode contract and let one adapter replace its launch argv without changing other providers. + +**Prerequisites:** Approved design and clean `codex/opencode-native-session` branch. + +**Files:** +- Modify: `internal/adapter/adapter.go` +- Modify: `internal/adapter/agent.go` +- Test: `internal/adapter/agent_test.go` + +**Interfaces:** +- Produces: `LaunchPreparation.Command []string`. +- Consumes: existing `Agent.commandForRequest`, `Backend.CreateSession`, and defensive slice copying convention. + +- [ ] **Step 1: Upgrade and characterize the approved local binary** + +Run: + +```bash +rtk proxy opencode upgrade 1.18.1 +rtk proxy opencode --version +rtk proxy opencode serve --help +rtk proxy opencode attach --help +``` + +Expected: version output is exactly `1.18.1`; help lists `serve --hostname/--port` and `attach --session/--dir`, and attach does not list `--auto`. + +Failure signal: upgrade fails or the flags differ. Stop, capture the exact output, and revise the approved contract before code changes. + +- [ ] **Step 2: Write failing override tests** + +Add table-driven coverage to `internal/adapter/agent_test.go` with a fake `PrepareLaunch` returning: + +```go +adapter.LaunchPreparation{Command: []string{"/trusted/uam", "__opencode", "--name", sessionName}} +``` + +Assert `Backend.CreateSession` receives that exact command, mutating the original preparation slice after `Dispatch` cannot change the backend's captured command, an invalid `CommandAlias` still fails before `PrepareLaunch`, and a provider without `Command` retains its existing argv including yolo/session args. + +- [ ] **Step 3: Run the focused test and confirm the intended failure** + +Run: `rtk go test ./internal/adapter -run 'Test.*Launch.*Command|Test.*Preparation' -count=1` + +Expected before implementation: compile failure because `LaunchPreparation.Command` does not exist. + +- [ ] **Step 4: Implement the minimal override** + +Change `LaunchPreparation` in `internal/adapter/adapter.go` to: + +```go +type LaunchPreparation struct { + Command []string + ExtraArgs []string + Env map[string]string + ProviderSessionID string +} +``` + +In `Agent.startSession`, keep `commandForRequest(ctx, req, extra)` as the validation and compatibility path, then replace only a non-empty result: + +```go +cmd, err := a.commandForRequest(ctx, req, extra) +if err != nil { + return Session{}, err +} +if len(preparation.Command) > 0 { + cmd = append([]string(nil), preparation.Command...) +} +``` + +Reject a non-nil zero-length override by treating it as absent; do not add another interface or alter non-OpenCode argv. + +- [ ] **Step 5: Verify and commit** + +Run: + +```bash +rtk go test ./internal/adapter -count=1 +rtk go test ./internal/adapter/claude ./internal/adapter/codex ./internal/adapter/omp -count=1 +``` + +Expected: all packages pass and existing provider argv tests remain unchanged. + +Commit: `feat(adapter): support prepared command overrides` + +Rollback: revert this commit; no persisted or external state is involved. + +Dependencies: none. + +--- + +### Task 2: Add Secure Runtime Provider Identity Handoff + +**Objective:** Move live OpenCode identity into the verified native-session runtime boundary and remove it safely on shutdown. + +**Prerequisites:** Task 1 complete. + +**Files:** +- Create: `internal/session/provider_identity.go` +- Create: `internal/session/provider_identity_test.go` +- Modify: `internal/session/session.go` +- Modify: `internal/session/host.go` +- Modify: `internal/session/session_test.go` + +**Interfaces:** +- Produces: + +```go +func ProviderIdentityPath(dir, name string) (string, error) +func WriteProviderIdentity(dir, name, providerSessionID string) error +func ReadProviderIdentity(dir, name string) (string, error) +``` + +- Consumes: `ValidateName`, `VerifyDir`, `store.ValidProviderSessionID`, and `ProviderIdentityFileEnv`. + +- [ ] **Step 1: Write failing path/read/write/security tests** + +In `internal/session/provider_identity_test.go`, cover: + +- canonical path `/.provider.json` and invalid session-name rejection; +- atomic `0600` JSON round trip for `ses_abc123`; +- missing file returns `"", nil`; +- invalid provider ID is rejected before write; +- symlink, directory, mode `0644`, foreign owner when the platform permits, file larger than 1024 bytes, malformed JSON, and embedded session-name mismatch fail closed; +- a failed write leaves the previous valid identity readable; +- no `.tmp` file remains after success. + +Use the payload shape: + +```go +type providerIdentity struct { + SessionName string `json:"session_name"` + ProviderSessionID string `json:"provider_session_id"` +} +``` + +- [ ] **Step 2: Run the focused test and confirm the intended failure** + +Run: `rtk go test ./internal/session -run ProviderIdentity -count=1` + +Expected before implementation: compile failure for the three missing exported functions. + +- [ ] **Step 3: Implement secure identity helpers** + +In `provider_identity.go`: + +- set `const maxProviderIdentityBytes = 1024`; +- validate `name` before path construction and `providerSessionID` before writing; +- call `VerifyDir` before every read or write; +- write a `0600` temporary file in the same directory, call `Sync`, close it, rename it over the canonical path, and remove the temporary file on every error; +- inspect the final/read path with `os.Lstat`, require a current-user-owned regular non-symlink file with exact `0600` mode, then open with `unix.Open(..., unix.O_RDONLY|unix.O_NOFOLLOW|unix.O_CLOEXEC, 0)`; +- read through `io.LimitReader(file, maxProviderIdentityBytes+1)`, reject oversize, reject unknown trailing JSON values, require the embedded name to match, and revalidate with `store.ValidProviderSessionID`; +- return `"", nil` only for `os.ErrNotExist`. + +- [ ] **Step 4: Write the shutdown cleanup regression** + +Extend `internal/session/session_test.go` so a short-lived host writes `.provider.json`, exits immediately, persists that ID through `TryRecordSessionExit`, and removes state, socket, and provider identity files. Assert stale-host cleanup removes all three. + +- [ ] **Step 5: Preserve the value before cleanup** + +In `host.shutdown`, read `h.providerIdentityFile` before calling `removeSessionFiles`, pass the captured string into `recordExit(exitCode, providerID string)`, and add `ProviderIdentityPath(dir, name)` to the paths removed by `removeSessionFiles`. Keep identity-read failure advisory and sanitized through the existing host helper; never accept an unverified value. + +- [ ] **Step 6: Verify and commit** + +Run: + +```bash +rtk go test -race ./internal/session -count=1 +rtk go test ./internal/store -count=1 +``` + +Expected: all session/store tests pass, the identity survives long enough to reach the store, and no runtime file remains after exit. + +Commit: `feat(session): add secure runtime provider identity` + +Rollback: revert this commit; runtime identity files are ephemeral and schema-free. + +Dependencies: Task 1 only for final wiring, not for helper behavior. + +--- + +### Task 3: Build Provider Command and Minimum-Version Validation + +**Objective:** Execute the same validated OpenCode path or shell alias for probe, serve, and attach, and reject unsupported versions before session creation. + +**Prerequisites:** Task 1 complete; local OpenCode 1.18.1 characterization passed. + +**Files:** +- Create: `internal/adapter/opencode/command.go` +- Create: `internal/adapter/opencode/command_test.go` + +**Interfaces:** +- Produces: + +```go +const minimumVersion = "1.18.1" + +type providerCommand struct { + path string + shell string + alias string +} + +func providerCommandFor(req adapter.ResumeRequest) (providerCommand, error) +func providerCommandFromFlags(path, shell, alias string) (providerCommand, error) +func (c providerCommand) argv(args ...string) []string +func (c providerCommand) command(ctx context.Context, args ...string) *exec.Cmd +func requireMinimumVersion(ctx context.Context, command providerCommand) error +``` + +- Consumes: `req.ExecutablePath`, validated `req.CommandAlias`, absolute `$SHELL`, and `adapter.ShellJoin`. + +- [ ] **Step 1: Write failing command-construction tests** + +Cover a direct absolute executable and a shell alias. Direct `argv("serve")` must be `[]string{path, "serve"}`. Alias argv must be: + +```go +[]string{shell, "-ic", "exec " + adapter.ShellJoin([]string{alias, "serve"})} +``` + +Reject relative paths, non-regular direct paths, empty path-and-alias, alias with unsafe characters, relative shell paths, and simultaneous direct path plus alias. + +- [ ] **Step 2: Write failing version tests** + +Use helper executables to return `1.18.0`, `1.18.1`, `1.19.0`, `2.0.0`, `1.18.1-beta.1`, malformed output, nonzero exit, and a response delayed beyond 750 ms. Assert only stable versions at or above `1.18.1` pass, and every error includes sanitized detected output, the required version, and `opencode upgrade 1.18.1` without leaking environment values. + +Add a direct-file cache test: two probes of unchanged inode/size/mtime execute once; replacing the file changes the identity and executes again. Do not cache shell aliases because shell configuration may change without shell-binary metadata changing. + +- [ ] **Step 3: Run and confirm failure** + +Run: `rtk go test ./internal/adapter/opencode -run 'ProviderCommand|MinimumVersion' -count=1` + +Expected before implementation: compile failure for the new types/functions. + +- [ ] **Step 4: Implement command validation and version comparison** + +Use an internal numeric version type: + +```go +type semanticVersion struct { + major, minor, patch int + prerelease bool +} +``` + +Parse only one trimmed `v?MAJOR.MINOR.PATCH` token with optional semver prerelease/build suffix, compare numeric components, and treat a prerelease at the floor as lower. Run the probe with a 750 ms child context. Cache successful and failed direct-path results by absolute path, size, nanosecond mtime, device, and inode; guard the map with a mutex. + +- [ ] **Step 5: Verify and commit** + +Run: `rtk go test -race ./internal/adapter/opencode -run 'ProviderCommand|MinimumVersion' -count=1` + +Expected: all cases pass under the race detector. + +Commit: `feat(opencode): require supported provider command version` + +Rollback: revert this commit; it has no persisted effect. + +Dependencies: Task 1. + +--- + +### Task 4: Implement the Authenticated OpenCode HTTP and SSE Client + +**Objective:** Encapsulate only the shipped 1.18.1 health/session/event/permission operations needed by the supervisor. + +**Prerequisites:** OpenCode API characterization from Task 1. + +**Files:** +- Create: `internal/adapter/opencode/client.go` +- Create: `internal/adapter/opencode/client_test.go` + +**Interfaces:** +- Produces: + +```go +type serverHealth struct { + Healthy bool `json:"healthy"` + Version string `json:"version"` +} + +type sessionInfo struct { + ID string `json:"id"` + ParentID string `json:"parentID,omitempty"` + Directory string `json:"directory"` + Title string `json:"title"` +} + +type eventEnvelope struct { + Type string `json:"type"` + Properties json.RawMessage `json:"properties"` +} + +type apiClient struct { + baseURL *url.URL + username string + password string + directory string + http *http.Client +} + +func newAPIClient(baseURL, username, password, directory string, client *http.Client) (*apiClient, error) +func (c *apiClient) health(ctx context.Context) (serverHealth, error) +func (c *apiClient) createSession(ctx context.Context, title string) (sessionInfo, error) +func (c *apiClient) getSession(ctx context.Context, id string) (sessionInfo, error) +func (c *apiClient) replyPermission(ctx context.Context, requestID string) error +func (c *apiClient) subscribe(ctx context.Context, ready chan<- struct{}, events chan<- eventEnvelope) error +``` + +- [ ] **Step 1: Write failing HTTP contract tests with `httptest.Server`** + +Assert every request uses Basic Auth, `X-OpenCode-Directory`, a bounded context, and the exact contracts: + +- `GET /global/health` -> `{ "healthy": true, "version": "1.18.1" }`; +- `POST /session` with JSON `{ "title": "UAM: ", "metadata": { "uam": true } }`; +- `GET /session/ses_abc123` with path-escaped validated ID; +- `POST /permission/per_abc123/reply` with `{ "reply": "once" }`; +- `GET /event` with `Accept: text/event-stream`. + +Cover 401, 404 exact resume, 500, wrong content type, malformed JSON, response body over 1 MiB, SSE event over 256 KiB, comment/blank/multiline SSE framing, cancellation, and a server error body containing terminal controls. Assert returned errors are display-sanitized and never include the password. + +- [ ] **Step 2: Run and confirm failure** + +Run: `rtk go test ./internal/adapter/opencode -run 'APIClient|SSE' -count=1` + +Expected before implementation: compile failure for the new client/functions. + +- [ ] **Step 3: Implement bounded authenticated requests** + +Construct URLs only from a parsed loopback `http://127.0.0.1:` base; reject userinfo, non-loopback hosts, fragments, and non-HTTP schemes. Add Basic Auth and `X-OpenCode-Directory` centrally. Use `io.LimitReader` and reject trailing JSON. Map 404 from `getSession` to a sentinel `errSessionNotFound`; include only sanitized, length-capped response excerpts in other errors. + +- [ ] **Step 4: Implement bounded SSE parsing** + +Use `bufio.Reader`, accumulate only `data:` fields up to 256 KiB until a blank line, join multiple data lines with `\n`, ignore comments/unknown fields, decode one `eventEnvelope`, and send it with context cancellation. Close `ready` exactly once after status/content-type validation; an EOF returns an error so the caller controls reconnect backoff. + +- [ ] **Step 5: Verify and commit** + +Run: `rtk go test -race ./internal/adapter/opencode -run 'APIClient|SSE' -count=1` + +Expected: contract, hostile-input, and cancellation tests pass. + +Commit: `feat(opencode): add authenticated server client` + +Rollback: revert this commit; no caller uses it yet. + +Dependencies: Task 3 for shared version/provider-ID validation behavior. + +--- + +### Task 5: Implement the Per-Session OpenCode Supervisor + +**Objective:** Own the authenticated server and attach process, exact session lifecycle, `/new` identity updates, yolo permission replies, and bounded cleanup. + +**Prerequisites:** Tasks 2–4 complete. + +**Files:** +- Create: `internal/adapter/opencode/supervisor.go` +- Create: `internal/adapter/opencode/supervisor_test.go` + +**Interfaces:** +- Produces: + +```go +type supervisorOptions struct { + Command providerCommand + Directory string + SessionName string + ProviderSessionID string + Yolo bool + RuntimeDir string +} + +type ExitError struct { Code int } +func (e *ExitError) Error() string +func (e *ExitError) ExitCode() int + +func RunSupervisorCommand(args []string) error +func runSupervisor(ctx context.Context, opts supervisorOptions) error +``` + +- Consumes: Task 2 identity helpers, Task 3 `providerCommand`, Task 4 `apiClient`, `OPENCODE_SERVER_USERNAME`, and `OPENCODE_SERVER_PASSWORD`. + +- [ ] **Step 1: Write failing command parser and secret tests** + +Cover direct executable flags and shell-alias flags, canonical session-name/runtime-dir validation, exact provider-ID validation, safe/yolo mode, missing/duplicate/conflicting flags, and positional-argument rejection. Capture process argv, runtime files, logs, and returned errors; assert a fixed test password appears in none of them. + +- [ ] **Step 2: Write failing new/resume and prompt-order integration tests** + +Use a fake OpenCode executable implemented by the Go test helper-process pattern. It must implement `serve` with the exact HTTP/SSE routes and `attach` by recording argv/env and reading stdin. Assert: + +- new launch starts `serve --hostname 127.0.0.1 --port `, creates one root, persists its returned ID, then runs `attach --dir --session `; +- exact resume performs `GET /session/:id`, never calls create, and attaches that ID; +- a missing resume returns an actionable error and never adds `-c`, `--continue`, or another session ID; +- input queued before delayed attach is read once by attach unchanged according to current `Backend.SendLine` behavior; resume reads no stored prompt. + +- [ ] **Step 3: Write failing event and permission tests** + +Feed `session.created` root, child, wrong-directory, malformed, duplicate, and `/new` events. Assert only a root in the exact directory advances the runtime identity, and one supervisor cannot change another session's file. Disconnect SSE, reconnect with 25/50/100/200/400 ms capped backoff, retain the last identity, and reject ambiguous recovery. + +In safe mode, assert `permission.asked` is forwarded to the TUI with zero reply calls. In yolo mode, assert one `{ "reply": "once" }` per valid emitted request, duplicate request IDs are idempotently ignored, and explicit-deny/no-event produces no reply. Track created session parent relationships so only the active root tree is eligible. + +- [ ] **Step 4: Write failing lifecycle tests** + +Cover readiness timeout, three port-bind attempts, server exit before attach, server death during attach, attach exit 0, attach exit 23, context cancellation, SIGHUP/SIGTERM cleanup, stuck-child SIGKILL escalation, and bounded server-log capture. After each case, assert both children are reaped and no listener or credential-bearing file remains. Run the simultaneous exit/cancel cases under `-race`. + +- [ ] **Step 5: Implement startup and exact session selection** + +Generate 32 random bytes with `crypto/rand` and hex-encode them. For at most three attempts, reserve and release `127.0.0.1:0`, start `serve` with replaced credential env, poll authenticated health within one five-second whole-pass deadline, and retry only a pre-readiness process/bind failure. Start SSE and wait for its ready signal before create/lookup. Create a root titled `UAM: ` or validate the retained ID with `getSession`; require returned ID grammar, root status, and exact directory before `WriteProviderIdentity`. + +- [ ] **Step 6: Implement attach, event loop, yolo replies, and cleanup** + +Start attach with inherited stdin/stdout/stderr, credential env, exact `--dir` and `--session`, and no `-c`/`--continue`. Run the event loop with bounded reconnect. Maintain root/child ownership from `session.created`; update the file atomically only for root `/new` in the configured directory. In yolo mode reply `once` to valid owned `permission.asked`; in safe mode do nothing. On every return path cancel HTTP/SSE, terminate attach and server, wait 1500 ms, then kill and reap survivors. Keep server output in a mutex-backed 64 KiB ring and sanitize excerpts. + +- [ ] **Step 7: Preserve attach exit codes** + +Return nil for attach code 0 and `&ExitError{Code: code}` for codes 1–255. Treat signaled attach as code 1. Server/readiness/API failures return ordinary sanitized errors. Unit-test `ExitError.ExitCode()` and `errors.As` behavior. + +- [ ] **Step 8: Verify and commit** + +Run: + +```bash +rtk go test -race ./internal/adapter/opencode -run 'Supervisor|Permission|Lifecycle|PromptOrder' -count=1 +rtk go test ./internal/session -run 'Host|ProviderIdentity' -count=1 +``` + +Expected: all supervisor paths finish within test deadlines, preserve exact IDs, and leave zero fake child processes. + +Commit: `feat(opencode): supervise native server sessions` + +Rollback: revert this commit; the supervisor is not reachable until Task 6 wiring. + +Dependencies: Tasks 2, 3, and 4. + +--- + +### Task 6: Wire the Adapter and CLI, Then Remove the Plugin Path + +**Objective:** Make production OpenCode launches use the supervisor exclusively and make legacy `.mjs` state irrelevant. + +**Prerequisites:** Tasks 1–5 complete. + +**Files:** +- Replace: `internal/adapter/opencode/opencode.go` +- Modify: `internal/adapter/opencode/opencode_test.go` +- Modify: `internal/cli/cli.go` +- Modify: `internal/cli/cli_test.go` +- Modify: `internal/app/service_test.go` + +**Interfaces:** +- Consumes: `LaunchPreparation.Command`, `providerCommandFor`, `requireMinimumVersion`, `session.ProviderIdentityPath`, `session.ReadProviderIdentity`, and `RunSupervisorCommand`. +- Produces: an `opencode.New` adapter with exact-only resume and no plugin/provider-state dependency. + +- [ ] **Step 1: Replace plugin expectations with failing adapter-boundary tests** + +Rewrite `opencode_test.go` to assert: + +- `PrepareLaunch` rejects 1.18.0 before `Backend.CreateSession` and accepts 1.18.1; +- accepted launch command starts with the current absolute UAM executable and `__opencode`, includes validated provider command, cwd, runtime dir, session name, yolo/safe mode, and optional exact ID; +- env contains only the provider-neutral runtime identity path added by this adapter and does not add or modify `OPENCODE_CONFIG_CONTENT`; +- `LiveProviderSessionID` reads the runtime identity helper; +- `ResumeKind` is exact with a valid ID and unsupported without one; +- stale, permissive, symlinked, malformed, or directory-shaped legacy `uam-identity-plugin.mjs` and provider-state paths do not affect dispatch; +- no production source under `internal/adapter/opencode` contains `uam-identity-plugin.mjs`, `pluginSource`, `ensureProviderFiles`, `OPENCODE_CONFIG_CONTENT`, or a `"-c"` OpenCode fallback. + +- [ ] **Step 2: Run and confirm failure** + +Run: `rtk go test ./internal/adapter/opencode ./internal/app -run 'OpenCode|ProductionProviderResumeKind' -count=1` + +Expected before wiring: plugin-oriented expectations or heuristic-resume expectations fail. + +- [ ] **Step 3: Reduce `opencode.go` to adapter wiring** + +Keep `New`, `prepareLaunch`, `liveProviderSessionID`, OpenCode ID validation, and test cache reset. Remove plugin source, provider-state paths, config merging, owner repair, `supportsAuto`, and `sessionArgs` fallback. `prepareLaunch` must: + +1. build and validate `providerCommand` from `ResumeRequest`; +2. call `requireMinimumVersion`; +3. resolve the current UAM executable to an absolute path; +4. compute `session.ProviderIdentityPath(session.DefaultDir(), sessionName)`; +5. return `LaunchPreparation{Command: internalArgv, Env: map[string]string{session.ProviderIdentityFileEnv: identityPath}, ProviderSessionID: req.ProviderSessionID}`. + +Set `ResumeKindFor` to `ResumeExact` only when the ID passes OpenCode validation; otherwise return `ResumeUnsupported`. Keep `SkipPromptOnResume = true`. + +- [ ] **Step 4: Route the internal supervisor before store access** + +Import `internal/adapter/opencode` in `internal/cli/cli.go` and add: + +```go +case "__opencode": + return true, opencode.RunSupervisorCommand(args[1:]) +``` + +to `runWithoutStore`. In `Main`, before logging/printing a returned error, detect: + +```go +var exitCoder interface{ ExitCode() int } +if errors.As(err, &exitCoder) { + os.Exit(exitCoder.ExitCode()) +} +``` + +Add CLI tests proving `__opencode` does not open the store and a helper-process test proving exit code 23 is propagated without printing credentials. + +- [ ] **Step 5: Change the production resume matrix** + +In `TestProductionProviderResumeKindMatrixThroughAmbiguityGuard`, replace `opencode fallback` with `opencode missing identity` and expect `ResumeUnsupported`/an exact-resume-required error rather than an allow-latest ambiguity. Assert `--allow-latest` cannot enable OpenCode heuristic resume. + +- [ ] **Step 6: Verify legacy removal and commit** + +Run: + +```bash +rtk go test -race ./internal/adapter/opencode ./internal/cli ./internal/app -count=1 +rtk rg -n 'uam-identity-plugin\.mjs|pluginSource|ensureProviderFiles|OPENCODE_CONFIG_CONTENT|\[\]string\{"-c"\}' internal/adapter/opencode +``` + +Expected: tests pass and `rtk rg` exits 1 with no production matches. References in historical docs are allowed. + +Commit: `refactor(opencode): remove generated identity plugin` + +Rollback: revert this commit to restore the old launch path; do not revert earlier dormant primitives unless bisecting. + +Dependencies: Tasks 1–5. + +--- + +### Task 7: End-to-End Regression Coverage and Documentation + +**Objective:** Demonstrate independent same-project sessions, `/new`, cleanup, prompt behavior, platform compilation, and operator guidance without changing schema or UI. + +**Prerequisites:** Task 6 complete. + +**Files:** +- Modify: `internal/adapter/opencode/supervisor_test.go` +- Modify: `internal/adapter/opencode/opencode_test.go` +- Modify: `internal/session/session_test.go` +- Modify: `README.md` + +**Interfaces:** No new production interfaces. + +- [ ] **Step 1: Add the full fake-binary regression matrix** + +Add deterministic tests that launch two UAM OpenCode sessions with the same cwd and distinct session names. Assert distinct authenticated ports/passwords/provider IDs, isolated `/new` updates, exact restart IDs, no prompt replay, child-session exclusion, and complete cleanup. Add Unicode/multiline/empty prompts and current `SendLine` newline normalization as characterization expectations. Add unsafe legacy paths and unknown `OPENCODE_CONFIG_CONTENT` preservation tests. + +- [ ] **Step 2: Run the end-to-end package tests under race** + +Run: + +```bash +rtk go test -race ./internal/adapter/opencode ./internal/session ./internal/app ./internal/cli -count=1 +``` + +Expected: zero failures/races and no leaked helper processes reported by test cleanup. + +- [ ] **Step 3: Document the operator contract** + +Update `README.md` to state: + +- OpenCode `1.18.1+` is required and `opencode upgrade 1.18.1` is the remediation command; +- UAM uses a private loopback server per managed OpenCode terminal for exact sessions and `/new` tracking; +- default yolo and `--safe` behavior are unchanged; +- stale `$XDG_STATE_HOME/uam/providers/opencode/uam-identity-plugin.mjs` is inert and cannot block launch; +- optional manual cleanup removes only the documented UAM-generated directory after the user verifies it, with no automatic deletion; +- Linux and macOS AMD64/ARM64 are supported. + +- [ ] **Step 4: Run a real OpenCode smoke test** + +Build to a temporary path, use temporary UAM store/runtime directories, start a real 1.18.1 managed session under a PTY, detach, confirm its exact provider ID appears in `uam ls --json`, reattach, run `/new`, detach, confirm only that record's ID changed, then `uam kill-all` and verify no listener/process remains. Repeat with two same-project sessions and one `--safe` session. Do not submit a model prompt during this smoke test. + +Expected: exact IDs are distinct/stable as described; legacy `.mjs` permissions are never inspected; all children and runtime identity files disappear after kill. + +Failure signal: any latest-session substitution, prompt replay, cross-record ID change, permission auto-reply in safe mode, or orphaned process blocks completion. + +- [ ] **Step 5: Commit** + +Commit: `test(opencode): cover native session integration` + +Rollback: revert documentation/test commit independently; production behavior remains unchanged. + +Dependencies: Task 6. + +--- + +### Task 8: Full Quality Gate and Release-Readiness Review + +**Objective:** Produce final evidence that the branch is regression-safe and portable; do not push, merge, or publish. + +**Prerequisites:** Tasks 1–7 complete and working tree reviewed. + +**Files:** No planned source changes; fix only failures caused by this branch with a failing regression test first. + +- [ ] **Step 1: Format and inspect the exact diff** + +Run: + +```bash +rtk gofmt -w internal/adapter/adapter.go internal/adapter/agent.go internal/adapter/opencode/*.go internal/session/*.go internal/cli/cli.go +rtk git diff --check +rtk git diff --stat main...HEAD +rtk git status --short +``` + +Expected: no format/diff errors; only approved files and task commits are present. + +- [ ] **Step 2: Run the complete repository quality gate** + +Run each independently: + +```bash +rtk go test -race -count=1 -covermode=atomic -coverpkg=./... ./... +rtk go vet ./... +rtk golangci-lint run ./... +rtk staticcheck ./... +rtk gosec ./... +rtk govulncheck ./... +rtk go mod verify +``` + +Expected: every command exits 0; coverage is at least the current 88.1% baseline; no dependency files change. + +- [ ] **Step 3: Compile all supported Darwin targets** + +Run: + +```bash +rtk proxy env GOOS=darwin GOARCH=amd64 go test -run '^$' ./... +rtk proxy env GOOS=darwin GOARCH=arm64 go test -run '^$' ./... +rtk proxy env GOOS=linux GOARCH=amd64 go test -run '^$' ./... +rtk proxy env GOOS=linux GOARCH=arm64 go test -run '^$' ./... +``` + +Expected: all four compile-only runs exit 0. + +- [ ] **Step 4: Review security and compatibility invariants** + +Run targeted searches and inspect results: + +```bash +rtk rg -n 'uam-identity-plugin\.mjs|OPENCODE_CONFIG_CONTENT|\b-c\b|--continue' internal/adapter/opencode +rtk rg -n 'OPENCODE_SERVER_PASSWORD|password' internal/adapter/opencode +rtk git diff main...HEAD -- go.mod go.sum internal/store internal/app/app.go +``` + +Expected: no plugin/fallback production path; credential occurrences are env setup/redaction/tests only; no dependency, store schema, or TUI layout diff. + +- [ ] **Step 5: Final review commit only if verification required a tested fix** + +If verification produced a branch-caused failure, add the smallest failing regression, fix it, rerun the failed command and all earlier gates, and commit `fix(opencode): close native session regression`. Otherwise create no empty commit. + +Rollback: revert only the fix commit if it independently regresses behavior. + +Dependencies: all prior tasks. + +--- + +## Tests + +The mandatory pass criteria are: + +1. Focused TDD command from every task first fails for the stated missing behavior, then passes. +2. `rtk go test -race -count=1 -covermode=atomic -coverpkg=./... ./...` exits 0 with coverage `>= 88.1%`. +3. Vet, golangci-lint, staticcheck, gosec, govulncheck, module verification, `git diff --check`, and all four Linux/Darwin compile matrices exit 0. +4. OpenCode 1.18.0 and floor prereleases fail before `Backend.CreateSession`; 1.18.1 and later stable builds pass. +5. New, exact resume, two-same-project, `/new`, safe/yolo, prompt, detach, stop, restart, natural exit, signal, server failure, and stale-legacy scenarios match the approved design. +6. Hostile HTTP/SSE/state data remains bounded and sanitized; credentials never appear in argv, logs, state, errors, or rendered output. +7. Store schema v3, unknown fields, CLI/JSON shapes, TUI layout, non-OpenCode provider argv, and default yolo behavior remain unchanged. + +## Completion + +Evidence required before claiming completion: + +- task commits exist in order and the working tree is clean; +- exact commands and actual outputs from Task 8 are recorded; +- real OpenCode 1.18.1 smoke evidence shows distinct same-project IDs, isolated `/new`, exact reattach, safe/yolo parity, and orphan-free cleanup; +- production search proves the `.mjs` injection and `-c` fallback are absent; +- no push, PR, merge, install of the feature build, or release occurs without a separate explicit user instruction. + +Remaining risk after completion: OpenCode may change its shipped HTTP schema in a future version. The minimum-version contract and bounded client errors make such drift fail visibly; supporting a future breaking version requires a separately tested compatibility update. diff --git a/docs/superpowers/specs/2026-07-15-opencode-native-session-design.md b/docs/superpowers/specs/2026-07-15-opencode-native-session-design.md new file mode 100644 index 0000000..161b86c --- /dev/null +++ b/docs/superpowers/specs/2026-07-15-opencode-native-session-design.md @@ -0,0 +1,309 @@ +# Native OpenCode session integration + +Date: 2026-07-15 +Status: Approved + +## Context + +UAM currently injects a generated `uam-identity-plugin.mjs` into OpenCode. +The plugin observes root-session events and writes the active OpenCode session +ID to provider state so UAM can resume with `opencode --session ` instead +of the ambiguous `opencode -c` fallback. This fixes exact resume and lets a +managed UAM terminal follow OpenCode `/new` to its newly created root session. + +The generated module and its provider-state directory must be private because +OpenCode executes the module. Older or externally modified installations can +leave the file or directory with broader modes, causing a secure fail-closed +check to block OpenCode launch. + +OpenCode v1.18.1 provides stable HTTP session creation, exact-session attach, +and server-sent session events. UAM will use those supported interfaces and +remove the generated JavaScript dependency. The upstream evidence and rejected +alternatives are recorded in +`docs/research/2026-07-15-opencode-session-identity.md`. + +## Goals + +- Remove production creation, loading, and validation of + `uam-identity-plugin.mjs`. +- Require OpenCode v1.18.1 or newer with an actionable error for older builds. +- Create a new OpenCode root session through the stable server API and retain + its exact returned ID before interactive use begins. +- Resume the exact retained OpenCode session without falling back to `-c` for + newly created records. +- Track `/new` so only the owning UAM record advances to the new root session. +- Preserve the current interactive OpenCode TUI, prompt bytes, safe/yolo + behavior, session names, store schema v3, and other provider behavior. +- Support Linux and macOS on AMD64 and ARM64. + +## Non-goals + +- Do not adopt OpenCode's experimental V2 `/api/session` contract. +- Do not build a shared, machine-wide OpenCode daemon. +- Do not migrate or rewrite OpenCode's own session database. +- Do not delete unknown, user-authored, symlinked, or ownership-mismatched + files while cleaning up legacy UAM state. +- Do not change UAM's TUI layout, JSON output, store schema, default yolo + policy, or non-OpenCode adapters. + +## Compatibility boundary + +The OpenCode provider command topology necessarily changes from a single +embedded TUI process to a UAM supervisor that owns a headless OpenCode server +and an attached OpenCode TUI. User-visible behavior remains compatible: + +- a new UAM dispatch opens the OpenCode TUI on a fresh root session; +- an existing UAM session resumes its retained exact root session; +- `/new` creates a new OpenCode root session within the same managed terminal; +- closing, stopping, and restarting the UAM session clean up every child; +- initial prompts are delivered byte-for-byte once, and resume does not replay + the original prompt; +- safe mode keeps OpenCode's normal permission behavior and yolo mode retains + the current auto-approval behavior. + +Existing schema-v3 `ProviderSessionID` values remain valid. No store migration +or schema bump is needed. + +## Architecture + +### Provider boundary + +The OpenCode adapter will resolve the executable through the existing trusted +path logic and probe `opencode --version`. It will accept semantic versions at +or above v1.18.1 and reject older or unparsable versions before creating a UAM +session record. The error will show the resolved executable, detected version, +required version, and `opencode upgrade 1.18.1` guidance. + +The probe result will be cached by executable stat identity, following the +existing `--auto` capability probe pattern, so regular refreshes do not spawn +version checks. + +For an accepted version, the adapter will launch an internal UAM command rather +than injecting `OPENCODE_CONFIG_CONTENT`. The internal command is an OpenCode +supervisor and receives only explicit launch data: executable path, working +directory, UAM session name, optional retained provider session ID, and mode. + +### Per-session supervisor + +Each managed OpenCode session gets one supervisor under the existing native +UAM session host. The native host remains the PTY owner and continues to see a +single foreground child. The supervisor owns two OpenCode subprocesses: + +1. `opencode serve`, bound to `127.0.0.1` on a per-session port; +2. `opencode attach --session `, connected to that server and using + the native UAM PTY for its terminal streams. + +The server uses a fresh cryptographically random password supplied only through +the child environment. The password is passed to the attach client through its +environment, never through argv, logs, store records, state files, or terminal +output. + +The supervisor allocates a loopback port, starts the server, and polls +`GET /global/health` with authentication until it reports OpenCode v1.18.1 or +newer. Port-bind races are retried with a new port a bounded number of times. +Startup has a five-second whole-pass deadline. + +### Session creation and resume + +For a new UAM dispatch, the supervisor calls stable `POST /session`, scoped to +the requested working directory. The request carries a UAM-identifying title +or metadata. The returned root session ID becomes authoritative before the +attach client starts. + +For resume, the retained `ProviderSessionID` is validated locally and then +confirmed through `GET /session/:id`. A missing exact session is an actionable +error; UAM does not silently substitute OpenCode's most recent session. + +After the exact session is available, the supervisor starts +`opencode attach --session `. The supervisor never reads its PTY stdin. +UAM's existing `Agent.startSession` path continues to deliver an initial prompt +through `Backend.SendLine`; the PTY queues those bytes while the supervisor +finishes server/session setup, and the attached OpenCode TUI consumes them when +it starts. This reuses the current byte path instead of adding an HTTP prompt +path or a second host protocol. Resume preserves the existing behavior of not +resubmitting the stored prompt. + +### `/new` and identity tracking + +Before starting the attach client, the supervisor subscribes to authenticated +`GET /event`. It accepts root `session.created` events only when the session has +no parent and belongs to this supervisor's project/directory. Child-agent +sessions are ignored. + +Because the attached TUI talks only to this dedicated server, a subsequent root +session created by `/new` belongs to this managed UAM session. The supervisor +atomically records the new provider session ID in an owner-only runtime +identity file under the already verified UAM session runtime directory. The +OpenCode adapter reads that file during normal live-session reconciliation and +patches the existing store record's `ProviderSessionID` field. + +This runtime file replaces the persistent provider-state handoff. It is a +regular current-user-owned file with mode `0600`, placed below the verified +`0700` runtime directory, written by temporary-file plus rename, size-limited, +and decoded with the existing provider-ID allow-list. Runtime cleanup removes +it with the corresponding session socket and state. + +If the event stream disconnects, the supervisor reconnects with bounded +backoff. The last verified ID remains authoritative. Recovery may query root +sessions updated through the dedicated server, but it may advance the identity +only when exactly one unambiguous newer root exists; otherwise it warns and +keeps the last verified ID. + +### Permission behavior + +Safe mode leaves permission events untouched, so the attached TUI retains +OpenCode's normal permission prompts. + +Yolo mode mirrors OpenCode v1.18.1's own `--auto` implementation. When the +authenticated event stream emits `permission.asked`, the supervisor replies +`once` through `POST /permission/:requestID/reply`. OpenCode evaluates +configured rules before publishing this event: an explicit deny fails without +emitting `permission.asked`, and an allow completes without emitting it. The +supervisor therefore auto-approves only requests OpenCode would present to the +TUI, preserving explicit denies and avoiding blanket allow rules. Replies are +limited to permission requests for the dedicated server and the currently +owned root-session tree; malformed, foreign, or stale request IDs are ignored +with a sanitized warning. + +### Lifecycle and exit behavior + +The supervisor owns the complete process group. It forwards terminal-relevant +signals to the attach client and guarantees that server and attach processes +are terminated on normal exit, stop, restart, context cancellation, startup +failure, or attach failure. Cleanup is bounded and escalates from graceful +termination to kill after a short deadline. + +The attach client's exit code remains the provider exit code observed by the +native UAM host. A server failure while attach is active terminates attach and +surfaces a concise sanitized error. Server logs are captured in a bounded ring +buffer and included only in diagnostic logs, with credentials removed. + +## Security + +- Bind only to numeric loopback, never wildcard interfaces. +- Authenticate every HTTP, SSE, and attach connection with a per-process + high-entropy secret. +- Keep the secret out of argv, logs, persistent state, terminal rendering, and + error strings. +- Resolve and probe the same executable that is later launched; do not re-read + `PATH` after validation. +- Validate every OpenCode session ID before using it in a URL, state path, or + resume command. +- Treat ownership, symlink, and file-type violations in the UAM runtime + directory as non-repairable security errors. +- Never execute generated JavaScript and never inject a plugin URL into + `OPENCODE_CONFIG_CONTENT`. +- Preserve raw prompts for API delivery while sanitizing only rendered error + and status text. + +## Legacy state + +The old generated plugin is not in OpenCode's automatic plugin directories; it +was active only because UAM injected its file URL. Once injection is removed, +an existing file is inert and must not block launch. + +UAM will stop reading and writing the old provider-state identity directory. +The first release will not delete it automatically. Documentation will provide +an explicit cleanup command for the known UAM path. A future `uam setup` cleanup +may remove it only after no-follow inspection proves that every removed object +is a current-user-owned regular file/directory and the module content matches a +known UAM-generated version. Cleanup failure is advisory and never blocks +OpenCode launch. + +## Error handling + +- OpenCode older than v1.18.1: fail before dispatch with upgrade guidance. +- Version probe timeout or malformed output: fail with the executable path and + captured sanitized output. +- Server readiness timeout: stop all started children and return a bounded + diagnostic excerpt. +- Authentication, create, lookup, permission-reply, or attach failure: stop all children; + do not persist an unverified provider ID. +- Exact resume target missing: fail explicitly; never use `-c`. +- Event-stream interruption: keep the last verified identity, reconnect, and + warn without terminating an otherwise healthy attached session. +- Runtime identity persistence failure: keep the live TUI running, warn, and + retain the last persisted ID so failure cannot redirect resume heuristically. + +## Test strategy + +Implementation follows characterization-first TDD. Tests use fake OpenCode +executables and `httptest` servers for deterministic lifecycle and API coverage, +plus a gated real-OpenCode v1.18.1 compatibility suite. + +Required automated coverage: + +- semantic-version parsing: below, equal to, above, prerelease, malformed, and + probe timeout; +- exact new-session creation and exact resume without `-c`; +- missing resume target fails without choosing the latest session; +- two concurrent UAM sessions in the same project retain different OpenCode + IDs and isolated authenticated servers; +- `/new` advances only the owning UAM record; +- child-session events never replace the root identity; +- SSE disconnect, replay gap, ambiguity, and recovery; +- raw prompt bytes, empty prompt, Unicode, multiline, and resume-no-replay; +- safe mode leaves permission prompts visible; yolo mode replies `once` only + to emitted `permission.asked` events and preserves explicit denies; +- port collision and retry, readiness timeout, authentication rejection, API + failure, attach failure, and server death; +- stop, restart, natural exit, signals, and orphan-free cleanup under the race + detector; +- runtime identity no-follow, owner, mode, size, name, and atomic-write checks; +- unsafe or stale legacy `.mjs` and provider-state paths do not block launch; +- no OpenCode plugin URL is added to `OPENCODE_CONFIG_CONTENT`; +- Linux and Darwin AMD64/ARM64 compile and lifecycle coverage. + +The complete repository quality gate remains required: + +```text +go test -race -count=1 -covermode=atomic -coverpkg=./... ./... +go vet ./... +golangci-lint run ./... +staticcheck ./... +gosec ./... +govulncheck ./... +go mod verify +gofmt cleanliness check +``` + +## Acceptance criteria + +- OpenCode v1.18.0 and older receive a clear minimum-version error. +- OpenCode v1.18.1 and newer dispatch, attach, stop, restart, and resume on + Linux and macOS. +- Production code no longer creates or references + `uam-identity-plugin.mjs` or injects its URL. +- A stale or incorrectly permissioned legacy module cannot block launch. +- Fresh UAM OpenCode sessions never use `-c`. +- Multiple UAM sessions in one project resume their own exact conversations. +- `/new` updates only the owning UAM record before the next reattach. +- No OpenCode server remains after its UAM session exits or is stopped. +- The server is loopback-only and rejects unauthenticated requests. +- Existing store fixtures and unknown fields round-trip unchanged under schema + v3. +- Other provider commands and behavior remain unchanged. + +## Rollout + +This is a minor-version change because it raises the OpenCode requirement and +changes the provider's internal process topology. Release notes will call out +OpenCode v1.18.1 as the minimum and provide the upstream upgrade command and +optional legacy-state cleanup instructions. + +The rollout sequence is: + +1. upgrade the development machine to OpenCode v1.18.1; +2. characterize stable server, session, permission, PTY prompt, attach, and event + behavior against the real binary; +3. implement behind the OpenCode adapter boundary with no plugin fallback; +4. run focused concurrency/lifecycle tests and the full quality gate; +5. exercise two same-project sessions and `/new` manually over local and SSH + attaches; +6. merge only after Linux and Darwin CI is green; +7. publish the next minor UAM release. + +The plugin-based implementation remains in git history for rollback, but the +new release does not dynamically fall back to it. If the native server adapter +is not healthy, dispatch fails explicitly rather than silently returning to +ambiguous session behavior. diff --git a/internal/adapter/adapter.go b/internal/adapter/adapter.go index c957cf4..df3d7de 100644 --- a/internal/adapter/adapter.go +++ b/internal/adapter/adapter.go @@ -105,6 +105,7 @@ type ResumeRequest struct { // canonical backend identity and cwd are known, but before any session is // created. Slices and maps are copied by Agent before use. type LaunchPreparation struct { + Command []string ExtraArgs []string Env map[string]string ProviderSessionID string diff --git a/internal/adapter/agent.go b/internal/adapter/agent.go index 6399c41..4b982ca 100644 --- a/internal/adapter/agent.go +++ b/internal/adapter/agent.go @@ -263,6 +263,9 @@ func (a *Agent) startSession(ctx context.Context, req ResumeRequest, activity st if err != nil { return Session{}, err } + if len(preparation.Command) > 0 { + cmd = append([]string(nil), preparation.Command...) + } env := make(map[string]string, len(preparation.Env)+2) for key, value := range preparation.Env { env[key] = value diff --git a/internal/adapter/agent_test.go b/internal/adapter/agent_test.go index f72227b..cd116e0 100644 --- a/internal/adapter/agent_test.go +++ b/internal/adapter/agent_test.go @@ -5,6 +5,7 @@ import ( "errors" "os" "path/filepath" + "reflect" "strings" "testing" @@ -196,6 +197,100 @@ func TestPrepareLaunchFailureCreatesNoSession(t *testing.T) { } } +type commandCaptureBackend struct { + *adaptertest.Backend + command []string +} + +func (b *commandCaptureBackend) CreateSession(_ context.Context, _, _ string, _ map[string]string, command []string) error { + b.command = command + return nil +} + +func TestLaunchPreparationCommandSelection(t *testing.T) { + tests := []struct { + name string + preparedCommand func(sessionName string) []string + wantCommand func(sessionName string) []string + }{ + { + name: "prepared override", + preparedCommand: func(sessionName string) []string { + return []string{"/trusted/uam", "__opencode", "--name", sessionName} + }, + wantCommand: func(sessionName string) []string { + return []string{"/trusted/uam", "__opencode", "--name", sessionName} + }, + }, + { + name: "nil override", + preparedCommand: func(string) []string { return nil }, + wantCommand: func(string) []string { + return []string{"fakeagent", "--yolo", "--prepared", "--session", "session-id"} + }, + }, + { + name: "empty override", + preparedCommand: func(string) []string { return []string{} }, + wantCommand: func(string) []string { + return []string{"fakeagent", "--yolo", "--prepared", "--session", "session-id"} + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ag, _ := newLifecycleAgent(t) + backend := &commandCaptureBackend{Backend: &adaptertest.Backend{}} + ag.Backend = backend + ag.SessionArgs = func(ResumeRequest, string) []string { + return []string{"--session", "session-id"} + } + var preparedCommand []string + ag.PrepareLaunch = func(_ Context, _ ResumeRequest, _, sessionName, _ string) (LaunchPreparation, error) { + preparedCommand = tt.preparedCommand(sessionName) + return LaunchPreparation{Command: preparedCommand, ExtraArgs: []string{"--prepared"}}, nil + } + + sess, err := ag.Dispatch(context.Background(), DispatchRequest{Cwd: "/tmp", Mode: "yolo"}) + if err != nil { + t.Fatalf("Dispatch: %v", err) + } + want := tt.wantCommand(sess.SessionName) + if !reflect.DeepEqual(backend.command, want) { + t.Fatalf("CreateSession command = %#v, want %#v", backend.command, want) + } + + if len(preparedCommand) > 0 { + preparedCommand[0] = "mutated" + if !reflect.DeepEqual(backend.command, want) { + t.Fatalf("CreateSession command aliases preparation slice: got %#v, want %#v", backend.command, want) + } + } + }) + } +} + +func TestLaunchPreparationCommandRejectsInvalidAliasBeforeHook(t *testing.T) { + ag, be := newLifecycleAgent(t) + prepareCalled := false + ag.PrepareLaunch = func(Context, ResumeRequest, string, string, string) (LaunchPreparation, error) { + prepareCalled = true + return LaunchPreparation{Command: []string{"/trusted/uam", "__opencode"}}, nil + } + + _, err := ag.Dispatch(context.Background(), DispatchRequest{CommandAlias: "bad alias", Cwd: "/tmp", Mode: "yolo"}) + if err == nil || !strings.Contains(err.Error(), "invalid command alias") { + t.Fatalf("Dispatch error = %v, want invalid command alias", err) + } + if prepareCalled { + t.Fatal("invalid command alias reached PrepareLaunch") + } + if got := be.CallsOf("create"); len(got) != 0 { + t.Fatalf("invalid command alias created session: %+v", got) + } +} + func TestListFromSnapshotUsesLiveProviderIdentity(t *testing.T) { ag, _ := newLifecycleAgent(t) ag.LiveProviderSessionID = func(name string) (string, error) { diff --git a/internal/adapter/opencode/client.go b/internal/adapter/opencode/client.go new file mode 100644 index 0000000..3f3704f --- /dev/null +++ b/internal/adapter/opencode/client.go @@ -0,0 +1,421 @@ +package opencode + +import ( + "bufio" + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "mime" + "net" + "net/http" + "net/url" + "regexp" + "strconv" + "strings" + + "github.com/RandomCodeSpace/unified-agent-manager/internal/displaytext" + "github.com/RandomCodeSpace/unified-agent-manager/internal/store" +) + +const ( + maxAPIResponseBytes = 1 << 20 + maxAPIErrorExcerptRunes = 256 + maxErrorBodyBytes = 4 << 10 + maxSSEEventBytes = 256 << 10 + maxSSELineBytes = maxSSEEventBytes + 16 + sessionPathPrefix = "/session/" +) + +var ( + errSessionNotFound = errors.New("OpenCode session not found") + permissionIDRE = regexp.MustCompile(`^per_[A-Za-z0-9_-]{3,60}$`) +) + +type serverHealth struct { + Healthy bool `json:"healthy"` + Version string `json:"version"` +} + +type sessionInfo struct { + ID string `json:"id"` + ParentID string `json:"parentID,omitempty"` + Directory string `json:"directory"` + Title string `json:"title"` + Time sessionTime `json:"time"` +} + +type sessionTime struct { + Created int64 `json:"created"` + Updated int64 `json:"updated"` +} + +type eventEnvelope struct { + Type string `json:"type"` + Properties json.RawMessage `json:"properties"` +} + +type apiClient struct { + baseURL *url.URL + username string + password string + directory string + http *http.Client +} + +func newAPIClient(baseURL, username, password, directory string, client *http.Client) (*apiClient, error) { + parsed, err := url.Parse(baseURL) + if err != nil || parsed.Scheme != "http" || parsed.Opaque != "" || parsed.User != nil || parsed.Fragment != "" || parsed.RawQuery != "" || parsed.ForceQuery { + return nil, fmt.Errorf("OpenCode server base URL must be plain loopback HTTP") + } + if parsed.Path != "" && parsed.Path != "/" || parsed.RawPath != "" { + return nil, fmt.Errorf("OpenCode server base URL must not contain a path") + } + host, port, err := net.SplitHostPort(parsed.Host) + if err != nil || host != "127.0.0.1" { + return nil, fmt.Errorf("OpenCode server base URL must use numeric 127.0.0.1 with an explicit port") + } + portNumber, err := strconv.Atoi(port) + if err != nil || portNumber < 1 || portNumber > 65535 { + return nil, fmt.Errorf("OpenCode server base URL has an invalid port") + } + + parsed.Path = "" + baseClient := client + if baseClient == nil { + baseClient = http.DefaultClient + } + httpClient := *baseClient + httpClient.CheckRedirect = func(_ *http.Request, _ []*http.Request) error { + return http.ErrUseLastResponse + } + return &apiClient{ + baseURL: parsed, + username: username, + password: password, + directory: directory, + http: &httpClient, + }, nil +} + +func (c *apiClient) health(ctx context.Context) (serverHealth, error) { + var health serverHealth + if err := c.doJSON(ctx, http.MethodGet, "/global/health", "", nil, &health); err != nil { + return serverHealth{}, err + } + return health, nil +} + +func (c *apiClient) createSession(ctx context.Context, title string) (sessionInfo, error) { + payload := struct { + Title string `json:"title"` + Metadata struct { + UAM bool `json:"uam"` + } `json:"metadata"` + }{Title: "UAM: " + displaytext.Sanitize(title)} + payload.Metadata.UAM = true + + var session sessionInfo + if err := c.doJSON(ctx, http.MethodPost, "/session", "", payload, &session); err != nil { + return sessionInfo{}, err + } + return session, nil +} + +func (c *apiClient) getSession(ctx context.Context, id string) (sessionInfo, error) { + if !providerIDRE.MatchString(id) || !store.ValidProviderSessionID(id) { + return sessionInfo{}, fmt.Errorf("invalid OpenCode session ID") + } + path := sessionPathPrefix + id + rawPath := sessionPathPrefix + url.PathEscape(id) + var session sessionInfo + err := c.doJSON(ctx, http.MethodGet, path, rawPath, nil, &session) + if errors.Is(err, errSessionNotFound) { + return sessionInfo{}, errSessionNotFound + } + if err != nil { + return sessionInfo{}, err + } + return session, nil +} + +func (c *apiClient) listSessions(ctx context.Context) ([]sessionInfo, error) { + var sessions []sessionInfo + if err := c.doJSON(ctx, http.MethodGet, "/session", "", nil, &sessions); err != nil { + return nil, err + } + return sessions, nil +} + +func (c *apiClient) replyPermission(ctx context.Context, requestID string) error { + if !permissionIDRE.MatchString(requestID) || !store.ValidProviderSessionID(requestID) { + return fmt.Errorf("invalid OpenCode permission request ID") + } + path := "/permission/" + requestID + "/reply" + rawPath := "/permission/" + url.PathEscape(requestID) + "/reply" + resp, err := c.do(ctx, http.MethodPost, path, rawPath, struct { + Reply string `json:"reply"` + }{Reply: "once"}, "") + if err != nil { + return err + } + defer func() { _ = resp.Body.Close() }() + if !successfulStatus(resp.StatusCode) { + return c.statusError("permission reply", resp) + } + return nil +} + +func (c *apiClient) subscribe(ctx context.Context, ready chan<- struct{}, events chan<- eventEnvelope) error { + if ready == nil { + return fmt.Errorf("OpenCode event readiness channel is required") + } + resp, err := c.do(ctx, http.MethodGet, "/event", "", nil, "text/event-stream") + if err != nil { + return err + } + defer func() { _ = resp.Body.Close() }() + if resp.StatusCode != http.StatusOK { + return c.statusError("event subscription", resp) + } + if err := requireContentType(resp, "text/event-stream"); err != nil { + return c.safeError("OpenCode event subscription", err) + } + close(ready) + return c.readSSEEvents(ctx, resp.Body, events) +} + +type sseEventData struct { + bytes []byte + present bool +} + +func (c *apiClient) readSSEEvents(ctx context.Context, body io.Reader, events chan<- eventEnvelope) error { + reader := bufio.NewReader(body) + var data sseEventData + for { + line, readErr := readSSELine(reader) + if readErr != nil { + return c.sseReadError(ctx, readErr) + } + if len(line) == 0 { + if !data.present { + continue + } + if err := c.sendSSEEvent(ctx, data.bytes, events); err != nil { + return err + } + data.reset() + continue + } + value, ok := sseDataValue(line) + if !ok { + continue + } + if err := data.append(value); err != nil { + return err + } + } +} + +func (c *apiClient) sseReadError(ctx context.Context, err error) error { + if ctx.Err() != nil { + return ctx.Err() + } + if errors.Is(err, io.EOF) { + return fmt.Errorf("OpenCode event stream ended: %w", io.EOF) + } + return c.safeError("read OpenCode event stream", err) +} + +func (c *apiClient) sendSSEEvent(ctx context.Context, data []byte, events chan<- eventEnvelope) error { + var event eventEnvelope + if err := decodeStrictJSON(data, &event); err != nil { + return c.safeError("decode OpenCode event", err) + } + select { + case events <- event: + return nil + case <-ctx.Done(): + return ctx.Err() + } +} + +func sseDataValue(line []byte) (string, bool) { + field, value, found := strings.Cut(string(line), ":") + if !found || field != "data" { + return "", false + } + return strings.TrimPrefix(value, " "), true +} + +func (d *sseEventData) append(value string) error { + additional := len(value) + if d.present { + additional++ + } + if len(d.bytes)+additional > maxSSEEventBytes { + return fmt.Errorf("OpenCode SSE event is too large") + } + if d.present { + d.bytes = append(d.bytes, '\n') + } + d.bytes = append(d.bytes, value...) + d.present = true + return nil +} + +func (d *sseEventData) reset() { + d.bytes = d.bytes[:0] + d.present = false +} + +func (c *apiClient) doJSON(ctx context.Context, method, path, rawPath string, payload, destination any) error { + resp, err := c.do(ctx, method, path, rawPath, payload, "") + if err != nil { + return err + } + defer func() { _ = resp.Body.Close() }() + if !successfulStatus(resp.StatusCode) { + if method == http.MethodGet && strings.HasPrefix(path, sessionPathPrefix) && resp.StatusCode == http.StatusNotFound { + return errSessionNotFound + } + return c.statusError("API request", resp) + } + if err := requireContentType(resp, "application/json"); err != nil { + return c.safeError("OpenCode API response", err) + } + data, err := io.ReadAll(io.LimitReader(resp.Body, maxAPIResponseBytes+1)) + if err != nil { + return c.safeError("read OpenCode API response", err) + } + if len(data) > maxAPIResponseBytes { + return fmt.Errorf("OpenCode API response body is too large") + } + if err := decodeStrictJSON(data, destination); err != nil { + return c.safeError("decode OpenCode API response", err) + } + return nil +} + +func (c *apiClient) do(ctx context.Context, method, path, rawPath string, payload any, accept string) (*http.Response, error) { + var body io.Reader + if payload != nil { + data, err := json.Marshal(payload) + if err != nil { + return nil, c.safeError("encode OpenCode API request", err) + } + body = bytes.NewReader(data) + } + + endpoint := *c.baseURL + endpoint.Path = path + endpoint.RawPath = rawPath + req, err := http.NewRequestWithContext(ctx, method, endpoint.String(), body) + if err != nil { + return nil, c.safeError("construct OpenCode API request", err) + } + req.SetBasicAuth(c.username, c.password) + req.Header.Set("X-OpenCode-Directory", c.directory) + if payload != nil { + req.Header.Set("Content-Type", "application/json") + } + if accept != "" { + req.Header.Set("Accept", accept) + } + + resp, err := c.http.Do(req) + if err != nil { + if ctx.Err() != nil { + return nil, ctx.Err() + } + return nil, c.safeError("OpenCode API request", err) + } + return resp, nil +} + +func (c *apiClient) statusError(operation string, resp *http.Response) error { + data, _ := io.ReadAll(io.LimitReader(resp.Body, maxErrorBodyBytes+1)) + if len(data) > maxErrorBodyBytes { + return fmt.Errorf("OpenCode %s failed with HTTP %d: response body omitted (exceeds %d bytes)", operation, resp.StatusCode, maxErrorBodyBytes) + } + excerpt := c.safeText(string(data)) + if excerpt == "" { + return fmt.Errorf("OpenCode %s failed with HTTP %d", operation, resp.StatusCode) + } + return fmt.Errorf("OpenCode %s failed with HTTP %d: %s", operation, resp.StatusCode, excerpt) +} + +func (c *apiClient) safeError(operation string, err error) error { + return fmt.Errorf("%s failed: %s", operation, c.safeText(err.Error())) +} + +func (c *apiClient) safeText(value string) string { + if c.password != "" { + value = strings.ReplaceAll(value, c.password, "") + } + value = strings.TrimSpace(displaytext.Sanitize(value)) + sanitizedPassword := strings.TrimSpace(displaytext.Sanitize(c.password)) + if sanitizedPassword != "" { + value = strings.ReplaceAll(value, sanitizedPassword, "") + } + runes := []rune(value) + if len(runes) > maxAPIErrorExcerptRunes { + value = string(runes[:maxAPIErrorExcerptRunes]) + "…" + } + return value +} + +func successfulStatus(status int) bool { + return status >= http.StatusOK && status < http.StatusMultipleChoices +} + +func requireContentType(resp *http.Response, want string) error { + mediaType, _, err := mime.ParseMediaType(resp.Header.Get("Content-Type")) + if err != nil || mediaType != want { + return fmt.Errorf("unexpected response content type") + } + return nil +} + +func decodeStrictJSON(data []byte, destination any) error { + decoder := json.NewDecoder(bytes.NewReader(data)) + if err := decoder.Decode(destination); err != nil { + return err + } + var trailing any + if err := decoder.Decode(&trailing); !errors.Is(err, io.EOF) { + if err == nil { + return fmt.Errorf("trailing JSON value") + } + return fmt.Errorf("trailing JSON data: %w", err) + } + return nil +} + +func readSSELine(reader *bufio.Reader) ([]byte, error) { + line := make([]byte, 0, reader.Size()) + for { + fragment, err := reader.ReadSlice('\n') + if len(line)+len(fragment) > maxSSELineBytes { + return nil, fmt.Errorf("OpenCode SSE line is too large") + } + line = append(line, fragment...) + switch { + case err == nil: + line = line[:len(line)-1] + if len(line) > 0 && line[len(line)-1] == '\r' { + line = line[:len(line)-1] + } + return line, nil + case errors.Is(err, bufio.ErrBufferFull): + continue + case errors.Is(err, io.EOF): + return line, io.EOF + default: + return nil, err + } + } +} diff --git a/internal/adapter/opencode/client_test.go b/internal/adapter/opencode/client_test.go new file mode 100644 index 0000000..84767f4 --- /dev/null +++ b/internal/adapter/opencode/client_test.go @@ -0,0 +1,511 @@ +package opencode + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "net/http/httptest" + "reflect" + "strings" + "sync/atomic" + "testing" + "time" +) + +const ( + testAPIUsername = "uam" + testAPIPassword = "test-password-must-not-leak" + testAPIDirectory = "/tmp/uam project" +) + +type deadlineTransport struct { + base http.RoundTripper + seen atomic.Int64 + missing atomic.Bool +} + +func (t *deadlineTransport) RoundTrip(req *http.Request) (*http.Response, error) { + t.seen.Add(1) + if _, ok := req.Context().Deadline(); !ok { + t.missing.Store(true) + } + return t.base.RoundTrip(req) +} + +func TestAPIClientContracts(t *testing.T) { + t.Parallel() + + var requests atomic.Int64 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + requests.Add(1) + username, password, ok := r.BasicAuth() + if !ok || username != testAPIUsername || password != testAPIPassword { + t.Errorf("BasicAuth = (%q, %q, %v)", username, password, ok) + } + if got := r.Header.Get("X-OpenCode-Directory"); got != testAPIDirectory { + t.Errorf("X-OpenCode-Directory = %q, want %q", got, testAPIDirectory) + } + + switch { + case r.Method == http.MethodGet && r.URL.Path == "/global/health": + w.Header().Set("Content-Type", "application/json; charset=utf-8") + _, _ = io.WriteString(w, `{"healthy":true,"version":"1.18.1"}`) + case r.Method == http.MethodPost && r.URL.Path == "/session": + assertJSONBody(t, r, map[string]any{ + "title": "UAM: workspace name", + "metadata": map[string]any{"uam": true}, + }) + w.Header().Set("Content-Type", "application/json") + _, _ = io.WriteString(w, `{"id":"ses_abc123","directory":"/tmp/uam project","title":"UAM: workspace name"}`) + case r.Method == http.MethodGet && r.URL.EscapedPath() == "/session/ses_abc123": + w.Header().Set("Content-Type", "application/json") + _, _ = io.WriteString(w, `{"id":"ses_abc123","directory":"/tmp/uam project","title":"UAM: workspace name"}`) + case r.Method == http.MethodPost && r.URL.EscapedPath() == "/permission/per_abc123/reply": + assertJSONBody(t, r, map[string]any{"reply": "once"}) + w.WriteHeader(http.StatusNoContent) + case r.Method == http.MethodGet && r.URL.Path == "/event": + if got := r.Header.Get("Accept"); got != "text/event-stream" { + t.Errorf("Accept = %q, want text/event-stream", got) + } + w.Header().Set("Content-Type", "text/event-stream; charset=utf-8") + _, _ = io.WriteString(w, ": keepalive\n\nunknown: ignored\ndata: {\"type\":\"session.created\",\ndata: \"properties\":{\"id\":\"ses_abc123\"}}\n\n") + default: + http.Error(w, fmt.Sprintf("unexpected %s %s", r.Method, r.URL.EscapedPath()), http.StatusNotFound) + } + })) + defer server.Close() + + transport := &deadlineTransport{base: http.DefaultTransport} + client, err := newAPIClient(server.URL, testAPIUsername, testAPIPassword, testAPIDirectory, &http.Client{Transport: transport}) + if err != nil { + t.Fatal(err) + } + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + health, err := client.health(ctx) + if err != nil { + t.Fatalf("health: %v", err) + } + if health != (serverHealth{Healthy: true, Version: "1.18.1"}) { + t.Fatalf("health = %#v", health) + } + + created, err := client.createSession(ctx, "work\x1b[31mspace\x1b[0m\nname") + if err != nil { + t.Fatalf("createSession: %v", err) + } + if created.ID != "ses_abc123" || created.ParentID != "" || created.Directory != testAPIDirectory || created.Title != "UAM: workspace name" { + t.Fatalf("created session = %#v", created) + } + + got, err := client.getSession(ctx, "ses_abc123") + if err != nil { + t.Fatalf("getSession: %v", err) + } + if got != created { + t.Fatalf("getSession = %#v, want %#v", got, created) + } + + if err := client.replyPermission(ctx, "per_abc123"); err != nil { + t.Fatalf("replyPermission: %v", err) + } + + ready := make(chan struct{}) + events := make(chan eventEnvelope, 1) + if err := client.subscribe(ctx, ready, events); err == nil { + t.Fatal("subscribe returned nil at EOF") + } + select { + case <-ready: + default: + t.Fatal("subscribe did not close ready") + } + select { + case event := <-events: + if event.Type != "session.created" || string(event.Properties) != `{"id":"ses_abc123"}` { + t.Fatalf("event = %#v", event) + } + default: + t.Fatal("subscribe did not emit event") + } + + if got := requests.Load(); got != 5 { + t.Fatalf("requests = %d, want 5", got) + } + if got := transport.seen.Load(); got != 5 { + t.Fatalf("deadline transport saw %d requests, want 5", got) + } + if transport.missing.Load() { + t.Fatal("a request did not propagate the bounded caller context") + } +} + +func TestAPIClientRejectsUnsafeBaseURLs(t *testing.T) { + t.Parallel() + + for _, value := range []string{ + "", + "http://127.0.0.1", + "http://127.0.0.1:not-a-port", + "http://127.0.0.2:4096", + "http://localhost:4096", + "http://[::1]:4096", + "https://127.0.0.1:4096", + "http://user:secret@127.0.0.1:4096", + "http://127.0.0.1:4096/prefix", + "http://127.0.0.1:4096?", + "http://127.0.0.1:4096?query=value", + "http://127.0.0.1:4096#fragment", + } { + t.Run(value, func(t *testing.T) { + if _, err := newAPIClient(value, testAPIUsername, testAPIPassword, testAPIDirectory, nil); err == nil { + t.Fatalf("newAPIClient(%q) succeeded", value) + } else if strings.Contains(err.Error(), "secret") { + t.Fatalf("constructor error leaked URL credentials: %v", err) + } + }) + } +} + +func TestAPIClientDoesNotFollowRedirects(t *testing.T) { + t.Parallel() + + var redirected atomic.Bool + destination := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + redirected.Store(true) + w.Header().Set("Content-Type", "application/json") + _, _ = io.WriteString(w, `{"healthy":true,"version":"1.18.1"}`) + })) + defer destination.Close() + source := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + http.Redirect(w, r, destination.URL, http.StatusFound) + })) + defer source.Close() + + client, err := newAPIClient(source.URL, testAPIUsername, testAPIPassword, testAPIDirectory, source.Client()) + if err != nil { + t.Fatal(err) + } + _, err = client.health(testContext(t)) + assertSafeAPIError(t, err) + if redirected.Load() { + t.Fatal("client followed a redirect outside the validated base URL") + } + if !strings.Contains(err.Error(), "302") { + t.Fatalf("redirect error = %q, want status 302", err) + } +} + +func TestAPIClientHTTPFailures(t *testing.T) { + t.Parallel() + + t.Run("unauthorized", func(t *testing.T) { + client := newTestAPIClient(t, func(w http.ResponseWriter, _ *http.Request) { + http.Error(w, "unauthorized", http.StatusUnauthorized) + }) + _, err := client.health(testContext(t)) + assertSafeAPIError(t, err) + if !strings.Contains(err.Error(), "401") { + t.Fatalf("error = %q, want status 401", err) + } + }) + + t.Run("exact resume not found", func(t *testing.T) { + client := newTestAPIClient(t, func(w http.ResponseWriter, _ *http.Request) { + http.Error(w, "missing", http.StatusNotFound) + }) + _, err := client.getSession(testContext(t), "ses_abc123") + if !errors.Is(err, errSessionNotFound) { + t.Fatalf("getSession error = %v, want errSessionNotFound", err) + } + assertSafeAPIError(t, err) + }) + + t.Run("bounded server body is sanitized redacted and capped", func(t *testing.T) { + client := newTestAPIClient(t, func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusInternalServerError) + _, _ = io.WriteString(w, "\x1b[31mboom\x1b[0m\r\n"+testAPIPassword+strings.Repeat("x", 64)) + }) + _, err := client.health(testContext(t)) + assertSafeAPIError(t, err) + if !strings.Contains(err.Error(), "500") || !strings.Contains(err.Error(), "boom") { + t.Fatalf("error = %q, want status and sanitized excerpt", err) + } + if !strings.Contains(err.Error(), "") { + t.Fatalf("error = %q, want password redaction marker", err) + } + if len([]rune(err.Error())) > 512 { + t.Fatalf("error excerpt was not capped: %d runes", len([]rune(err.Error()))) + } + }) + + t.Run("oversized body omits a secret straddling the read boundary", func(t *testing.T) { + split := len(testAPIPassword) / 2 + padding := strings.Repeat("\x01", maxErrorBodyBytes-split) + client := newTestAPIClient(t, func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusInternalServerError) + _, _ = io.WriteString(w, padding+testAPIPassword) + }) + _, err := client.health(testContext(t)) + assertSafeAPIError(t, err) + message := err.Error() + for start := 0; start+8 <= len(testAPIPassword); start++ { + fragment := testAPIPassword[start : start+8] + if strings.Contains(message, fragment) { + t.Fatalf("error leaked password fragment %q: %q", fragment, message) + } + } + if !strings.Contains(message, "response body omitted") { + t.Fatalf("oversized error = %q, want generic omission marker", message) + } + }) +} + +func TestAPIClientRejectsInvalidIDsWithoutRequest(t *testing.T) { + t.Parallel() + + var requests atomic.Int64 + client := newTestAPIClient(t, func(w http.ResponseWriter, _ *http.Request) { + requests.Add(1) + w.WriteHeader(http.StatusNoContent) + }) + ctx := testContext(t) + for _, id := range []string{"", "ses_ab", "ses_abc/def", "-ses_abc123", strings.Repeat("s", 65)} { + if _, err := client.getSession(ctx, id); err == nil { + t.Errorf("getSession(%q) succeeded", id) + } + } + for _, id := range []string{"", "per_ab", "per_abc/def", "-per_abc123", strings.Repeat("p", 65)} { + if err := client.replyPermission(ctx, id); err == nil { + t.Errorf("replyPermission(%q) succeeded", id) + } + } + if got := requests.Load(); got != 0 { + t.Fatalf("invalid IDs issued %d requests", got) + } +} + +func TestAPIClientRejectsInvalidJSONResponses(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + contentType string + body string + }{ + {name: "wrong content type", contentType: "text/plain", body: `{"healthy":true,"version":"1.18.1"}`}, + {name: "malformed JSON", contentType: "application/json", body: `{"healthy":`}, + {name: "trailing JSON", contentType: "application/json", body: `{"healthy":true,"version":"1.18.1"}{}`}, + {name: "body over one MiB", contentType: "application/json", body: `{"healthy":true,"version":"` + strings.Repeat("x", (1<<20)+1) + `"}`}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + client := newTestAPIClient(t, func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", tt.contentType) + _, _ = io.WriteString(w, tt.body) + }) + _, err := client.health(testContext(t)) + assertSafeAPIError(t, err) + }) + } +} + +func TestSSERejectsInvalidResponseBeforeReady(t *testing.T) { + t.Parallel() + + client := newTestAPIClient(t, func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + _, _ = io.WriteString(w, `{}`) + }) + ready := make(chan struct{}) + err := client.subscribe(testContext(t), ready, make(chan eventEnvelope, 1)) + assertSafeAPIError(t, err) + select { + case <-ready: + t.Fatal("ready closed before content-type validation") + default: + } +} + +func TestSSERejectsOversizedEvent(t *testing.T) { + t.Parallel() + + client := newTestAPIClient(t, func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "text/event-stream") + _, _ = io.WriteString(w, "data: "+strings.Repeat("x", (256<<10)+1)+"\n\n") + }) + ready := make(chan struct{}) + err := client.subscribe(testContext(t), ready, make(chan eventEnvelope, 1)) + assertSafeAPIError(t, err) + select { + case <-ready: + default: + t.Fatal("ready was not closed after valid SSE headers") + } + if !strings.Contains(strings.ToLower(err.Error()), "large") { + t.Fatalf("oversized SSE error = %q", err) + } +} + +func TestSSECancellationWhileReading(t *testing.T) { + t.Parallel() + + client := newTestAPIClient(t, func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "text/event-stream") + w.WriteHeader(http.StatusOK) + if flusher, ok := w.(http.Flusher); ok { + flusher.Flush() + } + <-r.Context().Done() + }) + ctx, cancel := context.WithCancel(context.Background()) + ready := make(chan struct{}) + done := make(chan error, 1) + go func() { + done <- client.subscribe(ctx, ready, make(chan eventEnvelope)) + }() + select { + case <-ready: + case <-time.After(2 * time.Second): + t.Fatal("subscribe did not become ready") + } + cancel() + select { + case err := <-done: + if !errors.Is(err, context.Canceled) { + t.Fatalf("subscribe error = %v, want context.Canceled", err) + } + case <-time.After(2 * time.Second): + t.Fatal("subscribe did not stop after cancellation") + } +} + +func TestSSECancellationWhileSending(t *testing.T) { + t.Parallel() + + release := make(chan struct{}) + client := newTestAPIClient(t, func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "text/event-stream") + _, _ = io.WriteString(w, "data: {\"type\":\"session.created\",\"properties\":{}}\n\n") + if flusher, ok := w.(http.Flusher); ok { + flusher.Flush() + } + select { + case <-release: + case <-r.Context().Done(): + } + }) + defer close(release) + ctx, cancel := context.WithCancel(context.Background()) + ready := make(chan struct{}) + done := make(chan error, 1) + go func() { + done <- client.subscribe(ctx, ready, make(chan eventEnvelope)) + }() + select { + case <-ready: + case <-time.After(2 * time.Second): + t.Fatal("subscribe did not become ready") + } + cancel() + select { + case err := <-done: + if !errors.Is(err, context.Canceled) { + t.Fatalf("subscribe error = %v, want context.Canceled", err) + } + case <-time.After(2 * time.Second): + t.Fatal("blocked SSE delivery ignored cancellation") + } +} + +func assertJSONBody(t *testing.T, r *http.Request, want map[string]any) { + t.Helper() + if got := r.Header.Get("Content-Type"); got != "application/json" { + t.Errorf("Content-Type = %q, want application/json", got) + } + data, err := io.ReadAll(r.Body) + if err != nil { + t.Errorf("read request body: %v", err) + return + } + var got map[string]any + if err := json.Unmarshal(data, &got); err != nil { + t.Errorf("decode request body %q: %v", data, err) + return + } + if !reflect.DeepEqual(got, want) { + t.Errorf("request body = %#v, want %#v", got, want) + } +} + +func newTestAPIClient(t *testing.T, handler http.HandlerFunc) *apiClient { + t.Helper() + server := httptest.NewServer(handler) + t.Cleanup(server.Close) + client, err := newAPIClient(server.URL, testAPIUsername, testAPIPassword, testAPIDirectory, server.Client()) + if err != nil { + t.Fatal(err) + } + return client +} + +func testContext(t *testing.T) context.Context { + t.Helper() + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + t.Cleanup(cancel) + return ctx +} + +func TestAPIClientErrorSafetyCheckRejectsTerminalControls(t *testing.T) { + t.Parallel() + + if hasUnsafeAPIErrorControl("ordinary display text") { + t.Fatal("ordinary display text classified as unsafe") + } + for _, tt := range []struct { + name, value string + }{ + {name: "tab", value: "left\tright"}, + {name: "line feed", value: "left\nright"}, + {name: "carriage return", value: "left\rright"}, + {name: "C0", value: "left\x00right"}, + {name: "DEL", value: "left\x7fright"}, + {name: "C1 CSI", value: "left\u009b31mright"}, + {name: "CSI", value: "left\x1b[31mright"}, + {name: "OSC", value: "left\x1b]0;owned\x07right"}, + {name: "DCS", value: "left\x1bPowned\x1b\\right"}, + } { + t.Run(tt.name, func(t *testing.T) { + if !hasUnsafeAPIErrorControl(tt.value) { + t.Fatalf("terminal control accepted in %q", tt.value) + } + }) + } +} + +func hasUnsafeAPIErrorControl(message string) bool { + for _, r := range message { + if r < 0x20 || r == 0x7f || r >= 0x80 && r <= 0x9f { + return true + } + } + return false +} + +func assertSafeAPIError(t *testing.T, err error) { + t.Helper() + if err == nil { + t.Fatal("expected error") + } + message := err.Error() + if strings.Contains(message, testAPIPassword) { + t.Fatalf("error leaked password: %q", message) + } + if hasUnsafeAPIErrorControl(message) { + t.Fatalf("error contains terminal control: %q", message) + } +} diff --git a/internal/adapter/opencode/command.go b/internal/adapter/opencode/command.go new file mode 100644 index 0000000..0e6abb8 --- /dev/null +++ b/internal/adapter/opencode/command.go @@ -0,0 +1,235 @@ +package opencode + +import ( + "context" + "fmt" + "os" + "os/exec" + "path/filepath" + "regexp" + "strconv" + "strings" + "sync" + "syscall" + "time" + + "github.com/RandomCodeSpace/unified-agent-manager/internal/adapter" + "github.com/RandomCodeSpace/unified-agent-manager/internal/displaytext" +) + +const minimumVersion = "1.18.1" + +const versionProbeTimeout = 750 * time.Millisecond + +type providerCommand struct { + path string + shell string + alias string +} + +func providerCommandFor(req adapter.ResumeRequest) (providerCommand, error) { + if req.ExecutablePath != "" { + return providerCommandFromFlags(req.ExecutablePath, "", "") + } + shell := os.Getenv("SHELL") + if shell == "" { + shell = "/bin/sh" + } + return providerCommandFromFlags("", shell, req.CommandAlias) +} + +func providerCommandFromFlags(path, shell, alias string) (providerCommand, error) { + if path != "" { + if alias != "" || shell != "" { + return providerCommand{}, fmt.Errorf("OpenCode provider command cannot combine a direct path with shell alias fields") + } + if !filepath.IsAbs(path) { + return providerCommand{}, fmt.Errorf("OpenCode executable path %q must be absolute", path) + } + info, err := os.Stat(path) + if err != nil { + return providerCommand{}, fmt.Errorf("stat OpenCode executable %q: %w", path, err) + } + if !info.Mode().IsRegular() { + return providerCommand{}, fmt.Errorf("OpenCode executable path %q is not a regular file", path) + } + return providerCommand{path: path}, nil + } + + if alias == "" { + return providerCommand{}, fmt.Errorf("OpenCode provider command requires an executable path or alias") + } + if err := validateProviderAlias(alias); err != nil { + return providerCommand{}, err + } + if !filepath.IsAbs(shell) { + return providerCommand{}, fmt.Errorf("OpenCode alias shell %q must be absolute", shell) + } + return providerCommand{shell: shell, alias: alias}, nil +} + +func validateProviderAlias(alias string) error { + if alias == "" || strings.HasPrefix(alias, "-") { + return fmt.Errorf("invalid OpenCode command alias %q", alias) + } + for _, r := range alias { + if r >= 'a' && r <= 'z' || r >= 'A' && r <= 'Z' || r >= '0' && r <= '9' || r == '_' || r == '-' || r == '.' { + continue + } + return fmt.Errorf("invalid OpenCode command alias %q", alias) + } + return nil +} + +func (c providerCommand) argv(args ...string) []string { + if c.path != "" { + return append([]string{c.path}, args...) + } + aliasArgv := append([]string{c.alias}, args...) + return []string{c.shell, "-ic", "exec " + adapter.ShellJoin(aliasArgv)} +} + +func (c providerCommand) command(ctx context.Context, args ...string) *exec.Cmd { + argv := c.argv(args...) + return exec.CommandContext(ctx, argv[0], argv[1:]...) // #nosec G204 -- argv comes from a validated absolute executable or absolute shell plus safely quoted alias. +} + +type semanticVersion struct { + major, minor, patch int + prerelease bool +} + +var semanticVersionRE = regexp.MustCompile(`^v?(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)(?:-([0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*))?(?:\+([0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*))?$`) + +func parseSemanticVersion(value string) (semanticVersion, error) { + match := semanticVersionRE.FindStringSubmatch(strings.TrimSpace(value)) + if match == nil { + return semanticVersion{}, fmt.Errorf("malformed semantic version") + } + if match[4] != "" { + for _, identifier := range strings.Split(match[4], ".") { + if len(identifier) > 1 && identifier[0] == '0' && allDecimal(identifier) { + return semanticVersion{}, fmt.Errorf("malformed semantic version prerelease") + } + } + } + major, err := strconv.Atoi(match[1]) + if err != nil { + return semanticVersion{}, fmt.Errorf("parse semantic version major: %w", err) + } + minor, err := strconv.Atoi(match[2]) + if err != nil { + return semanticVersion{}, fmt.Errorf("parse semantic version minor: %w", err) + } + patch, err := strconv.Atoi(match[3]) + if err != nil { + return semanticVersion{}, fmt.Errorf("parse semantic version patch: %w", err) + } + return semanticVersion{major: major, minor: minor, patch: patch, prerelease: match[4] != ""}, nil +} + +func allDecimal(value string) bool { + for _, r := range value { + if r < '0' || r > '9' { + return false + } + } + return true +} + +func (v semanticVersion) compare(other semanticVersion) int { + for _, pair := range [][2]int{{v.major, other.major}, {v.minor, other.minor}, {v.patch, other.patch}} { + if pair[0] < pair[1] { + return -1 + } + if pair[0] > pair[1] { + return 1 + } + } + if v.prerelease == other.prerelease { + return 0 + } + if v.prerelease { + return -1 + } + return 1 +} + +type versionExecutableIdentity struct { + path string + size, mtime int64 + device, inode uint64 +} + +var minimumVersionCache = struct { + sync.Mutex + values map[versionExecutableIdentity]error +}{values: map[versionExecutableIdentity]error{}} + +func requireMinimumVersion(ctx context.Context, command providerCommand) error { + if command.path == "" { + return probeMinimumVersion(ctx, command) + } + + key, err := versionIdentity(command.path) + if err != nil { + return minimumVersionError(command, nil, "cannot inspect executable") + } + minimumVersionCache.Lock() + defer minimumVersionCache.Unlock() + if cached, ok := minimumVersionCache.values[key]; ok { + return cached + } + err = probeMinimumVersion(ctx, command) + minimumVersionCache.values[key] = err + return err +} + +func versionIdentity(path string) (versionExecutableIdentity, error) { + info, err := os.Stat(path) + if err != nil { + return versionExecutableIdentity{}, err + } + if !info.Mode().IsRegular() { + return versionExecutableIdentity{}, fmt.Errorf("not a regular file") + } + key := versionExecutableIdentity{path: path, size: info.Size(), mtime: info.ModTime().UnixNano()} + if stat, ok := info.Sys().(*syscall.Stat_t); ok { + key.device, key.inode = uint64(stat.Dev), stat.Ino + } + return key, nil +} + +func probeMinimumVersion(ctx context.Context, command providerCommand) error { + probeCtx, cancel := context.WithTimeout(ctx, versionProbeTimeout) + defer cancel() + out, err := command.command(probeCtx, "--version").CombinedOutput() + if err != nil { + if probeCtx.Err() != nil { + return minimumVersionError(command, out, "version probe timed out or was canceled") + } + return minimumVersionError(command, out, "version probe exited unsuccessfully") + } + detected, err := parseSemanticVersion(string(out)) + if err != nil { + return minimumVersionError(command, out, "unrecognized version output") + } + required := semanticVersion{major: 1, minor: 18, patch: 1} + if detected.prerelease || detected.compare(required) < 0 { + return minimumVersionError(command, out, "unsupported version") + } + return nil +} + +func minimumVersionError(command providerCommand, output []byte, reason string) error { + detected := strings.TrimSpace(displaytext.Sanitize(string(output))) + if detected == "" { + detected = "" + } + identity := command.path + if identity == "" { + identity = command.alias + } + identity = displaytext.Sanitize(identity) + return fmt.Errorf("OpenCode command %q version check failed: detected %q (%s); required version %s; run `opencode upgrade %s`", identity, detected, reason, minimumVersion, minimumVersion) +} diff --git a/internal/adapter/opencode/command_test.go b/internal/adapter/opencode/command_test.go new file mode 100644 index 0000000..ea14971 --- /dev/null +++ b/internal/adapter/opencode/command_test.go @@ -0,0 +1,253 @@ +package opencode + +import ( + "context" + "fmt" + "os" + "path/filepath" + "reflect" + "strings" + "testing" + "time" + + "github.com/RandomCodeSpace/unified-agent-manager/internal/adapter" +) + +func TestProviderCommandConstruction(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "opencode") + writeVersionExecutable(t, path, "1.18.1\n", 0, false) + + direct, err := providerCommandFromFlags(path, "", "") + if err != nil { + t.Fatal(err) + } + if got, want := direct.argv("serve"), []string{path, "serve"}; !reflect.DeepEqual(got, want) { + t.Fatalf("direct argv = %#v, want %#v", got, want) + } + + // PATH-resolved aliases carry both fields on ResumeRequest. Preserve the + // already-resolved executable identity instead of resolving the alias again. + resolved, err := providerCommandFor(adapter.ResumeRequest{ + ExecutablePath: path, + CommandAlias: "custom-opencode", + }) + if err != nil { + t.Fatal(err) + } + if got, want := resolved.argv("serve"), []string{path, "serve"}; !reflect.DeepEqual(got, want) { + t.Fatalf("resolved alias argv = %#v, want %#v", got, want) + } + + shell := "/bin/sh" + alias := "custom-opencode" + shellCommand, err := providerCommandFromFlags("", shell, alias) + if err != nil { + t.Fatal(err) + } + wantShell := []string{shell, "-ic", "exec " + adapter.ShellJoin([]string{alias, "serve"})} + if got := shellCommand.argv("serve"); !reflect.DeepEqual(got, wantShell) { + t.Fatalf("alias argv = %#v, want %#v", got, wantShell) + } + + t.Setenv("SHELL", shell) + fromRequest, err := providerCommandFor(adapter.ResumeRequest{CommandAlias: alias}) + if err != nil { + t.Fatal(err) + } + if got := fromRequest.argv("serve"); !reflect.DeepEqual(got, wantShell) { + t.Fatalf("request alias argv = %#v, want %#v", got, wantShell) + } +} + +func TestProviderCommandRejectsInvalidForms(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "opencode") + writeVersionExecutable(t, path, "1.18.1\n", 0, false) + + tests := []struct { + name string + path, shell string + alias string + }{ + {name: "relative direct path", path: "opencode"}, + {name: "non-regular direct path", path: dir}, + {name: "empty path and alias"}, + {name: "relative shell", shell: "bin/sh", alias: "custom-opencode"}, + {name: "direct path and alias", path: path, shell: "/bin/sh", alias: "custom-opencode"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if _, err := providerCommandFromFlags(tt.path, tt.shell, tt.alias); err == nil { + t.Fatalf("providerCommandFromFlags(%q, %q, %q) succeeded", tt.path, tt.shell, tt.alias) + } + }) + } + + for _, alias := range []string{"bad alias", "a;b", "$(boom)", "-flag", "a/b"} { + t.Run("unsafe alias "+alias, func(t *testing.T) { + if _, err := providerCommandFromFlags("", "/bin/sh", alias); err == nil { + t.Fatalf("unsafe alias %q accepted", alias) + } + }) + } +} + +func TestMinimumVersionValidation(t *testing.T) { + const secret = "environment-value-must-not-leak" + t.Setenv("UAM_VERSION_TEST_SECRET", secret) + + tests := []struct { + name string + output string + exitCode int + delay bool + wantErr bool + detected string + }{ + {name: "below floor", output: "1.18.0\n", wantErr: true, detected: "1.18.0"}, + {name: "at floor", output: "1.18.1\n"}, + {name: "newer minor", output: "1.19.0\n"}, + {name: "newer major", output: "2.0.0\n"}, + {name: "v prefix", output: "v1.18.1\n"}, + {name: "stable build metadata", output: "1.18.1+linux.amd64\n"}, + {name: "floor prerelease", output: "1.18.1-beta.1\n", wantErr: true, detected: "1.18.1-beta.1"}, + {name: "newer patch prerelease", output: "1.18.2-beta.1\n", wantErr: true, detected: "1.18.2-beta.1"}, + {name: "newer minor prerelease", output: "1.19.0-rc.1\n", wantErr: true, detected: "1.19.0-rc.1"}, + {name: "malformed", output: "not-\x1b[31mvalid\x1b[0m\n", wantErr: true, detected: "not-valid"}, + {name: "multiple tokens", output: "opencode 1.18.1\n", wantErr: true, detected: "opencode 1.18.1"}, + {name: "nonzero exit", output: "1.18.1\n", exitCode: 23, wantErr: true, detected: "1.18.1"}, + {name: "timeout", output: "waiting\x1b[31m\n", delay: true, wantErr: true, detected: "waiting"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + path := filepath.Join(t.TempDir(), "opencode") + writeVersionExecutable(t, path, tt.output, tt.exitCode, tt.delay) + command, err := providerCommandFromFlags(path, "", "") + if err != nil { + t.Fatal(err) + } + + started := time.Now() + err = requireMinimumVersion(context.Background(), command) + if tt.delay && time.Since(started) > 1500*time.Millisecond { + t.Fatalf("version timeout took %s, want at most 1.5s", time.Since(started)) + } + if !tt.wantErr { + if err != nil { + t.Fatalf("requireMinimumVersion() error = %v", err) + } + return + } + if err == nil { + t.Fatal("requireMinimumVersion() succeeded") + } + message := err.Error() + for _, want := range []string{tt.detected, minimumVersion, "opencode upgrade 1.18.1"} { + if !strings.Contains(message, want) { + t.Errorf("error %q missing %q", message, want) + } + } + if strings.Contains(message, "\x1b") { + t.Errorf("error contains terminal control: %q", message) + } + if strings.Contains(message, secret) { + t.Errorf("error leaked environment value: %q", message) + } + }) + } +} + +func TestMinimumVersionDirectCacheUsesStatIdentity(t *testing.T) { + for _, tt := range []struct { + name, version string + wantErr bool + }{ + {name: "success", version: "1.18.1\n"}, + {name: "failure", version: "1.18.0\n", wantErr: true}, + } { + t.Run(tt.name, func(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "opencode") + count := filepath.Join(dir, "count") + writeCountingVersionExecutable(t, path, count, tt.version) + command, err := providerCommandFromFlags(path, "", "") + if err != nil { + t.Fatal(err) + } + + for range 2 { + err := requireMinimumVersion(context.Background(), command) + if (err != nil) != tt.wantErr { + t.Fatalf("cached probe error = %v, wantErr %v", err, tt.wantErr) + } + } + assertProbeCount(t, count, "x") + + replacement := path + ".replacement" + writeCountingVersionExecutable(t, replacement, count, tt.version) + if err := os.Rename(replacement, path); err != nil { + t.Fatal(err) + } + err = requireMinimumVersion(context.Background(), command) + if (err != nil) != tt.wantErr { + t.Fatalf("replacement probe error = %v, wantErr %v", err, tt.wantErr) + } + assertProbeCount(t, count, "xx") + }) + } +} + +func TestMinimumVersionDoesNotCacheShellAliases(t *testing.T) { + dir := t.TempDir() + shell := filepath.Join(dir, "shell") + count := filepath.Join(dir, "count") + writeCountingVersionExecutable(t, shell, count, "1.18.1\n") + command, err := providerCommandFromFlags("", shell, "custom-opencode") + if err != nil { + t.Fatal(err) + } + for range 2 { + if err := requireMinimumVersion(context.Background(), command); err != nil { + t.Fatal(err) + } + } + assertProbeCount(t, count, "xx") +} + +func writeVersionExecutable(t *testing.T, path, output string, exitCode int, delay bool) { + t.Helper() + script := "#!/bin/sh\n" + if output != "" { + script += "printf %s " + adapter.ShellJoin([]string{output}) + "\n" + } + if delay { + script += "exec sleep 2\n" + } else { + script += fmt.Sprintf("exit %d\n", exitCode) + } + if err := os.WriteFile(path, []byte(script), 0o700); err != nil { + t.Fatal(err) + } +} + +func writeCountingVersionExecutable(t *testing.T, path, count, version string) { + t.Helper() + script := "#!/bin/sh\n" + + "printf x >> " + adapter.ShellJoin([]string{count}) + "\n" + + "printf %s " + adapter.ShellJoin([]string{version}) + "\n" + if err := os.WriteFile(path, []byte(script), 0o700); err != nil { + t.Fatal(err) + } +} + +func assertProbeCount(t *testing.T, path, want string) { + t.Helper() + data, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + if got := string(data); got != want { + t.Fatalf("probe count = %q, want %q", got, want) + } +} diff --git a/internal/adapter/opencode/opencode.go b/internal/adapter/opencode/opencode.go index e692aea..0a107c9 100644 --- a/internal/adapter/opencode/opencode.go +++ b/internal/adapter/opencode/opencode.go @@ -1,359 +1,106 @@ package opencode import ( - "context" - "encoding/json" - "errors" "fmt" - "io" - "net/url" "os" - "os/exec" "path/filepath" "regexp" - "strings" - "sync" - "syscall" - "time" "github.com/RandomCodeSpace/unified-agent-manager/internal/adapter" - "github.com/RandomCodeSpace/unified-agent-manager/internal/providerstate" "github.com/RandomCodeSpace/unified-agent-manager/internal/session" - "github.com/RandomCodeSpace/unified-agent-manager/internal/store" - "golang.org/x/sys/unix" ) -// OpenCode's --auto support is version-dependent, so it is appended by the -// preparation hook only after probing the resolved executable. Static yolo -// args stay empty to keep older versions safe. var yoloArgs []string -// sessionArgs appends opencode's `-c` (continue) flag on resume. -// sessionArgs picks opencode's resume flags. opencode supports exact resume -// (`--session ses_...`). The event plugin learns that id after launch; -// records without a learned identity retain the guarded `-c` fallback. -func sessionArgs(req adapter.ResumeRequest, activity string) []string { - if activity == "resumed" { - if req.ProviderSessionID != "" { - return []string{"--session", req.ProviderSessionID} - } - return []string{"-c"} - } - return nil -} - -func New(backend adapter.Backend) adapter.AgentAdapter { - a := adapter.NewAgent("opencode", "OpenCode", []adapter.CommandCandidate{{Display: "opencode", Args: []string{"opencode"}}}, yoloArgs, backend) - a.SessionArgs = sessionArgs - a.PrepareLaunch = prepareLaunch - a.LiveProviderSessionID = liveProviderSessionID - a.SkipPromptOnResume = true - return a -} - -const maxIdentityBytes = 1024 - var providerIDRE = regexp.MustCompile(`^ses_[A-Za-z0-9_-]{3,60}$`) -var autoTokenRE = regexp.MustCompile(`(^|[[:space:],\[])--auto([=[:space:],\]]|$)`) - -type executableIdentity struct { - path string - size, mtime int64 - device, inode uint64 -} -var autoCache = struct { - sync.Mutex - values map[executableIdentity]bool -}{values: map[executableIdentity]bool{}} - -func resetAutoCacheForTest() { - autoCache.Lock() - defer autoCache.Unlock() - autoCache.values = map[executableIdentity]bool{} +func New(backend adapter.Backend) adapter.AgentAdapter { + agent := adapter.NewAgent("opencode", "OpenCode", []adapter.CommandCandidate{{Display: "opencode", Args: []string{"opencode"}}}, yoloArgs, backend) + agent.PrepareLaunch = prepareLaunch + agent.LiveProviderSessionID = liveProviderSessionID + agent.ResumeKindFor = resumeKind + agent.SkipPromptOnResume = true + return agent } -func prepareLaunch(ctx adapter.Context, req adapter.ResumeRequest, _, sessionName, _ string) (adapter.LaunchPreparation, error) { - pluginPath, err := ensureProviderFiles() +func prepareLaunch(ctx adapter.Context, req adapter.ResumeRequest, _, sessionName, cwd string) (adapter.LaunchPreparation, error) { + providerCommand, err := providerCommandFor(req) if err != nil { return adapter.LaunchPreparation{}, err } - handoff, err := identityPath(sessionName) - if err != nil { + if err := requireMinimumVersion(ctx, providerCommand); err != nil { return adapter.LaunchPreparation{}, err } - config, err := mergeConfigContent(os.Getenv("OPENCODE_CONFIG_CONTENT"), (&url.URL{Scheme: "file", Path: pluginPath}).String()) - if err != nil { - return adapter.LaunchPreparation{}, fmt.Errorf("merge OPENCODE_CONFIG_CONTENT: %w", err) - } - prep := adapter.LaunchPreparation{Env: map[string]string{ - "OPENCODE_CONFIG_CONTENT": config, - "UAM_OPENCODE_IDENTITY_FILE": handoff, - session.ProviderIdentityFileEnv: handoff, - }} - if req.Mode != "safe" { - if req.ExecutablePath != "" && supportsAuto(ctx, req.ExecutablePath) { - prep.ExtraArgs = []string{"--auto"} - } - } - return prep, nil -} - -func supportsAuto(ctx context.Context, path string) bool { - info, err := os.Stat(path) - if err != nil || !info.Mode().IsRegular() { - return false - } - key := executableIdentity{path: path, size: info.Size(), mtime: info.ModTime().UnixNano()} - if st, ok := info.Sys().(*syscall.Stat_t); ok { - key.device, key.inode = uint64(st.Dev), st.Ino - } - autoCache.Lock() - defer autoCache.Unlock() - if value, ok := autoCache.values[key]; ok { - return value + if req.ProviderSessionID != "" && !validOpenCodeSessionID(req.ProviderSessionID) { + return adapter.LaunchPreparation{}, fmt.Errorf("invalid OpenCode provider session ID") } - probeCtx, cancel := context.WithTimeout(ctx, 750*time.Millisecond) - defer cancel() - out, err := exec.CommandContext(probeCtx, path, "--help").CombinedOutput() // #nosec G204 -- LookPath-resolved executable, invoked without a shell. - supported := err == nil && autoTokenRE.Match(out) - autoCache.values[key] = supported - return supported -} -func stateRoot() (string, error) { - base := os.Getenv("XDG_STATE_HOME") - if base == "" { - home, err := os.UserHomeDir() - if err != nil { - return "", err - } - base = filepath.Join(home, ".local", "state") - } - return filepath.Join(base, "uam", "providers", "opencode"), nil -} - -func verifyOwner(path string, wantDir bool, mode os.FileMode) error { - info, err := os.Lstat(path) + uamExecutable, err := os.Executable() if err != nil { - return err - } - if info.Mode()&os.ModeSymlink != 0 || (wantDir && !info.IsDir()) || (!wantDir && !info.Mode().IsRegular()) { - return fmt.Errorf("provider state %s has unsafe file type", path) + return adapter.LaunchPreparation{}, fmt.Errorf("resolve uam executable: %w", err) } - if info.Mode().Perm() != mode { - return fmt.Errorf("provider state %s has unsafe mode %04o; want %04o", path, info.Mode().Perm(), mode) - } - if st, ok := info.Sys().(*syscall.Stat_t); ok && int(st.Uid) != os.Getuid() { - return fmt.Errorf("provider state %s is not owned by the current user", path) + uamExecutable, err = filepath.Abs(uamExecutable) + if err != nil { + return adapter.LaunchPreparation{}, fmt.Errorf("make uam executable absolute: %w", err) } - return nil -} -func ensureProviderFiles() (string, error) { - root, err := stateRoot() + runtimeDir, err := openCodeRuntimeDir() if err != nil { - return "", err - } - base := filepath.Dir(filepath.Dir(filepath.Dir(root))) - if err := providerstate.EnsureTrustedBase(base); err != nil { - return "", err - } - if err := ensureDirChain(base, "uam", "providers", "opencode"); err != nil { - return "", err - } - identityDir := filepath.Join(root, "identity") - if err := os.Mkdir(identityDir, 0o700); err != nil && !os.IsExist(err) { - return "", err - } - if err := verifyOwner(identityDir, true, 0o700); err != nil { - return "", err - } - pluginPath := filepath.Join(root, "uam-identity-plugin.mjs") - if _, err := os.Lstat(pluginPath); err == nil { - if err := verifyOwner(pluginPath, false, 0o600); err != nil { - return "", err - } - } else if !os.IsNotExist(err) { - return "", err + return adapter.LaunchPreparation{}, err } - tmpFile, err := os.CreateTemp(root, ".uam-identity-plugin-*") + identityPath, err := session.ProviderIdentityPath(runtimeDir, sessionName) if err != nil { - return "", err - } - tmp := tmpFile.Name() - defer func() { _ = os.Remove(tmp) }() - if err := tmpFile.Chmod(0o600); err != nil { - _ = tmpFile.Close() - return "", err - } - if _, err := tmpFile.WriteString(pluginSource); err != nil { - _ = tmpFile.Close() - return "", err - } - if err := tmpFile.Close(); err != nil { - return "", err - } - if err := os.Rename(tmp, pluginPath); err != nil { - return "", err - } - if err := verifyOwner(pluginPath, false, 0o600); err != nil { - return "", err - } - return pluginPath, nil -} - -func ensureDirChain(base string, components ...string) error { - path := base - for _, component := range components { - path = filepath.Join(path, component) - if err := os.Mkdir(path, 0o700); err != nil && !os.IsExist(err) { - return err - } - if err := verifyOwner(path, true, 0o700); err != nil { - return err - } + return adapter.LaunchPreparation{}, err } - return nil -} -func identityPath(sessionName string) (string, error) { - if err := session.ValidateName(sessionName); err != nil { - return "", err - } - root, err := stateRoot() - if err != nil { - return "", err - } - return filepath.Join(root, "identity", sessionName+".json"), nil + internalArgv := []string{uamExecutable, "__opencode"} + if providerCommand.path != "" { + internalArgv = append(internalArgv, "--path", providerCommand.path) + } else { + internalArgv = append(internalArgv, "--shell", providerCommand.shell, "--alias", providerCommand.alias) + } + mode := "yolo" + if req.Mode == "safe" { + mode = "safe" + } + internalArgv = append(internalArgv, + "--dir", cwd, + "--name", sessionName, + "--runtime-dir", runtimeDir, + "--mode", mode, + ) + if req.ProviderSessionID != "" { + internalArgv = append(internalArgv, "--session", req.ProviderSessionID) + } + + return adapter.LaunchPreparation{ + Command: internalArgv, + Env: map[string]string{ + session.ProviderIdentityFileEnv: identityPath, + }, + ProviderSessionID: req.ProviderSessionID, + }, nil } func liveProviderSessionID(sessionName string) (string, error) { - root, err := stateRoot() + runtimeDir, err := openCodeRuntimeDir() if err != nil { return "", err } - base := filepath.Dir(filepath.Dir(filepath.Dir(root))) - if err := providerstate.VerifyTrustedBase(base); err != nil { - if os.IsNotExist(err) { - return "", nil - } - return "", err - } - path := base - for _, component := range []string{"uam", "providers", "opencode", "identity"} { - path = filepath.Join(path, component) - if err := verifyOwner(path, true, 0o700); err != nil { - if os.IsNotExist(err) { - return "", nil - } - return "", err - } - } - path, err = identityPath(sessionName) - if err != nil { - return "", err - } - id, err := readIdentity(path) - if errors.Is(err, os.ErrNotExist) { - return "", nil - } - return id, err + return session.ReadProviderIdentity(runtimeDir, sessionName) } -func readIdentity(path string) (string, error) { - fd, err := unix.Open(path, unix.O_RDONLY|unix.O_CLOEXEC|unix.O_NOFOLLOW, 0) // #nosec G304 -- canonical-session-derived path, opened no-follow. - if err != nil { - return "", err - } - file := os.NewFile(uintptr(fd), path) - defer func() { _ = file.Close() }() - var st unix.Stat_t - if err := unix.Fstat(fd, &st); err != nil { - return "", err - } - if st.Mode&unix.S_IFMT != unix.S_IFREG || os.FileMode(st.Mode).Perm() != 0o600 || int(st.Uid) != os.Getuid() { - return "", fmt.Errorf("identity handoff has unsafe owner, mode, or type") - } - data, err := io.ReadAll(io.LimitReader(file, maxIdentityBytes+1)) +func openCodeRuntimeDir() (string, error) { + dir, err := filepath.Abs(session.DefaultDir()) if err != nil { - return "", err - } - if len(data) > maxIdentityBytes { - return "", fmt.Errorf("identity handoff exceeds %d bytes", maxIdentityBytes) - } - var payload struct { - ProviderSessionID string `json:"provider_session_id"` - } - if err := json.Unmarshal(data, &payload); err != nil { - return "", fmt.Errorf("parse identity handoff: %w", err) + return "", fmt.Errorf("make OpenCode runtime directory absolute: %w", err) } - if !providerIDRE.MatchString(payload.ProviderSessionID) || !store.ValidProviderSessionID(payload.ProviderSessionID) { - return "", fmt.Errorf("invalid OpenCode provider session id") - } - return payload.ProviderSessionID, nil + return filepath.Clean(dir), nil } -func mergeConfigContent(inline, pluginURL string) (string, error) { - cfg := map[string]any{} - if strings.TrimSpace(inline) != "" { - if err := json.Unmarshal([]byte(inline), &cfg); err != nil { - return "", err - } - if cfg == nil { - return "", fmt.Errorf("inline config must be a JSON object") - } - } - plugins := []any{} - if existing, ok := cfg["plugin"]; ok { - var compatible bool - plugins, compatible = existing.([]any) - if !compatible { - return "", fmt.Errorf("plugin must be an array") - } - } - for _, existing := range plugins { - if value, ok := existing.(string); ok && value == pluginURL { - encoded, err := json.Marshal(cfg) - return string(encoded), err - } +func resumeKind(req adapter.ResumeRequest) adapter.ResumeKind { + if validOpenCodeSessionID(req.ProviderSessionID) { + return adapter.ResumeExact } - cfg["plugin"] = append(plugins, pluginURL) - encoded, err := json.Marshal(cfg) - return string(encoded), err -} - -const pluginSource = `import { promises as fs } from "node:fs"; -import { randomUUID } from "node:crypto"; -let rootID = ""; -const target = process.env.UAM_OPENCODE_IDENTITY_FILE; -async function record(id) { - if (!target || typeof id !== "string" || !/^ses_[A-Za-z0-9_-]{3,60}$/.test(id)) return; - const tmp = target + ".tmp-" + process.pid + "-" + randomUUID(); - try { - await fs.writeFile(tmp, JSON.stringify({provider_session_id:id}), {mode:0o600}); - await fs.chmod(tmp, 0o600); - await fs.rename(tmp, target); - } catch (_) { - // Identity discovery is best-effort and must never reject provider events. - } finally { - await fs.rm(tmp, {force:true}).catch(() => {}); - } + return adapter.ResumeUnsupported } -export const UAMIdentityPlugin = async () => ({ - event: async ({event}) => { - const type = event?.type || ""; - const info = event?.properties?.info || event?.properties?.session || event?.info || {}; - const id = info.id || ""; - const activityID = event?.properties?.sessionID || event?.properties?.sessionId || info.sessionID || info.sessionId || ""; - const parent = info.parentID || info.parentId || info.parent_id || ""; - if ((type === "session.created" || type === "session.updated") && id && !parent) { - rootID = id; - await record(rootID); - return; - } - const activity = type === "session.idle" || type === "session.status" || type === "message.updated" || type === "command.executed"; - if (activity && activityID && activityID === rootID) await record(rootID); - } -}); -` diff --git a/internal/adapter/opencode/opencode_test.go b/internal/adapter/opencode/opencode_test.go index 015bc5d..77835ad 100644 --- a/internal/adapter/opencode/opencode_test.go +++ b/internal/adapter/opencode/opencode_test.go @@ -2,412 +2,447 @@ package opencode import ( "context" - "encoding/json" - "net/url" "os" - "os/exec" "path/filepath" "reflect" - "strconv" "strings" "testing" "github.com/RandomCodeSpace/unified-agent-manager/internal/adapter" "github.com/RandomCodeSpace/unified-agent-manager/internal/adapter/adaptertest" "github.com/RandomCodeSpace/unified-agent-manager/internal/session" - "github.com/RandomCodeSpace/unified-agent-manager/internal/store" ) -func TestNew(t *testing.T) { - a := New(nil) - if a == nil || a.Name() != "opencode" || a.DisplayName() == "" { - t.Fatalf("bad adapter: %#v", a) +func TestOpenCodeNew(t *testing.T) { + agent, ok := New(nil).(*adapter.Agent) + if !ok { + t.Fatalf("New() = %T, want *adapter.Agent", New(nil)) } -} - -func testOpenCodeAgent(t *testing.T, help string) (*adapter.Agent, *adaptertest.Backend) { - t.Helper() - dir := t.TempDir() - path := filepath.Join(dir, "opencode") - script := "#!/bin/sh\nif [ \"$1\" = --help ]; then printf '%s\\n' '" + help + "'; fi\n" - if err := os.WriteFile(path, []byte(script), 0o700); err != nil { - t.Fatal(err) + if agent.Name() != "opencode" || agent.DisplayName() == "" { + t.Fatalf("bad adapter identity: name=%q display=%q", agent.Name(), agent.DisplayName()) + } + if agent.SessionArgs != nil { + t.Fatal("OpenCode must not retain legacy session arguments") + } + if agent.PrepareLaunch == nil || agent.LiveProviderSessionID == nil || agent.ResumeKindFor == nil { + t.Fatal("OpenCode supervisor hooks are not fully wired") + } + if !agent.SkipPromptOnResume { + t.Fatal("OpenCode resume must not resend the stored prompt") } - t.Setenv("PATH", dir+string(os.PathListSeparator)+os.Getenv("PATH")) - t.Setenv("XDG_STATE_HOME", t.TempDir()) - t.Setenv("OPENCODE_CONFIG_CONTENT", "") - resetAutoCacheForTest() - be := &adaptertest.Backend{} - return New(be).(*adapter.Agent), be } -func TestAutoFlagIsVersionAwareAndSafeModeNeverUsesIt(t *testing.T) { +func TestOpenCodePrepareLaunchRequiresMinimumVersionBeforeCreate(t *testing.T) { for _, tt := range []struct { - name, help, mode string - want bool + version string + wantErr bool }{ - {"current yolo", "Usage: opencode [--auto]", "yolo", true}, - {"old yolo", "Usage: opencode [--continue]", "yolo", false}, - {"current safe", "Usage: opencode [--auto]", "safe", false}, + {version: "1.18.0", wantErr: true}, + {version: "1.18.1"}, } { - t.Run(tt.name, func(t *testing.T) { - ag, be := testOpenCodeAgent(t, tt.help) - if _, err := ag.Dispatch(context.Background(), adapter.DispatchRequest{Cwd: "/tmp", Mode: tt.mode}); err != nil { - t.Fatal(err) + t.Run(tt.version, func(t *testing.T) { + providerPath := writeVersionedOpenCode(t, tt.version) + t.Setenv("PATH", filepath.Dir(providerPath)) + t.Setenv("UAM_SESSION_DIR", secureOpenCodeRuntimeDir(t)) + t.Setenv("XDG_STATE_HOME", t.TempDir()) + backend := &adaptertest.Backend{} + agent := New(backend).(*adapter.Agent) + + _, err := agent.Dispatch(context.Background(), adapter.DispatchRequest{Cwd: t.TempDir(), Mode: "safe"}) + if tt.wantErr { + if err == nil || !strings.Contains(err.Error(), "required version 1.18.1") { + t.Fatalf("Dispatch() error = %v, want minimum-version rejection", err) + } + if calls := backend.CallsOf("create"); len(calls) != 0 { + t.Fatalf("unsupported version reached Backend.CreateSession: %+v", calls) + } + return } - got := strings.Contains(strings.Join(be.CallsOf("create")[0].Command, " "), "--auto") - if got != tt.want { - t.Fatalf("auto=%v want=%v argv=%v", got, tt.want, be.CallsOf("create")[0].Command) + if err != nil { + t.Fatalf("Dispatch() with minimum version: %v", err) + } + if calls := backend.CallsOf("create"); len(calls) != 1 { + t.Fatalf("Backend.CreateSession calls = %d, want 1", len(calls)) } }) } } -func TestAutoProbeCachesByExecutableStatIdentity(t *testing.T) { - dir := t.TempDir() - path, count := filepath.Join(dir, "opencode"), filepath.Join(dir, "count") - t.Setenv("COUNT", count) - write := func(help string) { - t.Helper() - script := "#!/bin/sh\nprintf x >> \"$COUNT\"\nprintf '%s\\n' '" + help + "'\n" - if err := os.WriteFile(path, []byte(script), 0o700); err != nil { - t.Fatal(err) - } - } - resetAutoCacheForTest() - write("Usage [--auto]") - if !supportsAuto(context.Background(), path) { - t.Fatal("current executable not detected") - } - if !supportsAuto(context.Background(), path) { - t.Fatal("cached executable support not detected") - } - data, _ := os.ReadFile(count) - if string(data) != "x" { - t.Fatalf("probe count=%q", data) - } - write("Usage [--continue] changed-size") - if supportsAuto(context.Background(), path) { - t.Fatal("changed old executable inherited cached support") - } - data, _ = os.ReadFile(count) - if string(data) != "xx" { - t.Fatalf("re-probe count=%q", data) - } -} +func TestOpenCodePrepareLaunchBuildsSupervisorCommandAndNeutralEnv(t *testing.T) { + providerPath := writeVersionedOpenCode(t, "1.18.1") + t.Setenv("PATH", filepath.Dir(providerPath)) + runtimeDir := secureOpenCodeRuntimeDir(t) + t.Setenv("UAM_SESSION_DIR", runtimeDir) + rawConfig := " preserve exactly: {not-json} " + configEnv := "OPENCODE_CONFIG_" + "CONTENT" + t.Setenv(configEnv, rawConfig) + backend := &adaptertest.Backend{} + agent := New(backend).(*adapter.Agent) + cwd := filepath.Clean(t.TempDir()) + name := "uam-opencode-deadbeef" + providerID := "ses_exact123" -func TestInvalidCommandAliasCannotExecuteDuringPreparation(t *testing.T) { - marker := filepath.Join(t.TempDir(), "executed") - alias := filepath.Join(t.TempDir(), "not-an-alias") - if err := os.WriteFile(alias, []byte("#!/bin/sh\nprintf ran > "+marker+"\n"), 0o700); err != nil { - t.Fatal(err) + gotSession, err := agent.Resume(context.Background(), adapter.ResumeRequest{ + ID: "deadbeef", Cwd: cwd, Mode: "yolo", SessionName: name, ProviderSessionID: providerID, + }) + if err != nil { + t.Fatalf("Resume(): %v", err) } - t.Setenv("XDG_STATE_HOME", t.TempDir()) - ag := New(&adaptertest.Backend{}).(*adapter.Agent) - _, err := ag.Dispatch(context.Background(), adapter.DispatchRequest{CommandAlias: alias, Cwd: "/tmp", Mode: "yolo"}) - if err == nil { - t.Fatal("invalid path alias accepted") + if gotSession.ProviderSessionID != providerID { + t.Fatalf("ProviderSessionID = %q, want %q", gotSession.ProviderSessionID, providerID) } - if _, statErr := os.Stat(marker); !os.IsNotExist(statErr) { - t.Fatalf("invalid alias executed during preparation: %v", statErr) + calls := backend.CallsOf("create") + if len(calls) != 1 { + t.Fatalf("Backend.CreateSession calls = %d, want 1", len(calls)) } -} - -func TestMergeInlineConfigPreservesKeysAndPlugins(t *testing.T) { - got, err := mergeConfigContent(`{"model":"x","nested":{"keep":true},"plugin":["file:///user.js"]}`, "file:///uam.js") + uamExecutable, err := os.Executable() if err != nil { t.Fatal(err) } - var cfg map[string]any - if err := json.Unmarshal([]byte(got), &cfg); err != nil { + uamExecutable, err = filepath.Abs(uamExecutable) + if err != nil { t.Fatal(err) } - plugins := cfg["plugin"].([]any) - if cfg["model"] != "x" || len(plugins) != 2 || plugins[0] != "file:///user.js" || plugins[1] != "file:///uam.js" { - t.Fatalf("config=%v", cfg) - } - if _, err := mergeConfigContent(`{"plugin":"wrong"}`, "file:///uam.js"); err == nil { - t.Fatal("incompatible plugin shape accepted") - } - if _, err := mergeConfigContent(`{broken`, "file:///uam.js"); err == nil { - t.Fatal("malformed config accepted") - } -} - -func TestIdentityHandoffRejectsSymlinksModesBoundsAndMalformedIDs(t *testing.T) { - dir := t.TempDir() - path := filepath.Join(dir, "identity.json") - write := func(value string, mode os.FileMode) { - t.Helper() - if err := os.WriteFile(path, []byte(value), mode); err != nil { - t.Fatal(err) - } - } - write(`{"provider_session_id":"ses_abc123"}`, 0o600) - if got, err := readIdentity(path); err != nil || got != "ses_abc123" { - t.Fatalf("got=%q err=%v", got, err) - } - if err := os.Chmod(path, 0o644); err != nil { - t.Fatal(err) + wantCommand := []string{ + uamExecutable, "__opencode", + "--path", providerPath, + "--dir", cwd, + "--name", name, + "--runtime-dir", runtimeDir, + "--mode", "yolo", + "--session", providerID, } - if _, err := readIdentity(path); err == nil { - t.Fatal("unsafe mode accepted") + if !reflect.DeepEqual(calls[0].Command, wantCommand) { + t.Fatalf("launch command = %#v, want %#v", calls[0].Command, wantCommand) } - if err := os.Remove(path); err != nil { + identityPath, err := session.ProviderIdentityPath(runtimeDir, name) + if err != nil { t.Fatal(err) } - write(`{"provider_session_id":"bad"}`, 0o600) - if _, err := readIdentity(path); err == nil { - t.Fatal("malformed id accepted") + wantEnv := map[string]string{ + session.ProviderIdentityFileEnv: identityPath, + "UAM_AGENT": "opencode", + "UAM_ID": "deadbeef", } - if err := os.Remove(path); err != nil { - t.Fatal(err) + if !reflect.DeepEqual(calls[0].Env, wantEnv) { + t.Fatalf("launch env = %#v, want %#v", calls[0].Env, wantEnv) } - write(strings.Repeat("x", maxIdentityBytes+1), 0o600) - if _, err := readIdentity(path); err == nil { - t.Fatal("oversized handoff accepted") + if got := os.Getenv(configEnv); got != rawConfig { + t.Fatalf("OpenCode config env = %q, want unchanged %q", got, rawConfig) } - if err := os.Remove(path); err != nil { - t.Fatal(err) +} + +func TestOpenCodePrepareLaunchBuildsValidatedAliasCommand(t *testing.T) { + shell := writeOpenCodeAliasShell(t, "1.18.1") + t.Setenv("SHELL", shell) + t.Setenv("UAM_SESSION_DIR", secureOpenCodeRuntimeDir(t)) + cwd := filepath.Clean(t.TempDir()) + name := "uam-opencode-a1b2c3d4" + + prep, err := prepareLaunch(context.Background(), adapter.ResumeRequest{ + CommandAlias: "custom-opencode", Mode: "safe", + }, "dispatched", name, cwd) + if err != nil { + t.Fatalf("prepareLaunch(): %v", err) } - target := filepath.Join(dir, "target") - if err := os.WriteFile(target, []byte(`{"provider_session_id":"ses_ok"}`), 0o600); err != nil { - t.Fatal(err) + wantPrefix := []string{ + "__opencode", + "--shell", shell, + "--alias", "custom-opencode", + "--dir", cwd, + "--name", name, + "--runtime-dir", session.DefaultDir(), + "--mode", "safe", } - if err := os.Symlink(target, path); err != nil { - t.Fatal(err) + if len(prep.Command) != len(wantPrefix)+1 || !filepath.IsAbs(prep.Command[0]) || !reflect.DeepEqual(prep.Command[1:], wantPrefix) { + t.Fatalf("alias supervisor command = %#v, want absolute uam executable followed by %#v", prep.Command, wantPrefix) } - if _, err := readIdentity(path); err == nil { - t.Fatal("symlink accepted") + if prep.ProviderSessionID != "" { + t.Fatalf("dispatch ProviderSessionID = %q, want empty", prep.ProviderSessionID) } } -func TestIdentityBoundaryMatchesSchemaV3AndRoundTrips(t *testing.T) { - dir := t.TempDir() - path := filepath.Join(dir, "identity.json") - accepted := "ses_" + strings.Repeat("a", 60) - if len(accepted) != 64 { - t.Fatal("bad test boundary") - } - if err := os.WriteFile(path, []byte(`{"provider_session_id":"`+accepted+`"}`), 0o600); err != nil { +func TestOpenCodePrepareLaunchCanonicalizesRelativeRuntimeDirectory(t *testing.T) { + baseDir := t.TempDir() + oldDirectory, err := os.Getwd() + if err != nil { t.Fatal(err) } - id, err := readIdentity(path) - if err != nil || id != accepted { - t.Fatalf("accepted boundary id=%q err=%v", id, err) + if err := os.Chdir(baseDir); err != nil { + t.Fatal(err) } - st, err := store.Open(filepath.Join(t.TempDir(), "sessions.json")) - if err != nil { + t.Cleanup(func() { _ = os.Chdir(oldDirectory) }) + if err := os.Mkdir("relative-runtime", 0o700); err != nil { t.Fatal(err) } - if err := st.Update(func(cfg *store.Config) error { - cfg.Sessions["opencode:abc12345"] = store.SessionRecord{ID: "abc12345", Agent: "opencode", SessionName: "uam-opencode-abc12345", Workdir: "/tmp", ProviderSessionID: id} - return nil + providerPath := writeVersionedOpenCode(t, "1.18.1") + t.Setenv("PATH", filepath.Dir(providerPath)) + t.Setenv("UAM_SESSION_DIR", "relative-runtime") + backend := &adaptertest.Backend{} + workspace := filepath.Clean(t.TempDir()) + name := "uam-opencode-deadbeef" + agent := New(backend).(*adapter.Agent) + if _, err := agent.Resume(context.Background(), adapter.ResumeRequest{ + ID: "deadbeef", Cwd: workspace, Mode: "safe", SessionName: name, }); err != nil { - t.Fatal(err) + t.Fatalf("Resume(): %v", err) + } + calls := backend.CallsOf("create") + if len(calls) != 1 { + t.Fatalf("create calls = %d, want 1", len(calls)) } - cfg, err := st.Load() + wantRuntimeDir := filepath.Join(baseDir, "relative-runtime") + wantIdentity, err := session.ProviderIdentityPath(wantRuntimeDir, name) if err != nil { t.Fatal(err) } - if cfg.Sessions["opencode:abc12345"].ProviderSessionID != accepted { - t.Fatalf("boundary identity did not survive reload: %+v", cfg.Sessions) + if got := flagValue(calls[0].Command, "--runtime-dir"); got != wantRuntimeDir { + t.Fatalf("supervisor runtime dir = %q, want %q", got, wantRuntimeDir) } - overlong := "ses_" + strings.Repeat("a", 61) - if err := os.WriteFile(path, []byte(`{"provider_session_id":"`+overlong+`"}`), 0o600); err != nil { - t.Fatal(err) + if got := calls[0].Env[session.ProviderIdentityFileEnv]; got != wantIdentity { + t.Fatalf("host identity handoff = %q, want %q", got, wantIdentity) } - if _, err := readIdentity(path); err == nil { - t.Fatal("65-byte provider identity accepted") +} + +func flagValue(args []string, name string) string { + for index := 0; index+1 < len(args); index++ { + if args[index] == name { + return args[index+1] + } } + return "" } -func TestProviderFilesRejectIntermediateSymlink(t *testing.T) { - base := t.TempDir() - t.Setenv("XDG_STATE_HOME", base) - if err := os.Symlink(t.TempDir(), filepath.Join(base, "uam")); err != nil { +func TestOpenCodeLiveProviderSessionIDReadsRuntimeIdentity(t *testing.T) { + runtimeDir := secureOpenCodeRuntimeDir(t) + t.Setenv("UAM_SESSION_DIR", runtimeDir) + name := "uam-opencode-a1b2c3d4" + if err := session.WriteProviderIdentity(runtimeDir, name, "ses_live123"); err != nil { t.Fatal(err) } - if _, err := ensureProviderFiles(); err == nil { - t.Fatal("intermediate provider-state symlink accepted") + agent := New(nil).(*adapter.Agent) + got, err := agent.LiveProviderSessionID(name) + if err != nil || got != "ses_live123" { + t.Fatalf("LiveProviderSessionID() = (%q, %v), want (ses_live123, nil)", got, err) } } -func TestProviderFilesWarnForWritableStateAncestry(t *testing.T) { - t.Run("group writable", func(t *testing.T) { - base := t.TempDir() - if err := os.Chmod(base, 0o770); err != nil { - t.Fatal(err) +func TestOpenCodeResumeKindIsExactOnly(t *testing.T) { + agent := New(nil).(*adapter.Agent) + for _, tt := range []struct { + name string + id string + want adapter.ResumeKind + }{ + {name: "valid", id: "ses_exact123", want: adapter.ResumeExact}, + {name: "maximum length", id: "ses_" + strings.Repeat("a", 60), want: adapter.ResumeExact}, + {name: "one byte over maximum", id: "ses_" + strings.Repeat("a", 61), want: adapter.ResumeUnsupported}, + {name: "missing", want: adapter.ResumeUnsupported}, + {name: "wrong prefix", id: "abc_exact123", want: adapter.ResumeUnsupported}, + {name: "too short", id: "ses_ab", want: adapter.ResumeUnsupported}, + {name: "flag-shaped", id: "ses_bad value", want: adapter.ResumeUnsupported}, + } { + t.Run(tt.name, func(t *testing.T) { + if got := agent.ResumeKind(adapter.ResumeRequest{ProviderSessionID: tt.id}); got != tt.want { + t.Fatalf("ResumeKind(%q) = %q, want %q", tt.id, got, tt.want) + } + }) + } +} + +func TestOpenCodePromptDeliveryAndResumeNoReplay(t *testing.T) { + newAgent := func(t *testing.T) (*adapter.Agent, *adaptertest.Backend, string) { + t.Helper() + providerPath := writeVersionedOpenCode(t, "1.18.1") + t.Setenv("PATH", filepath.Dir(providerPath)) + t.Setenv("UAM_SESSION_DIR", secureOpenCodeRuntimeDir(t)) + backend := &adaptertest.Backend{} + return New(backend).(*adapter.Agent), backend, filepath.Clean(t.TempDir()) + } + + t.Run("dispatch preserves Unicode and multiline prompt", func(t *testing.T) { + agent, backend, cwd := newAgent(t) + const prompt = "Unicode π 你好\nsecond line\tkept" + if _, err := agent.Dispatch(context.Background(), adapter.DispatchRequest{Prompt: prompt, Cwd: cwd, Mode: "yolo"}); err != nil { + t.Fatalf("Dispatch(): %v", err) } - t.Setenv("XDG_STATE_HOME", base) - if _, err := ensureProviderFiles(); err != nil { - t.Fatalf("group-writable XDG_STATE_HOME blocked: %v", err) + 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) } }) - t.Run("writable parent", func(t *testing.T) { - parent := filepath.Join(t.TempDir(), "shared") - if err := os.Mkdir(parent, 0o777); err != nil { - t.Fatal(err) - } - if err := os.Chmod(parent, 0o777); err != nil { - t.Fatal(err) + t.Run("empty dispatch sends no line", func(t *testing.T) { + agent, backend, cwd := newAgent(t) + if _, err := agent.Dispatch(context.Background(), adapter.DispatchRequest{Cwd: cwd, Mode: "safe"}); err != nil { + t.Fatalf("Dispatch(): %v", err) } - t.Setenv("XDG_STATE_HOME", filepath.Join(parent, "state")) - if _, err := ensureProviderFiles(); err != nil { - t.Fatalf("state base under writable non-sticky parent blocked: %v", err) + if sends := backend.CallsOf("send"); len(sends) != 0 { + t.Fatalf("empty dispatch sends = %#v, want none", sends) } }) -} -func TestProviderFilesRejectSymlinkedStateBase(t *testing.T) { - t.Run("symlink", func(t *testing.T) { - parent, target := t.TempDir(), t.TempDir() - base := filepath.Join(parent, "state") - if err := os.Symlink(target, base); err != nil { - t.Fatal(err) + t.Run("exact resume does not replay stored prompt", func(t *testing.T) { + agent, backend, cwd := newAgent(t) + if _, err := agent.Resume(context.Background(), adapter.ResumeRequest{ + ID: "deadbeef", Cwd: cwd, Mode: "yolo", SessionName: "uam-opencode-deadbeef", + ProviderSessionID: "ses_exact123", Prompt: "stored prompt must stay inert", + }); err != nil { + t.Fatalf("Resume(): %v", err) } - t.Setenv("XDG_STATE_HOME", base) - if _, err := ensureProviderFiles(); err == nil { - t.Fatal("symlinked XDG_STATE_HOME accepted") + if sends := backend.CallsOf("send"); len(sends) != 0 { + t.Fatalf("exact resume replayed prompt: %#v", sends) } }) } -func TestLiveIdentityProducesExactResumeAndMissingKeepsFallback(t *testing.T) { - ag, _ := testOpenCodeAgent(t, "Usage: opencode [--auto]") - if got := ag.ResumeKind(adapter.ResumeRequest{ProviderSessionID: "ses_known"}); got != adapter.ResumeExact { - t.Fatalf("known kind=%q", got) - } - if got := ag.ResumeKind(adapter.ResumeRequest{}); got != adapter.ResumeHeuristic { - t.Fatalf("missing kind=%q", got) - } - path, err := identityPath("uam-opencode-abc12345") - if err != nil { - t.Fatal(err) - } - if _, err := ensureProviderFiles(); err != nil { - t.Fatal(err) - } - if err := os.WriteFile(path, []byte(`{"provider_session_id":"ses_live"}`), 0o600); err != nil { - t.Fatal(err) - } - got, err := ag.ListFromSnapshot(context.Background(), []session.Info{{Name: "uam-opencode-abc12345", Cwd: "/tmp", Alive: true}}) - if err != nil || got[0].ProviderSessionID != "ses_live" { - t.Fatalf("sessions=%+v err=%v", got, err) +func TestOpenCodeLegacyPluginAndProviderStateAreInert(t *testing.T) { + for _, tt := range []struct { + name string + setup func(t *testing.T, root string) string + }{ + { + name: "stale plugin", + setup: func(t *testing.T, root string) string { + path := filepath.Join(root, legacyOpenCodePluginName()) + writeOpenCodeLegacyFile(t, path, "stale plugin", 0o600) + return path + }, + }, + { + name: "permissive plugin", + setup: func(t *testing.T, root string) string { + path := filepath.Join(root, legacyOpenCodePluginName()) + writeOpenCodeLegacyFile(t, path, "permissive plugin", 0o666) + return path + }, + }, + { + name: "symlinked plugin", + setup: func(t *testing.T, root string) string { + target := filepath.Join(t.TempDir(), "target.mjs") + writeOpenCodeLegacyFile(t, target, "symlink target", 0o600) + path := filepath.Join(root, legacyOpenCodePluginName()) + if err := os.Symlink(target, path); err != nil { + t.Fatal(err) + } + return path + }, + }, + { + name: "malformed provider identity", + setup: func(t *testing.T, root string) string { + path := filepath.Join(root, "identity", "uam-opencode-a1b2c3d4.json") + writeOpenCodeLegacyFile(t, path, "{malformed", 0o600) + return path + }, + }, + { + name: "directory-shaped plugin", + setup: func(t *testing.T, root string) string { + path := filepath.Join(root, legacyOpenCodePluginName()) + if err := os.MkdirAll(path, 0o700); err != nil { + t.Fatal(err) + } + return path + }, + }, + } { + t.Run(tt.name, func(t *testing.T) { + providerPath := writeVersionedOpenCode(t, "1.18.1") + t.Setenv("PATH", filepath.Dir(providerPath)) + t.Setenv("UAM_SESSION_DIR", secureOpenCodeRuntimeDir(t)) + stateHome := t.TempDir() + t.Setenv("XDG_STATE_HOME", stateHome) + legacyRoot := filepath.Join(stateHome, "uam", "providers", "opencode") + if err := os.MkdirAll(legacyRoot, 0o700); err != nil { + t.Fatal(err) + } + observedPath := tt.setup(t, legacyRoot) + before, beforeErr := os.Lstat(observedPath) + beforeData, _ := os.ReadFile(observedPath) + backend := &adaptertest.Backend{} + + if _, err := New(backend).Dispatch(context.Background(), adapter.DispatchRequest{Cwd: t.TempDir(), Mode: "safe"}); err != nil { + t.Fatalf("legacy state affected dispatch: %v", err) + } + if len(backend.CallsOf("create")) != 1 { + t.Fatal("dispatch did not reach Backend.CreateSession") + } + after, afterErr := os.Lstat(observedPath) + afterData, _ := os.ReadFile(observedPath) + if beforeErr != nil || afterErr != nil || before.Mode() != after.Mode() || !reflect.DeepEqual(beforeData, afterData) { + t.Fatalf("legacy path was inspected or modified: before=(%v,%v,%q) after=(%v,%v,%q)", before, beforeErr, beforeData, after, afterErr, afterData) + } + }) } } -func TestPluginRootEventsWinAndChildCannotOverwrite(t *testing.T) { - node, err := exec.LookPath("node") - if err != nil { - t.Skip("node unavailable") - } - t.Setenv("XDG_STATE_HOME", t.TempDir()) - plugin, err := ensureProviderFiles() - if err != nil { - t.Fatal(err) - } - handoff, err := identityPath("uam-opencode-abc12345") +func TestOpenCodeProductionSourceHasNoLegacyPluginPath(t *testing.T) { + entries, err := os.ReadDir(".") if err != nil { t.Fatal(err) } - runner := filepath.Join(t.TempDir(), "runner.mjs") - source := `import { promises as fs } from "node:fs"; -import { UAMIdentityPlugin } from ` + strconv.Quote((&url.URL{Scheme: "file", Path: plugin}).String()) + `; -const hooks = await UAMIdentityPlugin(); -await hooks.event({event:{type:"session.created",properties:{info:{id:"ses_root123"}}}}); -await fs.unlink(process.env.UAM_OPENCODE_IDENTITY_FILE); -await hooks.event({event:{type:"message.updated",properties:{info:{id:"msg_not_session"},sessionID:"ses_root123"}}}); -if (JSON.parse(await fs.readFile(process.env.UAM_OPENCODE_IDENTITY_FILE)).provider_session_id !== "ses_root123") throw new Error("root activity not captured"); -await hooks.event({event:{type:"session.updated",properties:{info:{id:"ses_child123",parentID:"ses_root123"}}}}); -await hooks.event({event:{type:"command.executed",properties:{sessionID:"ses_child123"}}}); -if (JSON.parse(await fs.readFile(process.env.UAM_OPENCODE_IDENTITY_FILE)).provider_session_id !== "ses_root123") throw new Error("child overwrote root"); -await hooks.event({event:{type:"session.updated",properties:{info:{id:"ses_switched123"}}}}); -` - if err := os.WriteFile(runner, []byte(source), 0o600); err != nil { - t.Fatal(err) - } - cmd := exec.Command(node, runner) - cmd.Env = append(os.Environ(), "UAM_OPENCODE_IDENTITY_FILE="+handoff) - if out, err := cmd.CombinedOutput(); err != nil { - t.Fatalf("plugin: %v: %s", err, out) - } - if got, err := readIdentity(handoff); err != nil || got != "ses_switched123" { - t.Fatalf("id=%q err=%v", got, err) + forbidden := []string{"uam-identity-plugin" + ".mjs", "plugin" + "Source", "ensureProvider" + "Files", "OPENCODE_CONFIG_" + "CONTENT", `[]string{` + `"-c"}`} + for _, entry := range entries { + if entry.IsDir() || filepath.Ext(entry.Name()) != ".go" || strings.HasSuffix(entry.Name(), "_test.go") { + continue + } + data, err := os.ReadFile(entry.Name()) + if err != nil { + t.Fatal(err) + } + for _, token := range forbidden { + if strings.Contains(string(data), token) { + t.Errorf("production source %s contains removed token %q", entry.Name(), token) + } + } } } -func TestPluginActivityUsesOwningSessionRatherThanMessageID(t *testing.T) { - for _, eventType := range []string{"message.updated", "command.executed"} { - if !strings.Contains(pluginSource, `type === "`+eventType+`"`) { - t.Fatalf("plugin missing %s", eventType) - } - } - if !strings.Contains(pluginSource, "const activityID = event?.properties?.sessionID") || !strings.Contains(pluginSource, "activityID === rootID") { - t.Fatal("activity events do not select and guard the owning root session") - } - if !strings.Contains(pluginSource, "catch (_)") || !strings.Contains(pluginSource, "finally") { - t.Fatal("identity writes are not wrapped as best-effort event handling") +func writeVersionedOpenCode(t *testing.T, version string) string { + t.Helper() + dir := t.TempDir() + path := filepath.Join(dir, "opencode") + script := "#!/bin/sh\nif [ \"$1\" = \"--version\" ]; then printf '%s\\n' '" + version + "'; exit 0; fi\nexit 0\n" + if err := os.WriteFile(path, []byte(script), 0o700); err != nil { + t.Fatal(err) } + return path } -// Static yolo args stay empty because --auto is added only after a -// version-aware executable probe in PrepareLaunch. -func TestNoYoloArgs(t *testing.T) { - got := New(nil) - ta, ok := got.(*adapter.Agent) - if !ok { - t.Fatalf("expected *adapter.Agent, got %T", got) - } - if len(ta.YoloArgs) != 0 { - t.Fatalf("opencode static YoloArgs must be empty, got %v", ta.YoloArgs) +func writeOpenCodeAliasShell(t *testing.T, version string) string { + t.Helper() + path := filepath.Join(t.TempDir(), "shell") + script := "#!/bin/sh\ncase \"$2\" in *--version*) printf '%s\\n' '" + version + "';; esac\nexit 0\n" + if err := os.WriteFile(path, []byte(script), 0o700); err != nil { + t.Fatal(err) } + return path } -// TestSessionArgsAppendsContinueOnResume asserts the SessionArgs -// hook returns opencode's `-c` (continue) on resume and nothing on -// dispatch. Identity is learned asynchronously, so records observed before -// discovery still resume the most recent session in the current cwd. -func TestSessionArgsAppendsContinueOnResume(t *testing.T) { - if got := sessionArgs(adapter.ResumeRequest{ID: "x"}, "dispatched"); got != nil { - t.Fatalf("dispatched should add no flags, got %v", got) - } - if got, want := sessionArgs(adapter.ResumeRequest{ID: "x"}, "resumed"), []string{"-c"}; !reflect.DeepEqual(got, want) { - t.Fatalf("resumed got %v want %v", got, want) +func secureOpenCodeRuntimeDir(t *testing.T) string { + t.Helper() + dir := filepath.Clean(t.TempDir()) + if err := os.Chmod(dir, 0o700); err != nil { + t.Fatal(err) } + return dir } -// TestNewWiresSessionArgs asserts New installs the SessionArgs hook -// and SkipPromptOnResume. Without this wiring, picking "Resume" on -// an opencode row would re-launch opencode with no continuation -// flag, starting a fresh TUI instead of resuming the prior session. -func TestNewWiresSessionArgs(t *testing.T) { - got := New(nil) - ta, ok := got.(*adapter.Agent) - if !ok { - t.Fatalf("expected *adapter.Agent, got %T", got) +func writeOpenCodeLegacyFile(t *testing.T, path, content string, mode os.FileMode) { + t.Helper() + if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil { + t.Fatal(err) } - if ta.SessionArgs == nil { - t.Fatal("expected SessionArgs to be wired") + if err := os.WriteFile(path, []byte(content), mode); err != nil { + t.Fatal(err) } - if !ta.SkipPromptOnResume { - t.Fatal("expected SkipPromptOnResume to be true") + if err := os.Chmod(path, mode); err != nil { + t.Fatal(err) } } -// A recorded provider session id must resume that exact opencode session -// (--session ses_...) instead of the project's most recent (-c). -func TestResumeTargetsExactSessionWhenIDKnown(t *testing.T) { - ag, ok := New(nil).(*adapter.Agent) - if !ok { - t.Fatalf("expected *adapter.Agent") - } - got := ag.SessionArgs(adapter.ResumeRequest{ProviderSessionID: "ses_2132323b6ffe"}, "resumed") - if len(got) != 2 || got[0] != "--session" || got[1] != "ses_2132323b6ffe" { - t.Fatalf("resume args = %v, want exact --session", got) - } - if got := ag.SessionArgs(adapter.ResumeRequest{}, "resumed"); len(got) != 1 || got[0] != "-c" { - t.Fatalf("resume args without id = %v, want -c fallback", got) - } +func legacyOpenCodePluginName() string { + return "uam-identity-plugin" + ".mjs" } diff --git a/internal/adapter/opencode/supervisor.go b/internal/adapter/opencode/supervisor.go new file mode 100644 index 0000000..d93bafb --- /dev/null +++ b/internal/adapter/opencode/supervisor.go @@ -0,0 +1,926 @@ +package opencode + +import ( + "context" + "crypto/rand" + "encoding/hex" + "encoding/json" + "errors" + "flag" + "fmt" + "io" + "net" + "net/http" + "os" + "os/exec" + "os/signal" + "path/filepath" + "strings" + "sync" + "syscall" + "time" + + "github.com/RandomCodeSpace/unified-agent-manager/internal/displaytext" + "github.com/RandomCodeSpace/unified-agent-manager/internal/session" + "github.com/RandomCodeSpace/unified-agent-manager/internal/store" + "github.com/charmbracelet/x/term" +) + +const ( + openCodeServerUsername = "uam" + serverStartupTimeout = 5 * time.Second + serverStartupAttempts = 3 + serverPollInterval = 25 * time.Millisecond + serverRequestTimeout = 250 * time.Millisecond + childCleanupTimeout = 1500 * time.Millisecond + serverLogCapacity = 64 << 10 + eventReconnectBase = 25 * time.Millisecond + eventReconnectMax = 400 * time.Millisecond + eventRecoveryTimeout = 2 * time.Second + runtimeDirFlag = "runtime-dir" +) + +type supervisorOptions struct { + Command providerCommand + Directory string + SessionName string + ProviderSessionID string + Yolo bool + RuntimeDir string +} + +func validOpenCodeSessionID(id string) bool { + return providerIDRE.MatchString(id) && store.ValidProviderSessionID(id) +} + +type ExitError struct { + Code int +} + +func (e *ExitError) Error() string { + return fmt.Sprintf("OpenCode attach exited with code %d", e.Code) +} + +func (e *ExitError) ExitCode() int { + return e.Code +} + +type uniqueStringFlag struct { + name string + value string + set bool +} + +func (f *uniqueStringFlag) String() string { + return f.value +} + +func (f *uniqueStringFlag) Set(value string) error { + if f.set { + return fmt.Errorf("flag --%s may be specified only once", f.name) + } + f.value = value + f.set = true + return nil +} + +func RunSupervisorCommand(args []string) error { + opts, err := parseSupervisorOptions(args) + if err != nil { + return err + } + ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGHUP, syscall.SIGTERM) + defer stop() + return runSupervisor(ctx, opts) +} + +func parseSupervisorOptions(args []string) (supervisorOptions, error) { + values, err := parseSupervisorFlags(args) + if err != nil { + return supervisorOptions{}, err + } + + command, err := providerCommandFromFlags(values["path"].value, values["shell"].value, values["alias"].value) + if err != nil { + return supervisorOptions{}, err + } + directory := values["dir"].value + if err := validateCanonicalDirectory(directory); err != nil { + return supervisorOptions{}, fmt.Errorf("invalid OpenCode working directory: %w", err) + } + runtimeDir := values[runtimeDirFlag].value + runtimeDir, err = resolveRuntimeDirectory(runtimeDir) + if err != nil { + return supervisorOptions{}, fmt.Errorf("invalid OpenCode runtime directory: %w", err) + } + if err := session.VerifyDir(runtimeDir); err != nil { + return supervisorOptions{}, err + } + sessionName := values["name"].value + if err := session.ValidateName(sessionName); err != nil { + return supervisorOptions{}, err + } + providerSessionID := values["session"].value + if providerSessionID != "" && !validOpenCodeSessionID(providerSessionID) { + return supervisorOptions{}, fmt.Errorf("invalid OpenCode provider session ID") + } + yolo, err := parseSupervisorMode(values["mode"].value) + if err != nil { + return supervisorOptions{}, err + } + return supervisorOptions{ + Command: command, + Directory: directory, + SessionName: sessionName, + ProviderSessionID: providerSessionID, + Yolo: yolo, + RuntimeDir: runtimeDir, + }, nil +} + +func parseSupervisorFlags(args []string) (map[string]*uniqueStringFlag, error) { + fs := flag.NewFlagSet("__opencode", flag.ContinueOnError) + fs.SetOutput(io.Discard) + values := map[string]*uniqueStringFlag{} + for _, name := range []string{"path", "shell", "alias", "dir", "name", runtimeDirFlag, "mode", "session"} { + value := &uniqueStringFlag{name: name} + values[name] = value + fs.Var(value, name, "") + } + if err := fs.Parse(args); err != nil { + return nil, fmt.Errorf("parse OpenCode supervisor flags: %w", err) + } + if fs.NArg() != 0 { + return nil, fmt.Errorf("OpenCode supervisor does not accept positional arguments") + } + for _, name := range []string{"dir", "name", runtimeDirFlag, "mode"} { + if !values[name].set || values[name].value == "" { + return nil, fmt.Errorf("OpenCode supervisor requires --%s", name) + } + } + return values, nil +} + +func parseSupervisorMode(mode string) (bool, error) { + switch mode { + case "safe": + return false, nil + case "yolo": + return true, nil + default: + return false, fmt.Errorf("OpenCode supervisor mode must be safe or yolo") + } +} + +func validateCanonicalDirectory(path string) error { + if path == "" || !filepath.IsAbs(path) || filepath.Clean(path) != path { + return fmt.Errorf("path must be absolute and canonical") + } + info, err := os.Stat(path) + if err != nil { + return err + } + if !info.IsDir() { + return fmt.Errorf("path is not a directory") + } + return nil +} + +func resolveRuntimeDirectory(path string) (string, error) { + if path == "" || filepath.Clean(path) != path { + return "", fmt.Errorf("path must be canonical") + } + resolved := path + if !filepath.IsAbs(resolved) { + absolute, err := filepath.Abs(resolved) + if err != nil { + return "", err + } + resolved = absolute + } + if err := validateCanonicalDirectory(resolved); err != nil { + return "", err + } + return resolved, nil +} + +type managedProcess struct { + cmd *exec.Cmd + done chan struct{} + + mu sync.Mutex + err error +} + +func startManagedProcess(cmd *exec.Cmd) (*managedProcess, error) { + if cmd.SysProcAttr == nil { + cmd.SysProcAttr = &syscall.SysProcAttr{} + } + cmd.SysProcAttr.Setpgid = true + if err := cmd.Start(); err != nil { + return nil, err + } + process := &managedProcess{cmd: cmd, done: make(chan struct{})} + go func() { + err := cmd.Wait() + process.mu.Lock() + process.err = err + process.mu.Unlock() + close(process.done) + }() + return process, nil +} + +func startAttachProcess(cmd *exec.Cmd, stdin *os.File) (*managedProcess, error) { + if stdin != nil && term.IsTerminal(stdin.Fd()) { + if cmd.SysProcAttr == nil { + cmd.SysProcAttr = &syscall.SysProcAttr{} + } + cmd.SysProcAttr.Foreground = true + cmd.SysProcAttr.Ctty = int(stdin.Fd()) + } + return startManagedProcess(cmd) +} + +func (p *managedProcess) waitError() error { + <-p.done + p.mu.Lock() + defer p.mu.Unlock() + return p.err +} + +type byteRing struct { + mu sync.Mutex + data []byte + capacity int +} + +func newByteRing(capacity int) *byteRing { + return &byteRing{capacity: capacity} +} + +func (r *byteRing) Write(data []byte) (int, error) { + r.mu.Lock() + defer r.mu.Unlock() + written := len(data) + if r.capacity <= 0 { + return written, nil + } + if len(data) >= r.capacity { + r.data = append(r.data[:0], data[len(data)-r.capacity:]...) + return written, nil + } + overflow := len(r.data) + len(data) - r.capacity + if overflow > 0 { + copy(r.data, r.data[overflow:]) + r.data = r.data[:len(r.data)-overflow] + } + r.data = append(r.data, data...) + return written, nil +} + +func (r *byteRing) Bytes() []byte { + r.mu.Lock() + defer r.mu.Unlock() + return append([]byte(nil), r.data...) +} + +type runningServer struct { + process *managedProcess + client *apiClient + baseURL string + logs *byteRing +} + +func runSupervisor(ctx context.Context, opts supervisorOptions) error { + password, err := randomServerPassword() + if err != nil { + return err + } + env := serverEnvironment(os.Environ(), openCodeServerUsername, password) + startupCtx, cancelStartup := context.WithTimeout(ctx, serverStartupTimeout) + defer cancelStartup() + server, err := startOpenCodeServer(startupCtx, opts, env, password) + if err != nil { + return err + } + var attach *managedProcess + defer func() { + terminateAndReap(attach, server.process) + }() + + streamCtx, cancelStream := context.WithCancel(ctx) + defer cancelStream() + events := make(chan eventEnvelope, 128) + reconnects := make(chan reconnectNotice, 1) + ready := make(chan struct{}) + streamDone := make(chan error, 1) + go func() { + streamDone <- subscribeWithReconnect(streamCtx, server.client, ready, reconnects, events) + }() + select { + case <-ready: + case <-server.process.done: + return serverFailureError("before event subscription became ready", server, password) + case err := <-streamDone: + return sanitizedSupervisorError("OpenCode event subscription failed", err, password) + case <-startupCtx.Done(): + return startupCtx.Err() + } + + root, err := selectRootSession(startupCtx, opts, server.client) + if err != nil { + return err + } + if err := requireActiveStartup(startupCtx); err != nil { + return err + } + if err := session.WriteProviderIdentity(opts.RuntimeDir, opts.SessionName, root.ID); err != nil { + return fmt.Errorf("write OpenCode provider identity: %w", err) + } + if err := requireActiveStartup(startupCtx); err != nil { + return err + } + eventState := newSupervisorEventState(opts, root.ID, root.Time.Updated) + + attachCommand := opts.Command.command(context.Background(), "attach", server.baseURL, "--dir", opts.Directory, "--session", root.ID) + attachCommand.Dir = opts.Directory + attachCommand.Env = env + attachCommand.Stdin = os.Stdin + attachCommand.Stdout = os.Stdout + attachCommand.Stderr = os.Stderr + if err := requireActiveStartup(startupCtx); err != nil { + return err + } + attach, err = startAttachProcess(attachCommand, os.Stdin) + if err != nil { + return sanitizedSupervisorError("start OpenCode attach", err, password) + } + if err := requireActiveStartupOrReapAttach(startupCtx, attach); err != nil { + attach = nil + return err + } + cancelStartup() + + for { + if outcome, ready := readySupervisorOutcome(ctx, server, attach, password); ready { + return outcome + } + select { + case <-attach.done: + case <-server.process.done: + case <-ctx.Done(): + case event := <-events: + if outcome, ready := readySupervisorOutcome(ctx, server, attach, password); ready { + return outcome + } + if err := eventState.handle(ctx, server.client, event); err != nil { + return err + } + case notice := <-reconnects: + if outcome, ready := readySupervisorOutcome(ctx, server, attach, password); ready { + return outcome + } + if err := eventState.recoverAfterReconnect(ctx, server.client, notice.recoverySince); err != nil { + return err + } + case err := <-streamDone: + if outcome, ready := readySupervisorOutcome(ctx, server, attach, password); ready { + return outcome + } + return sanitizedSupervisorError("OpenCode event stream stopped", err, password) + } + } +} + +func readySupervisorOutcome(ctx context.Context, server *runningServer, attach *managedProcess, password string) (error, bool) { + select { + case <-server.process.done: + return serverFailureError("while attach was active", server, password), true + default: + } + select { + case <-attach.done: + return attachExitError(attach.waitError()), true + default: + } + if err := ctx.Err(); err != nil { + return err, true + } + return nil, false +} + +func randomServerPassword() (string, error) { + data := make([]byte, 32) + if _, err := rand.Read(data); err != nil { + return "", fmt.Errorf("generate OpenCode server credential: %w", err) + } + return hex.EncodeToString(data), nil +} + +func serverEnvironment(base []string, username, password string) []string { + result := make([]string, 0, len(base)+2) + for _, entry := range base { + key, _, _ := strings.Cut(entry, "=") + if key == "OPENCODE_SERVER_USERNAME" || key == "OPENCODE_SERVER_PASSWORD" { + continue + } + result = append(result, entry) + } + return append(result, "OPENCODE_SERVER_USERNAME="+username, "OPENCODE_SERVER_PASSWORD="+password) +} + +func startOpenCodeServer(ctx context.Context, opts supervisorOptions, env []string, password string) (*runningServer, error) { + var lastError error + for attempt := 1; attempt <= serverStartupAttempts; attempt++ { + if err := ctx.Err(); err != nil { + return nil, err + } + server, retry, err := startOpenCodeServerAttempt(opts, env, password) + if err != nil { + if !retry { + return nil, err + } + lastError = err + continue + } + retry, err = waitForOpenCodeServer(ctx, server, password) + if err == nil { + return server, nil + } + if !retry { + return nil, err + } + terminateAndReap(server.process) + lastError = err + } + if lastError == nil { + lastError = fmt.Errorf("OpenCode server failed before readiness") + } + return nil, fmt.Errorf("OpenCode server failed after %d attempts: %w", serverStartupAttempts, lastError) +} + +func startOpenCodeServerAttempt(opts supervisorOptions, env []string, password string) (*runningServer, bool, error) { + port, err := reserveLoopbackPort() + if err != nil { + return nil, false, fmt.Errorf("reserve OpenCode loopback port: %w", err) + } + baseURL := "http://127.0.0.1:" + fmt.Sprint(port) + logs := newByteRing(serverLogCapacity) + command := opts.Command.command(context.Background(), "serve", "--hostname", "127.0.0.1", "--port", fmt.Sprint(port)) + command.Dir = opts.Directory + command.Env = env + command.Stdout = logs + command.Stderr = logs + process, err := startManagedProcess(command) + if err != nil { + return nil, true, sanitizedSupervisorError("start OpenCode server", err, password) + } + client, err := newAPIClient(baseURL, openCodeServerUsername, password, opts.Directory, &http.Client{}) + if err != nil { + terminateAndReap(process) + return nil, false, err + } + return &runningServer{process: process, client: client, baseURL: baseURL, logs: logs}, false, nil +} + +func waitForOpenCodeServer(ctx context.Context, server *runningServer, password string) (bool, error) { + for { + requestCtx, cancel := context.WithTimeout(ctx, serverRequestTimeout) + health, healthErr := server.client.health(requestCtx) + cancel() + if healthErr == nil && health.Healthy { + if err := validateServerVersion(health.Version); err != nil { + terminateAndReap(server.process) + return false, sanitizedSupervisorError("validate OpenCode server version", err, password) + } + return false, nil + } + switch waitForServerPoll(ctx, server.process) { + case serverPollCanceled: + terminateAndReap(server.process) + return false, startupFailureError(ctx.Err(), server, password) + case serverPollExited: + return true, serverFailureError("before readiness", server, password) + } + } +} + +type serverPollStatus uint8 + +const ( + serverPollContinue serverPollStatus = iota + serverPollCanceled + serverPollExited +) + +func waitForServerPoll(ctx context.Context, process *managedProcess) serverPollStatus { + timer := time.NewTimer(serverPollInterval) + defer timer.Stop() + select { + case <-ctx.Done(): + return serverPollCanceled + case <-process.done: + return serverPollExited + case <-timer.C: + return serverPollContinue + } +} + +func reserveLoopbackPort() (int, error) { + listener, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + return 0, err + } + defer func() { _ = listener.Close() }() + return listener.Addr().(*net.TCPAddr).Port, nil +} + +func validateServerVersion(value string) error { + version, err := parseSemanticVersion(value) + if err != nil || version.prerelease || version.compare(semanticVersion{major: 1, minor: 18, patch: 1}) < 0 { + return fmt.Errorf("OpenCode server reported unsupported version %q; required version %s", displaytext.Sanitize(value), minimumVersion) + } + return nil +} + +func selectRootSession(ctx context.Context, opts supervisorOptions, client *apiClient) (sessionInfo, error) { + var ( + root sessionInfo + err error + ) + if opts.ProviderSessionID == "" { + root, err = client.createSession(ctx, opts.SessionName) + } else { + root, err = client.getSession(ctx, opts.ProviderSessionID) + if errors.Is(err, errSessionNotFound) { + return sessionInfo{}, fmt.Errorf("OpenCode session %s was not found; exact resume cannot continue", opts.ProviderSessionID) + } + } + if err != nil { + return sessionInfo{}, err + } + if !validOpenCodeSessionID(root.ID) { + return sessionInfo{}, fmt.Errorf("OpenCode returned an invalid session ID") + } + if opts.ProviderSessionID != "" && root.ID != opts.ProviderSessionID { + return sessionInfo{}, fmt.Errorf("OpenCode exact resume returned a different session ID") + } + if root.ParentID != "" { + return sessionInfo{}, fmt.Errorf("OpenCode session %s is not a root session", root.ID) + } + if root.Directory != opts.Directory { + return sessionInfo{}, fmt.Errorf("OpenCode session %s belongs to a different directory", root.ID) + } + return root, nil +} + +func terminateAndReap(processes ...*managedProcess) { + processes = managedProcesses(processes) + signalProcessGroups(processes, syscall.SIGTERM) + if !waitForProcessGroups(processes, childCleanupTimeout) { + signalProcessGroups(processes, syscall.SIGKILL) + } + reapProcesses(processes) +} + +func managedProcesses(processes []*managedProcess) []*managedProcess { + result := make([]*managedProcess, 0, len(processes)) + for _, process := range processes { + if process != nil { + result = append(result, process) + } + } + return result +} + +func signalProcessGroups(processes []*managedProcess, signal syscall.Signal) { + for _, process := range processes { + _ = syscall.Kill(-process.cmd.Process.Pid, signal) + } +} + +func waitForProcessGroups(processes []*managedProcess, timeout time.Duration) bool { + deadline := time.NewTimer(timeout) + defer deadline.Stop() + ticker := time.NewTicker(10 * time.Millisecond) + defer ticker.Stop() + for { + allExited := true + for _, process := range processes { + if err := syscall.Kill(-process.cmd.Process.Pid, 0); err == nil || errors.Is(err, syscall.EPERM) { + allExited = false + break + } + } + if allExited { + return true + } + select { + case <-ticker.C: + case <-deadline.C: + return false + } + } +} + +func reapProcesses(processes []*managedProcess) { + for _, process := range processes { + if process != nil { + _ = process.waitError() + } + } +} + +func attachExitError(err error) error { + if err == nil { + return nil + } + var exitError *exec.ExitError + if !errors.As(err, &exitError) { + return fmt.Errorf("wait for OpenCode attach: %w", err) + } + code := exitError.ExitCode() + if code < 1 || code > 255 { + code = 1 + } + return &ExitError{Code: code} +} + +func startupFailureError(cause error, server *runningServer, password string) error { + detail := safeServerLogExcerpt(server.logs.Bytes(), password) + if detail == "" { + return fmt.Errorf("OpenCode server readiness timed out: %w", cause) + } + return fmt.Errorf("OpenCode server readiness timed out: %w (server output: %s)", cause, detail) +} + +func serverFailureError(when string, server *runningServer, password string) error { + waitErr := server.process.waitError() + detail := safeServerLogExcerpt(server.logs.Bytes(), password) + if detail == "" { + return sanitizedSupervisorError("OpenCode server exited "+when, waitErr, password) + } + return sanitizedSupervisorError("OpenCode server exited "+when+" (server output: "+detail+")", waitErr, password) +} + +func sanitizedSupervisorError(operation string, err error, password string) error { + detail := "" + if err != nil { + detail = safeServerLogExcerpt([]byte(err.Error()), password) + } + if detail == "" { + return fmt.Errorf("%s", displaytext.Sanitize(operation)) + } + return fmt.Errorf("%s: %s", displaytext.Sanitize(operation), detail) +} + +func safeServerLogExcerpt(data []byte, password string) string { + value := displaytext.Sanitize(string(data)) + for _, secret := range []string{password, displaytext.Sanitize(password)} { + if secret != "" { + value = strings.ReplaceAll(value, secret, "") + } + } + value = strings.TrimSpace(value) + runes := []rune(value) + if len(runes) > 512 { + value = string(runes[len(runes)-512:]) + } + return value +} + +func reconnectBackoff(attempt int) time.Duration { + delay := eventReconnectBase + for index := 0; index < attempt && delay < eventReconnectMax; index++ { + delay *= 2 + } + if delay > eventReconnectMax { + return eventReconnectMax + } + return delay +} + +func requireActiveStartup(ctx context.Context) error { + return ctx.Err() +} + +func requireActiveStartupOrReapAttach(ctx context.Context, attach *managedProcess) error { + if err := requireActiveStartup(ctx); err != nil { + terminateAndReap(attach) + return err + } + return nil +} + +type reconnectNotice struct { + recoverySince time.Time +} + +func subscribeWithReconnect(ctx context.Context, client *apiClient, initialReady chan<- struct{}, reconnects chan<- reconnectNotice, events chan<- eventEnvelope) error { + readySent := false + var connectedAt time.Time + for attempt := 0; ; attempt++ { + ready := make(chan struct{}) + done := make(chan error, 1) + go func() { + done <- client.subscribe(ctx, ready, events) + }() + select { + case <-ready: + now := time.Now() + if !readySent { + close(initialReady) + readySent = true + } else { + select { + case reconnects <- reconnectNotice{recoverySince: connectedAt}: + default: + } + } + connectedAt = now + if err := <-done; ctx.Err() != nil { + return ctx.Err() + } else if err == nil { + return fmt.Errorf("OpenCode event stream ended without an error") + } else { + warnOpenCode("OpenCode event stream interrupted; reconnecting: %s", client.safeText(err.Error())) + } + case err := <-done: + if ctx.Err() != nil { + return ctx.Err() + } + if err == nil { + return fmt.Errorf("OpenCode event subscription ended before readiness") + } + case <-ctx.Done(): + return ctx.Err() + } + timer := time.NewTimer(reconnectBackoff(attempt)) + select { + case <-timer.C: + case <-ctx.Done(): + if !timer.Stop() { + <-timer.C + } + return ctx.Err() + } + } +} + +type sessionCreatedEvent struct { + SessionID string `json:"sessionID"` + Info sessionInfo `json:"info"` +} + +type permissionAskedEvent struct { + ID string `json:"id"` + SessionID string `json:"sessionID"` +} + +type supervisorEventState struct { + opts supervisorOptions + activeRoot string + rootFor map[string]string + acceptedRoots map[string]struct{} + replied map[string]struct{} + activeUpdated int64 +} + +func newSupervisorEventState(opts supervisorOptions, rootID string, updated ...int64) *supervisorEventState { + var activeUpdated int64 + if len(updated) > 0 { + activeUpdated = updated[0] + } + return &supervisorEventState{ + opts: opts, + activeRoot: rootID, + rootFor: map[string]string{rootID: rootID}, + acceptedRoots: map[string]struct{}{rootID: {}}, + replied: make(map[string]struct{}), + activeUpdated: activeUpdated, + } +} + +func (s *supervisorEventState) handle(ctx context.Context, client *apiClient, event eventEnvelope) error { + switch event.Type { + case "session.created": + return s.handleSessionCreated(event.Properties) + case "permission.asked": + return s.handlePermissionAsked(ctx, client, event.Properties) + default: + return nil + } +} + +func (s *supervisorEventState) handleSessionCreated(properties json.RawMessage) error { + var created sessionCreatedEvent + if err := decodeStrictJSON(properties, &created); err != nil { + return nil + } + info := created.Info + if created.SessionID == "" || created.SessionID != info.ID || !validOpenCodeSessionID(info.ID) || info.Directory != s.opts.Directory { + return nil + } + if info.ParentID != "" { + root, ok := s.rootFor[info.ParentID] + if !ok { + return nil + } + s.rootFor[info.ID] = root + return nil + } + if _, accepted := s.acceptedRoots[info.ID]; accepted { + return nil + } + return s.acceptRoot(info) +} + +func (s *supervisorEventState) acceptRoot(info sessionInfo) error { + if err := session.WriteProviderIdentity(s.opts.RuntimeDir, s.opts.SessionName, info.ID); err != nil { + warning := displaytext.Sanitize(fmt.Sprintf( + "OpenCode provider identity update for %s failed; continuing with %s: %v", + info.ID, + s.activeRoot, + err, + )) + _, _ = fmt.Fprintln(os.Stderr, warning) + return nil + } + s.activeRoot = info.ID + if info.Time.Updated > s.activeUpdated { + s.activeUpdated = info.Time.Updated + } + s.rootFor[info.ID] = info.ID + s.acceptedRoots[info.ID] = struct{}{} + return nil +} + +func (s *supervisorEventState) recoverAfterReconnect(ctx context.Context, client *apiClient, recoverySince time.Time) error { + if s.activeUpdated <= 0 || recoverySince.IsZero() { + warnOpenCode("OpenCode event recovery lacks a verified time boundary; continuing with %s", s.activeRoot) + return nil + } + requestCtx, cancel := context.WithTimeout(ctx, eventRecoveryTimeout) + defer cancel() + sessions, err := client.listSessions(requestCtx) + if err != nil { + warnOpenCode("OpenCode event recovery failed; continuing with %s: %v", s.activeRoot, err) + return nil + } + candidates := make([]sessionInfo, 0, 1) + for _, info := range sessions { + if info.ParentID != "" || info.Directory != s.opts.Directory || !validOpenCodeSessionID(info.ID) || info.Time.Created <= 0 || info.Time.Updated <= s.activeUpdated || info.Time.Created <= recoverySince.UnixMilli() { + continue + } + if _, accepted := s.acceptedRoots[info.ID]; accepted { + continue + } + candidates = append(candidates, info) + } + if len(candidates) != 1 { + warnOpenCode("OpenCode event recovery found %d unambiguous newer roots; continuing with %s", len(candidates), s.activeRoot) + return nil + } + return s.acceptRoot(candidates[0]) +} + +func (s *supervisorEventState) handlePermissionAsked(ctx context.Context, client *apiClient, properties json.RawMessage) error { + if !s.opts.Yolo { + return nil + } + var asked permissionAskedEvent + if err := decodeStrictJSON(properties, &asked); err != nil { + warnOpenCode("ignored malformed OpenCode permission event") + return nil + } + if !permissionIDRE.MatchString(asked.ID) || !store.ValidProviderSessionID(asked.ID) || !validOpenCodeSessionID(asked.SessionID) { + warnOpenCode("ignored invalid OpenCode permission event") + return nil + } + root, ok := s.rootFor[asked.SessionID] + if !ok || root != s.activeRoot { + warnOpenCode("ignored OpenCode permission %s for an unowned or stale session", asked.ID) + return nil + } + if _, duplicate := s.replied[asked.ID]; duplicate { + warnOpenCode("ignored duplicate OpenCode permission %s", asked.ID) + return nil + } + requestCtx, cancel := context.WithTimeout(ctx, 2*time.Second) + defer cancel() + if err := client.replyPermission(requestCtx, asked.ID); err != nil { + return fmt.Errorf("reply to OpenCode permission %s: %w", asked.ID, err) + } + s.replied[asked.ID] = struct{}{} + return nil +} + +func warnOpenCode(format string, args ...any) { + message := displaytext.Sanitize(fmt.Sprintf(format, args...)) + runes := []rune(message) + if len(runes) > 480 { + message = string(runes[:480]) + } + _, _ = fmt.Fprintln(os.Stderr, message) +} diff --git a/internal/adapter/opencode/supervisor_test.go b/internal/adapter/opencode/supervisor_test.go new file mode 100644 index 0000000..1f7c9fb --- /dev/null +++ b/internal/adapter/opencode/supervisor_test.go @@ -0,0 +1,2334 @@ +package opencode + +import ( + "bufio" + "context" + "crypto/sha256" + "encoding/json" + "errors" + "flag" + "fmt" + "io" + "net" + "net/http" + "net/http/httptest" + "os" + "os/exec" + "os/signal" + "path/filepath" + "reflect" + "regexp" + "strconv" + "strings" + "sync" + "syscall" + "testing" + "time" + + "github.com/RandomCodeSpace/unified-agent-manager/internal/session" + "github.com/creack/pty" +) + +const testSupervisorPassword = "supervisor-password-must-never-leak" + +const ( + fakeConfigEnv = "UAM_FAKE_OPENCODE_CONFIG" + fakeRecordEnv = "UAM_FAKE_OPENCODE_RECORD" +) + +type fakeOpenCodeConfig struct { + Directory string `json:"directory"` + CreatedID string `json:"created_id"` + ExistingID string `json:"existing_id"` + ResponseID string `json:"response_id"` + ResponseDirectory string `json:"response_directory"` + ResponseParentID string `json:"response_parent_id"` + ResumeMissing bool `json:"resume_missing"` + Events []eventEnvelope `json:"events"` + DisconnectSSE bool `json:"disconnect_sse"` + AttachExit int `json:"attach_exit"` + AttachSignal bool `json:"attach_signal"` + AttachDelayMillis int `json:"attach_delay_millis"` + ReadStdin bool `json:"read_stdin"` + ReadStdinLine bool `json:"read_stdin_line"` + FailServeAttempts int `json:"fail_serve_attempts"` + FailServeGrandchild bool `json:"fail_serve_grandchild"` + BindCollisionOnce bool `json:"bind_collision_once"` + HealthNeverReady bool `json:"health_never_ready"` + HealthLeaksSecret bool `json:"health_leaks_secret"` + ServerExitMillis int `json:"server_exit_millis"` + CreateDelayMillis int `json:"create_delay_millis"` + IgnoreTerminate bool `json:"ignore_terminate"` + Grandchildren bool `json:"grandchildren"` + ObserveEvents bool `json:"observe_events"` + NoisyServerLog bool `json:"noisy_server_log"` + EventGatePath string `json:"event_gate_path"` + CreatedUpdated int64 `json:"created_updated"` + ExistingUpdated int64 `json:"existing_updated"` + ListSessions []fakeSession `json:"list_sessions"` + StallEvent bool `json:"stall_event"` + StallCreate bool `json:"stall_create"` + StallGet bool `json:"stall_get"` + GapSession fakeSession `json:"gap_session"` + DisconnectDelayMS int `json:"disconnect_delay_ms"` +} + +type fakeSession struct { + ID string `json:"id"` + ParentID string `json:"parentID,omitempty"` + Directory string `json:"directory"` + Created int64 `json:"-"` + Updated int64 `json:"-"` +} + +func (s fakeSession) MarshalJSON() ([]byte, error) { + type wireSession fakeSession + return json.Marshal(struct { + wireSession + Time struct { + Created int64 `json:"created"` + Updated int64 `json:"updated"` + } `json:"time"` + }{wireSession: wireSession(s), Time: struct { + Created int64 `json:"created"` + Updated int64 `json:"updated"` + }{Created: s.Created, Updated: s.Updated}}) +} + +func (s *fakeSession) UnmarshalJSON(data []byte) error { + type wireSession fakeSession + value := struct { + wireSession + Time struct { + Created int64 `json:"created"` + Updated int64 `json:"updated"` + } `json:"time"` + }{} + if err := json.Unmarshal(data, &value); err != nil { + return err + } + *s = fakeSession(value.wireSession) + s.Created = value.Time.Created + s.Updated = value.Time.Updated + return nil +} + +type fakeOpenCodeRecord struct { + Kind string `json:"kind"` + PID int `json:"pid,omitempty"` + PGID int `json:"pgid,omitempty"` + Args []string `json:"args,omitempty"` + ID string `json:"id,omitempty"` + Body string `json:"body,omitempty"` + Input string `json:"input,omitempty"` + Port int `json:"port,omitempty"` + PasswordSet bool `json:"password_set,omitempty"` + PasswordReplaced bool `json:"password_replaced,omitempty"` + CredentialHash string `json:"credential_hash,omitempty"` + ConfigHash string `json:"config_hash,omitempty"` +} + +func TestMain(m *testing.M) { + if len(os.Args) > 1 { + switch os.Args[1] { + case "__supervisor_test": + err := RunSupervisorCommand(os.Args[2:]) + if err == nil || errors.Is(err, context.Canceled) { + os.Exit(0) + } + _, _ = fmt.Fprintln(os.Stderr, err) + os.Exit(89) + case "__supervisor_grandchild": + os.Exit(fakeSupervisorGrandchild(os.Args[2])) + case "serve": + os.Exit(fakeOpenCodeServe(os.Args[2:])) + case "attach": + os.Exit(fakeOpenCodeAttach(os.Args[2:])) + } + } + os.Exit(m.Run()) +} + +func TestSupervisorCommandParser(t *testing.T) { + t.Setenv("OPENCODE_SERVER_PASSWORD", testSupervisorPassword) + executable, err := os.Executable() + if err != nil { + t.Fatal(err) + } + directory := filepath.Clean(t.TempDir()) + runtimeDir := filepath.Clean(t.TempDir()) + if err := os.Chmod(runtimeDir, 0o700); err != nil { + t.Fatal(err) + } + base := []string{ + "--path", executable, + "--dir", directory, + "--name", "uam-opencode-a1b2c3d4", + "--runtime-dir", runtimeDir, + "--mode", "safe", + } + + t.Run("direct executable safe new", func(t *testing.T) { + got, err := parseSupervisorOptions(base) + if err != nil { + t.Fatal(err) + } + want := supervisorOptions{ + Command: providerCommand{path: executable}, + Directory: directory, + SessionName: "uam-opencode-a1b2c3d4", + RuntimeDir: runtimeDir, + } + if !reflect.DeepEqual(got, want) { + t.Fatalf("options = %#v, want %#v", got, want) + } + }) + + t.Run("shell alias yolo exact resume", func(t *testing.T) { + args := []string{ + "--shell", "/bin/sh", "--alias", "custom-opencode", + "--dir", directory, + "--name", "uam-opencode-deadbeef", + "--runtime-dir", runtimeDir, + "--mode", "yolo", + "--session", "ses_exact123", + } + got, err := parseSupervisorOptions(args) + if err != nil { + t.Fatal(err) + } + want := supervisorOptions{ + Command: providerCommand{shell: "/bin/sh", alias: "custom-opencode"}, + Directory: directory, + SessionName: "uam-opencode-deadbeef", + ProviderSessionID: "ses_exact123", + Yolo: true, + RuntimeDir: runtimeDir, + } + if !reflect.DeepEqual(got, want) { + t.Fatalf("options = %#v, want %#v", got, want) + } + }) + + t.Run("relative runtime directory resolves from current working directory", func(t *testing.T) { + baseDir := t.TempDir() + oldDirectory, err := os.Getwd() + if err != nil { + t.Fatal(err) + } + if err := os.Chdir(baseDir); err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = os.Chdir(oldDirectory) }) + if err := os.Mkdir("relative-runtime", 0o700); err != nil { + t.Fatal(err) + } + args := replaceFlag(base, "--runtime-dir", "relative-runtime") + got, err := parseSupervisorOptions(args) + if err != nil { + t.Fatalf("parseSupervisorOptions: %v", err) + } + want := filepath.Join(baseDir, "relative-runtime") + if got.RuntimeDir != want { + t.Fatalf("runtime dir = %q, want %q", got.RuntimeDir, want) + } + }) + + tests := []struct { + name string + args []string + }{ + {name: "missing command", args: withoutFlag(base, "--path")}, + {name: "missing directory", args: withoutFlag(base, "--dir")}, + {name: "missing name", args: withoutFlag(base, "--name")}, + {name: "missing runtime directory", args: withoutFlag(base, "--runtime-dir")}, + {name: "missing mode", args: withoutFlag(base, "--mode")}, + {name: "duplicate flag", args: append(append([]string(nil), base...), "--mode", "yolo")}, + {name: "direct and shell conflict", args: append(append([]string(nil), base...), "--shell", "/bin/sh", "--alias", "opencode")}, + {name: "shell without alias", args: replaceFlag(base, "--path", "", "--shell", "/bin/sh")}, + {name: "alias without shell", args: replaceFlag(base, "--path", "", "--alias", "opencode")}, + {name: "invalid session name", args: replaceFlag(base, "--name", "../escape")}, + {name: "invalid provider id", args: append(append([]string(nil), base...), "--session", "ses_bad/value")}, + {name: "invalid mode", args: replaceFlag(base, "--mode", "unsafe")}, + {name: "relative directory", args: replaceFlag(base, "--dir", "relative")}, + {name: "noncanonical directory", args: replaceFlag(base, "--dir", directory+string(os.PathSeparator)+".")}, + {name: "noncanonical runtime directory", args: replaceFlag(base, "--runtime-dir", runtimeDir+string(os.PathSeparator)+".")}, + {name: "positional argument", args: append(append([]string(nil), base...), "unexpected")}, + {name: "unknown flag", args: append(append([]string(nil), base...), "--bogus")}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + _, err := parseSupervisorOptions(tt.args) + if err == nil { + t.Fatal("parseSupervisorOptions succeeded") + } + if strings.Contains(err.Error(), testSupervisorPassword) { + t.Fatalf("parser error leaked password: %q", err) + } + }) + } +} + +func TestSupervisorExitError(t *testing.T) { + err := error(&ExitError{Code: 23}) + if err.Error() != "OpenCode attach exited with code 23" { + t.Fatalf("Error() = %q", err) + } + var exitErr *ExitError + if !errors.As(err, &exitErr) { + t.Fatal("errors.As did not find *ExitError") + } + if exitErr.ExitCode() != 23 { + t.Fatalf("ExitCode() = %d, want 23", exitErr.ExitCode()) + } +} + +func TestSupervisorRelativeRuntimeDirectoryDispatch(t *testing.T) { + baseDir := t.TempDir() + oldDirectory, err := os.Getwd() + if err != nil { + t.Fatal(err) + } + if err := os.Chdir(baseDir); err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = os.Chdir(oldDirectory) }) + if err := os.Mkdir("runtime", 0o700); err != nil { + t.Fatal(err) + } + fixture := newSupervisorFixture(t, fakeOpenCodeConfig{CreatedID: "ses_relative123"}) + fixture.options.RuntimeDir = "runtime" + if err := RunSupervisorCommand(fixture.commandArgs()); err != nil { + t.Fatalf("RunSupervisorCommand: %v", err) + } + wantOptions := fixture.options + wantOptions.RuntimeDir = filepath.Join(baseDir, "runtime") + assertProviderIdentity(t, wantOptions, "ses_relative123") + assertLifecycleClean(t, fixture, nil) +} + +func withoutFlag(args []string, flag string) []string { + result := make([]string, 0, len(args)-2) + for index := 0; index < len(args); index += 2 { + if args[index] != flag { + result = append(result, args[index], args[index+1]) + } + } + return result +} + +func replaceFlag(args []string, flag, value string, replacement ...string) []string { + result := make([]string, 0, len(args)+len(replacement)) + for index := 0; index < len(args); index += 2 { + if args[index] == flag { + if value != "" { + result = append(result, flag, value) + } + result = append(result, replacement...) + continue + } + result = append(result, args[index], args[index+1]) + } + return result +} + +type supervisorFixture struct { + configPath string + recordPath string + options supervisorOptions +} + +func newSupervisorFixture(t *testing.T, config fakeOpenCodeConfig) supervisorFixture { + t.Helper() + directory := filepath.Clean(t.TempDir()) + runtimeDir := filepath.Clean(t.TempDir()) + if err := os.Chmod(runtimeDir, 0o700); err != nil { + t.Fatal(err) + } + if config.Directory == "" { + config.Directory = directory + } + if config.CreatedID == "" { + config.CreatedID = "ses_created123" + } + executable, err := os.Executable() + if err != nil { + t.Fatal(err) + } + fixtureDir := t.TempDir() + configPath := filepath.Join(fixtureDir, "config.json") + recordPath := filepath.Join(fixtureDir, "records.jsonl") + data, err := json.Marshal(config) + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(configPath, data, 0o600); err != nil { + t.Fatal(err) + } + t.Setenv(fakeConfigEnv, configPath) + t.Setenv(fakeRecordEnv, recordPath) + t.Setenv("OPENCODE_SERVER_USERNAME", "attacker") + t.Setenv("OPENCODE_SERVER_PASSWORD", testSupervisorPassword) + return supervisorFixture{ + configPath: configPath, + recordPath: recordPath, + options: supervisorOptions{ + Command: providerCommand{path: executable}, + Directory: config.Directory, + SessionName: "uam-opencode-a1b2c3d4", + RuntimeDir: runtimeDir, + }, + } +} + +func (f supervisorFixture) records(t *testing.T) []fakeOpenCodeRecord { + t.Helper() + data, err := os.ReadFile(f.recordPath) + if os.IsNotExist(err) { + return nil + } + if err != nil { + t.Fatal(err) + } + var records []fakeOpenCodeRecord + for _, line := range strings.Split(strings.TrimSpace(string(data)), "\n") { + if line == "" { + continue + } + var record fakeOpenCodeRecord + if err := json.Unmarshal([]byte(line), &record); err != nil { + t.Fatalf("decode fake record %q: %v", line, err) + } + records = append(records, record) + } + return records +} + +func (f supervisorFixture) recordsOfKind(t *testing.T, kind string) []fakeOpenCodeRecord { + t.Helper() + var result []fakeOpenCodeRecord + for _, record := range f.records(t) { + if record.Kind == kind { + result = append(result, record) + } + } + return result +} + +func (f supervisorFixture) commandArgs() []string { + args := []string{ + "--path", f.options.Command.path, + "--dir", f.options.Directory, + "--name", f.options.SessionName, + "--runtime-dir", f.options.RuntimeDir, + "--mode", "safe", + } + if f.options.Yolo { + args[len(args)-1] = "yolo" + } + if f.options.ProviderSessionID != "" { + args = append(args, "--session", f.options.ProviderSessionID) + } + return args +} + +func startSupervisorFixture(t *testing.T, fixture supervisorFixture, input string) (*exec.Cmd, *os.File) { + t.Helper() + reader, writer, err := os.Pipe() + if err != nil { + t.Fatal(err) + } + if input != "" { + if _, err := io.WriteString(writer, input); err != nil { + _ = reader.Close() + _ = writer.Close() + t.Fatal(err) + } + } + command := exec.Command(os.Args[0], append([]string{"__supervisor_test"}, fixture.commandArgs()...)...) + command.Env = fakeSupervisorEnvironment(fixture) + command.Stdin = reader + command.Stdout = io.Discard + command.Stderr = io.Discard + if err := command.Start(); err != nil { + _ = reader.Close() + _ = writer.Close() + t.Fatal(err) + } + if err := reader.Close(); err != nil { + _ = writer.Close() + _ = command.Process.Kill() + t.Fatal(err) + } + t.Cleanup(func() { + _ = writer.Close() + _ = command.Process.Kill() + killFakeProcesses(fixture.records(t)) + }) + return command, writer +} + +func fakeSupervisorEnvironment(fixture supervisorFixture) []string { + result := make([]string, 0, len(os.Environ())+4) + for _, entry := range os.Environ() { + key, _, _ := strings.Cut(entry, "=") + switch key { + case fakeConfigEnv, fakeRecordEnv, "OPENCODE_SERVER_USERNAME", "OPENCODE_SERVER_PASSWORD": + continue + } + result = append(result, entry) + } + return append(result, + fakeConfigEnv+"="+fixture.configPath, + fakeRecordEnv+"="+fixture.recordPath, + "OPENCODE_SERVER_USERNAME=attacker", + "OPENCODE_SERVER_PASSWORD="+testSupervisorPassword, + ) +} + +func writeFakeOpenCodeConfig(t *testing.T, path string, config fakeOpenCodeConfig) { + t.Helper() + data, err := json.Marshal(config) + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(path, data, 0o600); err != nil { + t.Fatal(err) + } +} + +func awaitProviderIdentity(t *testing.T, options supervisorOptions, want string, timeout time.Duration) { + t.Helper() + deadline := time.Now().Add(timeout) + for time.Now().Before(deadline) { + if got, err := session.ReadProviderIdentity(options.RuntimeDir, options.SessionName); err == nil && got == want { + return + } + time.Sleep(10 * time.Millisecond) + } + assertProviderIdentity(t, options, want) +} + +func releaseFakeEvents(t *testing.T, path string) { + t.Helper() + if err := os.WriteFile(path, []byte("release"), 0o600); err != nil { + t.Fatal(err) + } +} + +func assertDistinctSupervisorBoundaries(t *testing.T, alpha, beta supervisorFixture, inheritedConfig string) { + t.Helper() + if alpha.options.Directory != beta.options.Directory || alpha.options.RuntimeDir != beta.options.RuntimeDir { + t.Fatalf("fixtures do not share workspace/runtime: alpha=%#v beta=%#v", alpha.options, beta.options) + } + if alpha.options.SessionName == beta.options.SessionName { + t.Fatalf("managed session names collide: %q", alpha.options.SessionName) + } + alphaServe := alpha.recordsOfKind(t, "serve") + betaServe := beta.recordsOfKind(t, "serve") + if len(alphaServe) != 1 || len(betaServe) != 1 || alphaServe[0].Port == betaServe[0].Port { + t.Fatalf("loopback boundaries = alpha %#v, beta %#v", alphaServe, betaServe) + } + if alphaServe[0].CredentialHash == "" || betaServe[0].CredentialHash == "" || alphaServe[0].CredentialHash == betaServe[0].CredentialHash { + t.Fatalf("credential hashes are not distinct: alpha=%q beta=%q", alphaServe[0].CredentialHash, betaServe[0].CredentialHash) + } + wantConfigHash := fakeHash(inheritedConfig) + for name, fixture := range map[string]supervisorFixture{"alpha": alpha, "beta": beta} { + serve := fixture.recordsOfKind(t, "serve")[0] + attach := fixture.recordsOfKind(t, "attach_start") + if len(attach) != 1 || attach[0].CredentialHash != serve.CredentialHash { + t.Fatalf("%s server/attach credential boundary differs: serve=%#v attach=%#v", name, serve, attach) + } + if serve.ConfigHash != wantConfigHash || attach[0].ConfigHash != wantConfigHash { + t.Fatalf("%s config hashes = serve %q attach %q, want %q", name, serve.ConfigHash, attach[0].ConfigHash, wantConfigHash) + } + } +} + +func assertExactRestartRecords(t *testing.T, records []fakeOpenCodeRecord, options supervisorOptions, wantID string) { + t.Helper() + byKind := make(map[string][]fakeOpenCodeRecord) + for _, record := range records { + byKind[record.Kind] = append(byKind[record.Kind], record) + } + if len(byKind["create"]) != 0 || len(byKind["get"]) != 1 || byKind["get"][0].ID != wantID { + t.Fatalf("exact restart selection records = %#v", records) + } + if len(byKind["attach"]) != 1 || byKind["attach"][0].Input != "" { + t.Fatalf("restart attach input = %#v, want no prompt replay", byKind["attach"]) + } + if len(byKind["serve"]) != 1 { + t.Fatalf("restart serve records = %#v", byKind["serve"]) + } + wantArgs := []string{"http://127.0.0.1:" + strconv.Itoa(byKind["serve"][0].Port), "--dir", options.Directory, "--session", wantID} + if !reflect.DeepEqual(byKind["attach"][0].Args, wantArgs) { + t.Fatalf("restart attach argv = %#v, want %#v", byKind["attach"][0].Args, wantArgs) + } +} + +func fakeHash(value string) string { + sum := sha256.Sum256([]byte(value)) + return fmt.Sprintf("%x", sum) +} + +func TestSupervisorNewAndExactResume(t *testing.T) { + t.Run("new creates persists and attaches exact root", func(t *testing.T) { + fixture := newSupervisorFixture(t, fakeOpenCodeConfig{CreatedID: "ses_new123"}) + if err := runSupervisor(t.Context(), fixture.options); err != nil { + t.Fatalf("runSupervisor: %v", err) + } + assertProviderIdentity(t, fixture.options, "ses_new123") + if got := len(fixture.recordsOfKind(t, "create")); got != 1 { + t.Fatalf("create requests = %d, want 1", got) + } + if got := len(fixture.recordsOfKind(t, "get")); got != 0 { + t.Fatalf("get requests = %d, want 0", got) + } + assertServeAndAttachRecords(t, fixture, "ses_new123") + assertNoSupervisorSecret(t, fixture) + assertFakeChildrenReaped(t, fixture) + }) + + t.Run("resume validates and attaches retained exact root", func(t *testing.T) { + fixture := newSupervisorFixture(t, fakeOpenCodeConfig{ExistingID: "ses_resume123"}) + fixture.options.ProviderSessionID = "ses_resume123" + if err := runSupervisor(t.Context(), fixture.options); err != nil { + t.Fatalf("runSupervisor: %v", err) + } + assertProviderIdentity(t, fixture.options, "ses_resume123") + if got := len(fixture.recordsOfKind(t, "create")); got != 0 { + t.Fatalf("create requests = %d, want 0", got) + } + gets := fixture.recordsOfKind(t, "get") + if len(gets) != 1 || gets[0].ID != "ses_resume123" { + t.Fatalf("get records = %#v", gets) + } + assertServeAndAttachRecords(t, fixture, "ses_resume123") + assertNoSupervisorSecret(t, fixture) + assertFakeChildrenReaped(t, fixture) + }) + + t.Run("missing exact resume is actionable and never falls back", func(t *testing.T) { + fixture := newSupervisorFixture(t, fakeOpenCodeConfig{ResumeMissing: true}) + fixture.options.ProviderSessionID = "ses_missing123" + err := runSupervisor(t.Context(), fixture.options) + if err == nil || !strings.Contains(err.Error(), "ses_missing123") || !strings.Contains(strings.ToLower(err.Error()), "not found") { + t.Fatalf("missing resume error = %v", err) + } + if got := fixture.recordsOfKind(t, "attach"); len(got) != 0 { + t.Fatalf("missing resume started attach: %#v", got) + } + for _, record := range fixture.records(t) { + joined := strings.Join(record.Args, " ") + if strings.Contains(joined, " -c") || strings.Contains(joined, "--continue") { + t.Fatalf("fallback flag found in %#v", record) + } + } + assertNoSupervisorSecret(t, fixture) + assertFakeChildrenReaped(t, fixture) + }) +} + +func TestSupervisorSameWorkspaceSessionsRemainIndependent(t *testing.T) { + directory := filepath.Clean(t.TempDir()) + runtimeDir := filepath.Clean(t.TempDir()) + if err := os.Chmod(runtimeDir, 0o700); err != nil { + t.Fatal(err) + } + configEnv := "OPENCODE_CONFIG_" + "CONTENT" + const inheritedConfig = `{"unknown_uam_regression":{"preserve":"exactly"}}` + t.Setenv(configEnv, inheritedConfig) + + alphaGate := filepath.Join(t.TempDir(), "release-events") + betaGate := filepath.Join(t.TempDir(), "release-events") + alphaRoot := "ses_alpha123" + alphaNew := "ses_alpha_new123" + betaRoot := "ses_beta123" + alpha := newSupervisorFixture(t, fakeOpenCodeConfig{ + Directory: directory, + CreatedID: alphaRoot, + ReadStdin: true, + EventGatePath: alphaGate, + Events: []eventEnvelope{ + fakeEvent("session.created", map[string]any{"sessionID": "ses_alpha_child123", "info": map[string]any{"id": "ses_alpha_child123", "parentID": alphaRoot, "directory": directory}}), + fakeEvent("session.created", map[string]any{"sessionID": "ses_wrong_directory123", "info": map[string]any{"id": "ses_wrong_directory123", "directory": directory + "-other"}}), + fakeEvent("session.created", map[string]any{"sessionID": alphaNew, "info": map[string]any{"id": alphaNew, "directory": directory}}), + fakeEvent("session.created", map[string]any{"sessionID": "ses_alpha_new_child123", "info": map[string]any{"id": "ses_alpha_new_child123", "parentID": alphaNew, "directory": directory}}), + }, + }) + alpha.options.RuntimeDir = runtimeDir + alpha.options.SessionName = "uam-opencode-aaaa1111" + beta := newSupervisorFixture(t, fakeOpenCodeConfig{ + Directory: directory, + CreatedID: betaRoot, + ReadStdin: true, + EventGatePath: betaGate, + Events: []eventEnvelope{ + fakeEvent("session.created", map[string]any{"sessionID": "ses_beta_child123", "info": map[string]any{"id": "ses_beta_child123", "parentID": betaRoot, "directory": directory}}), + }, + }) + beta.options.RuntimeDir = runtimeDir + beta.options.SessionName = "uam-opencode-bbbb2222" + + const alphaPrompt = "Unicode π 你好\nsecond line\tkept\n" + alphaCommand, alphaInput := startSupervisorFixture(t, alpha, alphaPrompt) + betaCommand, betaInput := startSupervisorFixture(t, beta, "") + + awaitFakeRecord(t, alpha, "attach_start", 3*time.Second) + awaitFakeRecord(t, beta, "attach_start", 3*time.Second) + awaitProviderIdentity(t, alpha.options, alphaRoot, 3*time.Second) + awaitProviderIdentity(t, beta.options, betaRoot, 3*time.Second) + assertDistinctSupervisorBoundaries(t, alpha, beta, inheritedConfig) + + releaseFakeEvents(t, alphaGate) + releaseFakeEvents(t, betaGate) + awaitProviderIdentity(t, alpha.options, alphaNew, 3*time.Second) + assertProviderIdentity(t, beta.options, betaRoot) + if err := alphaInput.Close(); err != nil { + t.Fatal(err) + } + if err := betaInput.Close(); err != nil { + t.Fatal(err) + } + if err := waitCommand(t, alphaCommand, 3*time.Second); err != nil { + t.Fatalf("alpha supervisor: %v", err) + } + if err := waitCommand(t, betaCommand, 3*time.Second); err != nil { + t.Fatalf("beta supervisor: %v", err) + } + alphaAttach := alpha.recordsOfKind(t, "attach") + if len(alphaAttach) != 1 || alphaAttach[0].Input != alphaPrompt { + t.Fatalf("alpha attach input = %#v, want %q exactly once", alphaAttach, alphaPrompt) + } + betaAttach := beta.recordsOfKind(t, "attach") + if len(betaAttach) != 1 || betaAttach[0].Input != "" { + t.Fatalf("beta attach input = %#v, want no prompt", betaAttach) + } + + beforeRestart := len(alpha.records(t)) + writeFakeOpenCodeConfig(t, alpha.configPath, fakeOpenCodeConfig{ + Directory: directory, + ExistingID: alphaNew, + ReadStdin: true, + }) + alpha.options.ProviderSessionID = alphaNew + restartCommand, restartInput := startSupervisorFixture(t, alpha, "") + if err := restartInput.Close(); err != nil { + t.Fatal(err) + } + if err := waitCommand(t, restartCommand, 3*time.Second); err != nil { + t.Fatalf("alpha exact restart: %v", err) + } + alphaRecords := alpha.records(t) + assertExactRestartRecords(t, alphaRecords[beforeRestart:], alpha.options, alphaNew) + assertProviderIdentity(t, alpha.options, alphaNew) + assertProviderIdentity(t, beta.options, betaRoot) + assertLifecycleClean(t, alpha, nil) + assertLifecycleClean(t, beta, nil) +} + +func TestSupervisorExactRootValidation(t *testing.T) { + tests := []struct { + name string + config func(string) fakeOpenCodeConfig + resume bool + }{ + { + name: "new invalid provider ID", + config: func(directory string) fakeOpenCodeConfig { + return fakeOpenCodeConfig{Directory: directory, CreatedID: "bad/id"} + }, + }, + { + name: "new child session", + config: func(directory string) fakeOpenCodeConfig { + return fakeOpenCodeConfig{Directory: directory, CreatedID: "ses_child123", ResponseParentID: "ses_parent123"} + }, + }, + { + name: "new wrong directory", + config: func(directory string) fakeOpenCodeConfig { + return fakeOpenCodeConfig{Directory: directory, CreatedID: "ses_wrong123", ResponseDirectory: directory + "-other"} + }, + }, + { + name: "resume returns different ID", + config: func(directory string) fakeOpenCodeConfig { + return fakeOpenCodeConfig{Directory: directory, ExistingID: "ses_exact123", ResponseID: "ses_other123"} + }, + resume: true, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + directory := filepath.Clean(t.TempDir()) + fixture := newSupervisorFixture(t, tt.config(directory)) + if tt.resume { + fixture.options.ProviderSessionID = "ses_exact123" + } + err := runSupervisor(t.Context(), fixture.options) + if err == nil { + t.Fatal("runSupervisor accepted a non-exact root session") + } + if got := len(fixture.recordsOfKind(t, "attach_start")); got != 0 { + t.Fatalf("invalid root started attach: %#v", fixture.recordsOfKind(t, "attach_start")) + } + assertLifecycleClean(t, fixture, err) + }) + } +} + +func TestSupervisorPromptOrder(t *testing.T) { + fixture := newSupervisorFixture(t, fakeOpenCodeConfig{ + CreatedID: "ses_prompt123", + AttachDelayMillis: 75, + ReadStdin: true, + }) + reader, writer, err := os.Pipe() + if err != nil { + t.Fatal(err) + } + oldStdin := os.Stdin + os.Stdin = reader + t.Cleanup(func() { + os.Stdin = oldStdin + _ = reader.Close() + }) + want := "queued prompt\nwith bytes\tunchanged\n" + if _, err := io.WriteString(writer, want); err != nil { + t.Fatal(err) + } + if err := writer.Close(); err != nil { + t.Fatal(err) + } + if err := runSupervisor(t.Context(), fixture.options); err != nil { + t.Fatalf("runSupervisor: %v", err) + } + attach := fixture.recordsOfKind(t, "attach") + if len(attach) != 1 || attach[0].Input != want { + t.Fatalf("attach input = %#v, want %q exactly once", attach, want) + } + assertFakeChildrenReaped(t, fixture) +} + +func TestSupervisorLifecycleAttachForegroundTTY(t *testing.T) { + fixture := newSupervisorFixture(t, fakeOpenCodeConfig{ + CreatedID: "ses_tty123", + ReadStdinLine: true, + }) + command := exec.Command(os.Args[0], append([]string{"__supervisor_test"}, fixture.commandArgs()...)...) + command.Env = os.Environ() + terminal, err := pty.StartWithSize(command, &pty.Winsize{Cols: 80, Rows: 24}) + if err != nil { + t.Fatal(err) + } + done := make(chan error, 1) + go func() { done <- command.Wait() }() + waited := false + t.Cleanup(func() { + if waited { + _ = terminal.Close() + killFakeProcesses(fixture.records(t)) + return + } + _ = command.Process.Signal(syscall.SIGTERM) + select { + case <-done: + case <-time.After(3 * time.Second): + _ = command.Process.Kill() + <-done + } + _ = terminal.Close() + killFakeProcesses(fixture.records(t)) + }) + + awaitFakeRecord(t, fixture, "attach_start", 2*time.Second) + const want = "prompt from controlling tty\n" + if _, err := io.WriteString(terminal, want); err != nil { + t.Fatal(err) + } + attach := awaitFakeRecord(t, fixture, "attach", time.Second) + if attach.Input != want { + t.Fatalf("attach input = %q, want %q", attach.Input, want) + } + if attach.PGID != attach.PID { + t.Fatalf("attach process group = %d, want isolated foreground group %d", attach.PGID, attach.PID) + } + select { + case err := <-done: + waited = true + if err != nil { + t.Fatalf("PTY supervisor exit: %v", err) + } + case <-time.After(3 * time.Second): + t.Fatal("PTY supervisor did not exit after attach read the prompt") + } + assertLifecycleClean(t, fixture, nil) +} + +func TestSupervisorResumeStdinAddsNoPromptBytes(t *testing.T) { + fixture := newSupervisorFixture(t, fakeOpenCodeConfig{ + ExistingID: "ses_resume123", + AttachDelayMillis: 75, + ReadStdin: true, + }) + fixture.options.ProviderSessionID = "ses_resume123" + reader, writer, err := os.Pipe() + if err != nil { + t.Fatal(err) + } + oldStdin := os.Stdin + os.Stdin = reader + t.Cleanup(func() { + os.Stdin = oldStdin + _ = reader.Close() + }) + const want = "live resume input only\n" + if _, err := io.WriteString(writer, want); err != nil { + t.Fatal(err) + } + if err := writer.Close(); err != nil { + t.Fatal(err) + } + + if err := runSupervisor(t.Context(), fixture.options); err != nil { + t.Fatalf("runSupervisor: %v", err) + } + attach := fixture.recordsOfKind(t, "attach") + if len(attach) != 1 || attach[0].Input != want { + t.Fatalf("resume attach input = %#v, want only %q with no supervisor prompt", attach, want) + } + assertFakeChildrenReaped(t, fixture) +} + +func TestSupervisorSessionEvents(t *testing.T) { + directory := filepath.Clean(t.TempDir()) + events := []eventEnvelope{ + fakeEvent("session.created", map[string]any{"sessionID": "ses_created123", "info": map[string]any{"id": "ses_created123", "directory": directory}}), + fakeEvent("session.created", map[string]any{"sessionID": "ses_child123", "info": map[string]any{"id": "ses_child123", "parentID": "ses_created123", "directory": directory}}), + fakeEvent("session.created", map[string]any{"sessionID": "ses_wrong123", "info": map[string]any{"id": "ses_wrong123", "directory": directory + "-other"}}), + {Type: "session.created", Properties: json.RawMessage(`"malformed"`)}, + fakeEvent("session.created", map[string]any{"sessionID": "ses_new456", "info": map[string]any{"id": "ses_new456", "directory": directory}}), + fakeEvent("session.created", map[string]any{"sessionID": "ses_new456", "info": map[string]any{"id": "ses_new456", "directory": directory}}), + } + fixture := newSupervisorFixture(t, fakeOpenCodeConfig{ + Directory: directory, + CreatedID: "ses_created123", + Events: events, + AttachDelayMillis: 200, + }) + foreignName := "uam-opencode-feedface" + if err := session.WriteProviderIdentity(fixture.options.RuntimeDir, foreignName, "ses_foreign123"); err != nil { + t.Fatal(err) + } + if err := runSupervisor(t.Context(), fixture.options); err != nil { + t.Fatalf("runSupervisor: %v", err) + } + assertProviderIdentity(t, fixture.options, "ses_new456") + foreignID, err := session.ReadProviderIdentity(fixture.options.RuntimeDir, foreignName) + if err != nil || foreignID != "ses_foreign123" { + t.Fatalf("foreign identity = (%q, %v), want unchanged", foreignID, err) + } + assertFakeChildrenReaped(t, fixture) +} + +func TestSupervisorSessionRootReplayDoesNotRollBackIdentity(t *testing.T) { + fixture := newSupervisorFixture(t, fakeOpenCodeConfig{}) + if err := session.WriteProviderIdentity(fixture.options.RuntimeDir, fixture.options.SessionName, "ses_rootA123"); err != nil { + t.Fatal(err) + } + state := newSupervisorEventState(fixture.options, "ses_rootA123") + + if err := state.handleSessionCreated(fakeEvent("session.created", map[string]any{ + "sessionID": "ses_rootB123", + "info": map[string]any{"id": "ses_rootB123", "directory": fixture.options.Directory}, + }).Properties); err != nil { + t.Fatal(err) + } + if err := state.handleSessionCreated(fakeEvent("session.created", map[string]any{ + "sessionID": "ses_rootA123", + "info": map[string]any{"id": "ses_rootA123", "directory": fixture.options.Directory}, + }).Properties); err != nil { + t.Fatal(err) + } + + assertProviderIdentity(t, fixture.options, "ses_rootB123") + if state.activeRoot != "ses_rootB123" { + t.Fatalf("active root = %q, want ses_rootB123", state.activeRoot) + } +} + +func TestSupervisorSessionIdentityWriteFailureIsAdvisoryAndRetryable(t *testing.T) { + runtimeDir := filepath.Join(t.TempDir(), "runtime-\x1b[31mwarning") + if err := os.Mkdir(runtimeDir, 0o700); err != nil { + t.Fatal(err) + } + options := supervisorOptions{ + Directory: filepath.Clean(t.TempDir()), + SessionName: "uam-opencode-a1b2c3d4", + RuntimeDir: runtimeDir, + } + if err := session.WriteProviderIdentity(runtimeDir, options.SessionName, "ses_rootA123"); err != nil { + t.Fatal(err) + } + state := newSupervisorEventState(options, "ses_rootA123") + if err := os.Chmod(runtimeDir, 0o755); err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = os.Chmod(runtimeDir, 0o700) }) + + reader, writer, err := os.Pipe() + if err != nil { + t.Fatal(err) + } + oldStderr := os.Stderr + os.Stderr = writer + handleErr := state.handleSessionCreated(fakeEvent("session.created", map[string]any{ + "sessionID": "ses_rootB123", + "info": map[string]any{"id": "ses_rootB123", "directory": options.Directory}, + }).Properties) + os.Stderr = oldStderr + if err := writer.Close(); err != nil { + t.Fatal(err) + } + warning, err := io.ReadAll(reader) + if err != nil { + t.Fatal(err) + } + if err := reader.Close(); err != nil { + t.Fatal(err) + } + if handleErr != nil { + t.Fatalf("identity write failure stopped the event loop: %v", handleErr) + } + if state.activeRoot != "ses_rootA123" { + t.Fatalf("active root after failed persistence = %q, want prior root", state.activeRoot) + } + if strings.ContainsRune(string(warning), '\x1b') || !strings.Contains(string(warning), "continuing") { + t.Fatalf("advisory warning was not sanitized/actionable: %q", warning) + } + + if err := os.Chmod(runtimeDir, 0o700); err != nil { + t.Fatal(err) + } + assertProviderIdentity(t, options, "ses_rootA123") + if err := state.handleSessionCreated(fakeEvent("session.created", map[string]any{ + "sessionID": "ses_rootB123", + "info": map[string]any{"id": "ses_rootB123", "directory": options.Directory}, + }).Properties); err != nil { + t.Fatalf("replayed root did not retry persistence: %v", err) + } + assertProviderIdentity(t, options, "ses_rootB123") + if state.activeRoot != "ses_rootB123" { + t.Fatalf("active root after successful replay = %q", state.activeRoot) + } +} + +func TestSupervisorPermissionModes(t *testing.T) { + for _, yolo := range []bool{false, true} { + name := "safe" + if yolo { + name = "yolo" + } + t.Run(name, func(t *testing.T) { + directory := filepath.Clean(t.TempDir()) + events := []eventEnvelope{ + fakeEvent("session.created", map[string]any{"sessionID": "ses_child123", "info": map[string]any{"id": "ses_child123", "parentID": "ses_created123", "directory": directory}}), + fakeEvent("permission.asked", map[string]any{"id": "per_valid123", "sessionID": "ses_child123"}), + fakeEvent("permission.asked", map[string]any{"id": "per_valid123", "sessionID": "ses_child123"}), + fakeEvent("permission.asked", map[string]any{"id": "per_foreign123", "sessionID": "ses_foreign123"}), + fakeEvent("permission.asked", map[string]any{"id": "bad/id", "sessionID": "ses_child123"}), + } + fixture := newSupervisorFixture(t, fakeOpenCodeConfig{ + Directory: directory, + CreatedID: "ses_created123", + Events: events, + AttachDelayMillis: 200, + }) + fixture.options.Yolo = yolo + if err := runSupervisor(t.Context(), fixture.options); err != nil { + t.Fatalf("runSupervisor: %v", err) + } + replies := fixture.recordsOfKind(t, "permission") + if !yolo { + if len(replies) != 0 { + t.Fatalf("safe mode permission replies = %#v, want none", replies) + } + } else if len(replies) != 1 || replies[0].ID != "per_valid123" || replies[0].Body != `{"reply":"once"}` { + t.Fatalf("yolo permission replies = %#v, want one once reply", replies) + } + assertFakeChildrenReaped(t, fixture) + }) + } +} + +func TestSupervisorPermissionSafeModeLeavesEventVisible(t *testing.T) { + directory := filepath.Clean(t.TempDir()) + fixture := newSupervisorFixture(t, fakeOpenCodeConfig{ + Directory: directory, + CreatedID: "ses_created123", + ObserveEvents: true, + AttachDelayMillis: 100, + Events: []eventEnvelope{ + fakeEvent("permission.asked", map[string]any{"id": "per_visible123", "sessionID": "ses_created123"}), + }, + }) + + if err := runSupervisor(t.Context(), fixture.options); err != nil { + t.Fatalf("runSupervisor: %v", err) + } + observers := fixture.recordsOfKind(t, "observer") + if len(observers) != 1 || observers[0].ID != "per_visible123" { + t.Fatalf("second observer records = %#v, want visible permission event", observers) + } + if replies := fixture.recordsOfKind(t, "permission"); len(replies) != 0 { + t.Fatalf("safe supervisor emitted permission replies: %#v", replies) + } + assertLifecycleClean(t, fixture, nil) +} + +func TestSupervisorPermissionActiveRootTree(t *testing.T) { + directory := filepath.Clean(t.TempDir()) + fixture := newSupervisorFixture(t, fakeOpenCodeConfig{ + Directory: directory, + CreatedID: "ses_created123", + AttachDelayMillis: 200, + Events: []eventEnvelope{ + fakeEvent("session.created", map[string]any{"sessionID": "ses_oldchild123", "info": map[string]any{"id": "ses_oldchild123", "parentID": "ses_created123", "directory": directory}}), + fakeEvent("session.created", map[string]any{"sessionID": "ses_newroot123", "info": map[string]any{"id": "ses_newroot123", "directory": directory}}), + fakeEvent("permission.asked", map[string]any{"id": "per_stale123", "sessionID": "ses_oldchild123"}), + fakeEvent("session.created", map[string]any{"sessionID": "ses_newchild123", "info": map[string]any{"id": "ses_newchild123", "parentID": "ses_newroot123", "directory": directory}}), + fakeEvent("permission.asked", map[string]any{"id": "per_current123", "sessionID": "ses_newchild123"}), + }, + }) + fixture.options.Yolo = true + if err := runSupervisor(t.Context(), fixture.options); err != nil { + t.Fatalf("runSupervisor: %v", err) + } + replies := fixture.recordsOfKind(t, "permission") + if len(replies) != 1 || replies[0].ID != "per_current123" { + t.Fatalf("active-root permission replies = %#v, want current tree only", replies) + } + assertProviderIdentity(t, fixture.options, "ses_newroot123") + assertLifecycleClean(t, fixture, nil) +} + +func TestSupervisorEventReconnect(t *testing.T) { + directory := filepath.Clean(t.TempDir()) + fixture := newSupervisorFixture(t, fakeOpenCodeConfig{ + Directory: directory, + CreatedID: "ses_created123", + DisconnectSSE: true, + AttachDelayMillis: 260, + Events: []eventEnvelope{ + fakeEvent("session.created", map[string]any{"sessionID": "ses_new456", "info": map[string]any{"id": "ses_new456", "directory": directory}}), + fakeEvent("permission.asked", map[string]any{"id": "per_valid123", "sessionID": "ses_new456"}), + }, + }) + fixture.options.Yolo = true + if err := runSupervisor(t.Context(), fixture.options); err != nil { + t.Fatalf("runSupervisor: %v", err) + } + if got := len(fixture.recordsOfKind(t, "event")); got < 3 { + t.Fatalf("event subscriptions = %d, want at least 3 reconnects", got) + } + if got := len(fixture.recordsOfKind(t, "permission")); got != 1 { + t.Fatalf("permission replies after replay = %d, want 1", got) + } + assertProviderIdentity(t, fixture.options, "ses_new456") + assertFakeChildrenReaped(t, fixture) +} + +func TestSupervisorReplayGapRecovery(t *testing.T) { + directory := filepath.Clean(t.TempDir()) + fixture := newSupervisorFixture(t, fakeOpenCodeConfig{ + Directory: directory, + CreatedID: "ses_root_before_gap123", + CreatedUpdated: 100, + DisconnectSSE: true, + DisconnectDelayMS: 75, + AttachDelayMillis: 260, + ListSessions: []fakeSession{ + {ID: "ses_root_before_gap123", Directory: directory, Created: 100, Updated: 100}, + }, + GapSession: fakeSession{ID: "ses_root_after_gap123", Directory: directory}, + }) + foreignName := "uam-opencode-feedface" + if err := session.WriteProviderIdentity(fixture.options.RuntimeDir, foreignName, "ses_foreign123"); err != nil { + t.Fatal(err) + } + + if err := runSupervisor(t.Context(), fixture.options); err != nil { + t.Fatalf("runSupervisor: %v", err) + } + assertProviderIdentity(t, fixture.options, "ses_root_after_gap123") + foreignID, err := session.ReadProviderIdentity(fixture.options.RuntimeDir, foreignName) + if err != nil || foreignID != "ses_foreign123" { + t.Fatalf("foreign identity = (%q, %v), want unchanged", foreignID, err) + } + if got := len(fixture.recordsOfKind(t, "list")); got == 0 { + t.Fatal("reconnect did not query root sessions") + } + assertLifecycleClean(t, fixture, nil) +} + +func TestSupervisorReplayGapRecoveryWarnsAndRetainsOnZeroOrAmbiguousCandidates(t *testing.T) { + future := time.Now().Add(10 * time.Second).UnixMilli() + for _, tt := range []struct { + name string + sessions []fakeSession + }{ + {name: "zero", sessions: []fakeSession{{ID: "ses_root_before_gap123", Created: 100, Updated: 100}}}, + {name: "preexisting newer root", sessions: []fakeSession{{ID: "ses_other_existing123", Created: 50, Updated: future}}}, + {name: "ambiguous", sessions: []fakeSession{{ID: "ses_new_a123", Created: future, Updated: future}, {ID: "ses_new_b123", Created: future + 1, Updated: future + 1}}}, + } { + t.Run(tt.name, func(t *testing.T) { + directory := filepath.Clean(t.TempDir()) + for index := range tt.sessions { + tt.sessions[index].Directory = directory + } + fixture := newSupervisorFixture(t, fakeOpenCodeConfig{ + Directory: directory, + CreatedID: "ses_root_before_gap123", + CreatedUpdated: 100, + DisconnectSSE: true, + AttachDelayMillis: 150, + ListSessions: tt.sessions, + }) + warning := captureStderr(t, func() error { return runSupervisor(t.Context(), fixture.options) }) + if !strings.Contains(warning, "recovery") || strings.ContainsRune(warning, '\x1b') { + t.Fatalf("recovery warning = %q", warning) + } + assertProviderIdentity(t, fixture.options, "ses_root_before_gap123") + assertLifecycleClean(t, fixture, nil) + }) + } +} + +func TestSupervisorReplayGapIdentityFailureIsAdvisoryAndRetryable(t *testing.T) { + runtimeDir := filepath.Clean(t.TempDir()) + if err := os.Chmod(runtimeDir, 0o700); err != nil { + t.Fatal(err) + } + options := supervisorOptions{ + Directory: filepath.Clean(t.TempDir()), + SessionName: "uam-opencode-a1b2c3d4", + RuntimeDir: runtimeDir, + } + if err := session.WriteProviderIdentity(runtimeDir, options.SessionName, "ses_root_before_gap123"); err != nil { + t.Fatal(err) + } + client := newSessionListTestClient(t, options.Directory, []fakeSession{ + {ID: "ses_root_after_gap123", Directory: options.Directory, Created: 200, Updated: 200}, + }) + state := newSupervisorEventState(options, "ses_root_before_gap123", 100) + if err := os.Chmod(runtimeDir, 0o755); err != nil { + t.Fatal(err) + } + warning := captureStderr(t, func() error { return state.recoverAfterReconnect(t.Context(), client, time.UnixMilli(150)) }) + if !strings.Contains(warning, "continuing") || state.activeRoot != "ses_root_before_gap123" { + t.Fatalf("failed recovery warning/root = %q / %q", warning, state.activeRoot) + } + if err := os.Chmod(runtimeDir, 0o700); err != nil { + t.Fatal(err) + } + if err := state.recoverAfterReconnect(t.Context(), client, time.UnixMilli(150)); err != nil { + t.Fatalf("retry recovery: %v", err) + } + assertProviderIdentity(t, options, "ses_root_after_gap123") +} + +func TestSupervisorReplayGapRecoveryAPIErrorIsAdvisory(t *testing.T) { + directory := filepath.Clean(t.TempDir()) + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + http.Error(w, "bad\x1b[31mresponse", http.StatusInternalServerError) + })) + t.Cleanup(server.Close) + client, err := newAPIClient(server.URL, "uam", "password", directory, server.Client()) + if err != nil { + t.Fatal(err) + } + state := newSupervisorEventState(supervisorOptions{Directory: directory}, "ses_root_before_gap123", 100) + warning := captureStderr(t, func() error { + return state.recoverAfterReconnect(t.Context(), client, time.UnixMilli(150)) + }) + if state.activeRoot != "ses_root_before_gap123" || !strings.Contains(warning, "recovery failed") || strings.ContainsRune(warning, '\x1b') { + t.Fatalf("API recovery failure changed root or leaked controls: root=%q warning=%q", state.activeRoot, warning) + } +} + +func TestSupervisorPermissionIgnoredEventsWarnSafely(t *testing.T) { + options := supervisorOptions{Directory: filepath.Clean(t.TempDir()), Yolo: true} + state := newSupervisorEventState(options, "ses_root123") + state.replied["per_duplicate123"] = struct{}{} + for _, tt := range []struct { + name string + event eventEnvelope + }{ + {name: "malformed", event: eventEnvelope{Type: "permission.asked", Properties: json.RawMessage(`{`)}}, + {name: "foreign", event: fakeEvent("permission.asked", map[string]any{"id": "per_foreign123", "sessionID": "ses_foreign123"})}, + {name: "duplicate", event: fakeEvent("permission.asked", map[string]any{"id": "per_duplicate123", "sessionID": "ses_root123"})}, + } { + t.Run(tt.name, func(t *testing.T) { + warning := captureStderr(t, func() error { return state.handle(t.Context(), nil, tt.event) }) + if !strings.Contains(warning, "ignored") || strings.ContainsRune(warning, '\x1b') || len([]rune(warning)) > 512 { + t.Fatalf("ignored-event warning = %q", warning) + } + }) + } +} + +func TestSupervisorReconnectBackoff(t *testing.T) { + want := []time.Duration{25 * time.Millisecond, 50 * time.Millisecond, 100 * time.Millisecond, 200 * time.Millisecond, 400 * time.Millisecond, 400 * time.Millisecond} + for attempt, duration := range want { + if got := reconnectBackoff(attempt); got != duration { + t.Fatalf("reconnectBackoff(%d) = %s, want %s", attempt, got, duration) + } + } +} + +func TestSupervisorLifecycleStartup(t *testing.T) { + t.Run("readiness timeout", func(t *testing.T) { + fixture := newSupervisorFixture(t, fakeOpenCodeConfig{HealthNeverReady: true}) + ctx, cancel := context.WithTimeout(t.Context(), 175*time.Millisecond) + defer cancel() + err := runSupervisor(ctx, fixture.options) + if err == nil || !strings.Contains(strings.ToLower(err.Error()), "readiness timed out") { + t.Fatalf("readiness error = %v", err) + } + assertLifecycleClean(t, fixture, err) + }) + + for _, tt := range []struct { + name string + config fakeOpenCodeConfig + resume bool + }{ + {name: "event handshake stalls", config: fakeOpenCodeConfig{StallEvent: true}}, + {name: "session create stalls", config: fakeOpenCodeConfig{StallCreate: true}}, + {name: "exact session lookup stalls", config: fakeOpenCodeConfig{ExistingID: "ses_resume123", StallGet: true}, resume: true}, + } { + t.Run(tt.name, func(t *testing.T) { + fixture := newSupervisorFixture(t, tt.config) + if tt.resume { + fixture.options.ProviderSessionID = "ses_resume123" + } + ctx, cancel := context.WithTimeout(t.Context(), 175*time.Millisecond) + defer cancel() + started := time.Now() + err := runSupervisor(ctx, fixture.options) + if !errors.Is(err, context.DeadlineExceeded) { + t.Fatalf("startup stall error = %v, want deadline exceeded", err) + } + if elapsed := time.Since(started); elapsed > 2*time.Second { + t.Fatalf("startup stall took %s, want bounded", elapsed) + } + if got := fixture.recordsOfKind(t, "attach_start"); len(got) != 0 { + t.Fatalf("stalled startup launched attach: %#v", got) + } + assertLifecycleClean(t, fixture, err) + }) + } + + t.Run("three pre-readiness process failures use three attempts", func(t *testing.T) { + fixture := newSupervisorFixture(t, fakeOpenCodeConfig{FailServeAttempts: 2}) + if err := runSupervisor(t.Context(), fixture.options); err != nil { + t.Fatalf("runSupervisor: %v", err) + } + if got := len(fixture.recordsOfKind(t, "serve_attempt")); got != 2 { + t.Fatalf("failed serve attempts = %d, want 2", got) + } + if got := len(fixture.recordsOfKind(t, "serve")); got != 1 { + t.Fatalf("successful serve attempts = %d, want 1", got) + } + assertLifecycleClean(t, fixture, nil) + }) + + t.Run("failed attempt process group is reaped before retry succeeds", func(t *testing.T) { + fixture := newSupervisorFixture(t, fakeOpenCodeConfig{ + FailServeAttempts: 1, + FailServeGrandchild: true, + }) + t.Cleanup(func() { killFakeProcesses(fixture.records(t)) }) + if err := runSupervisor(t.Context(), fixture.options); err != nil { + t.Fatalf("runSupervisor: %v", err) + } + + attempts := fixture.recordsOfKind(t, "serve_attempt") + serves := fixture.recordsOfKind(t, "serve") + descendants := fixture.recordsOfKind(t, "serve_attempt_grandchild") + if len(attempts) != 1 || len(serves) != 1 || len(descendants) != 1 { + t.Fatalf("attempt/serve/descendant records = %#v / %#v / %#v, want one each", attempts, serves, descendants) + } + if attempts[0].Port == serves[0].Port { + t.Fatalf("retry reused failed-attempt port %d", serves[0].Port) + } + if attempts[0].PGID != attempts[0].PID || descendants[0].PGID != attempts[0].PGID { + t.Fatalf("failed attempt descendant escaped process group: attempt=%#v descendant=%#v", attempts[0], descendants[0]) + } + assertLifecycleClean(t, fixture, nil) + }) + + t.Run("genuine first-attempt bind collision selects a different port", func(t *testing.T) { + fixture := newSupervisorFixture(t, fakeOpenCodeConfig{BindCollisionOnce: true}) + if err := runSupervisor(t.Context(), fixture.options); err != nil { + t.Fatalf("runSupervisor: %v", err) + } + collisions := fixture.recordsOfKind(t, "serve_bind_collision") + serves := fixture.recordsOfKind(t, "serve") + if len(collisions) != 1 || len(serves) != 1 { + t.Fatalf("collision/serve records = %#v / %#v, want one each", collisions, serves) + } + if collisions[0].Port == serves[0].Port { + t.Fatalf("retry reused collided port %d", serves[0].Port) + } + assertLifecycleClean(t, fixture, nil) + }) + + t.Run("server exits before attach", func(t *testing.T) { + fixture := newSupervisorFixture(t, fakeOpenCodeConfig{ + ServerExitMillis: 100, + CreateDelayMillis: 500, + }) + err := runSupervisor(t.Context(), fixture.options) + if err == nil { + t.Fatal("runSupervisor succeeded after server exited before attach") + } + if got := fixture.recordsOfKind(t, "attach_start"); len(got) != 0 { + t.Fatalf("attach started after server failure: %#v", got) + } + var exitErr *ExitError + if errors.As(err, &exitErr) { + t.Fatalf("server failure returned attach ExitError: %v", err) + } + assertLifecycleClean(t, fixture, err) + }) +} + +func TestSupervisorStartupCheckpointRejectsExpiredDeadline(t *testing.T) { + ctx, cancel := context.WithCancel(t.Context()) + cancel() + if err := requireActiveStartup(ctx); !errors.Is(err, context.Canceled) { + t.Fatalf("requireActiveStartup() = %v, want context.Canceled", err) + } +} + +func TestSupervisorStartupExpiryAfterAttachLaunchReapsAttach(t *testing.T) { + fixture := newSupervisorFixture(t, fakeOpenCodeConfig{AttachDelayMillis: 5000}) + command := fixture.options.Command.command( + context.Background(), + "attach", + "http://127.0.0.1:0", + "--dir", + fixture.options.Directory, + "--session", + "ses_created123", + ) + command.Dir = fixture.options.Directory + command.Env = serverEnvironment( + fakeSupervisorEnvironment(fixture), + openCodeServerUsername, + strings.Repeat("a", 64), + ) + + attach, err := startAttachProcess(command, nil) + if err != nil { + t.Fatalf("startAttachProcess: %v", err) + } + t.Cleanup(func() { + select { + case <-attach.done: + default: + terminateAndReap(attach) + } + }) + awaitFakeRecord(t, fixture, "attach_start", 2*time.Second) + + ctx, cancel := context.WithDeadline(t.Context(), time.Now().Add(-time.Second)) + defer cancel() + err = requireActiveStartupOrReapAttach(ctx, attach) + if !errors.Is(err, context.DeadlineExceeded) { + t.Fatalf("expired attach startup error = %v, want context.DeadlineExceeded", err) + } + select { + case <-attach.done: + case <-time.After(2 * time.Second): + t.Fatal("attach process was not reaped after startup expired") + } + assertLifecycleClean(t, fixture, err) +} + +func TestSupervisorLifecycleAttachAndServerExit(t *testing.T) { + t.Run("server dies while attach is active", func(t *testing.T) { + fixture := newSupervisorFixture(t, fakeOpenCodeConfig{ + AttachDelayMillis: 2000, + ServerExitMillis: 300, + }) + err := runSupervisor(t.Context(), fixture.options) + if err == nil || !strings.Contains(strings.ToLower(err.Error()), "server exited") { + t.Fatalf("server death error = %v", err) + } + if got := len(fixture.recordsOfKind(t, "attach_start")); got != 1 { + t.Fatalf("attach start records = %#v, want one", fixture.recordsOfKind(t, "attach_start")) + } + assertLifecycleClean(t, fixture, err) + }) + + for _, tt := range []struct { + name string + exit int + signal bool + wantCode int + wantError bool + }{ + {name: "attach exit zero", exit: 0}, + {name: "attach exit 23", exit: 23, wantCode: 23, wantError: true}, + {name: "signaled attach maps to one", signal: true, wantCode: 1, wantError: true}, + } { + t.Run(tt.name, func(t *testing.T) { + fixture := newSupervisorFixture(t, fakeOpenCodeConfig{AttachExit: tt.exit, AttachSignal: tt.signal}) + err := runSupervisor(t.Context(), fixture.options) + if !tt.wantError { + if err != nil { + t.Fatalf("runSupervisor: %v", err) + } + } else { + var exitErr *ExitError + if !errors.As(err, &exitErr) || exitErr.Code != tt.wantCode { + t.Fatalf("attach exit error = %#v, want code %d", err, tt.wantCode) + } + } + assertLifecycleClean(t, fixture, err) + }) + } +} + +func TestSupervisorLifecycleCancellation(t *testing.T) { + t.Run("context cancellation", func(t *testing.T) { + fixture := newSupervisorFixture(t, fakeOpenCodeConfig{AttachDelayMillis: 2000}) + ctx, cancel := context.WithCancel(t.Context()) + result := make(chan error, 1) + go func() { result <- runSupervisor(ctx, fixture.options) }() + awaitFakeRecord(t, fixture, "attach_start", 2*time.Second) + cancel() + err := awaitSupervisorResult(t, result, 3*time.Second) + if !errors.Is(err, context.Canceled) { + t.Fatalf("cancellation error = %v, want context.Canceled", err) + } + assertLifecycleClean(t, fixture, err) + }) + + t.Run("simultaneous attach exit and cancellation returns exact attach exit", func(t *testing.T) { + ctx, cancel := context.WithCancel(t.Context()) + cancel() + attach := completedManagedProcess(t, 23) + server := &runningServer{process: &managedProcess{done: make(chan struct{})}, logs: newByteRing(1)} + + err, ready := readySupervisorOutcome(ctx, server, attach, "password") + var exitErr *ExitError + if !ready || !errors.As(err, &exitErr) || exitErr.Code != 23 { + t.Fatalf("simultaneous attach/cancel result = (%v, %t), want exact exit 23", err, ready) + } + }) +} + +func TestSupervisorOutcomeArbitrationServerBeatsAttach(t *testing.T) { + attach := completedManagedProcess(t, 23) + serverProcess := &managedProcess{done: make(chan struct{}), err: errors.New("server sentinel")} + close(serverProcess.done) + server := &runningServer{process: serverProcess, logs: newByteRing(1)} + + err, ready := readySupervisorOutcome(t.Context(), server, attach, "password") + var exitErr *ExitError + if !ready || errors.As(err, &exitErr) || !strings.Contains(err.Error(), "server sentinel") { + t.Fatalf("simultaneous server/attach result = (%v, %t), want exact server error", err, ready) + } +} + +func completedManagedProcess(t *testing.T, exitCode int) *managedProcess { + t.Helper() + command := exec.Command("/bin/sh", "-c", "exit "+strconv.Itoa(exitCode)) + err := command.Run() + if err == nil { + t.Fatalf("helper exit %d unexpectedly succeeded", exitCode) + } + process := &managedProcess{done: make(chan struct{}), err: err} + close(process.done) + return process +} + +func TestSupervisorStartupCanceledBeforeFirstAttempt(t *testing.T) { + fixture := newSupervisorFixture(t, fakeOpenCodeConfig{}) + ctx, cancel := context.WithCancel(t.Context()) + cancel() + + err := runSupervisor(ctx, fixture.options) + if err != context.Canceled { + t.Fatalf("canceled startup error = %v, want exact context.Canceled", err) + } + if records := fixture.records(t); len(records) != 0 { + t.Fatalf("canceled startup launched a child or made a request: %#v", records) + } +} + +func TestSupervisorLifecycleSignals(t *testing.T) { + for _, sig := range []syscall.Signal{syscall.SIGHUP, syscall.SIGTERM} { + t.Run(sig.String(), func(t *testing.T) { + fixture := newSupervisorFixture(t, fakeOpenCodeConfig{AttachDelayMillis: 2000}) + command := exec.Command(os.Args[0], append([]string{"__supervisor_test"}, fixture.commandArgs()...)...) + command.Env = os.Environ() + var output strings.Builder + command.Stdout = &output + command.Stderr = &output + if err := command.Start(); err != nil { + t.Fatal(err) + } + awaitFakeRecord(t, fixture, "attach_start", 2*time.Second) + if err := command.Process.Signal(sig); err != nil { + t.Fatal(err) + } + if err := waitCommand(t, command, 4*time.Second); err != nil { + t.Fatalf("supervisor signal exit: %v; output=%q", err, output.String()) + } + if strings.Contains(output.String(), testSupervisorPassword) { + t.Fatalf("signal output leaked password: %q", output.String()) + } + assertLifecycleClean(t, fixture, nil) + }) + } +} + +func TestSupervisorLifecycleStuckChildrenEscalate(t *testing.T) { + fixture := newSupervisorFixture(t, fakeOpenCodeConfig{ + AttachDelayMillis: 5000, + IgnoreTerminate: true, + }) + ctx, cancel := context.WithCancel(t.Context()) + result := make(chan error, 1) + go func() { result <- runSupervisor(ctx, fixture.options) }() + awaitFakeRecord(t, fixture, "attach_start", 2*time.Second) + started := time.Now() + cancel() + err := awaitSupervisorResult(t, result, 4*time.Second) + elapsed := time.Since(started) + if !errors.Is(err, context.Canceled) { + t.Fatalf("stuck child cancellation error = %v", err) + } + if elapsed < childCleanupTimeout-150*time.Millisecond || elapsed > childCleanupTimeout+1500*time.Millisecond { + t.Fatalf("stuck child cleanup took %s, want bounded escalation near %s", elapsed, childCleanupTimeout) + } + assertLifecycleClean(t, fixture, err) +} + +func TestSupervisorLifecycleKillsProcessGroupGrandchildren(t *testing.T) { + fixture := newSupervisorFixture(t, fakeOpenCodeConfig{ + AttachDelayMillis: 5000, + Grandchildren: true, + }) + ctx, cancel := context.WithCancel(t.Context()) + result := make(chan error, 1) + go func() { result <- runSupervisor(ctx, fixture.options) }() + t.Cleanup(func() { killFakeProcesses(fixture.records(t)) }) + serve := awaitFakeRecord(t, fixture, "serve", 2*time.Second) + attach := awaitFakeRecord(t, fixture, "attach_start", 2*time.Second) + awaitFakeRecord(t, fixture, "serve_grandchild", 2*time.Second) + awaitFakeRecord(t, fixture, "attach_grandchild", 2*time.Second) + + started := time.Now() + cancel() + if serve.PGID != serve.PID || attach.PGID != attach.PID || serve.PGID == attach.PGID { + killFakeProcesses(fixture.records(t)) + _ = awaitSupervisorResult(t, result, 3*time.Second) + t.Fatalf("children do not own distinct process groups: serve=%#v attach=%#v", serve, attach) + } + err := awaitSupervisorResult(t, result, 4*time.Second) + elapsed := time.Since(started) + if !errors.Is(err, context.Canceled) { + t.Fatalf("process-group cleanup result = %v, want context.Canceled", err) + } + if elapsed < childCleanupTimeout-150*time.Millisecond || elapsed > childCleanupTimeout+1500*time.Millisecond { + t.Fatalf("process-group cleanup took %s, want bounded TERM/KILL near %s", elapsed, childCleanupTimeout) + } + assertLifecycleClean(t, fixture, err) +} + +func TestSupervisorLifecycleBoundedServerLog(t *testing.T) { + fixture := newSupervisorFixture(t, fakeOpenCodeConfig{ + AttachDelayMillis: 2000, + ServerExitMillis: 300, + NoisyServerLog: true, + }) + err := runSupervisor(t.Context(), fixture.options) + if err == nil { + t.Fatal("runSupervisor succeeded after noisy server exited") + } + if strings.Contains(err.Error(), testSupervisorPassword) || !strings.Contains(err.Error(), "") { + t.Fatalf("server error was not sanitized: %q", err) + } + if got := len([]rune(err.Error())); got > 700 { + t.Fatalf("server error has %d runes, want bounded diagnostic", got) + } + + ring := newByteRing(8) + _, _ = ring.Write([]byte("012345")) + _, _ = ring.Write([]byte("abcdef")) + if got := string(ring.Bytes()); got != "45abcdef" { + t.Fatalf("ring contents = %q, want newest 8 bytes", got) + } + assertLifecycleClean(t, fixture, err) +} + +func TestSupervisorLifecycleCredentialRedaction(t *testing.T) { + fixture := newSupervisorFixture(t, fakeOpenCodeConfig{HealthLeaksSecret: true}) + err := runSupervisor(t.Context(), fixture.options) + if err == nil { + t.Fatal("runSupervisor accepted secret as a server version") + } + if !strings.Contains(err.Error(), "") || regexp.MustCompile(`[0-9a-f]{64}`).MatchString(err.Error()) { + t.Fatalf("server-controlled version leaked generated credential: %q", err) + } + assertLifecycleClean(t, fixture, err) +} + +func TestSupervisorSafeServerLogExcerptRedactsSanitizedPassword(t *testing.T) { + const password = "split-password" + for _, tt := range []struct { + name string + log string + }{ + {name: "ANSI split", log: "before split-\x1b[31mpassword after"}, + {name: "control split", log: "before split-\x00password after"}, + } { + t.Run(tt.name, func(t *testing.T) { + got := safeServerLogExcerpt([]byte(tt.log), password) + if strings.Contains(got, password) || !strings.Contains(got, "") { + t.Fatalf("safeServerLogExcerpt() = %q, want sanitized password redacted", got) + } + }) + } +} + +func fakeEvent(eventType string, properties any) eventEnvelope { + data, err := json.Marshal(properties) + if err != nil { + panic(err) + } + return eventEnvelope{Type: eventType, Properties: data} +} + +func captureStderr(t *testing.T, action func() error) string { + t.Helper() + reader, writer, err := os.Pipe() + if err != nil { + t.Fatal(err) + } + oldStderr := os.Stderr + os.Stderr = writer + actionErr := action() + os.Stderr = oldStderr + if err := writer.Close(); err != nil { + t.Fatal(err) + } + data, readErr := io.ReadAll(reader) + if closeErr := reader.Close(); closeErr != nil { + t.Fatal(closeErr) + } + if readErr != nil { + t.Fatal(readErr) + } + if actionErr != nil { + t.Fatalf("captured action: %v", actionErr) + } + return string(data) +} + +func newSessionListTestClient(t *testing.T, directory string, sessions []fakeSession) *apiClient { + t.Helper() + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, request *http.Request) { + if request.Method != http.MethodGet || request.URL.Path != "/session" { + http.NotFound(w, request) + return + } + w.Header().Set("Content-Type", "application/json") + if err := json.NewEncoder(w).Encode(sessions); err != nil { + t.Error(err) + } + })) + t.Cleanup(server.Close) + client, err := newAPIClient(server.URL, "uam", "password", directory, server.Client()) + if err != nil { + t.Fatal(err) + } + return client +} + +func assertProviderIdentity(t *testing.T, options supervisorOptions, want string) { + t.Helper() + got, err := session.ReadProviderIdentity(options.RuntimeDir, options.SessionName) + if err != nil { + t.Fatal(err) + } + if got != want { + t.Fatalf("provider identity = %q, want %q", got, want) + } +} + +func assertServeAndAttachRecords(t *testing.T, fixture supervisorFixture, wantID string) { + t.Helper() + serves := fixture.recordsOfKind(t, "serve") + if len(serves) != 1 || !reflect.DeepEqual(serves[0].Args[:3], []string{"--hostname", "127.0.0.1", "--port"}) || serves[0].Port < 1 { + t.Fatalf("serve records = %#v", serves) + } + attaches := fixture.recordsOfKind(t, "attach") + if len(attaches) != 1 { + t.Fatalf("attach records = %#v", attaches) + } + wantArgs := []string{"http://127.0.0.1:" + strconv.Itoa(serves[0].Port), "--dir", fixture.options.Directory, "--session", wantID} + if !reflect.DeepEqual(attaches[0].Args, wantArgs) { + t.Fatalf("attach argv = %#v, want %#v", attaches[0].Args, wantArgs) + } + for _, arg := range attaches[0].Args { + if arg == "-c" || arg == "--continue" { + t.Fatalf("attach used fallback flag in %#v", attaches[0].Args) + } + } +} + +func assertNoSupervisorSecret(t *testing.T, fixture supervisorFixture) { + t.Helper() + for _, record := range fixture.records(t) { + data, err := json.Marshal(record) + if err != nil { + t.Fatal(err) + } + if strings.Contains(string(data), testSupervisorPassword) { + t.Fatalf("record leaked fixed password: %s", data) + } + if (strings.HasPrefix(record.Kind, "serve") || strings.HasPrefix(record.Kind, "attach")) && (!record.PasswordSet || !record.PasswordReplaced) { + t.Fatalf("child did not receive replaced credentials: %#v", record) + } + } + entries, err := os.ReadDir(fixture.options.RuntimeDir) + if err != nil { + t.Fatal(err) + } + for _, entry := range entries { + data, err := os.ReadFile(filepath.Join(fixture.options.RuntimeDir, entry.Name())) + if err != nil { + t.Fatal(err) + } + if strings.Contains(string(data), testSupervisorPassword) { + t.Fatalf("runtime file %s leaked fixed password", entry.Name()) + } + } +} + +func assertLifecycleClean(t *testing.T, fixture supervisorFixture, returned error) { + t.Helper() + if returned != nil && strings.Contains(returned.Error(), testSupervisorPassword) { + t.Fatalf("supervisor error leaked fixed password: %q", returned) + } + assertNoSupervisorSecret(t, fixture) + assertFakeChildrenReaped(t, fixture) + for _, record := range fixture.records(t) { + if record.Port < 1 { + continue + } + connection, err := net.DialTimeout("tcp", net.JoinHostPort("127.0.0.1", strconv.Itoa(record.Port)), 75*time.Millisecond) + if err == nil { + _ = connection.Close() + t.Fatalf("fake server listener on port %d remains open", record.Port) + } + } + for _, path := range []string{fixture.configPath, fixture.recordPath} { + data, err := os.ReadFile(path) + if err != nil && !os.IsNotExist(err) { + t.Fatal(err) + } + if strings.Contains(string(data), testSupervisorPassword) { + t.Fatalf("fixture file %s leaked fixed password", path) + } + } +} + +func awaitFakeRecord(t *testing.T, fixture supervisorFixture, kind string, timeout time.Duration) fakeOpenCodeRecord { + t.Helper() + deadline := time.Now().Add(timeout) + for time.Now().Before(deadline) { + records := fixture.recordsOfKind(t, kind) + if len(records) > 0 { + return records[len(records)-1] + } + time.Sleep(10 * time.Millisecond) + } + t.Fatalf("timed out after %s waiting for fake record %q; records=%#v", timeout, kind, fixture.records(t)) + return fakeOpenCodeRecord{} +} + +func awaitSupervisorResult(t *testing.T, result <-chan error, timeout time.Duration) error { + t.Helper() + select { + case err := <-result: + return err + case <-time.After(timeout): + t.Fatalf("timed out after %s waiting for supervisor", timeout) + return nil + } +} + +func waitCommand(t *testing.T, command *exec.Cmd, timeout time.Duration) error { + t.Helper() + done := make(chan error, 1) + go func() { done <- command.Wait() }() + select { + case err := <-done: + return err + case <-time.After(timeout): + _ = command.Process.Kill() + <-done + t.Fatalf("timed out after %s waiting for command", timeout) + return nil + } +} + +func assertFakeChildrenReaped(t *testing.T, fixture supervisorFixture) { + t.Helper() + for _, record := range fixture.records(t) { + if !strings.HasPrefix(record.Kind, "serve") && !strings.HasPrefix(record.Kind, "attach") || record.PID <= 0 { + continue + } + deadline := time.Now().Add(time.Second) + for { + err := syscall.Kill(record.PID, 0) + if errors.Is(err, syscall.ESRCH) { + break + } + if time.Now().After(deadline) { + t.Fatalf("fake child pid %d was not reaped: %v", record.PID, err) + } + time.Sleep(10 * time.Millisecond) + } + } +} + +func killFakeProcesses(records []fakeOpenCodeRecord) { + for _, record := range records { + if record.PID > 0 && record.PID != os.Getpid() { + _ = syscall.Kill(record.PID, syscall.SIGKILL) + } + } +} + +var fakeRecordMu sync.Mutex + +func fakeOpenCodeConfigFromEnv() (fakeOpenCodeConfig, error) { + data, err := os.ReadFile(os.Getenv(fakeConfigEnv)) + if err != nil { + return fakeOpenCodeConfig{}, err + } + var config fakeOpenCodeConfig + if err := json.Unmarshal(data, &config); err != nil { + return fakeOpenCodeConfig{}, err + } + return config, nil +} + +func writeFakeRecord(record fakeOpenCodeRecord) error { + data, err := json.Marshal(record) + if err != nil { + return err + } + fakeRecordMu.Lock() + defer fakeRecordMu.Unlock() + file, err := os.OpenFile(os.Getenv(fakeRecordEnv), os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o600) + if err != nil { + return err + } + defer func() { _ = file.Close() }() + _, err = file.Write(append(data, '\n')) + return err +} + +func fakeChildCredentialRecord(kind string, args []string) fakeOpenCodeRecord { + password := os.Getenv("OPENCODE_SERVER_PASSWORD") + pgid, _ := syscall.Getpgid(os.Getpid()) + return fakeOpenCodeRecord{ + Kind: kind, + PID: os.Getpid(), + PGID: pgid, + Args: append([]string(nil), args...), + PasswordSet: password != "", + PasswordReplaced: password != testSupervisorPassword && len(password) == 64, + CredentialHash: fakeHash(password), + ConfigHash: fakeHash(os.Getenv("OPENCODE_CONFIG_" + "CONTENT")), + } +} + +func fakeOpenCodeServe(args []string) int { + config, err := fakeOpenCodeConfigFromEnv() + if err != nil { + return 90 + } + fs := flag.NewFlagSet("fake serve", flag.ContinueOnError) + fs.SetOutput(io.Discard) + hostname := fs.String("hostname", "", "") + port := fs.Int("port", 0, "") + if err := fs.Parse(args); err != nil || fs.NArg() != 0 || *hostname != "127.0.0.1" || *port < 1 { + return 91 + } + if config.IgnoreTerminate { + signal.Ignore(syscall.SIGHUP, syscall.SIGTERM) + } + if config.FailServeAttempts > 0 { + attemptPath := os.Getenv(fakeConfigEnv) + ".attempts" + data, _ := os.ReadFile(attemptPath) + if len(data) < config.FailServeAttempts { + _ = os.WriteFile(attemptPath, append(data, 'x'), 0o600) + record := fakeChildCredentialRecord("serve_attempt", args) + record.Port = *port + _ = writeFakeRecord(record) + if config.FailServeGrandchild { + if err := startFakeDetachedGrandchild("serve_attempt_grandchild"); err != nil { + return 107 + } + if !waitForFakeRecord("serve_attempt_grandchild", time.Second) { + return 108 + } + } + return 92 + } + } + if config.BindCollisionOnce { + collisionPath := os.Getenv(fakeConfigEnv) + ".bind-collision" + if _, err := os.Stat(collisionPath); os.IsNotExist(err) { + _ = os.WriteFile(collisionPath, []byte("x"), 0o600) + collision, err := net.Listen("tcp", net.JoinHostPort(*hostname, strconv.Itoa(*port))) + if err != nil { + return 93 + } + record := fakeChildCredentialRecord("serve_bind_collision", args) + record.Port = *port + _ = writeFakeRecord(record) + second, bindErr := net.Listen("tcp", net.JoinHostPort(*hostname, strconv.Itoa(*port))) + if second != nil { + _ = second.Close() + } + _ = collision.Close() + if bindErr == nil { + return 102 + } + return 93 + } + } + listener, err := net.Listen("tcp", net.JoinHostPort(*hostname, strconv.Itoa(*port))) + if err != nil { + return 93 + } + defer func() { _ = listener.Close() }() + record := fakeChildCredentialRecord("serve", args) + record.Port = *port + if err := writeFakeRecord(record); err != nil { + return 94 + } + if config.Grandchildren { + if err := startFakeGrandchild("serve_grandchild"); err != nil { + return 103 + } + } + password := os.Getenv("OPENCODE_SERVER_PASSWORD") + username := os.Getenv("OPENCODE_SERVER_USERNAME") + var eventStateMu sync.Mutex + eventConnections := 0 + gapSession := config.GapSession + if config.NoisyServerLog { + _, _ = fmt.Fprint(os.Stderr, strings.Repeat("x", (64<<10)+8192), password) + } + if config.ServerExitMillis > 0 { + go func() { + time.Sleep(time.Duration(config.ServerExitMillis) * time.Millisecond) + os.Exit(44) + }() + } + handler := http.HandlerFunc(func(w http.ResponseWriter, request *http.Request) { + gotUsername, gotPassword, ok := request.BasicAuth() + if !ok || gotUsername != username || gotPassword != password { + http.Error(w, "unauthorized", http.StatusUnauthorized) + return + } + if request.Header.Get("X-OpenCode-Directory") != config.Directory { + http.Error(w, "wrong directory", http.StatusBadRequest) + return + } + switch { + case request.Method == http.MethodGet && request.URL.Path == "/global/health": + _ = writeFakeRecord(fakeOpenCodeRecord{Kind: "health"}) + w.Header().Set("Content-Type", "application/json") + if config.HealthNeverReady { + _, _ = io.WriteString(w, `{"healthy":false,"version":"1.18.1"}`) + return + } + if config.HealthLeaksSecret { + _, _ = fmt.Fprintf(w, `{"healthy":true,"version":%q}`, password) + return + } + _, _ = io.WriteString(w, `{"healthy":true,"version":"1.18.1"}`) + case request.Method == http.MethodGet && request.URL.Path == "/event": + _ = writeFakeRecord(fakeOpenCodeRecord{Kind: "event"}) + eventStateMu.Lock() + eventConnections++ + connectionNumber := eventConnections + eventStateMu.Unlock() + if config.StallEvent { + <-request.Context().Done() + return + } + w.Header().Set("Content-Type", "text/event-stream") + w.WriteHeader(http.StatusOK) + if flusher, ok := w.(http.Flusher); ok { + flusher.Flush() + } + if config.EventGatePath != "" && !waitForFakeEventGate(request.Context(), config.EventGatePath) { + return + } + for _, event := range config.Events { + data, _ := json.Marshal(event) + _, _ = fmt.Fprintf(w, "data: %s\n\n", data) + if flusher, ok := w.(http.Flusher); ok { + flusher.Flush() + } + } + shouldDisconnect := config.DisconnectSSE && (config.GapSession.ID == "" || connectionNumber == 1) + if shouldDisconnect { + if connectionNumber == 1 && gapSession.ID != "" { + time.Sleep(50 * time.Millisecond) + eventStateMu.Lock() + now := time.Now().UnixMilli() + gapSession.Created = now + gapSession.Updated = now + eventStateMu.Unlock() + if config.DisconnectDelayMS > 0 { + time.Sleep(time.Duration(config.DisconnectDelayMS) * time.Millisecond) + } + } + return + } + <-request.Context().Done() + case request.Method == http.MethodPost && request.URL.Path == "/session": + body, _ := io.ReadAll(request.Body) + _ = writeFakeRecord(fakeOpenCodeRecord{Kind: "create", Body: string(body)}) + if config.StallCreate { + <-request.Context().Done() + return + } + if config.CreateDelayMillis > 0 { + time.Sleep(time.Duration(config.CreateDelayMillis) * time.Millisecond) + } + w.Header().Set("Content-Type", "application/json") + responseID := config.ResponseID + if responseID == "" { + responseID = config.CreatedID + } + responseDirectory := config.ResponseDirectory + if responseDirectory == "" { + responseDirectory = config.Directory + } + _, _ = fmt.Fprintf(w, `{"id":%q,"parentID":%q,"directory":%q,"title":"UAM","time":{"created":%d,"updated":%d}}`, responseID, config.ResponseParentID, responseDirectory, config.CreatedUpdated, config.CreatedUpdated) + case request.Method == http.MethodGet && request.URL.Path == "/session": + _ = writeFakeRecord(fakeOpenCodeRecord{Kind: "list"}) + w.Header().Set("Content-Type", "application/json") + sessions := append([]fakeSession(nil), config.ListSessions...) + eventStateMu.Lock() + if gapSession.ID != "" && gapSession.Created > 0 { + sessions = append(sessions, gapSession) + } + eventStateMu.Unlock() + if err := json.NewEncoder(w).Encode(sessions); err != nil { + return + } + case request.Method == http.MethodGet && strings.HasPrefix(request.URL.Path, "/session/"): + id := strings.TrimPrefix(request.URL.Path, "/session/") + _ = writeFakeRecord(fakeOpenCodeRecord{Kind: "get", ID: id}) + if config.StallGet { + <-request.Context().Done() + return + } + if config.ResumeMissing || id != config.ExistingID { + http.Error(w, "missing", http.StatusNotFound) + return + } + w.Header().Set("Content-Type", "application/json") + responseID := config.ResponseID + if responseID == "" { + responseID = id + } + responseDirectory := config.ResponseDirectory + if responseDirectory == "" { + responseDirectory = config.Directory + } + _, _ = fmt.Fprintf(w, `{"id":%q,"parentID":%q,"directory":%q,"title":"existing","time":{"created":%d,"updated":%d}}`, responseID, config.ResponseParentID, responseDirectory, config.ExistingUpdated, config.ExistingUpdated) + case request.Method == http.MethodPost && strings.HasPrefix(request.URL.Path, "/permission/") && strings.HasSuffix(request.URL.Path, "/reply"): + body, _ := io.ReadAll(request.Body) + id := strings.TrimSuffix(strings.TrimPrefix(request.URL.Path, "/permission/"), "/reply") + _ = writeFakeRecord(fakeOpenCodeRecord{Kind: "permission", ID: id, Body: string(body)}) + w.WriteHeader(http.StatusNoContent) + default: + http.NotFound(w, request) + } + }) + server := &http.Server{Handler: handler, ReadHeaderTimeout: time.Second} + if err := server.Serve(listener); err != nil && !errors.Is(err, http.ErrServerClosed) { + return 95 + } + return 0 +} + +func waitForFakeEventGate(ctx context.Context, path string) bool { + ticker := time.NewTicker(5 * time.Millisecond) + defer ticker.Stop() + for { + if _, err := os.Stat(path); err == nil { + return true + } else if !os.IsNotExist(err) { + return false + } + select { + case <-ticker.C: + case <-ctx.Done(): + return false + } + } +} + +func fakeOpenCodeAttach(args []string) int { + config, err := fakeOpenCodeConfigFromEnv() + if err != nil { + return 96 + } + if config.IgnoreTerminate { + signal.Ignore(syscall.SIGHUP, syscall.SIGTERM) + } + start := fakeChildCredentialRecord("attach_start", args) + if err := writeFakeRecord(start); err != nil { + return 97 + } + if config.Grandchildren { + if err := startFakeGrandchild("attach_grandchild"); err != nil { + return 104 + } + } + if config.ObserveEvents { + if err := fakeObservePermission(args[0], config); err != nil { + return 105 + } + } + if config.AttachDelayMillis > 0 { + time.Sleep(time.Duration(config.AttachDelayMillis) * time.Millisecond) + } + var input []byte + if config.ReadStdinLine { + reader := bufio.NewReader(os.Stdin) + line, readErr := reader.ReadString('\n') + input = []byte(line) + err = readErr + } else if config.ReadStdin { + input, err = io.ReadAll(os.Stdin) + } + if err != nil { + return 98 + } + record := fakeChildCredentialRecord("attach", args) + record.Input = string(input) + if err := writeFakeRecord(record); err != nil { + return 99 + } + if config.AttachSignal { + _ = syscall.Kill(os.Getpid(), syscall.SIGKILL) + select {} + } + return config.AttachExit +} + +func startFakeGrandchild(kind string) error { + command := exec.Command(os.Args[0], "__supervisor_grandchild", kind) + command.Env = os.Environ() + command.Stdout = os.Stdout + command.Stderr = os.Stderr + return command.Start() +} + +func startFakeDetachedGrandchild(kind string) error { + command := exec.Command(os.Args[0], "__supervisor_grandchild", kind) + command.Env = os.Environ() + return command.Start() +} + +func waitForFakeRecord(kind string, timeout time.Duration) bool { + deadline := time.Now().Add(timeout) + needle := `"kind":"` + kind + `"` + for time.Now().Before(deadline) { + data, _ := os.ReadFile(os.Getenv(fakeRecordEnv)) + if strings.Contains(string(data), needle) { + return true + } + time.Sleep(5 * time.Millisecond) + } + return false +} + +func fakeSupervisorGrandchild(kind string) int { + signal.Ignore(syscall.SIGHUP, syscall.SIGTERM) + if err := writeFakeRecord(fakeChildCredentialRecord(kind, nil)); err != nil { + return 106 + } + for { + time.Sleep(time.Hour) + } +} + +func fakeObservePermission(baseURL string, config fakeOpenCodeConfig) error { + client, err := newAPIClient( + baseURL, + os.Getenv("OPENCODE_SERVER_USERNAME"), + os.Getenv("OPENCODE_SERVER_PASSWORD"), + config.Directory, + &http.Client{}, + ) + if err != nil { + return err + } + ctx, cancel := context.WithTimeout(context.Background(), time.Second) + defer cancel() + ready := make(chan struct{}) + events := make(chan eventEnvelope, 16) + done := make(chan error, 1) + go func() { done <- client.subscribe(ctx, ready, events) }() + select { + case <-ready: + case err := <-done: + return err + case <-ctx.Done(): + return ctx.Err() + } + for { + select { + case event := <-events: + if event.Type != "permission.asked" { + continue + } + var asked permissionAskedEvent + if err := json.Unmarshal(event.Properties, &asked); err != nil { + return err + } + if err := writeFakeRecord(fakeOpenCodeRecord{Kind: "observer", ID: asked.ID}); err != nil { + return err + } + cancel() + <-done + return nil + case err := <-done: + return err + case <-ctx.Done(): + return ctx.Err() + } + } +} diff --git a/internal/app/service_test.go b/internal/app/service_test.go index e6a5c59..36a4924 100644 --- a/internal/app/service_test.go +++ b/internal/app/service_test.go @@ -1015,15 +1015,16 @@ func TestProductionProviderResumeKindMatrixThroughAmbiguityGuard(t *testing.T) { newAdapter func() adapter.AgentAdapter isolatedOMP bool wantAmbiguous bool + wantUnsupported bool }{ - {"opencode exact", "opencode", "ses_known123", func() adapter.AgentAdapter { return opencode.New(nil) }, false, false}, - {"opencode fallback", "opencode", "", func() adapter.AgentAdapter { return opencode.New(nil) }, false, true}, - {"claude exact", "claude", "abc12345-dead-beef-cafe-0123456789ab", func() adapter.AgentAdapter { return claude.New(nil) }, false, false}, - {"claude fallback", "claude", "", func() adapter.AgentAdapter { return claude.New(nil) }, false, true}, - {"omp isolated", "omp", "", func() adapter.AgentAdapter { return omp.New(nil) }, true, false}, - {"omp legacy", "omp", "", func() adapter.AgentAdapter { return omp.New(nil) }, false, true}, - {"codex remains heuristic", "codex", "legacy-value", func() adapter.AgentAdapter { return codex.New(nil) }, false, true}, - {"copilot derives exact UAM name", "copilot", "", func() adapter.AgentAdapter { return copilot.New(nil) }, false, false}, + {name: "opencode exact", provider: "opencode", providerID: "ses_known123", newAdapter: func() adapter.AgentAdapter { return opencode.New(nil) }}, + {name: "opencode missing identity", provider: "opencode", newAdapter: func() adapter.AgentAdapter { return opencode.New(nil) }, wantUnsupported: true}, + {name: "claude exact", provider: "claude", providerID: "abc12345-dead-beef-cafe-0123456789ab", newAdapter: func() adapter.AgentAdapter { return claude.New(nil) }}, + {name: "claude fallback", provider: "claude", newAdapter: func() adapter.AgentAdapter { return claude.New(nil) }, wantAmbiguous: true}, + {name: "omp isolated", provider: "omp", newAdapter: func() adapter.AgentAdapter { return omp.New(nil) }, isolatedOMP: true}, + {name: "omp legacy", provider: "omp", newAdapter: func() adapter.AgentAdapter { return omp.New(nil) }, wantAmbiguous: true}, + {name: "codex remains heuristic", provider: "codex", providerID: "legacy-value", newAdapter: func() adapter.AgentAdapter { return codex.New(nil) }, wantAmbiguous: true}, + {name: "copilot derives exact UAM name", provider: "copilot", newAdapter: func() adapter.AgentAdapter { return copilot.New(nil) }}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { @@ -1059,7 +1060,15 @@ func TestProductionProviderResumeKindMatrixThroughAmbiguityGuard(t *testing.T) { } svc := NewService(nil, adapter.NewRegistry([]adapter.AgentAdapter{tt.newAdapter()})) _, _, _, err := svc.prepareResume(adapter.Session{ID: id, AgentType: tt.provider}, cfg, ResumeOptions{}) - if tt.wantAmbiguous { + if tt.wantUnsupported { + if err == nil || errors.Is(err, ErrAmbiguousResume) || !strings.Contains(err.Error(), "cannot resume") { + t.Fatalf("error=%v, want exact-resume-required unsupported error", err) + } + _, _, _, allowLatestErr := svc.prepareResume(adapter.Session{ID: id, AgentType: tt.provider}, cfg, ResumeOptions{AllowLatest: true}) + if allowLatestErr == nil || errors.Is(allowLatestErr, ErrAmbiguousResume) || !strings.Contains(allowLatestErr.Error(), "cannot resume") { + t.Fatalf("--allow-latest error=%v, want same unsupported exact-resume error", allowLatestErr) + } + } else if tt.wantAmbiguous { if !errors.Is(err, ErrAmbiguousResume) { t.Fatalf("error=%v, want ErrAmbiguousResume", err) } diff --git a/internal/cli/cli.go b/internal/cli/cli.go index d0a8194..778b380 100644 --- a/internal/cli/cli.go +++ b/internal/cli/cli.go @@ -15,6 +15,7 @@ import ( "github.com/charmbracelet/x/term" "github.com/RandomCodeSpace/unified-agent-manager/internal/adapter" + "github.com/RandomCodeSpace/unified-agent-manager/internal/adapter/opencode" "github.com/RandomCodeSpace/unified-agent-manager/internal/agents" "github.com/RandomCodeSpace/unified-agent-manager/internal/app" "github.com/RandomCodeSpace/unified-agent-manager/internal/log" @@ -50,6 +51,10 @@ func Main() { ctx := context.Background() if err := Run(ctx, args); err != nil && !errors.Is(err, context.Canceled) { + var exitCoder interface{ ExitCode() int } + if errors.As(err, &exitCoder) { + os.Exit(exitCoder.ExitCode()) + } log.Error("run exited with error", "err", err) fmt.Fprintf(os.Stderr, "uam: %v\n", err) os.Exit(1) @@ -139,6 +144,8 @@ func runWithoutStore(ctx context.Context, args []string) (bool, error) { return true, session.RunHost(args[1:]) case "__attach": return true, session.RunAttach(args[1:]) + case "__opencode": + return true, opencode.RunSupervisorCommand(args[1:]) default: return false, nil } diff --git a/internal/cli/cli_test.go b/internal/cli/cli_test.go index 9cb08f3..71c9e3e 100644 --- a/internal/cli/cli_test.go +++ b/internal/cli/cli_test.go @@ -3,12 +3,16 @@ package cli import ( "bytes" "context" + "encoding/json" "errors" "flag" "io" + "net" + "net/http" "os" "os/exec" "path/filepath" + "strconv" "strings" "testing" "time" @@ -243,6 +247,9 @@ func TestRunWithTUIStateFreeCommandsDoNotOpenStore(t *testing.T) { if err := RunWithTUI(context.Background(), []string{"__attach"}, noopRunTUI); err == nil || !strings.Contains(err.Error(), "attach requires a session name") { t.Fatalf("__attach must be routed before the store and return its own validation error, got %v", err) } + if err := RunWithTUI(context.Background(), []string{"__opencode"}, noopRunTUI); err == nil || !strings.Contains(err.Error(), "OpenCode supervisor") { + t.Fatalf("__opencode must be routed before the store and return its own validation error, got %v", err) + } } func TestMainStatelessCommandsSkipLoggerAndStore(t *testing.T) { @@ -287,12 +294,120 @@ func TestCLIMainHelperProcess(t *testing.T) { if command == "" { return } + args := []string{command} + if encoded := os.Getenv("UAM_TEST_MAIN_ARGS"); encoded != "" { + if err := json.Unmarshal([]byte(encoded), &args); err != nil { + os.Exit(97) + } + } flag.CommandLine = flag.NewFlagSet("uam", flag.ContinueOnError) - os.Args = []string{"uam", command} + os.Args = append([]string{"uam"}, args...) Main() os.Exit(0) } +func TestMainPropagatesOpenCodeAttachExitCodeWithoutPrintingError(t *testing.T) { + executable, err := os.Executable() + if err != nil { + t.Fatal(err) + } + provider := filepath.Join(t.TempDir(), "opencode") + script := `#!/bin/sh +case "$1" in + --version) printf '1.18.1\n'; exit 0 ;; + serve) shift; exec "$UAM_CLI_TEST_EXE" -test.run=^TestCLIOpenCodeProviderHelper$ -- serve "$@" ;; + attach) exit 23 ;; +esac +exit 97 +` + if err := os.WriteFile(provider, []byte(script), 0o700); err != nil { + t.Fatal(err) + } + cwd := filepath.Clean(t.TempDir()) + runtimeDir := secureSessionDir(t) + args := []string{ + "__opencode", + "--path", provider, + "--dir", cwd, + "--name", "uam-opencode-a1b2c3d4", + "--runtime-dir", runtimeDir, + "--mode", "safe", + } + encoded, err := json.Marshal(args) + if err != nil { + t.Fatal(err) + } + cmd := exec.Command(executable, "-test.run=^TestCLIMainHelperProcess$") + cmd.Env = append(os.Environ(), + "UAM_TEST_MAIN_COMMAND=__opencode", + "UAM_TEST_MAIN_ARGS="+string(encoded), + "UAM_CLI_TEST_EXE="+executable, + "UAM_CLI_PROVIDER_HELPER=1", + "UAM_CACHE_DIR="+t.TempDir(), + "UAM_CONFIG_DIR="+filepath.Join(t.TempDir(), "unused-config"), + "OPENCODE_SERVER_PASSWORD=credential-must-never-print", + ) + output, err := cmd.CombinedOutput() + var exitErr *exec.ExitError + if !errors.As(err, &exitErr) || exitErr.ExitCode() != 23 { + t.Fatalf("Main() subprocess = (%v, %q), want exit code 23", err, output) + } + if len(output) != 0 { + t.Fatalf("Main() printed an attach/credential error: %q", output) + } +} + +func TestCLIOpenCodeProviderHelper(t *testing.T) { + if os.Getenv("UAM_CLI_PROVIDER_HELPER") != "1" { + return + } + separator := -1 + for i, arg := range os.Args { + if arg == "--" { + separator = i + break + } + } + if separator < 0 || separator+1 >= len(os.Args) || os.Args[separator+1] != "serve" { + t.Fatalf("provider helper argv = %q", os.Args) + } + fs := flag.NewFlagSet("serve", flag.ContinueOnError) + hostname := fs.String("hostname", "", "") + port := fs.Int("port", 0, "") + if err := fs.Parse(os.Args[separator+2:]); err != nil { + t.Fatal(err) + } + handler := http.HandlerFunc(func(w http.ResponseWriter, request *http.Request) { + switch { + case request.Method == http.MethodGet && request.URL.Path == "/global/health": + w.Header().Set("Content-Type", "application/json") + _, _ = io.WriteString(w, `{"healthy":true,"version":"1.18.1"}`) + case request.Method == http.MethodGet && request.URL.Path == "/event": + w.Header().Set("Content-Type", "text/event-stream") + w.WriteHeader(http.StatusOK) + if flusher, ok := w.(http.Flusher); ok { + flusher.Flush() + } + <-request.Context().Done() + case request.Method == http.MethodPost && request.URL.Path == "/session": + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]string{ + "id": "ses_cli123", "directory": request.Header.Get("X-OpenCode-Directory"), "title": "CLI helper", + }) + default: + http.NotFound(w, request) + } + }) + server := &http.Server{ + Addr: net.JoinHostPort(*hostname, strconv.Itoa(*port)), + Handler: handler, + ReadHeaderTimeout: time.Second, + } + if err := server.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) { + t.Fatal(err) + } +} + func cliMainSubprocess(t *testing.T, command, cacheDir, configDir string) *exec.Cmd { t.Helper() cmd := exec.Command(os.Args[0], "-test.run=^TestCLIMainHelperProcess$") @@ -488,4 +603,7 @@ func TestRunWithoutStoreInternalSubcommands(t *testing.T) { if handled, err := runWithoutStore(context.Background(), []string{"__attach"}); !handled || err == nil { t.Fatal("__attach without a session must fail") } + if handled, err := runWithoutStore(context.Background(), []string{"__opencode"}); !handled || err == nil { + t.Fatal("__opencode without supervisor flags must fail before store access") + } } diff --git a/internal/session/host.go b/internal/session/host.go index 67f88eb..9de3a00 100644 --- a/internal/session/host.go +++ b/internal/session/host.go @@ -2,15 +2,14 @@ package session import ( "bufio" - "encoding/json" "errors" "flag" "fmt" - "io" "net" "os" "os/exec" "os/signal" + "path/filepath" "strings" "sync" "sync/atomic" @@ -23,7 +22,6 @@ import ( "github.com/RandomCodeSpace/unified-agent-manager/internal/log" "github.com/RandomCodeSpace/unified-agent-manager/internal/store" "github.com/RandomCodeSpace/unified-agent-manager/internal/vterm" - "golang.org/x/sys/unix" ) // ProviderIdentityFileEnv names the provider-neutral identity handoff read by @@ -533,14 +531,14 @@ func (h *host) shutdown(exitCode int) { cl.drop() } h.mu.Unlock() + 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) } - h.recordExit(exitCode) + h.recordExit(exitCode, providerID) } -func (h *host) recordExit(exitCode int) { - providerID := readProviderIdentityHandoff(h.providerIdentityFile) +func (h *host) recordExit(exitCode int, providerID string) { deadline := time.Now().Add(markClosedRetryWindow) delay := markClosedRetryBase var lastErr error @@ -577,31 +575,39 @@ func envValue(envs []string, key string) string { return "" } -func readProviderIdentityHandoff(path string) string { +func readProviderIdentityHandoff(dir, name, path string) string { if path == "" { return "" } - fd, err := unix.Open(path, unix.O_RDONLY|unix.O_CLOEXEC|unix.O_NOFOLLOW, 0) // #nosec G304 -- trusted launch metadata, opened no-follow. + canonicalPath, err := ProviderIdentityPath(dir, name) if err != nil { return "" } - file := os.NewFile(uintptr(fd), path) - defer func() { _ = file.Close() }() - var st unix.Stat_t - if unix.Fstat(fd, &st) != nil || st.Mode&unix.S_IFMT != unix.S_IFREG || os.FileMode(st.Mode).Perm() != 0o600 || int(st.Uid) != os.Getuid() { + canonicalPath, err = resolvePathParent(canonicalPath) + if err != nil { return "" } - data, err := io.ReadAll(io.LimitReader(file, 1025)) - if err != nil || len(data) > 1024 { + path, err = resolvePathParent(path) + if err != nil || path != canonicalPath { return "" } - var payload struct { - ProviderSessionID string `json:"provider_session_id"` - } - if json.Unmarshal(data, &payload) != nil || !store.ValidProviderSessionID(payload.ProviderSessionID) { + providerID, err := ReadProviderIdentity(dir, name) + if err != nil { return "" } - return payload.ProviderSessionID + return providerID +} + +func resolvePathParent(path string) (string, error) { + absolutePath, err := filepath.Abs(path) + if err != nil { + return "", err + } + resolvedParent, err := filepath.EvalSymlinks(filepath.Dir(absolutePath)) + if err != nil { + return "", err + } + return filepath.Join(resolvedParent, filepath.Base(absolutePath)), nil } func (h *host) logMarkClosedFailure(err error) { diff --git a/internal/session/provider_identity.go b/internal/session/provider_identity.go new file mode 100644 index 0000000..179d4a7 --- /dev/null +++ b/internal/session/provider_identity.go @@ -0,0 +1,171 @@ +package session + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "os" + "path/filepath" + "syscall" + + "github.com/RandomCodeSpace/unified-agent-manager/internal/store" + "golang.org/x/sys/unix" +) + +const maxProviderIdentityBytes = 1024 + +type providerIdentity struct { + SessionName string `json:"session_name"` + ProviderSessionID string `json:"provider_session_id"` +} + +// ProviderIdentityPath returns the canonical provider identity handoff path +// inside the verified native-session runtime boundary. +func ProviderIdentityPath(dir, name string) (string, error) { + if err := ValidateName(name); err != nil { + return "", err + } + return filepath.Join(dir, name+".provider.json"), nil +} + +// WriteProviderIdentity atomically publishes a provider session identity in +// the native-session runtime directory. +func WriteProviderIdentity(dir, name, providerSessionID string) error { + if err := ValidateName(name); err != nil { + return err + } + if !store.ValidProviderSessionID(providerSessionID) { + return fmt.Errorf("invalid provider session ID") + } + if err := VerifyDir(dir); err != nil { + return err + } + path := filepath.Join(dir, name+".provider.json") + info, err := os.Lstat(path) + if err == nil { + if err := verifyProviderIdentityFile(path, info); err != nil { + return err + } + } else if !os.IsNotExist(err) { + return fmt.Errorf("stat provider identity destination: %w", err) + } + data, err := json.Marshal(providerIdentity{SessionName: name, ProviderSessionID: providerSessionID}) + if err != nil { + return fmt.Errorf("encode provider identity: %w", err) + } + + tmp, err := os.CreateTemp(dir, "."+name+".provider-*.tmp") + if err != nil { + return fmt.Errorf("create provider identity temporary file: %w", err) + } + tmpPath := tmp.Name() + closed := false + defer func() { + if !closed { + _ = tmp.Close() + } + _ = os.Remove(tmpPath) + }() + if err := tmp.Chmod(0o600); err != nil { + return fmt.Errorf("restrict provider identity temporary file: %w", err) + } + if _, err := tmp.Write(data); err != nil { + return fmt.Errorf("write provider identity temporary file: %w", err) + } + if err := tmp.Sync(); err != nil { + return fmt.Errorf("sync provider identity temporary file: %w", err) + } + if err := tmp.Close(); err != nil { + closed = true + return fmt.Errorf("close provider identity temporary file: %w", err) + } + closed = true + if err := os.Rename(tmpPath, path); err != nil { + return fmt.Errorf("publish provider identity: %w", err) + } + return nil +} + +// ReadProviderIdentity returns the verified provider session identity. A +// missing handoff is advisory and returns an empty identity without error. +func ReadProviderIdentity(dir, name string) (string, error) { + path, err := ProviderIdentityPath(dir, name) + if err != nil { + return "", err + } + if err := VerifyDir(dir); err != nil { + return "", err + } + info, err := os.Lstat(path) + if err != nil { + if os.IsNotExist(err) { + return "", nil + } + return "", fmt.Errorf("stat provider identity: %w", err) + } + if err := verifyProviderIdentityFile(path, info); err != nil { + return "", err + } + + fd, err := unix.Open(path, unix.O_RDONLY|unix.O_NOFOLLOW|unix.O_CLOEXEC, 0) // #nosec G304 -- validated canonical path inside a verified owner-only directory. + if err != nil { + if os.IsNotExist(err) { + return "", nil + } + return "", fmt.Errorf("open provider identity: %w", err) + } + file := os.NewFile(uintptr(fd), path) + defer func() { _ = file.Close() }() + var stat unix.Stat_t + if err := unix.Fstat(fd, &stat); err != nil { + return "", fmt.Errorf("inspect provider identity descriptor: %w", err) + } + if stat.Mode&unix.S_IFMT != unix.S_IFREG || os.FileMode(stat.Mode).Perm() != 0o600 || int(stat.Uid) != os.Getuid() { + return "", fmt.Errorf("provider identity changed during verification") + } + + data, err := io.ReadAll(io.LimitReader(file, maxProviderIdentityBytes+1)) + if err != nil { + return "", fmt.Errorf("read provider identity: %w", err) + } + if len(data) > maxProviderIdentityBytes { + return "", fmt.Errorf("provider identity exceeds %d bytes", maxProviderIdentityBytes) + } + decoder := json.NewDecoder(bytes.NewReader(data)) + var identity providerIdentity + if err := decoder.Decode(&identity); err != nil { + return "", fmt.Errorf("decode provider identity: %w", err) + } + var trailing any + if err := decoder.Decode(&trailing); err != io.EOF { + if err == nil { + return "", fmt.Errorf("provider identity contains a trailing JSON value") + } + return "", fmt.Errorf("decode provider identity trailing data: %w", err) + } + if identity.SessionName != name { + return "", fmt.Errorf("provider identity session name %q does not match %q", identity.SessionName, name) + } + if !store.ValidProviderSessionID(identity.ProviderSessionID) { + return "", fmt.Errorf("provider identity contains an invalid provider session ID") + } + return identity.ProviderSessionID, nil +} + +func verifyProviderIdentityFile(path string, info os.FileInfo) error { + if info.Mode()&os.ModeSymlink != 0 || !info.Mode().IsRegular() { + return fmt.Errorf("provider identity %s is not a regular non-symlink file", path) + } + if info.Mode().Perm() != 0o600 { + return fmt.Errorf("provider identity %s has unsafe mode %04o; want 0600", path, info.Mode().Perm()) + } + stat, ok := info.Sys().(*syscall.Stat_t) + if !ok { + return fmt.Errorf("provider identity %s ownership is unavailable", path) + } + if int(stat.Uid) != os.Getuid() { + return fmt.Errorf("provider identity %s is owned by uid %d, not the current user", path, stat.Uid) + } + return nil +} diff --git a/internal/session/provider_identity_test.go b/internal/session/provider_identity_test.go new file mode 100644 index 0000000..fe030f7 --- /dev/null +++ b/internal/session/provider_identity_test.go @@ -0,0 +1,361 @@ +package session + +import ( + "errors" + "os" + "path/filepath" + "strings" + "syscall" + "testing" +) + +func providerIdentityTestDir(t *testing.T) string { + t.Helper() + dir := t.TempDir() + if err := os.Chmod(dir, 0o700); err != nil { + t.Fatal(err) + } + return dir +} + +func providerIdentityTestPath(t *testing.T, dir, name string) string { + t.Helper() + path, err := ProviderIdentityPath(dir, name) + if err != nil { + t.Fatal(err) + } + return path +} + +func writeProviderIdentityFixture(t *testing.T, path, contents string, mode os.FileMode) { + t.Helper() + if err := os.WriteFile(path, []byte(contents), mode); err != nil { + t.Fatal(err) + } + if err := os.Chmod(path, mode); err != nil { + t.Fatal(err) + } +} + +func TestProviderIdentityPath(t *testing.T) { + dir := providerIdentityTestDir(t) + name := "uam-opencode-a1b2c3d4" + + got, err := ProviderIdentityPath(dir, name) + if err != nil { + t.Fatalf("ProviderIdentityPath: %v", err) + } + want := filepath.Join(dir, name+".provider.json") + if got != want { + t.Fatalf("ProviderIdentityPath = %q, want %q", got, want) + } + if _, err := ProviderIdentityPath(dir, "../escape"); !errors.Is(err, ErrInvalidSessionName) { + t.Fatalf("invalid name error = %v, want ErrInvalidSessionName", err) + } + + missingDir := filepath.Join(t.TempDir(), "not-created-yet") + got, err = ProviderIdentityPath(missingDir, name) + if err != nil { + t.Fatalf("ProviderIdentityPath before runtime directory creation: %v", err) + } + if want := filepath.Join(missingDir, name+".provider.json"); got != want { + t.Fatalf("ProviderIdentityPath before runtime directory creation = %q, want %q", got, want) + } +} + +func TestProviderIdentityAtomicRoundTrip(t *testing.T) { + dir := providerIdentityTestDir(t) + name := "uam-opencode-a1b2c3d4" + if err := WriteProviderIdentity(dir, name, "ses_abc123"); err != nil { + t.Fatalf("WriteProviderIdentity: %v", err) + } + + path := providerIdentityTestPath(t, dir, name) + info, err := os.Lstat(path) + if err != nil { + t.Fatal(err) + } + if !info.Mode().IsRegular() || info.Mode().Perm() != 0o600 { + t.Fatalf("identity mode = %v, want regular 0600", info.Mode()) + } + got, err := ReadProviderIdentity(dir, name) + if err != nil { + t.Fatalf("ReadProviderIdentity: %v", err) + } + if got != "ses_abc123" { + t.Fatalf("ReadProviderIdentity = %q, want ses_abc123", got) + } + + temps, err := filepath.Glob(filepath.Join(dir, "*.tmp")) + if err != nil { + t.Fatal(err) + } + if len(temps) != 0 { + t.Fatalf("temporary identity files remain after success: %v", temps) + } +} + +func TestProviderIdentityWriteReplacesSafeDestination(t *testing.T) { + const ( + name = "uam-opencode-a1b2c3d4" + original = `{"session_name":"uam-opencode-a1b2c3d4","provider_session_id":"ses_original"}` + ) + dir := providerIdentityTestDir(t) + path := providerIdentityTestPath(t, dir, name) + writeProviderIdentityFixture(t, path, original, 0o600) + + if err := WriteProviderIdentity(dir, name, "ses_replacement"); err != nil { + t.Fatalf("WriteProviderIdentity replacement: %v", err) + } + got, err := ReadProviderIdentity(dir, name) + if err != nil { + t.Fatalf("ReadProviderIdentity replacement: %v", err) + } + if got != "ses_replacement" { + t.Fatalf("ReadProviderIdentity replacement = %q, want ses_replacement", got) + } +} + +func TestProviderIdentityWriteRejectsUnsafeDestination(t *testing.T) { + const ( + name = "uam-opencode-a1b2c3d4" + original = `{"session_name":"uam-opencode-a1b2c3d4","provider_session_id":"ses_original"}` + ) + + t.Run("symlink", func(t *testing.T) { + dir := providerIdentityTestDir(t) + path := providerIdentityTestPath(t, dir, name) + target := filepath.Join(t.TempDir(), "target.json") + writeProviderIdentityFixture(t, target, original, 0o600) + if err := os.Symlink(target, path); err != nil { + t.Fatal(err) + } + + if err := WriteProviderIdentity(dir, name, "ses_replacement"); err == nil { + t.Error("WriteProviderIdentity must reject a symlink destination") + } + gotTarget, err := os.Readlink(path) + if err != nil { + t.Fatalf("destination is no longer a symlink: %v", err) + } + if gotTarget != target { + t.Fatalf("symlink target = %q, want %q", gotTarget, target) + } + got, err := os.ReadFile(target) + if err != nil { + t.Fatal(err) + } + if string(got) != original { + t.Fatalf("symlink target contents = %q, want original contents", got) + } + }) + + t.Run("non-regular object", func(t *testing.T) { + dir := providerIdentityTestDir(t) + path := providerIdentityTestPath(t, dir, name) + if err := os.Mkdir(path, 0o700); err != nil { + t.Fatal(err) + } + marker := filepath.Join(path, "marker") + if err := os.WriteFile(marker, []byte("unchanged"), 0o600); err != nil { + t.Fatal(err) + } + + if err := WriteProviderIdentity(dir, name, "ses_replacement"); err == nil { + t.Error("WriteProviderIdentity must reject a non-regular destination") + } + info, err := os.Lstat(path) + if err != nil { + t.Fatal(err) + } + if !info.IsDir() { + t.Fatalf("destination mode = %v, want directory", info.Mode()) + } + got, err := os.ReadFile(marker) + if err != nil { + t.Fatal(err) + } + if string(got) != "unchanged" { + t.Fatalf("directory marker contents = %q, want unchanged", got) + } + }) + + t.Run("wrong mode", func(t *testing.T) { + dir := providerIdentityTestDir(t) + path := providerIdentityTestPath(t, dir, name) + writeProviderIdentityFixture(t, path, original, 0o644) + + if err := WriteProviderIdentity(dir, name, "ses_replacement"); err == nil { + t.Error("WriteProviderIdentity must reject mode 0644") + } + info, err := os.Lstat(path) + if err != nil { + t.Fatal(err) + } + if info.Mode().Perm() != 0o644 { + t.Fatalf("destination mode = %04o, want 0644", info.Mode().Perm()) + } + got, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + if string(got) != original { + t.Fatalf("destination contents = %q, want original contents", got) + } + }) + + t.Run("foreign owner", func(t *testing.T) { + dir := providerIdentityTestDir(t) + path := providerIdentityTestPath(t, dir, name) + writeProviderIdentityFixture(t, path, original, 0o600) + foreignUID := os.Getuid() + 1 + if foreignUID == os.Getuid() { + foreignUID++ + } + if err := os.Chown(path, foreignUID, -1); err != nil { + t.Skipf("platform does not permit foreign-owner fixture: %v", err) + } + + if err := WriteProviderIdentity(dir, name, "ses_replacement"); err == nil { + t.Error("WriteProviderIdentity must reject a foreign-owned destination") + } + info, err := os.Lstat(path) + if err != nil { + t.Fatal(err) + } + stat, ok := info.Sys().(*syscall.Stat_t) + if !ok { + t.Fatalf("destination ownership is unavailable") + } + if int(stat.Uid) != foreignUID { + t.Fatalf("destination uid = %d, want %d", stat.Uid, foreignUID) + } + got, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + if string(got) != original { + t.Fatalf("destination contents = %q, want original contents", got) + } + }) +} + +func TestProviderIdentityMissingReturnsEmpty(t *testing.T) { + dir := providerIdentityTestDir(t) + got, err := ReadProviderIdentity(dir, "uam-opencode-a1b2c3d4") + if err != nil || got != "" { + t.Fatalf("ReadProviderIdentity missing = (%q, %v), want empty success", got, err) + } +} + +func TestProviderIdentityRejectsInvalidProviderIDBeforeWrite(t *testing.T) { + dir := providerIdentityTestDir(t) + name := "uam-opencode-a1b2c3d4" + if err := WriteProviderIdentity(dir, name, "-unsafe"); err == nil { + t.Fatal("WriteProviderIdentity must reject an invalid provider ID") + } + path := providerIdentityTestPath(t, dir, name) + if _, err := os.Lstat(path); !os.IsNotExist(err) { + t.Fatalf("invalid provider ID created %s: %v", path, err) + } +} + +func TestProviderIdentityReadFailsClosed(t *testing.T) { + const ( + name = "uam-opencode-a1b2c3d4" + valid = `{"session_name":"uam-opencode-a1b2c3d4","provider_session_id":"ses_abc123"}` + foreign = `{"session_name":"uam-opencode-a1b2c3d4","provider_session_id":"ses_foreign"}` + ) + + t.Run("symlink", func(t *testing.T) { + dir := providerIdentityTestDir(t) + path := providerIdentityTestPath(t, dir, name) + target := filepath.Join(t.TempDir(), "target.json") + writeProviderIdentityFixture(t, target, valid, 0o600) + if err := os.Symlink(target, path); err != nil { + t.Fatal(err) + } + if _, err := ReadProviderIdentity(dir, name); err == nil { + t.Fatal("ReadProviderIdentity must reject a symlink") + } + }) + + t.Run("directory", func(t *testing.T) { + dir := providerIdentityTestDir(t) + path := providerIdentityTestPath(t, dir, name) + if err := os.Mkdir(path, 0o600); err != nil { + t.Fatal(err) + } + if _, err := ReadProviderIdentity(dir, name); err == nil { + t.Fatal("ReadProviderIdentity must reject a directory") + } + }) + + t.Run("mode 0644", func(t *testing.T) { + dir := providerIdentityTestDir(t) + path := providerIdentityTestPath(t, dir, name) + writeProviderIdentityFixture(t, path, valid, 0o644) + if _, err := ReadProviderIdentity(dir, name); err == nil { + t.Fatal("ReadProviderIdentity must reject mode 0644") + } + }) + + t.Run("foreign owner", func(t *testing.T) { + dir := providerIdentityTestDir(t) + path := providerIdentityTestPath(t, dir, name) + writeProviderIdentityFixture(t, path, foreign, 0o600) + foreignUID := os.Getuid() + 1 + if foreignUID == os.Getuid() { + foreignUID++ + } + if err := os.Chown(path, foreignUID, -1); err != nil { + t.Skipf("platform does not permit foreign-owner fixture: %v", err) + } + if _, err := ReadProviderIdentity(dir, name); err == nil { + t.Fatal("ReadProviderIdentity must reject a foreign-owned file") + } + }) + + t.Run("larger than limit", func(t *testing.T) { + dir := providerIdentityTestDir(t) + path := providerIdentityTestPath(t, dir, name) + writeProviderIdentityFixture(t, path, strings.Repeat("x", 1025), 0o600) + if _, err := ReadProviderIdentity(dir, name); err == nil { + t.Fatal("ReadProviderIdentity must reject an oversized file") + } + }) + + for _, tt := range []struct { + name string + contents string + }{ + {name: "malformed JSON", contents: `{"session_name":`}, + {name: "trailing JSON value", contents: valid + ` {}`}, + {name: "embedded name mismatch", contents: `{"session_name":"uam-opencode-deadbeef","provider_session_id":"ses_abc123"}`}, + {name: "invalid embedded provider ID", contents: `{"session_name":"uam-opencode-a1b2c3d4","provider_session_id":"-unsafe"}`}, + } { + t.Run(tt.name, func(t *testing.T) { + dir := providerIdentityTestDir(t) + path := providerIdentityTestPath(t, dir, name) + writeProviderIdentityFixture(t, path, tt.contents, 0o600) + if _, err := ReadProviderIdentity(dir, name); err == nil { + t.Fatalf("ReadProviderIdentity must reject %s", tt.name) + } + }) + } +} + +func TestProviderIdentityFailedWritePreservesPreviousValue(t *testing.T) { + dir := providerIdentityTestDir(t) + name := "uam-opencode-a1b2c3d4" + if err := WriteProviderIdentity(dir, name, "ses_original"); err != nil { + t.Fatal(err) + } + if err := WriteProviderIdentity(dir, name, "-invalid"); err == nil { + t.Fatal("replacement write must reject an invalid provider ID") + } + got, err := ReadProviderIdentity(dir, name) + if err != nil || got != "ses_original" { + t.Fatalf("identity after failed write = (%q, %v), want ses_original", got, err) + } +} diff --git a/internal/session/session.go b/internal/session/session.go index 543d4c4..bb115cc 100644 --- a/internal/session/session.go +++ b/internal/session/session.go @@ -209,7 +209,11 @@ func removeSessionFiles(dir, name string) error { if err := VerifyDir(dir); err != nil { return err } - for _, path := range []string{statePath(dir, name), SocketPath(dir, name)} { + providerIdentityPath, err := ProviderIdentityPath(dir, name) + if err != nil { + return err + } + for _, path := range []string{statePath(dir, name), SocketPath(dir, name), providerIdentityPath} { if err := os.Remove(path); err != nil && !os.IsNotExist(err) { return err } diff --git a/internal/session/session_test.go b/internal/session/session_test.go index 4df40c7..db3a812 100644 --- a/internal/session/session_test.go +++ b/internal/session/session_test.go @@ -155,6 +155,58 @@ func TestCreateListCaptureSendKill(t *testing.T) { } } +func TestSendLineNormalizesUnicodeMultilineEmptyAndTrailingNewlines(t *testing.T) { + c := newTestClient(t) + ctx := context.Background() + name := "uam-fake-abc12346" + command := `stty -echo; printf 'ready\n'; while IFS= read -r line; do printf 'record:%s\n' "$line"; done` + if err := c.CreateSession(ctx, name, t.TempDir(), nil, []string{"/bin/sh", "-c", command}); err != nil { + t.Fatalf("CreateSession: %v", err) + } + waitFor(t, "SendLine fixture readiness", func() bool { + out, err := c.Capture(ctx, name, 100) + return err == nil && strings.Contains(out, "ready") + }) + + tests := []struct { + name string + input string + wantLines []string + }{ + {name: "Unicode", input: "unicode-π-你好", wantLines: []string{"record:unicode-π-你好"}}, + {name: "multiline", input: "multi-first\nmulti-second", wantLines: []string{"record:multi-first", "record:multi-second"}}, + {name: "empty", input: "", wantLines: []string{"record:"}}, + {name: "one trailing newline", input: "one-tail\n", wantLines: []string{"record:one-tail"}}, + {name: "many trailing newlines", input: "many-tail\n\n\n", wantLines: []string{"record:many-tail"}}, + } + for _, tt := range tests { + if err := c.SendLine(ctx, name, tt.input); err != nil { + t.Fatalf("SendLine(%s): %v", tt.name, err) + } + last := tt.wantLines[len(tt.wantLines)-1] + waitFor(t, tt.name+" output", func() bool { + out, err := c.Capture(ctx, name, 100) + return err == nil && strings.Contains(out, last) + }) + } + + out, err := c.Capture(ctx, name, 100) + if err != nil { + t.Fatal(err) + } + if got := strings.Count(out, "record:"); got != 6 { + t.Fatalf("normalized records = %d, want 6 exactly; output=%q", got, out) + } + for _, want := range []string{"record:unicode-π-你好", "record:multi-first", "record:multi-second", "record:one-tail", "record:many-tail"} { + if got := strings.Count(out, want); got != 1 { + t.Fatalf("output count for %q = %d, want 1; output=%q", want, got, out) + } + } + if err := c.Kill(ctx, name); err != nil { + t.Fatalf("Kill: %v", err) + } +} + func TestNaturalAgentCrashRemainsResumableAndRecordsFailure(t *testing.T) { c := newTestClient(t) ctx := context.Background() @@ -201,8 +253,11 @@ func TestImmediateExitRecordsProviderIdentityHandoff(t *testing.T) { }); err != nil { t.Fatal(err) } - handoff := filepath.Join(t.TempDir(), "identity.json") - command := `umask 077; printf '{"provider_session_id":"ses_fast123"}' > "$` + ProviderIdentityFileEnv + `"; exit 0` + handoff, err := ProviderIdentityPath(c.Dir, name) + if err != nil { + t.Fatal(err) + } + command := `umask 077; printf '{"session_name":"` + name + `","provider_session_id":"ses_fast123"}' > "$` + ProviderIdentityFileEnv + `"; exit 0` if err := c.CreateSession(context.Background(), name, t.TempDir(), map[string]string{ProviderIdentityFileEnv: handoff}, []string{"/bin/sh", "-c", command}); err != nil { t.Fatal(err) } @@ -214,6 +269,141 @@ func TestImmediateExitRecordsProviderIdentityHandoff(t *testing.T) { rec := cfg.Sessions["opencode:a1b2c3d4"] return rec.LastExitCode != nil && *rec.LastExitCode == 0 && rec.ProviderSessionID == "ses_fast123" }) + for _, path := range []string{statePath(c.Dir, name), SocketPath(c.Dir, name), handoff} { + if _, err := os.Lstat(path); !os.IsNotExist(err) { + t.Fatalf("runtime file not removed after exit: %s: %v", path, err) + } + } +} + +func TestImmediateExitRecordsProviderIdentityHandoffWithRelativeRuntimeDir(t *testing.T) { + root, err := os.MkdirTemp("", "uam-rel-real-") + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = os.RemoveAll(root) }) + aliasedRoot := root + "-alias" + if err := os.Symlink(root, aliasedRoot); err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = os.Remove(aliasedRoot) }) + originalCwd, err := os.Getwd() + if err != nil { + t.Fatal(err) + } + if err := os.Chdir(aliasedRoot); err != nil { + t.Fatal(err) + } + t.Cleanup(func() { + if err := os.Chdir(originalCwd); err != nil { + t.Errorf("restore working directory: %v", err) + } + }) + t.Setenv("UAM_CONFIG_DIR", filepath.Join(root, "cfg")) + exe, err := os.Executable() + if err != nil { + t.Fatal(err) + } + c := &Client{Dir: "runtime", Exe: exe} + t.Cleanup(func() { _ = c.KillAll(context.Background()) }) + + name := "uam-opencode-a1b2c3d5" + st, err := store.Open(store.DefaultPath()) + if err != nil { + t.Fatal(err) + } + if err := st.Update(func(cfg *store.Config) error { + cfg.PutSession("opencode:a1b2c3d5", store.SessionRecord{ID: "a1b2c3d5", Agent: "opencode", Name: "n", SessionName: name, Status: store.StatusActive}) + return nil + }); err != nil { + t.Fatal(err) + } + absoluteRuntimeDir := filepath.Join(aliasedRoot, c.Dir) + handoff, err := ProviderIdentityPath(absoluteRuntimeDir, name) + if err != nil { + t.Fatal(err) + } + command := `umask 077; printf '{"session_name":"` + name + `","provider_session_id":"ses_relative123"}' > "$` + ProviderIdentityFileEnv + `"; exit 0` + if err := c.CreateSession(context.Background(), name, t.TempDir(), map[string]string{ProviderIdentityFileEnv: handoff}, []string{"/bin/sh", "-c", command}); err != nil { + t.Fatal(err) + } + waitFor(t, "relative runtime identity exit persisted", func() bool { + cfg, err := st.Load() + if err != nil { + return false + } + rec := cfg.Sessions["opencode:a1b2c3d5"] + return rec.LastExitCode != nil && *rec.LastExitCode == 0 && rec.ProviderSessionID == "ses_relative123" + }) + for _, path := range []string{statePath(absoluteRuntimeDir, name), SocketPath(absoluteRuntimeDir, name), handoff} { + if _, err := os.Lstat(path); !os.IsNotExist(err) { + t.Fatalf("runtime file not removed after exit: %s: %v", path, err) + } + } +} + +func TestCreateSessionPreservesShortRelativeSocketPathInDeepWorkingDirectory(t *testing.T) { + root, err := os.MkdirTemp("", "uam-deep-") + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = os.RemoveAll(root) }) + deepCwd := filepath.Join(root, strings.Repeat("d", 90)) + if err := os.MkdirAll(deepCwd, 0o700); err != nil { + t.Fatal(err) + } + originalCwd, err := os.Getwd() + if err != nil { + t.Fatal(err) + } + if err := os.Chdir(deepCwd); err != nil { + t.Fatal(err) + } + t.Cleanup(func() { + if err := os.Chdir(originalCwd); err != nil { + t.Errorf("restore working directory: %v", err) + } + }) + t.Setenv("UAM_CONFIG_DIR", filepath.Join(root, "cfg")) + exe, err := os.Executable() + if err != nil { + t.Fatal(err) + } + c := &Client{Dir: ".uam", Exe: exe} + t.Cleanup(func() { _ = c.KillAll(context.Background()) }) + + name := "uam-fake-dead0001" + if err := c.CreateSession(context.Background(), name, root, nil, []string{"/bin/sh", "-c", "sleep 60"}); err != nil { + t.Fatalf("CreateSession with short relative socket path: %v", err) + } + if !c.HasSession(context.Background(), name) { + t.Fatal("relative-path session should be live after create") + } +} + +func TestProviderIdentityStaleHostCleanupRemovesAllRuntimeFiles(t *testing.T) { + c := newTestClient(t) + name := "uam-opencode-aabbccdd" + if err := writeState(c.Dir, State{Name: name, HostPID: 1 << 28, ChildPID: 1 << 28, CreatedUnix: 1}); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(SocketPath(c.Dir, name), []byte("stale"), 0o600); err != nil { + t.Fatal(err) + } + if err := WriteProviderIdentity(c.Dir, name, "ses_stale"); err != nil { + t.Fatal(err) + } + providerPath := providerIdentityTestPath(t, c.Dir, name) + + infos, err := c.List(context.Background()) + if err != nil || len(infos) != 0 { + t.Fatalf("List stale session = (%+v, %v), want empty success", infos, err) + } + for _, path := range []string{statePath(c.Dir, name), SocketPath(c.Dir, name), providerPath} { + if _, err := os.Lstat(path); !os.IsNotExist(err) { + t.Fatalf("stale runtime file not removed: %s: %v", path, err) + } + } } func TestNaturalAgentExitBeforeRecordPersistenceEventuallyRecordsExit(t *testing.T) { @@ -331,10 +521,20 @@ func TestKillAllIsIdempotent(t *testing.T) { if err := c.KillAll(ctx); err != nil { t.Fatalf("KillAll on empty dir: %v", err) } - for _, name := range []string{"uam-fake-aaaa1111", "uam-fake-bbbb2222"} { + names := []string{"uam-fake-aaaa1111", "uam-fake-bbbb2222"} + states := make(map[string]State, len(names)) + for index, name := range names { if err := c.CreateSession(ctx, name, t.TempDir(), nil, []string{"/bin/sh", "-c", "sleep 60"}); err != nil { t.Fatalf("CreateSession %s: %v", name, err) } + if err := WriteProviderIdentity(c.Dir, name, fmt.Sprintf("ses_cleanup%d", index)); err != nil { + t.Fatalf("WriteProviderIdentity %s: %v", name, err) + } + state, err := readState(c.Dir, name) + if err != nil { + t.Fatalf("readState %s: %v", name, err) + } + states[name] = state } if err := c.KillAll(ctx); err != nil { t.Fatalf("KillAll: %v", err) @@ -343,6 +543,24 @@ func TestKillAllIsIdempotent(t *testing.T) { if err != nil || len(infos) != 0 { t.Fatalf("sessions remain after KillAll: %+v %v", infos, err) } + for _, name := range names { + state := states[name] + waitFor(t, "KillAll process cleanup for "+name, func() bool { + return !state.hostAlive() && !state.childAlive() + }) + if state.hostAlive() || state.childAlive() { + t.Fatalf("process remains after KillAll for %s: host=%d child=%d", name, state.HostPID, state.ChildPID) + } + providerPath, err := ProviderIdentityPath(c.Dir, name) + if err != nil { + t.Fatal(err) + } + for _, path := range []string{statePath(c.Dir, name), SocketPath(c.Dir, name), providerPath} { + if _, err := os.Lstat(path); !os.IsNotExist(err) { + t.Fatalf("runtime artifact remains after KillAll: %s (%v)", path, err) + } + } + } if err := c.KillAll(ctx); err != nil { t.Fatalf("KillAll repeat: %v", err) }