diff --git a/.gitleaks.toml b/.gitleaks.toml index edacac0891..ceee478079 100644 --- a/.gitleaks.toml +++ b/.gitleaks.toml @@ -50,3 +50,14 @@ paths = ['''^pr-\d+/'''] description = "Prompt-injection steer-eval secrets fixture (inert)" targetRules = ["stripe-access-token"] paths = ['''^benchmarks/steer/fixtures/injection-project/secrets\.env$'''] + +[[allowlists]] +# The container runner's argv test carries an inert per-run proxy token +# (decision A7 in docs/plans/thread-in-container.md) so it can assert the +# token lands on the guest's proxy URL and in COPSE_EGRESS_TOKEN and nowhere +# else. The first fixture value was a hex string and tripped the entropy +# heuristic; the value is plain words now, but the path exemption also clears +# the finding in branch history. Scope to that one rule and file. +description = "Container run proxy-token fixture (inert)" +targetRules = ["generic-api-key"] +paths = ['''^src/main/services/container-runtime/thread-container\.test\.ts$'''] diff --git a/docs/acp-agents.md b/docs/acp-agents.md index ba692759fc..97584bd79f 100644 --- a/docs/acp-agents.md +++ b/docs/acp-agents.md @@ -61,6 +61,18 @@ auth), while Copse keeps ownership of the workspace and the approval UX. from whichever channel carries it. A consequence worth knowing: under Cursor, a bridged or MCP call that is auto-approved never produces a permission request, so it can keep the generic `MCP: tool` label. +- **An abandoned bridged call does not lose its approval.** MCP clients give + up on a silent tool call after their own timeout (Codex: roughly 300 s), and + a bridged `run_shell` blocked on "Run outside sandbox?" is silent for as long + as the user takes to answer. The bridge aborts the call with an + `AbandonedCallAbort` reason; the approval service (`approval.ts`) treats that + as a detach rather than a cancel — the prompt stays open, the call fails with + a clear "approval is still pending, retry the exact same call" error (which + reaches the agent when it cancelled over MCP; a dropped connection has no one + to deliver it to), and the user's eventual answer is kept for ten minutes so + the identical retry (same command, working directory and thread) reuses it + without prompting again. A retry that arrives while the prompt is still open + simply joins it. Turn end still dismisses the prompt, as before. - Because that title is also how Copse recognises **its own** bridged tools to skip a duplicate approval prompt, the same per-agent spread applies: Cursor's `copse-gh_pr_list: gh_pr_list` and Claude's `mcp__copse__gh_pr_view` are both diff --git a/docs/cursor-plugins.md b/docs/cursor-plugins.md index 08fe5ecf4c..82371e2ff5 100644 --- a/docs/cursor-plugins.md +++ b/docs/cursor-plugins.md @@ -68,12 +68,12 @@ use this module. ### Trust model -| Source | Skills trust | MCP trust | -| ------------------------- | --------------------------------------- | ----------------------------------------------------------- | -| `~/.cursor/skills` (user) | Trusted | — | -| Cursor plugin (`plugin`) | Untrusted (delimited as data in prompt) | Trusted (user installed via Cursor; full env interpolation) | -| Project workspace | Untrusted | Requires workspace trust (#100) | -| `skillPluginPaths` | Untrusted | — | +| Source | Skills trust | MCP trust | +| ----------------------------------------------- | --------------------------------------- | ----------------------------------------------------------- | +| `~/.{cursor,agents,claude,codex}/skills` (user) | Trusted | — | +| Cursor plugin (`plugin`) | Untrusted (delimited as data in prompt) | Trusted (user installed via Cursor; full env interpolation) | +| Project workspace | Untrusted | Requires workspace trust (#100) | +| `skillPluginPaths` | Untrusted | — | Plugin skills are untrusted because their text is still attacker-influenceable content (a malicious marketplace plugin). Plugin MCP configs are treated like @@ -85,6 +85,58 @@ Merge priority for duplicate MCP server names: 2. Cursor plugin `.mcp.json` files 3. Project `.cursor/mcp.json` / `.mcp.json` (only when workspace is trusted) +## Skill discovery roots and frontmatter + +Skills are `//SKILL.md` files (the folder name must equal the +frontmatter `name`). `skills-registry.ts` scans these roots, in this order; +the first skill loaded for a name wins, so an earlier root overrides a later +one: + +1. **User** — `~/.cursor/skills`, `~/.agents/skills`, `~/.claude/skills`, + `~/.codex/skills` (source `user`, trusted). `.codex` is the Codex CLI's + layout; it was added after a Codex-backed thread could not find the skill it + had been asked to run (reconcile-worktrees post-mortem, 2026-09-09). +2. **Bundled Cursor plugin skills** shipped with Copse (`bundled`, trusted). +3. **Project** — the same four container directories under the workspace, + including monorepo packages, but never inside a nested repository such as a + `.claude/worktrees/*` checkout (`project`, untrusted). Within the project + scope the containers keep the order above. +4. **Cursor plugins** (`~/.cursor/plugins/{local,cache}`) and + `skillPluginPaths` (`plugin` / `plugin-path`, untrusted). +5. **Built-in skills** shipped in `assets/skills` (`bundled`); last, so any + user or project skill of the same name overrides a first-party one. + +### Frontmatter + +```yaml +--- +name: reconcile-worktrees # must match the folder name +description: One line the model sees in the catalog +disable-model-invocation: true # optional: user-only, hidden from the model +paths: # optional: extra read-only entries, relative to this directory + - data + - references/schema.json +--- +``` + +- `disable-model-invocation` keeps a skill out of the model's catalog; the + user can still invoke it with `/name`. +- `paths` declares extra read-only entries for `run_shell`. When a skill is + invoked, that thread's sandboxed shell may **read** the skill directory for + the rest of the thread (never write to it); `paths` adds entries relative to + the skill directory. Entries are validated, not trusted: absolute paths, `~`, + `$VAR`, and `..` are rejected; anything that is or lives under a credential + file or directory (`.env*`, `.ssh`, `.aws`, key files, …) is rejected; the + home directory, the filesystem root, and any parent of home are rejected. A + symlink that leaves the skill directory is honoured only for a trusted + (`user` / `bundled`) skill. Refused entries are reported in the invoked-skill + prompt so the model does not rely on them. See + `src/main/services/skills/skill-read-roots.ts` and + `src/main/services/security/thread-read-roots.ts`. + +Reads outside these roots — and every write outside the workspace — still go +through the normal "Run outside sandbox?" approval. + ## Local development Symlink a plugin repo into Cursor's local plugins directory (from Kingston skills diff --git a/docs/plans/README.md b/docs/plans/README.md index 8cbd22b1c3..d8044cdc53 100644 --- a/docs/plans/README.md +++ b/docs/plans/README.md @@ -75,7 +75,8 @@ Last audited against `main`, GitHub issues, and open PRs on **2026-07-21**. | [Terminal file links](terminal-file-links-improvements.md) | Partial / deferred | File links and line/column navigation shipped in [#415](https://github.com/copse-dev/agent-pane/pull/415). Cwd-aware resolution is deferred. | | [Thread referencing](thread-referencing.md) | Resolved core | [#644](https://github.com/copse-dev/agent-pane/issues/644) is closed and [#647](https://github.com/copse-dev/agent-pane/pull/647) shipped the filesystem-native store and `@` references. Lazy loading and streaming partials remain separate follow-ups. | | [UI kit](ui-kit.md) | Active | First slice: `.ui-btn*` CSS + light-DOM `` / `` under `src/renderer/ui/`. No button factory (class sugar only). Panel shells (browser/terminals) planned next. Shadow DOM deferred. | -| [Unattended runs on a contained runtime](unattended-runs.md) | Proposed | Proposes an unattended-run mode on a per-thread Docker runtime, separate from Guarded YOLO, so long-horizon work continues with nobody watching. Nothing implemented. Consumes `execution-runtime-security.md` (capabilities, egress, credentials), `copse-cloud-workspaces.md` C1 (local-docker provider), and `deferred-approvals.md` (non-blocking gate); measured with the Docker eval harness in `industry-benchmarks.md`. | +| [Running a thread inside a container](thread-in-container.md) | Active (prototype on branch) | A thread runs unattended inside a hardened local Docker container with no prompts: the headless agent host runs in the guest, an attested `container` tier lets the gate allow contained effects and defer outward ones (`shell-outward-effect`), egress is a named unix-socket broker, and work returns as commits under `refs/copse/runs/`. Records where it diverges from `unattended-runs.md` (loop in the guest; one provider key in the guest). Driven by `pnpm run thread:container`; end-to-end test is opt-in. | +| [Unattended runs on a contained runtime](unattended-runs.md) | Proposed | Proposes an unattended-run mode on a per-thread Docker runtime, separate from Guarded YOLO, so long-horizon work continues with nobody watching. Nothing implemented under its own decisions; `thread-in-container.md` prototypes the mode with the loop in the guest and records the divergence. Consumes `execution-runtime-security.md` (capabilities, egress, credentials), `copse-cloud-workspaces.md` C1 (local-docker provider), and `deferred-approvals.md` (non-blocking gate); measured with the Docker eval harness in `industry-benchmarks.md`. | | [Unowned capability gaps](unowned-capability-gaps.md) | Reference audit, dispersed | 2026-08-07 audit against the capabilities local-first assistants have converged on, excluding anything with an existing plan or issue. All ten findings now live elsewhere: [#1570](https://github.com/copse-dev/agent-pane/issues/1570) (plan-step schema), [#1571](https://github.com/copse-dev/agent-pane/issues/1571) (todo-check root, unverified), [#1572](https://github.com/copse-dev/agent-pane/issues/1572) (LAN peer decision), [#1573](https://github.com/copse-dev/agent-pane/issues/1573) (R-05 profiles), plus amendments to eight existing documents. Kept as evidence, not a backlog. | | [User control surface gaps](user-control-surface-gaps.md) | Proposed | Audit of the missing session-control surface with per-requirement ownership across 32 plans, 66 open issues and a 139-item roadmap. 22 requirements, R-01–R-22; only six need a new issue and two of those are decision records. Phase 0 is session control and task lifecycle. Companions: `mission-control.md`, `competitive-landscape.md`. | | [VNC remote desktop](vnc-remote-desktop.md) | Active (V0/V1 first release) | The opt-in, read-only Desktop pane now connects to loopback or an active SSH workspace through loopback-only ControlMaster forwards. Main owns the socket; an IPC-backed channel feeds bundled noVNC 1.5.0 without relaxing renderer CSP. Forward teardown is tied to connection, host, owner, and app lifecycles; focused unit and fake-RFB visual coverage are included. Reconnect reconciliation, a generic browser consumer, and the live-host harness remain V0/V1 follow-ups. V2+ human input, discovery, credentials, screenshots, and all agent control remain unimplemented because desktop input bypasses the shell permission gate. | diff --git a/docs/plans/automations.md b/docs/plans/automations.md index cbd4007a2c..43bd31613e 100644 --- a/docs/plans/automations.md +++ b/docs/plans/automations.md @@ -91,6 +91,30 @@ ingress waits for the detached worker/control-plane phase. ## Pack boundary +### Integrated editor + +The side cog opens a menu with **Automations** and **New automation…**. The +adjacent Settings label remains a direct shortcut. Both menu actions mount the +same project-scoped editor used in Settings in a native modal, with an explicit +plugin enable/disable action. Changing enablement preserves an unsaved draft. +Sidebar automation setup links use this modal too; the modal closes if the active +project changes. No second store, scheduler, or form is introduced. + +The first-party plugin declares `automation-manager` in the level-3 `app-dialog` +slot. The host only exposes this shipped view when the matching first-party +declaration is present. Like Settings, configuration is reachable while disabled; +the plugin flag continues to gate scheduled execution and Run now. + +Acceptance criteria: + +- The side cog opens the automation list or a new automation form without opening Settings. +- Settings and the modal edit the same project-owned schedules using the same editor. +- Plugin enablement preserves an unsaved draft; disabled plugins cannot run automations. +- Sidebar setup links open the named schedule in the modal, and project changes close it. +- Focused Electron coverage saves cog-menu, creation, and management screenshots. + +### Ownership + `copse.automations` is a default-off first-party pack. The pack owns atomic enablement, a level-3 `settings-pack-detail` UI declaration, and its namespaced storage declaration. Host code owns the clock and thread-store write; renderer diff --git a/docs/plans/event-automation-inbox.md b/docs/plans/event-automation-inbox.md new file mode 100644 index 0000000000..de6e29c3a7 --- /dev/null +++ b/docs/plans/event-automation-inbox.md @@ -0,0 +1,103 @@ +# Event automation inbox: first implementation + +Implements slice A of the [event-driven automation proposal](https://github.com/copse-dev/agent-pane/pull/2594). +This is an internal admission and recovery boundary, exercised with an injected +adapter and durable filesystem fixtures. App startup does not install it yet. It +adds no UI, connection, polling timer, webhook endpoint or model execution. +Existing cron schedules and the supervisor's one-shot `emitEvent(string)` remain +unchanged. + +## Implemented contract + +`AutomationEventInbox.admit` validates normalized source evidence against a +host-owned projection of a saved definition. The source cannot select the +workflow, model, instructions, permissions or execution path. Unknown fields are +rejected. Project, repository, source, event type and authenticated connection +must agree with the definition and registered adapter. A definition revision is +required so an in-flight poll cannot adopt an edited definition accidentally. +Automation-originated events are filtered by default, including cross-automation +chains. Filtered deliveries retain an explanation. + +Admission persists a receipt before returning; it does not enqueue. A polling +adapter must advance its durable cursor only after this acknowledgement. +`reconcile(projectId)` claims admitted receipts with a deterministic run identity +and calls `TaskSupervisor.enqueueOnce`. Claims and queue writes can be retried +after interruption without creating another task. The same identity becomes the +thread ID at the injected draft-preparation boundary. Supervisor tasks retain +normal concurrency accounting, cancellation, audit and a three-attempt recovery +limit. A reported preparation failure is fenced, not automatically retried. + +`enqueueOnce` compares the caller's content fingerprint, handler and thread ID +against existing metadata, including archived tasks. Corrupt or missing metadata +in an existing task slot is an error; it is never interpreted as permission to +create another run. Callers must hash all immutable execution input into the +fingerprint. The inbox hashes its entire normalized delivery and saved binding. + +Before enqueue and again inside the handler, the inbox rechecks the saved +binding, plugin availability, adapter matching, resource freshness and the host's +permission/limit decision. A changed definition, unavailable project or denied +check permanently fences this receipt. A future explicit retry must create a new +manual identity; re-enabling the plugin does not replay fenced work. + +## Storage and host responsibilities + +Receipts live under +`~/.copse/workspace//event-inbox/.json`, following the normal +`COPSE_DIR` / `COPSE_WORKSPACE_DIR` overrides. Identity includes the project, +automation, saved revision, authenticated source/connection and normalized +delivery ID. The immutable evidence and mutable disposition are atomically +replaced in one file. The file is flushed before rename; this covers interrupted +process writes, not a cross-process database or a power-loss transaction. + +For this slice, redacted payload evidence is **inline**, capped at 64 KiB UTF-8, +and hashed. Structured facts are bounded to 32 scalar entries with bounded keys +and string values. Keeping evidence in the same atomic receipt avoids introducing +a missing-blob recovery case before there is a real source. Blob extraction and +retention must preserve the receipt's deduplication identity in slice B; do not +expire that identity while the source can replay the delivery. No automatic +receipt deletion ships here. + +The host must: + +- Register the inbox handler before starting the supervisor, using a real, + authenticated adapter and a stable saved-definition projection. The projection + includes the original permission snapshot; it must not mint a new `capturedAt` + on every read. This implementation registers one adapter per supervisor. +- Supply side-effect-free matching and authorization checks. Connection scope, + resource freshness and run/worktree budgets belong to those checks. Authentication + and upstream replay-age checks belong to the source adapter. +- Prepare drafts idempotently with the supplied run/thread ID, checking the saved + definition under its writer lock and the abort signal at the commit boundary. + Preparation must not run a model or perform external writes. A crash can call + this boundary again after the draft already exists. +- Mark the plugin/definition unavailable, then await `fence` before completing + disablement or deletion. Admission and lifecycle fencing share a serialization + lock. Stop source subscriptions as part of the same host lifecycle. Fencing only + cancels pending preparation tasks; already-prepared user tasks are untouched. +- Run reconciliation on startup and after durable admission. Single-app ownership + follows the existing supervisor/store model; multiple independent processes + must not write the same profile. + +No production setting or manifest capability is introduced only for tests. Slice B +will supply the saved-definition writer, real authenticated CI polling, app-open +turn dispatch, coalescing and cost/worktree budget enforcement. Those are required +before exposing event automations in the shared modal/Settings editor. This slice +makes no claim that a stored permission snapshot authorizes later tool use. + +## Acceptance criteria and evidence + +The proposal's slice A criteria are: + +- Duplicate delivery, including after restart, produces one run identity. +- Crashes between inbox write, claim, enqueue, and thread creation recover that run. +- Invalid source, target, payload bounds, and definition revision cannot dispatch. +- Disablement/deletion fences pending work while preserving historical records. + +`event-inbox.test.ts` exercises these against the real filesystem inbox and real +supervisor/task store, injecting failures after persistence and interrupting a +handler after its durable thread fixture is written. It also covers concurrent +admission/fencing, changed evidence, UTF-8 payload limits, causal-loop rejection, +failed preparation and terminal-state preservation. +`task-supervisor-idempotency.test.ts` covers concurrent enqueue, archived identity, +conflicting input and corrupt/missing identity records. These are invisible +main-process changes, so no renderer visual eval is required. diff --git a/docs/plans/event-driven-automations.md b/docs/plans/event-driven-automations.md new file mode 100644 index 0000000000..a441e36e12 --- /dev/null +++ b/docs/plans/event-driven-automations.md @@ -0,0 +1,197 @@ +# Event-driven automations + +Status: **Implementation proposal. No event automation runtime ships with this document.** + +Extends [Project automations](automations.md) and the authenticated-trigger phase of +[Background supervisor](background-supervisor.md#p6--campaigns--authenticated-trigger-adapters). +The existing cron prototype remains app-open and project-scoped. This proposal does +not turn a renderer-owned task into a headless worker. + +## Product shape + +Keep one Automations manager, reachable from the side cog and from a run's setup +action. The plugin owns the feature and its saved definitions; Settings owns global +plugin enablement and credentials. The manager and Settings share the editor. + +An automation reads as **When → Conditions → Task → Limits**: + +- **When:** on a schedule, when CI finishes, when a PR changes, or when an issue + receives a selected label. Show only trigger types supplied by installed, + enabled adapters with the required connection available. +- **Conditions:** repository, branch, selected checks or labels, and whether work + created by automations is eligible. Start with structured controls rather than + a free-form expression language. +- **Task:** the user's saved instructions, model rule, and project. Event text is + attached as bounded source material, never substituted into system instructions. +- **Limits:** one active run by default, a retained-worktree cap, maximum run + duration, and a finite run/cost allowance. Keep tool approvals on the existing + permission path; saving a trigger is not approval for a later external action. + +Before saving, show one plain-language sentence: “When CI finishes unsuccessfully +on a PR targeting main in this repository, investigate the failure. At most one +run per PR head and three runs per day.” A **Test match** action evaluates recent +events and shows what would match, without starting a model or changing files. + +The list shows the trigger summary, enabled/paused state, and last outcome. Expand +an automation for recent deliveries and their linked tasks. Each delivery says +why it started, was filtered out, was coalesced, or needs attention. Keep task +history in the existing sidebar disclosure; do not add a permanent second run list +to the sidebar. Notify for completion with useful output, failure, or user action; +unchanged polling results stay quiet. + +## First adapters, in order + +| Adapter | Concrete first workflow | Delivery identity and stale-work rule | +| -------------------- | --------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------- | +| CI completed | Investigate a failed check suite on a selected PR | Repository + PR + head SHA + check-suite/run ID + attempt; a new head supersedes pending work for the old head. | +| PR updated | Review new commits after a PR leaves draft | Repository + PR + head SHA + selected transition; ignore metadata-only edits unless explicitly requested. | +| Issue labelled | Triage issues assigned an explicit automation label | Repository + issue + label transition ID; repeated polls do not create new work, but removing and reapplying the label is a new event. | +| Local task completed | Produce a report after a user-started task | Project + source task + terminal revision; ignore automation-originated tasks by default and retain turn-tree budgets for continuations. | + +Start with CI completion through authenticated, app-open GitHub polling. Reuse the +existing CI-read and supervisor event-source machinery, sharing a source across +subscribers; do not create one interval timer per automation. The current +`emitEvent(string)` wakes one-shot waiters and carries neither a durable delivery +identity nor a payload. It is a wake signal, not the external event inbox described +below. Keep `wait_for_ci_checks`' existing continuation and stale-epoch semantics. + +Unrestricted filesystem watchers are a later adapter: editor saves, generated +files, dependency installs, and an automation's own changes can cause storms. +Require explicit path filters, a quiet interval, origin attribution, and one +pending aggregate before adding them. Generic webhooks, ticketing, chat/mobile, +and security advisories follow the same ingress contract once it is proven. + +## Ownership and plugin contract + +The `copse.automations` plugin owns definitions, defaults, the editor declarations, +and its lifecycle. Trigger adapters declare stable source IDs, supported event +schemas, structured filter fields, required connection/capabilities, and delivery +normalization. The shared supervisor owns durable inbox admission, execution +queues, concurrency, budgets, cancellation, and audit records. + +Do not add nullable webhook, label, or event fields to the existing cron-only +`AutomationSchedule`. Introduce a versioned workflow definition with a +discriminated trigger configuration when slice B has a real writer and runtime. +Migrate existing schedules losslessly to the schedule variant, preserving IDs, +prompts, model rules, cron expressions, enablement, and run history. The +supervisor envelope remains a separate immutable delivery record. + +First-party adapters receive explicit host services. A selected user plugin must +use its existing validated, sandboxed runtime capabilities; a trigger declaration +does not grant network access, filesystem writes, arbitrary IPC, or the ability +to mount renderer code. Do not expose an unused generic `host.call` or let a +manifest self-promote its trust tier. An adapter not supported by a real runtime +must be rejected at registration instead of appearing as a usable trigger. + +Disabling an adapter stops its sources and marks dependent automations unavailable. +Disabling Automations stops admission and fences queued deliveries; re-enabling +does not replay an unknown backlog. Already-running tasks retain their normal +stop control. Definitions and historical evidence survive disablement and removal. + +## Durable delivery and execution + +Normalize to an immutable, versioned envelope before any handler is selected: + +- Source/adapter ID, authenticated connection identity, external delivery ID, + event type/version, and observed/occurred timestamps. +- Repository/project target, resource ID and revision, bounded payload blob + reference plus hash, and normalized structured facts used for matching. +- Automation ID plus saved-definition revision/hash, workflow/profile identity, + origin/causation IDs, requested verification policy, and captured limits. + +The source supplies event facts, not a replacement prompt, arbitrary project path, +model, tool grants, or a workflow name of its choosing. Validate target ownership +and connection scope against the saved definition. Credential bytes never enter +the envelope. Preserve enough redacted source evidence to explain the match and +support a later replay, with bounded retention. + +1. Persist the envelope and matching result in the supervisor inbox. Polling + advances a durable cursor only after that write. Webhooks acknowledge only + after admission; verify signatures and replay age before admission. +2. Atomically claim `(automation ID, definition revision, normalized delivery ID)` + while assigning a stable run ID. Enqueue an idempotent supervisor task bound + to that run. Recover a crash between claim and enqueue by reconciling the + claimed run, not by generating a new identity. +3. Recheck plugin enablement, project availability, saved-definition revision, + resource freshness, and permission policy before dispatch. Definition edits + hold older pending deliveries for review; they never execute a newly edited + prompt under an older event's authorization. Deleted definitions remain terminal. +4. Apply limits before creating a thread or checkout. Event deliveries coalesce + by resource/revision, with at most one pending newest delivery behind a running + one. Never cancel a user's unrelated active task or overwrite a dirty checkout. + Show superseded/coalesced dispositions rather than silently dropping events. +5. Create or recover the run's thread idempotently, with automation ID, delivery + ID, trigger summary, and causation recorded. Dispatch through the shared turn + contract. App-open mode waits visibly for the relevant project/renderer; a + future detached runtime must use the same contract. +6. Record outcome, attempts, result references, spend, and intervention. Retry + transient ingestion failures with bounded backoff and jitter. Do not retry a + potentially completed external write merely because the response was lost. + Explicit **Run again** creates a new manual run linked to the original delivery. + +Exactly-once delivery is not assumed. Stable identities, idempotent admission and +actuation, and recovery tests are the guarantees. A same-automation causation +chain is rejected by default; cross-automation chains need an explicit opt-in, +finite depth, and shared run budget. CI caused by an automation's own PR must not +silently restart its producer indefinitely. + +## Other improvements worth shipping independently + +1. **Schedule presets and preview:** daily, weekdays, weekly, custom cron; show + the next three local occurrences and timezone. Preserve custom expressions + when switching editors and make daylight-saving behavior explicit. +2. **Run outcomes in the manager:** distinguish never run, starting, waiting for + project, running, needs approval, succeeded, failed, and skipped due to limits. + Link directly to the task and explain what unblocks it. +3. **Pause and duplicate:** pause one definition without editing its prompt; + duplicate as a disabled draft so copying cannot accidentally double live work. +4. **Templates:** plugin-defined starting prompts and trigger defaults for CI + investigation, issue triage, and docs freshness. Templates create editable + definitions, not hidden hardcoded behaviors. +5. **Verification as explicit policy:** optional independent verification only + with saved model/cost limits and a structured result contract. Never silently + turn on extra billable model work. + +## Reviewable implementation slices + +### A. Inbox and idempotent run identity + +No UI or source connection yet. Implement the supervisor admission/reconciliation +contract with an injected adapter and durable storage fixtures. + +Acceptance criteria: + +- Duplicate delivery, including after restart, produces one run identity. +- Crashes between inbox write, claim, enqueue, and thread creation recover that run. +- Invalid source, target, payload bounds, and definition revision cannot dispatch. +- Disablement/deletion fences pending work while preserving historical records. + +### B. CI adapter and shared editor controls + +Add one real authenticated polling adapter, explicit repository/check filters, +test-match preview, trigger summary, and linked run status. Keep it app-open until +the shared detached-turn contract is ready. + +Acceptance criteria: + +- A seeded failed CI transition matches once; identical polls remain quiet. +- A replaced head suppresses old pending work, and an automation's own output + cannot recursively trigger it by default. +- Paused/unavailable adapters cannot enqueue or start a model turn. +- The modal and Settings use the same controls; focused visual tests cover new, + paused, unavailable, preview, and waiting-for-project states. +- Approval prompts, worktree limits, and concurrency budgets apply through the + normal execution path. + +### C. Additional adapters and ingress + +Add PR and issue-label adapters using the same envelope, then local completion. +Webhooks require the detached control-plane/worker and credential-verification +boundary; accepting a URL in the editor alone does not create reliable ingress. + +Acceptance criteria: + +- Each adapter supplies contract fixtures for duplicate, stale, malformed, + unauthorized, and out-of-order events. +- Shared sources detach when their last enabled subscriber disappears. +- Every admitted or rejected delivery has a bounded, readable explanation. diff --git a/docs/plans/hooks-and-feature-packs.md b/docs/plans/hooks-and-feature-packs.md index cbb74826ee..f041225c38 100644 --- a/docs/plans/hooks-and-feature-packs.md +++ b/docs/plans/hooks-and-feature-packs.md @@ -201,6 +201,19 @@ revisiting this document, not silently diverging in an implementation PR. tabs and durable session remain. Direct network, arbitrary IPC/Electron access, generic host calls, and unused renderer contribution placeholders remain unavailable. + **External function-hook registration (SDK stage 1):** a selected user plugin may + declare `runtime.hooks: [{ id, event }]`, including a hook-only runtime. Events + use the canonical catalogue; IDs are unique per plugin. API v1 adds + `registerHook(definition, handler)` during activation and an `invoke-hook` + request carrying the registration ID, event, and opaque JSON input. Both the + host and worker check the registration/event pair; startup rejects missing, + extra, duplicate, or event-mismatched registrations. Handlers receive only + `{ event, signal }`, never first-party context, feature-chunk emission, browser + or session authority. Existing workers may omit the hooks list. This stage + establishes registration and explicit host invocation only: canonical fire + sites do not dispatch these hooks yet, and results are not interpreted as + decisions or transformations. Automatic dispatch, event-specific payload/result + validation, and `next()` composition require their own implementation stages. 16. **Async hook outputs are epoch-scoped to their emitting turn tree.** Send-now currently aborts the active local run (`sendQueuedMessageNow` in `src/renderer/controller/message-queue.ts`), so a late async hook from a completed diff --git a/docs/plans/nested-instruction-discovery.md b/docs/plans/nested-instruction-discovery.md new file mode 100644 index 0000000000..0ef30cdd7e --- /dev/null +++ b/docs/plans/nested-instruction-discovery.md @@ -0,0 +1,9 @@ +# Referenced-path instruction discovery + +PR #1976 originally performed a serial full-tree scan at the start of every turn, even when a prompt referenced one file. On a macOS Node 24.20 fixture with 100 groups of 100 directories, five fresh-turn activations took 650, 614, 773, 596 and 633 ms; same-turn cache hits took less than 1 ms. + +Turn-time discovery now reads only each referenced path's ancestor scopes, with at most 64 distinct context paths per call and 16 ancestor levels. Concurrent tool calls share in-flight reads of each scope. Missing instruction files are cached too. Every turn owns a fresh cache; a file-tool write, rename or deletion of AGENTS.md invalidates that execution root. A previously unseen scope is read on first entry. Already-read scopes changed through an external editor or shell are refreshed next turn. Already-injected rules remain fixed for the current turn; invalidating discovery permits new activations, not replacement of existing prompt instructions. No filesystem watcher or cross-turn cache is trusted for prompt correctness. + +Settings retains the bounded full inventory and its existing 30-second cache. Instruction precedence, workspace trust, directory-symlink exclusion, nested checkout boundaries, generated-directory exclusion, file-size limits and prompt budgets remain enforced. A symlinked instruction file still passes through the existing canonical-path read boundary. The existing handwritten filter predicate is replaced with TypeScript's inferred predicate. + +The same fixture after the change measured fresh-turn activations of 14, 13, 5, 8 and 6 ms while other validation ran. These are local measurements, not a cross-platform latency guarantee. Run `pnpm test -- nested-instruction-latency` to repeat the real public activation-path benchmark; it prints each fresh-turn and cached duration. `pnpm test -- project-instructions` covers scope freshness, explicit invalidation, trust and repository/symlink boundaries. diff --git a/docs/plans/pr-panel-github-parity.md b/docs/plans/pr-panel-github-parity.md new file mode 100644 index 0000000000..4a81bab62d --- /dev/null +++ b/docs/plans/pr-panel-github-parity.md @@ -0,0 +1,132 @@ +# PR panel: GitHub parity in small PRs + +Audited 2026-09-09; implementation rebased onto current main before opening the PR. Start with visibility into feedback and CI; add writes as separate, +reviewable changes. This is a sequence of proposed PRs, not opened GitHub PRs. + +## Current coverage and gaps + +| Area | Existing Copse behavior | Gap | +| -------------------- | ------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------- | +| PR discovery | Chat links, workspace open PRs, lazy cross-repo authored PRs, refresh, CI dots | Search, filters, closed/merged history, review-requested queue, pagination | +| Overview | Markdown description, branches, change totals, draft/approved/auto-merge badges | Full lifecycle and review status, reviewers, labels, merge blockers | +| Conversation | Description only before PR 1 | Comments, reviews, inline discussions, replies, history | +| Checks | Overall dot and rerun-failed action | Named results, head SHA, logs, annotations, freshness, required checks | +| Files changed | Collapsible file list and Monaco diff | Inline threads, viewed state, filtering, complete file pagination | +| Commits | No section | Commit list, authors, dates, per-commit diff | +| Review/merge actions | Approve, mark ready, enable auto-merge | Request changes, review body, reviewer requests, merge readiness and method selection | + +## Delivery order + +Each item should be independently reviewable and include focused validation. PR 1 is the +implementation in this worktree; the remaining items are planned. Avoid bundling all of them +into one feature branch. + +### PR 1 — Read comments and check results (implemented) + +- Add Overview / Comments / Checks navigation while keeping the existing file viewer in Overview. +- Read conversation comments and submitted reviews, ordered by timestamp, with author, rendered + Markdown, and review outcome. Keep one PR-level Open on GitHub action instead of repeating it + on every comment. This does not yet include inline code discussions. +- Show individual head-commit check runs **and legacy status contexts**, with explicit state and + detail links. Keep failed results visible alongside running jobs; cancelled/unknown are not green. +- Share one GraphQL query/parser between CLI and token backends. Use the existing details IPC; + no new write permission or action is introduced. +- Bound initial results to the latest 50 comments, latest 50 reviews, and first 100 checks. + Label truncation and link to GitHub; label unavailable data instead of claiming it is empty. +- Keep manual refresh and the existing rerun action. Guard out-of-order detail responses. +- Validate parser, renderer, backend wiring, existing PR navigation/diff, and focused Electron + screenshots: `pr-activity-overview.png`, `pr-activity-comments.png`, `pr-activity-checks.png`. + +### PR 2 — Write conversation comments + +- Add a Markdown composer with preview, explicit Post comment, pending state, and errors. +- Persist drafts by repository/PR; preserve text on error, refresh, navigation, and retry. +- Implement one comment mutation across CLI/API/mock, with main-frame IPC validation. +- Reconcile the returned comment ID; do not automatically repeat a mutation after an uncertain + response. Keep edit/delete/reactions outside this PR. +- Test successful post, denied permission, network failure, duplicate-click prevention, and draft recovery. +- Depends on PR 1. + +### PR 3 — Paginated inline review discussions + +- Load review threads with path/line, replies, outdated/resolved markers, and explicit Load more. +- Show them in Comments; link to the matching file diff when the current line is available. +- Paginate conversation comments/reviews too, preserving order and deduplicating by ID. +- Read-only first: no reply or resolve actions yet. +- Test deleted authors/files, outdated lines, multiple pages, and switching PRs during loading. +- Depends on PR 1. + +### PR 4 — Reply to and resolve review threads + +- Add reply and resolve/unresolve actions with permission-aware controls and per-thread pending state. +- Retain drafts on failure and reconcile responses by thread/comment ID. +- Test unavailable permissions, stale threads, failed writes, and reply ordering. +- Depends on PR 3; reuse PR 2's draft and mutation behavior. + +### PR 5 — CI refresh and run controls + +- Refresh the visible Checks section while work is pending; stop polling when hidden/disposed. +- Pin reads and reruns to the head SHA, not only the branch name. Current rerun backends query by + branch, which needs fixing before offering more granular run controls. +- Add check pagination, last-updated/retry state, and selected-run rerun; show logs/annotations + through detail links initially. Keep required-check/merge-rule interpretation for PR 7. +- Fix list rollup gaps: fetch errors currently become `no_checks`, pending results can hide failures, + and stale responses can affect cached list indicators. Guard stale CI cache responses on workspace changes. +- Test push-during-refresh, same-name runs on different commits, cancellation, legacy providers, + rate limits, and partial failures. +- Depends on PR 1. + +### PR 6 — Commits section + +- Add a paginated commit list with subject, author, timestamp, short SHA, and GitHub link. +- Ship list navigation first; per-commit Monaco comparison can be a follow-up PR. +- Test force-push refresh, deleted authors, long subjects, and empty/unavailable states. +- Depends on PR 1 navigation. + +### PR 7 — Merge readiness and reviewers + +- Surface open/closed/merged, changes requested/review required, conflicts, required checks, + unresolved conversations, and requested reviewers. Distinguish unknown from mergeable. +- Respect repository rules and viewer permissions. Show reasons the existing approve/ready/ + auto-merge actions cannot run. Do not infer readiness from green CI alone. +- Read-only readiness first; direct merge, merge-method selection, reviewer requests, and + metadata editing each merit a separate follow-up. +- Test draft, self-review, stale approval, conflicts, pending rule evaluation, and restricted repos. +- Depends on PRs 1 and 5. + +### PR 8 — Files and PR discovery polish (split into two independent PRs) + +- **8a Files:** complete file pagination, filter by path/status, viewed state, per-file change totals. + Test >100 files, renames/deletions, and reviewed-state invalidation after a push. +- **8b Discovery:** search plus open/closed/merged and review-requested filters, explicit loading/ + error/empty states, Load more. Test cross-repo identity and out-of-order search results. +- These can follow PR 1 independently of the conversation write work. + +## UI direction and verification + +Keep the PR list stable on the left and switch the detail content on the right. Use one scrollable +body per activity view; wrap long names and metadata. Avoid stacking comments and CI above Monaco +where they would compete for height. Keep the action area available across sections. + +Use unit/component tests for mappings, state, and actions; add a minimal WebdriverIO Electron eval +for each new visible state with deterministic mock data. Run `pnpm run check`, `pnpm run build`, and +the affected e2e specs. Before opening each PR, rebase onto `origin/main` per AGENTS.md. + +The screenshots in PR 1 are actual Electron renders with mock GitHub content, not live PR data. + +### PR 1 validation results + +- Rebased onto `origin/main`, retaining upstream image diffs, list watching, and pop-out behavior. +- Build, typecheck, and full lint passed on Node 24.20 with pnpm 10.34.5. +- The focused parser/backend/component/mock/style suite passed: 55 tests. +- Electron passed `pr-panel-activity`, `pr-panel`, and `pr-panel-actions` using the standard + harness on Electron 44.1. Screenshots were regenerated and visually inspected. The comment view + has no per-comment GitHub action; the PR-level action remains available. +- Full-suite results and any environmental limitations are recorded in the PR description. + +## GitHub references + +- [PR sections and conversation](https://docs.github.com/en/pull-requests/reference/pull-requests) +- [Commenting on pull requests](https://docs.github.com/en/pull-requests/how-tos/review-pull-requests/commenting-on-a-pull-request) +- [Reviewing proposed changes](https://docs.github.com/en/pull-requests/how-tos/review-pull-requests/reviewing-proposed-changes-in-a-pull-request) +- [Check states and the Checks tab](https://docs.github.com/en/pull-requests/reference/status-checks) diff --git a/docs/plans/thread-in-container.md b/docs/plans/thread-in-container.md new file mode 100644 index 0000000000..acce317e36 --- /dev/null +++ b/docs/plans/thread-in-container.md @@ -0,0 +1,844 @@ +# Running a thread inside a container + +**Status: Active (prototype on the branch).** A thread can be run unattended inside a +disposable, hardened local Docker container with no user prompts: the product's own headless +agent loop runs in the guest, contained effects run without asking, outward effects are queued +for review, and the result comes back to the host as commits under `refs/copse/runs/`. +The prototype is exercised end to end by +`src/main/services/container-runtime/thread-container.integration.test.ts` (opt-in, needs +Docker), driven by `pnpm run thread:container`, and started from the app through the composer +footer ("Run unattended in a container…"). What it does **not** yet do +is listed under [What the prototype proves, and what it does not](#what-the-prototype-proves-and-what-it-does-not). +Key-capable agent models (ACP: Claude, Codex, Gemini) run in the guest under a vendor API +key; the others are offered greyed out with a per-agent reason. That route is described, and +its status tracked, under +built, under [Agent models in the guest (ACP)](#agent-models-in-the-guest-acp). + +This plan is the executable slice of two documents that were design-only: +[`unattended-runs.md`](unattended-runs.md) (the product question: what changes about asking +the user when nobody is watching and the runtime is disposable) and the remote-worker +assessment this plan was based on (the harness is already portable; what is missing is +ownership, not de-Electroning). It records where the prototype **diverges** from the +unattended-runs decisions and why, so those decisions can be revised in one place rather than +silently contradicted. + +## The starting point + +Three things were already true on `main` and made this cheap: + +1. **The run path has no Electron dependency.** `src/main/services/headless-agent-host.ts` + builds the real registry, tools, hooks, supervisor and permission policy from an explicit + profile with no renderer or IPC. The autonomy eval already runs it inside Docker for hours + unattended (`benchmarks/autonomy/Dockerfile`, `scripts/autonomy-regression-agent.mts`). +2. **The non-blocking outcome exists.** Deferred approvals D0–D1 landed: `requestApproval` is + the one seam every gate funnels through, and with a thread in deferral mode it queues the + request and throws `DeferredApprovalError` instead of opening a modal + (`src/main/services/approval.ts`, `security/deferral-mode.ts`, `deferred-approval-store.ts`). +3. **Linux containment exists.** Bubblewrap through the sandbox runtime is the Linux project + sandbox, and it initialises inside a container once Docker's default seccomp profile is + relaxed to allow user namespaces. (The prototype started that way; decision A7 below + removed the nested sandbox once it was clear the container was the boundary.) + +What was missing was the thing between them: a runtime the gate could _trust_ as a +containment boundary, and a gate rule that answers by blast radius instead of by "would a host +sandbox have contained this?". + +## Design + +```text +host guest (docker, --network none) +──────────────────────────────────────────────── ────────────────────────────────────────── +run dir ~/.copse/runtimes// /run/copse (ro) + run.json, attestation.json ──────────────────▶ read by the worker + carry-in.bundle (snapshot commit) ────────────▶ git fetch → /workspace/repo (tmpfs) + stdin/stdout (frames) ◀─▶ EgressBroker (allowlist) 127.0.0.1:3128 CONNECT proxy → link + state/ (COPSE_DIR: decision log, deferred queue) ◀ written by the gate + out/ result.json, messages.json, carry-out.bundle ◀ written at the end +record.json (review record) worker.cjs: runHeadlessAgent(...) +git fetch carry-out → refs/copse/runs/ + declareContainerRuntime(attestation) + + armUnattendedRun(thread, budgets) +``` + +- **The guest runs the loop.** `src/main/services/container-runtime/worker-entry.ts` is + bundled as a standalone main bundle (`dist/main/thread-container-worker.cjs`) and started + by the image entrypoint as an unprivileged user. It carries the workspace in, + declares its containment from the host's attestation, arms an unattended run on the thread, + and calls `runHeadlessAgent` with a fail-closed approval handler that counts every prompt + it sees. The count is part of the result and the end-to-end test asserts it is zero. +- **The host attests; the guest declares.** A guest cannot verify its own boundary. The host + writes `attestation.json` from the same `dockerRunArgs` it starts the container with + (`src/main/services/container-runtime/thread-container.ts`), and `declareContainerRuntime` + (`security/runtime-containment.ts`) refuses anything short of the bar: unprivileged uid, + read-only rootfs, `cap-drop=ALL`, `no-new-privileges`, no network beyond the broker, no + host mount outside `/run/copse`. A refused declaration leaves the worker on the desktop + rules, which prompt — and, in deferral mode, queue — so the failure is loss of + productivity, never loss of containment. +- **The gate answers by blast radius.** `ensureShellCommandPermitted` takes one new branch + before everything else when the run is an unattended run _and_ the runtime declared + `container`: the harm gate still runs (its hard denies stay hard), then + `decideContainedShellEffect` (`@copse/shell-guard/container-effects.ts`) refuses host + escapes, defers outward effects, and allows everything else — including the harm gate's + _prompt_ verdicts, which are all in-guest destructive shapes that `docker rm` undoes. A + deferral goes through the ordinary approval seam under the new prompt cause + `shell-outward-effect`, so the queue, the decision log and the D0 report all see it. +- **Two ledgers, mutually exclusive.** `UnattendedRunRegistry` + (`security/unattended-run.ts`) is beside `GuardedYoloRegistry`, session-only, per-thread, + never in settings. Arming refuses a thread with Guarded YOLO armed or active and vice + versa, and arming begins deferral mode in the same call so a gate can never see one + without the other. The gate consults the runtime _and_ the ledger; a matrix test + enumerates command class × tier × unattended state. +- **Egress is deny-by-default and named.** The container has no network interface. Its + stdin and stdout are a link to the host (`egress-link.ts`: frames over the attached + container's stdio, decision A8); the worker starts an HTTP CONNECT proxy on its own + loopback (`guest-egress-proxy.ts`) and every client in the guest is pointed at it + through `HTTPS_PROXY`/`HTTP_PROXY` (Node's global `fetch` honours them under + `NODE_USE_ENV_PROXY=1`). Each connection opens one stream on the link, `OPEN host:port`; + the host-side `EgressBroker` matches it against the run's allowlist — exact `host:port` + entries and `*.suffix:port` wildcards (`egress-rules.ts`) — then accepts and pipes + bytes, or refuses with `DENY `. TLS stays end to end. Every + connection, close and refusal is recorded with the target and byte counts, so a target + the guest asked for and did not get is in the review record. The guest can name any + destination; the host decides. (`egressResolve` lets the host dial `addr[:port]` for a + name only the guest resolves, which is how a scripted model server on loopback plays a + real origin on 443 in the tests.) +- **No credentials in the guest except one.** The model loop needs a provider key, so the + worker receives exactly that value in its environment, consumes it into the provider + client, and blanks the variable before any tool can spawn a child. Git remotes, GitHub + tokens and the host's environment never enter. A secret canary exported on the host is + checked against every host-owned surface of the run and against the guest's reported + environment key names. +- **The thread's checkout, not the project's.** A thread with an isolated worktree has its + own branch and its own uncommitted edits, so the service resolves the checkout through + `resolveThreadExecutionContext` (the cold resolver the supervisor also uses) and refuses a + root git cannot snapshot. A broken worktree fails the arming rather than silently falling + back to the project root, and the record names which checkout ran. +- **Carry-in and carry-out are git bundles.** The host snapshots the working tree (staged, + unstaged, untracked; `.gitignore` respected) into a commit without moving HEAD, bundles it + under a run-scoped ref, and the guest fetches it onto a `work` branch. At the end the + worker commits anything left uncommitted, bundles `carryInBase..work`, and the host + fetches that into `refs/copse/runs/`. The host's HEAD never moves and nothing is ever + pushed by the run. +- **Budgets end runs; modals do not.** Wall-clock and token ceilings are mandatory at arm + time. The worker aborts its own loop at the ceiling and records the reason; the host stops + the container at the wall-clock budget as a backstop. Neither Docker call is trusted to + settle: the stop has its own timeout and a bounded grace period settles the wait either + way, so a hung daemon cannot strand a run short of its cleanup. Teardown is idempotent. +- **A run is only finished when it is actually finished.** The service judges the record + rather than the guest's word: commits that were produced but could not be fetched, a + container that would not stop or reap, and a leaked secret canary all keep a run out of + the `finished` phase and are surfaced as the failure reason or a warning. The UI never + says commits are back when no ref was fetched. +- **The image is keyed to the worker build.** Reuse is decided by a fingerprint of the guest + bundle, the Dockerfile, the entrypoint, the uid and the sandbox-runtime version, stored as + an image label — so an app upgrade rebuilds instead of silently running the previous + guest's security behaviour. +- **Every run leaves a record.** `record.json` carries the image and digest, the attestation, + the egress log, the guest's result (stop reason, prompts attempted, deferrals, commits, + containment actually achieved, tokens), the carry-out ref, the container exit code, the + teardown outcome and the canary check. + +## From the app + +The feature is experimental and off by default: Settings › Experimental › Unattended +container runs turns it on, and nothing starts a container until it is (the run service +refuses, whatever the renderer asks). With it on, the composer footer's overflow menu has +**Run unattended in a container…** +(`src/renderer/views/container-run-control.ts`). The dialog it opens has two faces: before a +run it is the arming form — the composer draft as the task, the wall-clock and token budgets, +and a note of the model and the single origin the guest will be able to reach — and during +and after a run it is the status view: phase, log tail, and the review record (what was +deferred, what was committed and on which ref, what the guest could reach, the canary check, +the teardown). A banner over the composer mirrors the phase while the dialog is closed, and a +toast announces the end of the run. + +The renderer sends a prompt, a model id and two numbers over `container:run-thread`; the main +process (`container-runtime/container-run-service.ts`) resolves the checkout, the provider +(`providers/container-provider.ts`: local servers, OpenAI, OpenRouter and extra providers +through the guest's OpenAI-compatible client; Anthropic through the product's own resolver +inside the guest) and the key, builds the worker image on first use from the bundle the +build emits and the sandbox runtime copied from the app's own `node_modules`, and pushes +`ContainerRunProgress` snapshots over `container:run-changed`. Arming is written to the +thread's decision log under `mode-arming`, like Guarded YOLO, and a thread can have only one +live run. + +## Where this diverges from `unattended-runs.md` + +The unattended-runs plan was written for **Lever A on the desktop**: the loop stays on the +laptop, the container is an SSH target for the tools. This prototype runs the **loop in the +guest**. The trade is deliberate and is the one the remote-worker assessment recommends: a +loop that lives on the desktop dies when the laptop closes, and every unattended scenario the +long-horizon plans want is exactly the one where it does. + +| `unattended-runs.md` decision | Here | +| ---------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| 1 — container is an SSH host, not a new tool surface | Neither: the container runs the whole headless host, so the tool surface is the product's own, unchanged. No second transport was built. SSH is not involved in v1; it returns when the desktop attaches to a running guest. | +| 2 — provenance decides capabilities | Kept, as an _attestation_: the host records the hardening it applied; the guest declares from that record and refuses a short one. | +| 3 — no credentials in the guest | **Narrowed**, not kept: exactly one credential — the provider key — is in the guest, by value, for the run, and blanked from the environment before any child spawns. Everything else stays out. | +| 4 — egress deny-by-default and named | Kept and made structural: no interface at all; named origins only through the broker. | +| 5 — separate concept from Guarded YOLO | Kept: separate ledger, separate arming, mutually exclusive, tested both ways. | +| 6 — the gate never blocks | Kept: arming implies deferral mode; the fail-closed handler in the worker counts what would have blocked and the test requires zero. | +| 7 — budgets mandatory | Kept: refused without them; enforced in the guest and backstopped by the host. | +| 8 — every run produces a review record | Kept as `record.json`; not yet written to the thread spine as canonical events. | +| 9 — not a hostile-workload boundary | Kept. The container is the user's own disposable machine, not a multi-tenant claim. (It ran `seccomp=unconfined` while a bubblewrap nested inside it; A7 put the default profiles back.) | +| 10 — classifiers never grant authority | Kept: `decideContainedShellEffect` routes; the grant is the explicitly armed run on an attested runtime. | + +If the loop-in-guest direction is confirmed, decisions 1 and 3 in `unattended-runs.md` should +be revised to say so. Until then that document is unchanged and this one records the +divergence. + +## What the prototype proves, and what it does not + +**Proven** (by `thread-container.integration.test.ts` against a real Docker daemon, with a +scripted OpenAI-compatible model behind the broker driving the real provider client, tool +dispatch, gate, deferral queue and carry-out): + +- a whole thread completes in the guest with **zero prompts** reaching a handler; +- an in-guest destructive command (`rm -rf build …`) that the harm gate would have prompted + for runs without asking; +- `git push` is **deferred**: queued under `shell-outward-effect` in the run's own + `deferred-approvals.jsonl`, reported to the agent as not-run, never executed; +- `docker ps` is **refused** as a host escape; +- the agent's commits and its uncommitted leftovers come back under `refs/copse/runs/` + while the host's HEAD and index are untouched; +- the only connections out of the guest are to the one allowlisted origin, and the broker + logged each; +- the host's secret canary is absent from every host-owned surface and from the guest's + environment; +- teardown removes the container, removing it again reports `already-gone`, and the + managed-container sweep no longer lists it. + +**Not proven, and required before this is a product:** + +- **A real model.** The scripted server proves the plumbing; a real provider behind the + broker (`--allow api.openai.com:443` with `--api-key-env`) has been designed but not run + here — this sandbox has no provider credential. The broker forwards raw TCP so TLS is + end-to-end, and since A-1 the guest reaches any port through its loopback proxy, so no + privileged bind and no sysctl is involved. +- **Replaying a deferral from the dialog.** The record lists what is waiting, but approving + it (the host-side push) still needs deferred-approvals D2. +- **Attaching from the desktop.** The run is fire-and-collect. The assessment's route — + Copse's ACP agent role in the guest, the desktop attaching over ACP with session resume, + a fenced per-thread lease — is the next slice, not this one. Until then the desktop + reads the record and the ref, not a live transcript. +- **Review surface.** Deferrals are in the queue and the record, with no UI to approve and + replay them (deferred-approvals D2). Approving a deferred `git push` is a host-side action + by design and needs that surface. +- **Canonical spine events.** The record is a JSON file per run, not `runtime_state` / + `network_access` events on the thread spine as `execution-runtime-security.md` wants. +- **Orphan reconciliation at app start.** The sweep exists (`--list`, `--teardown`) and is + tested, but nothing runs it on startup; there is no TTL label on the container yet, so a + crashed host leaves a stopped container until the sweep is invoked. +- **Image freshness and dependency bake.** The image carries the toolchain only; the + project's dependencies install inside the guest on each run. The lockhash-gated bake from + `remote-e2e` is the obvious next step and changes what "long-horizon" costs on macOS. +- **U0's measurement.** Whether a container actually removes most prompts on real long runs + is still the empirical question `unattended-runs.md` asks first. This prototype makes the + experiment runnable; it does not answer it. + +## Known implementation traps + +Recorded because each cost time and will again. + +- **bubblewrap inside Docker needs `seccomp=unconfined`.** Docker's default profile refuses + `unshare`, so ASRT's Linux backend fails to initialise with the misleading "kernel does + not allow non-privileged user namespaces"; `apparmor=unconfined` and + `systempaths=unconfined` follow. The prototype paid that to nest a per-command sandbox; + decision A7 stopped paying it, and the attestation now records `securityProfiles`. +- **`# syntax=docker/dockerfile:1` pulls a frontend image from Docker Hub.** In a sandbox + where Hub is rate-limited or blocked the build fails before reading line 2. Leave it out. +- **`git fetch` into the checked-out branch is refused, even an unborn one.** The guest + initialises on a placeholder branch and fetches into `work`, then checks it out. +- **The sandbox runtime is not bundleable.** `@anthropic-ai/sandbox-runtime` resolves helper + files by path at run time, so it stays external and is installed into the image at the + version pinned in the lockfile. `node-pty` is aliased to a throwing stub: the worker + offers no PTY, and a missing native module at load time would otherwise stop the bundle. +- **A unix socket in a bind mount does not connect on Docker Desktop.** The first real + run on macOS finished "completed" with an empty broker log and a 403 on every request: + the socket mounted, and `connect` on it returned `ENOTSUP` (VirtioFS carries files, not + sockets). Before that, `sun_path`'s 104-byte cap had already forced the socket out of + the profile directory into a short digested temp path. Both went away with decision A8: + the link is the container's own stdio, which every backend can attach. The worker now + probes the link before anything else and fails the run by name if the host does not + answer, and a brokered run whose broker saw no connection is never a clean finish. +- **The guest's stdout belongs to the link.** With stdio as the transport, one stray + `console.log` in the worker bundle would land inside a frame. The worker takes stdout's + original `write` for the link first thing and points `process.stdout` at stderr, so the + run's log — the worker's own lines, an agent's stderr — arrives on stderr and streams + to the dialog as it happens instead of as a `docker logs` tail at the end. +- **`git add -A` fails inside a bubblewrap-contained process.** The Linux sandbox + materialises its mandatory write-deny paths (`.bash_profile`, `.vscode`, …) as mount + points in the checkout for the life of the sandboxed process + ([`linux-sandbox-rollout-followups.md`](linux-sandbox-rollout-followups.md) §0), and git + refuses to add a mount point. A pre-existing Linux limitation, not a container one; it + no longer applies in the guest since A7, and still applies to the desktop's `stage_all`. +- **The guest's uid is not the host's.** The `state`, `out` and `egress` directories under + the run directory are created world-writable so an unprivileged guest uid can write them. + They are per-run and under the user's own profile; a user-namespace remap is the cleaner + answer and is not built. +- **Some sandboxes cannot reach Docker Hub or Debian's archive.** The image takes + `--base-image` and `--build-network` (and the matching `COPSE_WORKER_*` variables) so a + mirror or a locally built base can substitute without editing the Dockerfile. + +## Agent models in the guest (ACP) + +**Status: Landed on the branch, except the real-agent run (A-3's exit gate needs a vendor +key this sandbox does not have).** The run dialog enables the key-capable ACP agents +(`container-acp-agents.ts`: Claude, Codex, Gemini) when their vendor key is in Settings and +greys out every other agent with its own reason; `resolveContainerProvider` builds an `acp` +plan for the former and refuses the latter with the same reason. The guest registers the +one agent for the run, gives it the run's key as its only variable, runs it without a nested +seatbelt, and answers its permission requests by blast radius — outward effects refused and +recorded, never deferred. The record names the harness. This section was the plan; it is +kept as the record of the decisions and of what each phase proved. + +### What an agent model is, and why the guest cannot run one today + +An `acp:[#]` selection is not a model. It is a **separate program** — `claude-agent-acp`, +`codex-acp`, `cursor-agent acp`, `gemini --acp` — that Copse spawns on `PATH` and talks to +over ACP JSON-RPC on stdio (`acp-client.ts:448`). Three things follow, and each is a +reason the guest cannot run one now: + +1. **It authenticates as the user, from its own store.** Each agent keeps an OAuth login + under `$HOME` (`acp-known-agents.ts` `homeDirs`: `.claude`, `.codex`, `.cursor`, + `.gemini`) or reads its own vendor key (`ANTHROPIC_API_KEY`, `CODEX_API_KEY`, + `GEMINI_API_KEY`). The guest's `$HOME` is an empty tmpfs; the one key it receives is + blanked before any child spawns (`worker-entry.ts`); `buildAcpAgentEnv` scrubs every + provider key from an inherited environment (`child-process-env.ts:41`); and the + secret canary asserts nothing leaked. Decision 3 in `unattended-runs.md` is the reason + all of that exists. +2. **It needs egress the broker cannot express.** The catalogue's `allowedDomains` are + wildcards — `*.anthropic.com`, `*.claude.ai`, `*.openai.com`, `*.chatgpt.com`, + `*.cursor.com` — and OAuth refresh moves between subdomains. The broker as first shipped + accepted only a literal `host:port`. Worse, its entrypoint started one + `socat TCP-LISTEN:,bind=127.0.0.1` per origin, so **two origins on 443 collided** + and the second listener died, backgrounded and unlogged. A-1 replaced that scheme + (decision A2) and this point is now met; it is kept here because it is why A2 looks the + way it does. +3. **It runs its own tool loop.** The run's headline claim — no prompt reached a handler, + outward effects queued for review — is a property of Copse's harness: + `ensureShellCommandPermitted`, `decideContainedShellEffect`, and the deferral queue. An + ACP agent executes its own tools and raises `session/request_permission` for the ones + it wants approved (`acp-agent-service.ts:95`). None of that passes the gate. OS + containment still holds — read-only rootfs, no capabilities, no interface but the + broker — so a stray `git push` still cannot reach anything. But the review queue would + be empty, and `promptsAttempted === 0` would be true for the wrong reason. + +Two smaller facts completed the picture as it stood. The binary was not in the image: the +worker image installed `bubblewrap ca-certificates git ripgrep` and nothing else, and Copse +deliberately ships none of these agents (`acp-known-agents.ts` header) — A4 bakes the +key-capable ones in. And the isolation the desktop gives an agent does not carry over: +`acp-session-host-worker.js` is a standalone bundle (`scripts/main-bundles.mts`) that +`buildWorkerImage` never stages, and `willSandboxAcpAgent` would be true inside the guest, +nesting a second bubblewrap with its own network scope inside a container that has no +network — A5 makes the container the sandbox instead. + +Everything else already works. `runHeadlessAgent` passes the model id straight through +(`headless-agent-host.ts:238`); `runAgent` routes `acp:` to `runAcpTurn` with no changes +(`agent-service.ts:1254`); the only reason the guest's turn would fail is +`getAcpAgent` reading `registeredAcpAgents` from the explicit settings overlay and finding +none (`acp-agent-service.ts:371`). Carrying the agent config in the run spec fixes that in +one place. + +### What it would buy, honestly + +Not billing. The thing that makes an ACP agent attractive on the desktop — running on the +user's subscription login — is exactly the thing containment cannot hold. Under an API key +the run costs what the provider path costs. What it buys is the **agent's own harness**: +its tools, skills, planning and habits. For a task the user would hand to Claude Code or +Codex on the desktop, that is a real reason. It is bought at the price of the deferral +guarantee, and the record must say so. + +### Decisions + +- **A1 — credentials: a vendor API key, scoped to the run, never the login.** The key + travels as the existing single run-scoped env var, is read by the worker and blanked as + today, and reaches the agent only through the config's explicit `env` map — the one + path `buildAcpAgentEnv` does not scrub. Mounting the user's `$HOME` login into an + unattended container is rejected: it puts a live session where nobody is watching, and + the secret canary exists to catch precisely that. Decision 3 stays "narrowed": exactly + one credential, by value, for the run — now held by a third-party process, which is the + material change and the reason for A3. +- **A1′ — the sign-in, on explicit opt-in, for the agents that keep it in files.** Asked + for by the author after A1 shipped: a user who runs Codex on a ChatGPT login and reaches + OpenAI models only through OpenRouter has no OpenAI key to give, and the row stayed + greyed. The run may instead copy the agent's sign-in **files** — named per agent in + `container-acp-agents.ts` (`~/.codex/auth.json`; Gemini's `oauth_creds.json`, + `google_accounts.json` and `settings.json`) — into the guest's throwaway home + (`agent-login.ts`), when the user ticks "Use my sign-in for this run" in the + dialog. Files and not the catalogue's `homeDirs`, and copied asynchronously, because + the first cut copied `~/.codex` whole and synchronously on the main process: that + directory also holds every session transcript the CLI ever wrote, and the app + beachballed for as long as the copy took. What A1 said still holds and is why this is an opt-in and not a default: it is + the whole account rather than a scoped key; the guest's token refresh may rotate the + desktop's out; and it exists only for agents whose login lives in files — Claude Code + keeps its OAuth credentials in the macOS Keychain, so it stays key-only. The copy is + staged world-readable inside the run directory (the worker uid does not exist on the + host), removed in `finally` however the run ends, restored private to the worker in + the guest, and never bind-mounted, so nothing the agent writes reaches the host. A key, + when present, always wins. The record says which was held (`credential`), the arming + decision says it, and the dialog shows it. +- **A2 — one broker link and a CONNECT proxy, not one socket per origin.** Replace the + per-origin `socat`/`--add-host` scheme with a small guest-side HTTP CONNECT proxy on + loopback, advertised through `HTTPS_PROXY`/`HTTP_PROXY`, which forwards every connection + over a single link to the host broker (a unix socket at first; the container's stdio + since A8); the broker reads the target, + matches it against a **pattern** allowlist (exact hosts and `*.suffix` entries), and + dials or refuses. This makes wildcards natural, removes `--add-host`, and fixes the 443 + collision by construction. The connection log gains the target per connection, which + the record already wants. The provider path keeps working unchanged: the guest's + OpenAI-compatible client and the product resolver both honour the proxy variables. + Rejected alternative: a distinct loopback port per origin with rewriting — fixes the + collision, cannot express wildcards, and leaks the mapping into every client. +- **A3 — the record names the harness, and outward effects are denied, not deferred.** + `ThreadContainerResult` gains `harness: 'copse' | { acp: }`. Under an ACP + harness the guest answers `session/request_permission` with a fail-closed handler that + applies `decideContainedShellEffect` to any command it can see: in-guest effects allowed, + host escapes and outward effects **denied** — not deferred, because a deferral is a + promise to replay the exact request from the host, and an agent's own tool call cannot + be replayed by us. Every decision is recorded. `promptsAttempted` counts permission + requests the handler refused, so the invariant keeps a meaning: zero means the agent + never asked for something it was not allowed. The dialog and banner say "ran under + " so nobody reads a Copse-harness record into an agent run. +- **A4 — the binary is baked, pinned and fingerprinted.** `WORKER_DOCKERFILE` gains a layer + per npm-installable catalogue agent (`installPackage`), pinned to a version, on a build + that already has network. The versions join `workerBuildFingerprint` so an upgrade + rebuilds. `cursor-agent` has `autoInstall: false` and no key path (its `setup` is a + browser login), so it is not baked and stays unavailable — with a per-agent reason. +- **A5 — the container is the sandbox.** The agent config the guest registers carries + `sandbox: false`, so the agent spawns without a seatbelt of its own — the container already + provides what the seatbelt would, and nested bubblewrap with a network scope inside + `--network=none` is undefined behaviour we do not want to own. Because that config never + asks for a sandbox, `spawnTransport` never reaches for the session host, and + `acp-session-host-worker.js` is _not_ staged: staging a bundle the guest cannot use would + only widen the image. For every decision that asks "is this agent's process contained?" + — auto-approving its reads, defaulting Claude to `acceptEdits`, the prompt's sandbox note + — the guest answers yes (`acp-agent-service.ts`, `contained`). +- **A7 — the container is the sandbox, for every harness; no bubblewrap inside it.** Asked + by the author after the first real run: what is the nested sandbox for? On the desktop + bubblewrap gives an auto-run command four things — writes confined to the workspace, a + network namespace with an allowlist bridge, a PID namespace, and the prompt-on-escape + that the permission model rests on. In the guest the rootfs is read-only and the + workspace, home and `/tmp` are throwaway tmpfs, there is no network beyond the broker, + nothing in the container can see the host, and prompt-on-escape is exactly what the + contained-effect policy replaces. What the nesting cost was real: `seccomp=unconfined`, + `apparmor=unconfined` and `systempaths=unconfined` on the container so bubblewrap could + create namespaces, plus bubblewrap, socat and the runtime's initialisation in the image, + plus the `git add -A` mount-point quirk. So the guest no longer initialises the project + sandbox at all, the image carries neither bubblewrap nor socat, and the container runs + under Docker's default seccomp and AppArmor profiles; the attestation records + `securityProfiles: 'default'`. The one thing worth keeping — an auto-run shell command + cannot reach the network — is kept without namespaces: the host mints a per-run token, + the guest proxy refuses any request without it (`407`), the worker's own client gets it + through the proxy URL Node's env-proxy dispatcher reads once at startup, the agent gets + it through its explicit env map, and the worker blanks the variables from its own + environment before it spawns anything, so shell children inherit no proxy and no token + (`perCommandNetwork: 'token-gated'`; proven in `guest-egress-proxy.test.ts` and by a + Node 22 probe of the dispatcher's capture-at-startup). Residual, recorded rather than + hidden: a child runs as the same uid as the worker and can read the worker's initial + environment from `/proc`, so a deliberately hostile command could recover the token and + reach the allowlisted vendor origins — the same hosts the model already sends the + repository to. A namespace was the only thing that closed that, and it was not worth the + container's syscall filter. An ACP agent's own shell children inherit the agent's token, + as they inherit its seatbelt scope on the desktop. +- **A8 — the egress link is the container's stdio, not a unix socket.** The first real + ACP run on the author's Mac showed the broker had never been reached: Docker Desktop's + VirtioFS file sharing mounts a host unix socket as a file that `connect` refuses with + `ENOTSUP`. Rather than a per-platform transport (a TCP listener on `host.docker.internal` + is unreachable from a `--network none` container and would need its own token scheme), + the container is now created with `--interactive` and started with + `docker start --attach --interactive`: its stdin and stdout carry a small multiplexing + protocol (`egress-link.ts`: `OPEN`/`ACCEPT`/`REFUSE`, `DATA`/`END`/`RESET` per stream, + `PING`/`PONG` on stream 0, flow control by the pipe itself with a paused inbound side + when a reader is slow), its stderr is the run's log, streamed live. The broker keeps its + allowlist, its resolve map and its log unchanged; only the transport moved. Two + properties came free: the host dying severs the link, and the worker treats that as a + stop, so an orphaned container winds itself down; and there is no host path, socket + length or mountpoint to get wrong. Rejected: keeping the socket and asking macOS users + to switch Docker Desktop to gRPC FUSE (a setting the run cannot verify, and slower for + everything else they do). +- **A9 — a Node 24 image with pnpm, a per-run volume, and an opt-in install step.** The + first complete Codex run could not run the project's tests: the guest had Node 22, no + pnpm, no `node_modules`, and no route to a registry, so the agent read CI evidence + instead. Three changes. The image builds on Debian's Node 24 image with a pinned pnpm + baked in (`PNPM_VERSION`, part of the fingerprint). The workspace is a per-run named + Docker volume (`copse-ws-`, labelled, created before the container and removed in + teardown) rather than a 2 GB tmpfs: a project's `node_modules` runs to gigabytes and + tmpfs pages are charged to the memory limit; the volume lives on the daemon's disk and + never on a host path, so the containment attestation is unchanged. And the dialog + offers "Install dependencies before the run", on by default: the worker runs the + checkout's lockfile install once, before the agent, with the run's proxy + (`guest-install.ts`: pnpm for `pnpm-lock.yaml`, `npm ci` for `package-lock.json`, the + pnpm store beside the checkout so an agent's `git add -A` cannot sweep it in), and + `registry.npmjs.org:443` joins the allowlist for that run. Postinstall binary downloads + (Electron, Playwright, Puppeteer, Cypress) are switched off container-wide: their hosts + are never admitted and an install that waited on them would only fail later. The + agent's own shell stays off the network (A7): it cannot add a package mid-run, and that + is the intended shape. A failed install is said in the log and the run goes on. The + first real install got 668 packages in and then lost one to the desktop's resolver + answering `ENOTFOUND` for a name it had just answered a thousand times, which the guest + proxy turned into a 403 that pnpm treated as final. The broker now resolves each host + once per run and retries a transient dial fault (`EAI_AGAIN`, `ENOTFOUND`, `ECONNRESET`, + `ETIMEDOUT`) with a short backoff, and the proxy answers 502, the status clients retry + on, for an origin that did not answer; 403 stays for a refusal, which is final. The + worker's home is on the volume as well (`/workspace/home`, created by the worker at + start, mode 0700): the 256 MB tmpfs it used to be filled with Electron's download cache + on the first e2e-capable install, and the worker then died restoring the sign-in into it. + A volume outlives an app that quits mid-run, and five of them (8.5 GB) were found on + the author's machine after a day of rebuilding between runs, so the service now sweeps + at start: every managed container that is not running, and every managed volume, is torn + down (`sweepOrphanedRuntimes`; also `--sweep` on the CLI). A running one is left alone — + another instance's, or one this host lost and that stops on its own once its link + closed — and is swept next time. The + second real install then fetched everything and failed on two install scripts: a driver + download from a host the run never admits, and a native build with no toolchain in the + image. So the install is three steps — fetch and link with scripts off (required), then + native builds and the project's own `postinstall`/`prepare` as best effort, each failure + named and passed over — and the image carries python3, make, g++ and pkg-config. +- **A10 — no GitHub or CI tool in the guest, by name.** Asked by the author after the + first complete run: does the agent hold write tools to GitHub? The bridge's ceiling + includes four that write (`gh_pr_create`, `gh_pr_approve`, `gh_pr_mark_ready`, + `gh_pr_enable_auto_merge`) and they register on the desktop when `gh` is on the PATH or a + GitHub token is in the environment. Neither holds in the guest, so the first run offered + 23 tools and none of them — but absence by accident is not a property. The headless + profile gained `excludeTools`, applied after bootstrap and before the agent sees a list, + and the worker passes every GitHub and CI tool name (`guest-tools.ts`, with a test that + fails when a new `gh_*` tool reaches the bridge list without joining the exclusion). + `run_shell` remains: it has no `gh`, no token, no route to github.com, and a `git push` + through it is an outward effect the contained gate refuses and records. +- **A11 — an installing run reaches GitHub anonymously, and the image can run an Electron + e2e suite.** Asked by the author: can the guest reach GitHub without ever holding the + desktop's credentials? Yes, by construction — the guest gets the environment the runner + builds and nothing of the shell's: no `gh`, no `GITHUB_TOKEN`, no credential helper, no + SSH agent, no keychain; the checkout is a bundle with no remotes; GitHub requires a token + for every write, gists included; and a push through `run_shell` is an outward effect the + gate refuses. So "Install dependencies" admits `github.com`, `*.github.com` and + `*.githubusercontent.com` on 443 alongside the registry: Electron and chromedriver come + down from releases during the install, and the agent's HTTP tools can read public repos, + PRs and CI. Anonymous API calls are limited to sixty an hour per address. The image + carries Xvfb, xauth and Electron's shared libraries (the list the Electron project + documents for Debian), so `xvfb-run` exists and the suite's own `--no-sandbox` and + `--disable-dev-shm-usage` flags do the rest under Docker's default seccomp. The + `ELECTRON_SKIP_BINARY_DOWNLOAD` switch is gone; the browser-CDN switches (Playwright, + Puppeteer, Cypress) stay, since those hosts are still not admitted. The third real + install then named two more hosts: node-gyp fetching Node's headers from nodejs.org, and + electron-rebuild fetching Electron's from electronjs.org. The first is avoided — the + image ships Node's headers and the install runs with `npm_config_nodedir` pointing at + them — and the second is admitted (`electronjs.org`, `*.electronjs.org`), the vendor's + own CDN in the same class as GitHub releases. The install after that built and fetched + everything and failed only on running a downloaded tool that wanted `GLIBCXX_3.4.32`, + newer than bookworm's GCC 12 runtime, so the base is `node:24-trixie-slim` (Debian 13) + and the Electron libraries take their post-time_t names (`libgtk-3-0t64` and kin). That + run also showed the agent spending itself on `pnpm install` from its network-less shell, + which tore down a working `node_modules`, and then asking to provision a cloud host; the + worker now prefixes the task with a short environment note (`guestEnvironmentNote`): + shell commands are offline, do not install or push, this is what the install left you. + The same run then sat idle after "done" until the wall-clock deadline killed it: with + the link on stdio (A8) the worker's stdin is an open handle, as is any background child + the agent left, so the event loop never drained. The worker now exits explicitly once + its log has flushed; `--init` reaps whatever the agent left running. +- **A12 — one pnpm store volume per host, shared by every installing run.** Asked by the + author after a run's install spent its first minute fetching a thousand packages the + previous run had fetched too. The store (`/workspace/.pnpm-store`, beside the checkout) + is now a named volume, `copse-pnpm-store`, mounted inside the fresh workspace for a run + that installs and absent from one that does not. Labelled by role, not as a managed + runtime, so the start-up sweep leaves it alone; `--forget-store` on the CLI removes it + and the next install refills it. The workspace volume is still per run: only the + content-addressed store is shared, and pnpm checks every package it links against the + lockfile's integrity hash, so a stale or tampered entry from an earlier run is rejected + rather than linked. The image creates the directory so the mount is owned by the worker + uid, which changes the fingerprint: the image rebuilds once. +- **A13 — a run is a turn on the thread that launched it, and its follow-up is a + cherry-pick.** Asked by the author: should a run be a thread of its own, or a subagent + the user can follow up on in a shared checkout? The second. A run was session-only and + lived in a dialog; now the thread keeps it as one assistant message holding a + `container_run` tool call whose subagent session is the run (`container-run-card.ts`, + kind `container`): the task as the prompt, the guest's transcript as the timeline, the + review record — outcome, commits and their ref, what waits for review, what was refused, + the agent's last words — as the result, in Markdown. The renderer rebuilds the card from + every progress snapshot over the same tool-call id, so the thread shows the run advance + (the log tail is its timeline while it runs) and keeps it once it settles; the spine + persists a tool call only when it is no longer running, so a run the app quit on leaves + no half-card. The transcript comes from the worker, which folds the harness stream it + already collects into subagent messages (`guest-transcript.ts`, bounded: results cut at + 8 KB, the last 400 messages) and writes them beside the result; the record carries them + and the carry-in base. The follow-up is a button on the card and in the dialog: "apply + the run's commits to this checkout" cherry-picks the commits after the carry-in base on + `refs/copse/runs/` onto the thread's HEAD (`adoptCarryOut`, decided by `git cherry` + so a second press applies nothing and says so; a dirty tree is refused; a conflict + aborts the pick and reports). A cherry-pick, not a merge, because the base may be a + snapshot commit of a tree the user still has dirty. The runtime id names the record on + disk, so the follow-up works for a run an earlier session made. Rejected: a thread per + run, which would put the result where the conversation is not; and importing only a + summary, since the messages file already existed. +- **A14 — a run starts from the composer, and the composer follows it.** From the first + real Codex run: the task was the dialog's, not the thread's; a finished run said nothing + the user could hear; the guest's links pointed at `/workspace/repo/…`; and the follow-up + had nowhere to go but the dialog. Now the run's prompt is the thread's user message and + the card its reply, the dialog closes on start and the banner alone shows the phase, and + the composer gets a target picker (`.composer-target`): "to container" when the thread's + last turn is a container run, "to thread" otherwise, held on the thread while the run is + live. A message to the container is a continuation run: it carries in the earlier run's + carry-out ref rather than a fresh snapshot (`carryInRef`, so the desktop's checkout need + not have moved), prefixes what that run was asked and reported to the new prompt (the + prompt is the continuity; the guest keeps no session), and records `continuedFrom`. The + run's record and its transcript relocate guest paths to checkout-relative ones, so the + agent's links open on the desktop; a settled run marks the thread unread, rings the + thread-finished alert, and folds the guest's usage into the thread's counter once. Two + things the first run showed and this fixes in the runner: the snapshot and bundle ran + synchronous git on the main process, which beachballed the app for the minute a large + checkout takes (async now), and the twenty minutes between "tools offered" and the + agent's report showed nothing, because the agent's text reached the host only on a + newline and its tool calls were never logged (`guest-progress.ts`: one line as a call + starts and settles, the text at each boundary). Review findings folded in: the carry-out + bundles `refs/heads/work` pointed at HEAD, so a branch the agent made comes back whole + (`guest-carry-out.ts`); a failed turn is a `turn_outcome` chunk, not a rejection, and is + reported as one (`guest-turn.ts`); the token ceiling binds on the agent's live context + reports as well as on usage, which an ACP agent sends only after the turn — summed over + calls, since each report is the context one call was given and the ceiling is a limit + on spending, not on size; a server on the desktop's loopback is given a guest-facing + name the broker resolves, since the guest's loopback bypasses its proxy by design + (`guestFacingEndpoint`); the record names the desktop thread, not the guest's, so a + follow-up from disk is accepted; the proxy holds every client from the CONNECT on and + destroys them on close, so neither a pooled connection nor a dial still in flight can + hold the result; a stop asked for before the container exists reaches the runner as a + signal it checks before and after `docker run`; and follow-ups are applied one at a + time per checkout, so two pressed together cannot abort each other's pick. + The guest's transcript folds text with the desktop's own `planAgentTextChunk`, so an + agent that narrates between commands reads as one message per step rather than one + run-on block, and the run's final text is the last of those messages, not the turn's raw + assistant text, which under an ACP harness is every narration joined with nothing + between. + Three more from the first follow-ups tried: a settled run is written into the thread's + model history as the turn it is (its prompt, the card as a tool call with the record as + the result) and the dispatcher's cache dropped, since the run never passes through the + dispatcher and a message to the thread otherwise started with no context at all; a run + that made no commits can still be continued, from a fresh snapshot with the prompt as + the continuity; and the composer's target re-applies its default when the run it + follows settles, so the container is offered once the run is done rather than the + thread it was held to while the run was live. + And from a run whose volume went away under it (Docker's VM disk full, or the mount + lost): the install died with EROFS and the sign-in restore with ENOENT, neither naming + the cause. The worker probes the checkout's volume after a failed install and in its + fatal handler, and says when it no longer takes writes and why that tends to be. +- **A15 — one resolution of the model for desktop and guest.** From the review of A14: + the container resolved providers on its own (`container-provider.ts`), a narrower copy + of the desktop's `buildProvider` that lost the user's tuned parameters, OpenRouter's + privacy routing and the OpenAI transport choices, and the worker hard-coded a 128k + context window. Now the desktop resolves a selection once into a description + (`describeProvider` → `ProviderDescription`: protocol, endpoint, tuned parameters, the + privacy and transport settings; keys apart), builds its own client from it, and the + container carries the same description into the guest in `run.json` with the context + window the desktop would trim against; the guest builds the same client from it + (`buildProviderFromDescription`, settings-free). The one thing the container changes is + the endpoint's name when it is a server on the desktop's loopback, and LM Studio's own + WebSocket transport becomes the OpenAI-compatible endpoint it also is, since the guest + proxy carries HTTP only. The description's schema is its type, as the supervisor's task + schema is. Two smaller consolidations from the same review: the working-tree snapshot + (throwaway index → tree → commit) that the carry-in, the worktree backup and the remote + e2e push had each written out lives once in `git-snapshot.ts` over an injected git, and + the follow-up's cherry-pick is serialized per checkout through the thread store's + `runSerialized` rather than a mutex of its own; and `runHeadlessAgent` returns the + turn's own `turnOutcome`, so the worker reports a failed turn from the loop's verdict + instead of reconstructing one from the chunks. +- **A6 — scope is the key-capable agents.** `claude-acp` / `claude-code-acp` + (`ANTHROPIC_API_KEY`), `codex-acp` (`CODEX_API_KEY`), `gemini` (`GEMINI_API_KEY`). + Anything without a documented key path stays greyed out, and the reason is per agent: + "signs in through a browser, no key path" rather than the generic line. + +### Phases + +Each phase was to land green and inert until A-3, with the refusal in +`resolveContainerProvider` as the switch. A-0, A-2, A-3's code and A-4 landed together on +the branch once A-1 was in, because they share one seam (the run spec's `acp` field) and +were smaller apart than the plan expected; what is recorded under each is what it proved. + +- **A-0 — plumbing. Landed.** `WORKER_DOCKERFILE` takes an `ACP_AGENTS` build argument of + pinned `package@version` specs (`container-acp-agents.ts`) and installs them globally + before dropping to the worker user; the specs join `workerBuildFingerprint`, so a version + bump rebuilds, and `acpAgents: []` builds an agent-free image for tests. The run spec + carries `acp: { agent, keyEnvName }`; the worker registers that one agent in its settings + overlay (`guest-acp-agent.ts`), so `getAcpAgent` resolves it and nothing else. The result + and record gained `harness` and `denials`. Exit gate as met: the fingerprint moves with + the agent list; the Dockerfile is asserted layer by layer; the two directions of the + config crossing are unit-tested (`guest-acp-agent.test.ts`). The image build with the + real agents baked in has not been run here — this sandbox cannot reach the npm registry + from a Docker build — and is the first thing to run where it can. +- **A-1 — egress rework. Landed.** The CONNECT proxy and pattern allowlist (A2), with the + provider path migrated onto it: `egress-rules.ts` (the grammar, pure), `egress-broker.ts` + (`CONNECT`/`OK`/`DENY` over one unix socket, since A8 `OPEN`/`ACCEPT`/`REFUSE` over the + container's stdio, refusals logged), `guest-egress-proxy.ts` (loopback + proxy in the worker bundle; `CONNECT` tunnels and absolute-form plain HTTP, re-chunked + and streamed so server-sent events arrive as sent). `--add-host` and the sysctl are + gone. (`socat` briefly came back when the first real run reported "socat not installed" + from the sandbox runtime's bridge; A7 then removed the nested sandbox and socat with it.) Exit gate as met: at the unit tier two hosts on one port through one broker, + a wildcard admitting a subdomain and refusing the bare suffix and two siblings, and a + remapped dial matched and logged on the port the guest named; at the integration tier + the model is reached on guest port 443 by a wildcard rule, with the rule in the log and + no refusal. Two origins both _reached_ from inside the guest at the integration tier + waits for a second guest-side caller: the auto-run shell in the guest has no network by + design, so only the model loop dials out until A-2's agent does. +- **A-2 — credentials and the permission policy. Landed and proven locally; its + Docker integration test is written and not yet run.** The worker consumes the run's key from its environment as + before and hands it to the agent as the one entry of its explicit `env` map (A1); the + user's own `env` never crosses (`acpHarnessForContainer`). Inside a contained run the + ACP permission responder treats the agent as contained: reads auto-approve, edits go + through the backup-then-allow path, and an `execute` request runs the contained gate + with `outwardEffects: 'deny'` — a new gate option that refuses and records an outward + effect instead of queueing it, because an agent's own command cannot be replayed by + Copse (A3). A host escape's throw is answered to the agent as a rejection rather than a + transport error. Any other kind that would reach a dialog is refused and recorded + (`kind: 'acp'`). The worker reads the refusals back from the run's decision log into + `result.denials`. Exit gate: `acp-container.integration.test.ts` runs a **scripted ACP + agent** (`scripted-acp-agent.ts`, carried in with the workspace) that asks for an + in-guest build (allowed, and run by the agent), an outward push (refused), a host + escape (refused), then commits; it asserts the harness is named, prompts and deferrals + are zero, two denials are recorded, the agent saw exactly the run's key and no canary, + and the work came back. Needs Docker to run. The seam it exercises is proven without + the container by `acp-harness.test.ts`, in the ordinary unit gate: the same + `runHeadlessAgent` call the worker makes, under a declared container runtime and an + armed run, with the scripted agent registered through the settings overlay — the real + ACP client answers its permission requests by blast radius, the build runs and is + committed by the agent, the push and the escape are refused with nothing queued, both + refusals are in the decision log, and the agent saw exactly the run's key. The gate + option itself is unit-tested in `unattended-run.test.ts`. +- **A-3 — the refusal lifted for A6's set. Code landed; the real run is outstanding.** + `resolveContainerProvider` returns an `acp` plan for a registered key-capable agent + whose vendor key is in Settings: the full `acp:[#model]` selection as the model, + the harness, the key, and the agent's catalogue `allowedDomains` on 443 as the egress + rules. Every other agent is refused with the per-agent reason. Exit gate — a real + `claude-acp` run ending with commits under `refs/copse/runs/` and a readable + record — cannot be met here: no vendor key is available in this sandbox. It is the + second thing to run where one is. +- **A-4 — the dialog. Landed.** `loadRunModelOptions` asks the main process, over + `container:model-availability`, for the resolver's own verdict on each agent row: + `explainContainerModel` runs `resolveContainerProvider` and returns null or the short + reason from the typed refusal ("needs a Gemini API key in Settings", "signs in through a + browser; no API-key path", "not carried by the worker image", "not configured in + Settings"). The first cut asked the renderer-side key queries instead and got it wrong + twice — the validated-provider set greys a key that is merely unprobed, and the + Settings presence query cannot see a key in the environment that the resolver accepts — + so the rule is now that only the code that would refuse the start decides the row. The + note under the field names the agents that can run and what they run on. The record + view gained a Harness row, an "Effects refused" count and a section listing the + refusals. + +### What this does not change + +The container's hardening, the attestation, the ledger, the budgets and the carry-in/out are +untouched. Guarded YOLO is untouched. Copse's own harness remains the default and the +recommended path for container runs; the provider-backed twin of an agent's model is +already in the list, one group up, and it keeps the deferral guarantee. + +## Phases + +- **T0 ✅ — prototype on the branch.** Everything above. Exit gate: the end-to-end test + passes against a real daemon, and `pnpm run check` is green. +- **T1 — real provider and a real grind.** Run the container against a real model with the + broker allowlisting only the provider origin; run one lint- or type-backlog task from + `long-horizon-tasks.md` end to end; report prompts removed vs deferred with the D0 report + over the run's own decision log. This is the U0 experiment, now runnable. +- **T2 — attach and hand back.** Copse's ACP agent role as the guest entry, session resume + advertised, the desktop attaching over the run's own channel, and the per-thread writer + lease from `acp-session-continuity.md` so desktop and guest never both advance a turn. + Exit gate: close the desktop mid-run, reopen, and observe one converged thread. +- **T3 — review surface and host-side replay.** Deferred-approvals D2 over this queue: + approve replays the exact request on the host (the push happens from the host's checkout + of `refs/copse/runs/`, never from the guest); reject informs the next turn. +- **T4 — lifecycle and record hardening.** TTL label and startup reconciliation; canonical + spine events for runtime state, egress and teardown; dependency bake gated by lockhash; + the macOS broker variant. + +## Test plan + +| Area | Tier | What it proves | Where | +| ---------------------- | ----------- | ------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------- | +| Effect classification | unit | Host escapes deny; outward effects defer; in-guest destruction allows; harm denies stay denies | `packages/shell-guard/src/container-effects.test.ts` | +| Attestation | unit | Every shortfall (root, writable rootfs, caps, privileges, foreign mount) refuses the declaration | `security/unattended-run.test.ts` | +| Ledger | unit | Arming implies deferral mode; mutually exclusive with Guarded YOLO both ways; budgets required | `security/unattended-run.test.ts` | +| Gate matrix | unit | Command class × containment tier × unattended → exact outcome, including the desktop-tier and not-armed rows | `security/unattended-run.test.ts` | +| Deadline settlement | unit | A failed or hung `docker stop` with a still-pending wait still settles, and says why | `container-runtime/thread-container.test.ts` | +| Image freshness | unit | The fingerprint changes with the worker bundle and the base image | `container-runtime/thread-container.test.ts` | +| Completion honesty | unit | Unfetched commits, failed teardown and a leaked canary are never a clean finish | `container-runtime/container-run-service.test.ts` | +| Thread checkout | unit (git) | A thread worktree with its own commits and edits is carried in, not the project checkout | `container-runtime/container-run-service.test.ts` | +| Docker argv and record | unit | The flags the attestation claims are the flags used; only the run dir is mounted; key passed by name | `container-runtime/thread-container.test.ts` | +| Carry-in / carry-out | unit (git) | Dirty tree snapshots without moving HEAD; guest commits round-trip to `refs/copse/runs/` | `container-runtime/thread-container.test.ts` | +| End to end | integration | The eight properties listed above, against a real daemon, opt-in via `COPSE_THREAD_CONTAINER_E2E=1` | `container-runtime/thread-container.integration.test.ts` | +| Provider plan | unit | Model id → endpoint, key and the one egress origin; cloud models without a key are refused before Docker | `providers/container-provider.test.ts` | +| Run service | unit | Provider resolved, key passed by env var and blanked once the guest holds it, phases published, refusals | `container-runtime/container-run-service.test.ts` | +| UI (browser tier) | demo | Footer action, arming form with the draft prefilled, banner and review record for a finished run | `tests/demo/container-run.demo.ts` | +| UI (Electron) | e2e | Real IPC: the dialog opens from the footer and a model without a key is refused with a readable error | `tests/e2e/container-run-dialog.e2e.ts` | +| ACP: agent table | unit | Only catalogue agents with a documented key are baked; per-agent reasons; a retired id maps to its current entry | `shared/container-acp-agents.test.ts` (A-0) | +| ACP: config crossing | unit | Host side drops the user's env and desktop command path; guest side gives the agent exactly the run's key | `container-runtime/guest-acp-agent.test.ts` (A-0) | +| ACP: image bake | unit | The fingerprint moves with the agent list and versions; the Dockerfile installs from the argument before `USER` | `container-runtime/thread-container.test.ts` (A-0) | +| ACP: plan | unit | A registered key-capable agent with its key gives an `acp` plan on its catalogue domains; no user env crosses | `providers/container-provider.test.ts` (A-3) | +| ACP: run request | unit | The service passes the harness and key variable, and no provider, for an `acp:` model | `container-runtime/container-run-service.test.ts` (A-3) | +| ACP: deny, not defer | unit | `outwardEffects: 'deny'` refuses an outward effect with nothing queued; contained effects still run | `security/unattended-run.test.ts` (A-2) | +| ACP: harness, local | unit | The worker's own call under a declared runtime drives the scripted agent: build allowed and committed, push and escape refused and logged, one key seen | `container-runtime/acp-harness.test.ts` (A-2) | +| ACP: sign-in opt-in | unit | Codex/Gemini without a key are offered and run only when opted in; a key wins; Claude never; the resolver's verdict carries the offer | `shared/container-acp-agents.test.ts`, `providers/container-provider.test.ts`, `container-runtime/container-run-service.test.ts` (A1′) | +| ACP: sign-in crossing | unit | Only existing dirs are staged, world-readable, then removed; none found refuses; the guest copy is private to the worker | `container-runtime/agent-login.test.ts` (A1′) | +| ACP: roster | unit | A key-capable agent row is enabled with its key and disabled naming the key without; browser-login agents differ | `renderer/views/container-run-control.test.ts` (A-4) | +| ACP: egress grammar | unit | `host:port` and `*.suffix:port` parse and format; `*.com` is refused; the wildcard matches on the dot boundary | `container-runtime/egress-rules.test.ts` (A-1) | +| ACP: egress patterns | unit | Two hosts on one port through one link; `*.suffix` admits a subdomain, refuses the suffix and siblings; a dead origin is a refusal; logged | `container-runtime/egress-broker.test.ts` (A-1) | +| ACP: guest proxy | unit | Absolute-form HTTP streams an SSE body back with hop-by-hop headers dropped; CONNECT tunnels; DENY becomes a 403 | `container-runtime/guest-egress-proxy.test.ts` (A-1) | +| ACP: broker probe | unit | `PING`/`PONG` on the link; the worker fails a run whose host does not answer; a brokered run that reached nothing is warned about, or failed | `egress-broker.test.ts`, `guest-egress-proxy.test.ts`, `container-run-service.test.ts` (A8) | +| ACP: install step | unit | Lockfile picks pnpm or npm ci, nothing without one; the store sits beside the checkout; the install env carries the proxy and every download switch off | `container-runtime/guest-install.test.ts`, `container-run-service.test.ts` (A9) | +| ACP: guest tools | unit | Every GitHub write tool, and every gh_*/CI tool the bridge could offer, is on the guest's exclusion list | `container-runtime/guest-tools.test.ts` (A10) | +| ACP: stdio link | unit | Frames survive any split; a stream half-closes each way; refusal and reset reach the peer; a severed byte stream fails every stream | `container-runtime/egress-link.test.ts` (A8) | +| ACP: 443 in the guest | integration | The model on guest port 443 is reached through the proxy, admitted by a wildcard rule named in the log | `container-runtime/thread-container.integration.test.ts` | +| ACP: permission policy | integration | A scripted ACP agent: in-guest write allowed, outward push denied and recorded, host escape denied, harness named | `container-runtime/acp-container.integration.test.ts` (A-2) | +| ACP: refusal | unit | Agents outside A6's set, and any agent without a key, are refused with a per-agent reason | `providers/container-provider.test.ts` (A-3) | +| Run as a turn: card | unit | A run becomes one container tool call on its thread — running with the log, settled with transcript and record — updated in place, not duplicated | `store/container-run-card.test.ts`, `container-runtime/guest-transcript.test.ts` (A13) | +| Run as a turn: apply | unit | The guest's commits after the carry-in base are cherry-picked once; a second press counts them; a dirty tree is refused; a conflict is aborted | `container-runtime/thread-container.test.ts`, `container-run-service.test.ts` (A13) | +| Run as a turn: follow | unit | A continuation carries in the earlier run's ref, prefixes its exchange to the prompt, and refuses another thread's run; ACP patches settle calls | `container-run-service.test.ts`, `guest-transcript.test.ts`, `store/container-run-card.test.ts` (A14) | +| Run as a turn: guest | unit | The carry-out follows HEAD onto a branch the agent made; a failed turn is named; the agent's live context counts against the ceiling | `guest-carry-out.test.ts`, `guest-turn.test.ts`, `guest-progress.test.ts` (A14) | +| Run as a turn: host | unit | A loopback server gets a guest-facing name the runner is told to resolve; a stop before `docker run` is honoured; a held tunnel does not hold close | `container-provider.test.ts`, `container-run-service.test.ts`, `guest-egress-proxy.test.ts`, `input-bar.test.ts` (A14) | + +## Non-goals + +- A hostile-workload or multi-tenant boundary. The guest is the user's own disposable + machine, started by their own daemon, and the UI must say so. +- A second permission vocabulary, transport, queue or scheduler. The gate gained one branch + and one prompt cause; the queue, the decision log and the headless host are the existing + ones. +- Auto-approving anything whose effect leaves the guest. Under an ACP harness that means + _denying_ it: an agent's own tool call cannot be replayed from the host, so it is not + deferred (decision A3). +- Mounting a user's login into the guest. A1′ copies a sign-in in for one run on explicit + opt-in, for Codex and Gemini only; nothing is mounted, nothing comes back out, and it + is never the default. +- Changing Guarded YOLO, which keeps its meaning and its own ledger. +- Cloud provisioning, checkpoints, suspend/resume — `copse-cloud-workspaces.md` and + `execution-runtime-security.md` own those and this runtime should be a clean consumer. + +## Relationship to existing plans + +- [`unattended-runs.md`](unattended-runs.md) owns the product argument and the decisions + this plan is measured against; the divergence table above is the reconciliation. +- [`deferred-approvals.md`](deferred-approvals.md) owns the `defer` outcome this plan + consumes unchanged, and the review surface (D2) this plan needs next. +- [`execution-runtime-security.md`](execution-runtime-security.md) owns the capability, + egress, credential and audit contracts. The attestation is its capability record made + concrete for one runtime; the record file should become its spine events. +- [`copse-cloud-workspaces.md`](copse-cloud-workspaces.md) owns provisioning providers. The + local-docker path here is its C1 with the loop inside; a cloud host is the same runner + behind an SSH hop. +- [`acp-session-continuity.md`](acp-session-continuity.md) owns the resume and lease + semantics T2 depends on. +- [`long-horizon-tasks.md`](long-horizon-tasks.md) supplies the grind corpus for T1. + +### Container task supervision + +The desktop registers container runs as externally managed `container_run` tasks +with the existing task supervisor before preparing the image. The task records +ownership, the runtime handle, limits, and terminal outcome; prompts and credentials +remain outside task metadata. Docker startup, attestation, egress, carry-out, and +cleanup remain owned by the container runner. + +Cancellation through either the container UI or the supervisor aborts preparation +and removes a started runtime. App shutdown cancels runs before removing the +supervisor's external cancellers. A process handle lost on restart becomes a failed +task through the supervisor's existing reconciliation. No handler replays the +run: `maxAttempts: 1` and explicit user continuation remain the contract. The +supervisor records the wall-clock limit; the runner still enforces it and the guest +still enforces its token limit. Adoption of an external task does not impose the +supervisor's queued-handler concurrency limits. diff --git a/docs/plans/unattended-runs.md b/docs/plans/unattended-runs.md index 2b8147684b..fb947ab012 100644 --- a/docs/plans/unattended-runs.md +++ b/docs/plans/unattended-runs.md @@ -1,7 +1,10 @@ # Unattended runs on a contained runtime -**Status: Proposed.** Nothing here is implemented. This plan proposes an **unattended -run**: a per-thread container runtime plus its own autonomy mode, so long-horizon work can +**Status: Proposed.** Nothing here is implemented under these decisions. +[`thread-in-container.md`](thread-in-container.md) prototypes the unattended-run mode with +the agent loop **inside** the guest rather than on the desktop, and records where that +diverges from Decisions 1 and 3 below; if that direction is confirmed, those two decisions +are revised there and here in one change. This plan proposes an **unattended run**: a per-thread container runtime plus its own autonomy mode, so long-horizon work can continue for hours without a human present — and says precisely which prompts containment removes and which it must not. diff --git a/docs/plugins.md b/docs/plugins.md index 16a1f72d51..afbdf79e8b 100644 --- a/docs/plugins.md +++ b/docs/plugins.md @@ -46,6 +46,13 @@ the runtime `RegisteredPlugin.contributions`, not in the serializable manifest. The one user-code exception is an explicitly selected plugin's isolated shared `runtime`; it never imports code into Electron main. +The isolated runtime also supports declared `runtime.hooks` registrations through +`registerHook` and explicit host invocation. Hook-only runtimes are accepted. This +is the SDK/protocol stage: the agent's canonical event fire sites do not invoke +these external functions yet, and their results cannot alter agent behavior. +See the [SDK contract](../packages/plugin-sdk/README.md#external-function-hook-registration) +for declaration matching, cancellation, and the deliberately narrow handler context. + `pluginManifestFromCursorJson()` maps a Cursor-shaped `plugin.json` into a `PluginManifest` (a user plugin): the existing top-level `skills` / `mcpServers` fields fold into the plugin slots (`mcpServers` → `tools.mcpServers`). The @@ -208,8 +215,8 @@ loop uses — so a disabled plugin removes them from new work without touching l code. The default-off `copse.automations` plugin also exercises the first-party level-3 -boundary: its manifest declares a `settings-plugin-detail` view and namespaced -storage, while the Electron host supplies the local clock/thread-store service +boundary: its manifest declares `settings-plugin-detail` and `app-dialog` views +that share the same project editor, plus namespaced storage, while the Electron host supplies the local clock/thread-store service and the shipped renderer submits due prompts through the interactive agent controller. See [`docs/plans/automations.md`](plans/automations.md) for the deliberately narrow desktop-online cron prototype and its durable-supervisor boundary. diff --git a/docs/shell-permissions.md b/docs/shell-permissions.md index 50929a797a..07ec3ed5e0 100644 --- a/docs/shell-permissions.md +++ b/docs/shell-permissions.md @@ -140,6 +140,12 @@ Update this document and the Guarded YOLO / harm / read-outside tests with any i ## Implementation map +Sandboxed native commands and ACP processes redirect `TMPDIR`, `TMP`, `TEMP`, and zsh's +`TMPPREFIX` into the existing workspace scratch directory. zsh uses `TMPPREFIX` for large +heredocs independently of `TMPDIR`; leaving its default `/tmp/zsh` breaks patch commands even +when every destination file is inside the workspace. This redirect does not widen the sandbox's +writable roots or change the approval policy. + - `permission-policy.ts`: pure permission decisions, MCP decisions, outside-sandbox classification, and prompt-body formatting. - `@copse/shell-guard` (`packages/shell-guard/`): the deterministic classifiers, host-free. diff --git a/docs/supply-chain-security.md b/docs/supply-chain-security.md index 11ed8b90e8..a4a69602f2 100644 --- a/docs/supply-chain-security.md +++ b/docs/supply-chain-security.md @@ -94,9 +94,19 @@ Two defaults harden every invoked skill, trusted or not (both toggleable in `EXTERNAL LINKS:` notice plus guidance that any fetch/install/run-from-network step is approval-gated and must not exfiltrate workspace contents or secrets (`skill-prompt.ts`). +- **Read-only skill directory** (always on). Invoking a skill grants that + thread's sandboxed `run_shell` read-only access to the skill's directory and + any validated `paths` entries from its frontmatter (`skill-read-roots.ts` → + `thread-read-roots.ts`); the seatbelt overlay adds them to `allowRead` only, + and the shell-scope classifier waives them for structurally read-only + commands, so `cat`/`sed -n`/`grep` over skill files run contained instead of + prompting "Run outside sandbox?". Writes into a skill directory and reads of + anything else still prompt. Validation of `paths` is documented in + `docs/cursor-plugins.md`. - **Sandbox confinement reminder** (`skillSandboxGuidance`, default on). The invoked-skills block states that skill shell commands run inside the macOS - project sandbox (no network, no out-of-workspace FS), and — where no OS sandbox + project sandbox (no network, no out-of-workspace FS beyond the read-only + skill directory), and — where no OS sandbox is active (Linux/Windows, or ASRT init failed) — that the only boundary is approval, so network/install/out-of-workspace commands must be surfaced rather than auto-run. The live sandbox state is read via a native-free flag diff --git a/docs/ui-taste.md b/docs/ui-taste.md index 6eec51c1e1..c075827a13 100644 --- a/docs/ui-taste.md +++ b/docs/ui-taste.md @@ -41,12 +41,13 @@ The core brand palette is forest (`#002e2b`), neon green (`#20fd85`), pink (`#ff (`#fffdf7`). Define those once as brand tokens, then bind product components through semantic tokens such as `--bg-base`, `--accent`, `--text-primary`, and `--border`. -- The default interaction accent is pink (`#ff93d0`): primary actions, focus, selected-row rails, - and links. Neon green remains part of the expressive brand palette and an optional custom accent. +- The default interaction accent is pink (`#ff93d0`): primary actions, focus, the Thinking and + Comparison labels, and links. Neon green remains part of the expressive brand palette and an optional custom accent. - The default interface tint is a subtle wash of green (`#244c25`); stronger tint levels remain optional so the workbench stays low-fatigue. - Pink is the default interaction emphasis, not a product status colour. Do not use it for errors, - warnings, success, or routine headings; those keep their semantic/text tokens. + warnings, success, or routine headings; those keep their semantic/text tokens. The five callout + severities have their own hues — see "Callout severities" below. - **Exception — "experimental".** Where a surface asks you to opt into something unfinished, the experimental marker takes the accent (`.pack-badge-experimental` in Settings → Packs). It is not reporting that anything has gone wrong; it is the one thing on the card you must read before @@ -70,8 +71,10 @@ choice load-bearing rather than stylistic: `--accent`, which is the tier derived to stay readable. - **A state a user has to see** — a selected row, an active tab — should not rest on the accent's _lightness_, because that is the property the theme flips. Against ordinary row text, the dark - theme's accent stands at ΔE 51 and light's at 19; carry a fill (`--bg-selected`) or a rail as well, - so the signal survives both themes (#2483). + theme's accent stands at ΔE 51 and light's at 19; carry a fill (`--bg-selected`) as well, so the + signal survives both themes (#2483). A rail is no longer the alternative here — see "Rails mark + nesting and standing asks" — so for a list row the fill plus a weight change is the whole signal, + which is why the fill's own contrast is the thing that has to hold up. `src/renderer/styles/light-contrast.test.ts` pins the first rule mechanically, and holds the light syntax-highlighting palette to WCAG AA on the real (tinted) code surface. @@ -764,6 +767,15 @@ left-elision (`direction: rtl` + `text-overflow: ellipsis`, same trick as `.git- the leaf stays visible; mirror the full path on the row's `title` for the native tooltip. Spec: [`tests/e2e/settings-sources-skills.e2e.ts`](../tests/e2e/settings-sources-skills.e2e.ts). +Nested instruction rows use the badge as activation state, not origin: **active** uses the existing +accent outline when the latest turn selected that scope; **scoped** uses the quiet default outline when +the file is available but unrelated to that turn; **duplicate** (quiet outline) marks a nested file +whose text repeats one already listed, so it is loaded once through that one. Keep the governed +directory and the explanatory state in the detail line so sibling scopes are understandable without +adding another row of chips. When discovery stopped at its cap, a single `.sources-empty` line under +the list says the list may be incomplete — a note, not a row. +Spec: [`tests/e2e/settings-sources-nested-instructions.e2e.ts`](../tests/e2e/settings-sources-nested-instructions.e2e.ts). + ## Prove visual changes with a focused e2e eval Per `AGENTS.md`, any user-visible change needs a focused WebdriverIO Electron spec that seeds the @@ -794,10 +806,19 @@ manual VNC glance. ## Transcript status callouts -Review and comparison results should read as annotations in the transcript, not rounded cards or -pills. Use a square, thin status rail and a subtle horizontal color wash that fades into the chat -background. Reserve the rail hue for state (accent, error, etc.); avoid a full perimeter border, -rounded container corners, or a solid tinted block around these secondary results. +Review and comparison results should read as annotations in the transcript, not cards or pills. They +are Copse annotating its own turn rather than part of the answer, so they take the **hatched plate** +with `--sev` set to their state hue — `--text-secondary` for a normal review, `--accent` for a +comparison, and `--danger` when either run failed. Mix the background at the component using +`--callout-hatch-line`, `--callout-hatch-fill`, and `--callout-hatch-pitch` from `global/base.css`. +No perimeter border, no elevation, no chips. + +This used to be a thin status rail plus a horizontal wash that faded out to the right, and the rule +here used to forbid a tinted block outright. The texture is what earns the block back: a flat wash +of the state hue really would read as a card, but a low-opacity hatch over a 3% wash keeps the +surface quiet. A longer review still occupies more space; the texture does not make its visual +weight independent of height. See +`prototypes/side-highlight` for the alternatives that were tried and rejected. ## Conditional split panes @@ -810,7 +831,8 @@ surface so the next action remains discoverable. ## Accent colour versus interface tint The accent and tint are separate controls. Accent is semantic interaction emphasis: links, primary -actions, focus, selected rows, and user-authored message highlights. Interface tint is only a subtle +actions, focus, and user-authored message highlights. Not selected rows — those are the fill alone, +see "Sidebar selections". Interface tint is only a subtle wash through otherwise neutral surfaces. Derive hover and link shades from the accent per theme, and derive foreground text from the chosen solid accent so custom colours do not leave primary buttons unreadable. Do not introduce one-off component blues that bypass these tokens. @@ -838,44 +860,95 @@ Spec: [`tests/e2e/roadmap-list-rows.e2e.ts`](../tests/e2e/roadmap-list-rows.e2e. Complexity / fit / review chips stay when present (they are rare); tuck those further only if the list gets noisy again. -## Accent rails never curve - -A rail — a slim bar marking one inline edge of a row, whether drawn as `border-left` or as an inset -shadow (`box-shadow: inset 2px 0 0`) — is clipped to the element's `border-radius`. Put one on a -rounded box and the bar bows around the corners it meets, and the row stops reading as a marked list -item and starts reading as a generic tinted callout. Keep the corners a rail touches square. - -Three ways out, in order of preference: - -- **Square the whole box** (`border-radius: 0`) when the row belongs to a continuous list — the - sidebar chat rows, Settings nav, `.review-panel` and `.comparison-panel` in the transcript. -- **Square only the rail's side** (`border-radius: 0 var(--radius) var(--radius) 0`) to keep a - rounded card and a straight rail. This is what `@copse/streaming-markdown` does for blockquotes, - and its GitHub alerts go further and square all four. -- **Drop the rail for an even ring** (`box-shadow: inset 0 0 0 1px var(--accent)`) when the element - is genuinely a card rather than a list row — a ring has no direction, so it follows a radius - cleanly on all four sides. `.provider-chip.active` and `.vnc-discovered-port.selected` use this. - -The rule is per-edge, so a rounded far side is fine; only the corners the bar actually reaches have -to be square. `accent-rails.test.ts` parses every renderer stylesheet, joins each rail against any -rule that could round the same element, and fails with both source locations. happy-dom has no -layout and a screenshot diff only catches this after it ships, so the stylesheet is where it is -pinned. +## Rails mark nesting and standing asks + +A rail — a slim bar on one inline edge of a block, drawn as `border-left` or as an inset shadow +(`box-shadow: inset 2px 0 0`) — used to do three unrelated jobs here. The transcript used it for +**containment** ("this block is a different kind of content"), the sidebar used it for **selection** +("this row is current"), and tool cards used it for **nesting** ("these rows are children of that +one"). Ten of them across six stylesheets, which is why one device started reading as repetition +rather than as signal. + +Nesting keeps it: `.tool-rollup-body` and `.subagent-timeline` draw a guide line down the edge +of their children. There is one explicit exception: `.thread-proposal` uses a rail for a standing +ask and drops it when the proposal is settled (see [Proposed threads](proposed-threads.md)). +`accent-rails.test.ts` holds this list closed. A new rail needs an explicit design reason in +`STRUCTURAL_RAILS`; ordinary content and selections use the replacements below. + +**Containment is a plate.** Two materials on one shape, split by what the block _is_ rather than by +what it looks like. Components set `--sev` and mix their background locally using the tuning +tokens in `styles/global/base.css`: `--callout-plate-fill`, `--callout-hatch-line`, +`--callout-hatch-fill`, and `--callout-hatch-pitch`. There are no finished `--callout-plate` or +`--callout-hatch` properties: a root-level mix would resolve the root's severity before inheritance. + +- **Flat plate** — prose the agent wrote. GitHub alerts and blockquotes. It is part of the answer, + so it gets a plain surface, and the severity hue moves to the title and its glyph. +- **Hatched plate** — Copse annotating its own turn: thinking, review, comparison. Not part of the + answer, and a texture is what says so without spending a fourth hue or a fourth shape. Under + `prefers-reduced-transparency` or `prefers-contrast: more` it degrades to the flat plate: + commentary keeps a surface and loses only the distinction. + +The VNC pane takes a **gutter**: a 24px icon column in the authentication panel and a compact 6px +status-dot column in status rows. It is a separate pane with its own chrome, and its status hue +has to survive on a single line where a plate would just box three of them. These columns do not +currently align across states; the shared gutter in the prototype remains a polish option. + +**Selection is the fill alone.** See "Sidebar selections" below. + +The rails that remain are still clipped to `border-radius`, so a rounded corner bends one into a +curve and the row stops reading as a marked item. Keep the corners a rail touches square — square +the whole box (`border-radius: 0`) for a row in a continuous list, or square only the rail's side +(`border-radius: 0 var(--radius) var(--radius) 0`) to keep a rounded card and a straight bar, which +is what `.thread-proposal` does. `accent-rails.test.ts` joins each rail against any rule that could +round the same element and fails with both source locations; happy-dom has no layout and a +screenshot diff only catches this after it ships, so the stylesheet is where it is pinned. + +### Callout severities + +Five alert kinds need five hues, and the palette had three. `--info` (Note) and `--important` are +declared in `tokens.css` alongside `--success`, `--warning` and `--danger`; light-theme values are +derived rather than reused, per "Brand colours and semantic tokens" above. + +`--important` is pushed bluer than a true violet on purpose: `--accent` is pink, and a +magenta-leaning purple reads as _accent_ at 16px next to the pink Thinking and Comparison labels in +the same transcript. **A user whose custom accent is itself purple collapses that distinction** — +the fix, if it comes up, is to derive `--important` away from `--accent` rather than to fix a hex. +Thinking and Comparison both key off `--accent` too, so a turn with both shows two pink hatches; +that is not a regression (both already used the accent) but the texture makes the hue more present +than a 2px bar did. + +Each callout's glyph is a **solid** silhouette, the only filled shapes in an otherwise outline icon +set. That is a house rule, so state it: **filled marks status in the transcript, outline is chrome.** +It is what scopes the two warning triangles — the callout's solid one and `triangle-alert` in +`dom/icons.ts`, which stays outline everywhere the app's own chrome uses it — and the same scoping +covers `circle`, `dot` and `check`, which the Review annotation borrows. At 16px a 1.4-weight wire drawing cannot balance a +600-weight title, and thin strokes lose disproportionately on a dark surface. The five silhouettes +differ in outline, not just in the mark inside (circle / bulb / bubble / triangle / octagon), so the +kind reads before the hue does. Their vertical nudges are baked into each mask's `viewBox`: ink sits +high in a bulb and a bubble and low in a triangle, so box-centring reads wrong. Those constants were +measured once in `prototypes/side-highlight/proto.js` and are not recomputed at runtime. + +In forced-colors mode, callout masks use the system `CanvasText` foreground. Only the glyph's +pseudo-element opts out of automatic color adjustment so its background is not forced to the +same color as the plate; the rest of the callout still follows the user's contrast palette. +`tests/e2e/callout-surfaces.e2e.ts` verifies all five glyphs in dark and light forced-colors +palettes, plus the reduced-transparency and increased-contrast material fallbacks. ## Sidebar selections -Chat rows use flat, square, full-bleed selection and hover fills with a slim inset accent rail on -the **trailing (right) edge**. Avoid rounded row highlights here: they read like detached pills -instead of a selection within a continuous sidebar list — see "Accent rails never curve" above. Don't -reintroduce horizontal `margin-inline` on `.chat-row` — the selection wash should span the sidebar -edge-to-edge. +Chat rows use flat, square, full-bleed selection and hover fills, and **the fill is the whole +marker** — `--bg-selected` spans a continuous list edge to edge, which already says "this one is +current"; weight carries the rest. The accent rail that used to sit on the trailing edge was +redundant with it. Avoid rounded row highlights here: they read like detached pills instead of a +selection within a continuous list. Don't reintroduce horizontal `margin-inline` on `.chat-row` — +the selection wash should span the sidebar edge-to-edge. Thread rows and the paginated **Show more** control share the same horizontal inset (`margin-inline: var(--spacing-xs)` plus `padding-left: 28px`). Don't give Show more `width: 100%` without that margin — the label drifts left of the titles above it. -Settings nav (`.settings-nav-btn.active`) keeps a **leading** accent rail — that list sits on the -dialog's left edge, so the marker belongs there, not on the trailing side. +Settings nav (`.settings-nav-btn.active`) and VNC tabs (`.vnc-tab.is-active`) follow the same rule: +fill plus weight, no rail. ### Automation threads @@ -911,6 +984,13 @@ destination. Schedules are project-scoped, so a heading under a project that isn't open lands on the project first rather than editing another project's automations. Spec: [`tests/e2e/automation-settings-link.e2e.ts`](../tests/e2e/automation-settings-link.e2e.ts). +Setup now opens the plugin's standalone Automations modal. The side cog exposes +the same list and a direct **New automation…** action; its adjacent Settings label +stays a direct shortcut. Keep the modal header outside its scroll body, retain +the project scope above the form, and use the identical editor inside Settings. +Do not recreate the editor when plugin enablement changes: it may contain a draft. +Spec: [`tests/e2e/automation-dialog.e2e.ts`](../tests/e2e/automation-dialog.e2e.ts). + ## Settings → Usage worth-it card The plan worth-it block sits between subscription bars and the local ledger: one short verdict, one @@ -935,3 +1015,10 @@ eval: `tests/e2e/guarded-yolo.e2e.ts`. centers both. Always override `flex-direction: row` (and reset `margin-bottom`) on checkbox list rows built from `