Skip to content

Promote main to release - #2631

Merged
copse-release-bot[bot] merged 173 commits into
releasefrom
main
Sep 9, 2026
Merged

Promote main to release#2631
copse-release-bot[bot] merged 173 commits into
releasefrom
main

Conversation

@copse-release-bot

Copy link
Copy Markdown
Contributor

Daily automated promotion of main to the release-ready release branch.

The repository ruleset requires the full CI Passed gate. This PR will
merge automatically when that gate is green; failures leave it open
until the underlying issue is fixed on main.

jonathanKingston and others added 30 commits September 6, 2026 09:05
`prototypes/side-highlight` inventoried ten accent rails across six
stylesheets doing three unrelated jobs — containment in the transcript,
selection in the sidebar, nesting under a tool card — and proposed "M, the
mix", split by what each block *is* rather than by what it looks like. This
implements it. Only nesting keeps the bar, which is literally what a nesting
guide depicts; `accent-rails.test.ts` now holds that list closed.

Containment becomes a plate, in two materials (base.css):

- **Flat** — GitHub alerts and blockquotes. Prose the agent wrote is part of
  the answer, so it gets a plain surface and the hue moves to the title. These
  are styled by `@copse/streaming-markdown`, so markdown.css overrides at the
  package's own specificity and wins on source order; no upstream change.
- **Hatched** — thinking, review, comparison. Copse annotating its own turn,
  and a texture says so without spending a fourth hue or a fourth shape. It
  degrades to the flat plate under `prefers-reduced-transparency` and
  `prefers-contrast: more`.

The VNC pane takes the gutter instead: a separate pane whose status hue has to
survive on one line, where a plate would just box three of them. Selection —
chat rows, Settings nav, VNC tabs — becomes the full-bleed fill plus weight.

Answering the prototype's open questions:

- **Moiré is settled by the number.** 8 is a multiple of 4, so at every
  quarter-step display scale the hatch period lands on a whole count of device
  pixels and there is no beat frequency. The pitch is load bearing.
- **Two new hues.** Five alert kinds need five, and the palette had three.
  `--info` and `--important` join the semantic tokens with derived light values.
  `--important` is pushed bluer than a true violet so it does not read as the
  pink accent; a user whose accent *is* purple still collapses that, which is
  written down rather than fixed.
- **Callout glyphs are solid**, the only filled shapes in an outline set, with
  distinct silhouettes so the kind reads before the hue. Their optical-centring
  nudges are baked into each mask's viewBox — measured once by the prototype,
  not recomputed at runtime.

The tuning lives in base.css but the colour is mixed at each use site, because
a custom property is substituted where it is *declared*: one holding the whole
`color-mix(… var(--sev) …)` freezes the root's grey into its computed value and
inherits that grey everywhere, whatever each block sets --sev to. Every plate
came out the same neutral, and a screenshot is not how you catch that — the new
`callout-surfaces` e2e spec is, and it did.

`.thread-proposal` (#2334) landed after the prototype's inventory and keeps its
rail, allowlisted with the reason: a standing offer is an *ask*, a fourth job
the bar was not doing when this was surveyed. Worth revisiting deliberately.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
CI's full e2e tier found what the stylesheet-level guard could not: four specs
assert the old design directly, so removing the rails made them fail rather than
pass. Each is repointed at what the surface means now, not just at a new number.

- `conversation-visual-hierarchy` pinned review and comparison to a 2px left
  border, a zero radius and a fading `linear-gradient` wash. They take the
  hatched plate now, and the radius is the part a rail could never have had —
  it would have bowed around the corner.
- `markdown-streaming-blockquote` used the rail's width as its proof that the
  stylesheet had reached a *pending* quote at all. That proof is the plate now:
  a filled background and a radius, plus an explicit `0px` border so the rail
  cannot creep back on the streaming path alone.
- `thread-sidebar-selection` and `ui-polish` both asserted a rail — trailing on
  a chat row, leading on Settings nav. Both are the full-bleed fill plus weight,
  and in `ui-polish` the two now being *identical* is the assertion: one
  selection gesture wherever the list sits, where the old spec required them to
  differ.

Unrelated: `thread-terminal-rename-archive` also failed on shard 8 with
"expected renamed terminal label", a wait on the terminal tab's rename. It
passes locally against this build and touches nothing this branch changed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Prototype for docs/plans/thread-in-container.md: the product's headless agent
loop runs inside a disposable local Docker container with no user prompts.

- Contained-effect gate branch: when an unattended run is armed and the
  runtime attests to container containment, the shell gate allows in-guest
  effects, defers outward effects (git push, GitHub writes, publishes) under
  the new prompt cause `shell-outward-effect`, and refuses host escapes.
  Harm-gate hard denies stay hard.
- Runtime containment declaration from a host-written attestation; refused
  when short of the bar (unprivileged uid, read-only rootfs, cap-drop ALL,
  no-new-privileges, brokered-or-no network, no foreign mounts).
- Unattended-run ledger beside Guarded YOLO, mutually exclusive both ways;
  arming begins deferral mode and requires budgets.
- Container worker image and entry; host runner with git-bundle carry-in and
  carry-out to refs/copse/runs/<id>, --network none plus a per-origin
  unix-socket egress broker, secret canary, idempotent teardown, a managed
  container sweep, and a JSON review record. `pnpm run thread:container`.
- Unit tests for classification, attestation, the ledger, the gate matrix,
  Docker argv and the bundle round trip; an opt-in end-to-end test against a
  real daemon with a scripted OpenAI-compatible model behind the broker.
- Plan document, plans index row, and a cross-reference from unattended-runs.md
  recording where this diverges from its Decisions 1 and 3.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NzkY3kYdcQvYk8EH3EW8uv
Move the container runner into the main process and let the UI trigger it.

- The runner, egress broker and guest worker live under
  src/main/services/container-runtime/; the worker is a standalone main
  bundle (dist/main/thread-container-worker.cjs) with node-pty stubbed and
  only the sandbox runtime external, which the image context stages by
  copying the package from the app's own node_modules (no npm at run time).
  The Dockerfile and entrypoint are embedded strings. The CLI wrapper now
  bundles its entry the way the autonomy runner does.
- resolveContainerProvider maps a product model id to the guest's provider
  (OpenAI-compatible URL for local servers, OpenAI, OpenRouter and extra
  providers; the product's own resolver in the guest for Anthropic), the
  one egress origin, and the key, which travels as a run-scoped environment
  variable and is blanked once the guest holds it.
- ContainerRunService owns one run per thread, refuses SSH projects and
  unresolvable models before Docker, records the arming as a mode-arming
  decision, and pushes ContainerRunProgress snapshots over container:*
  IPC; preload and the demo API expose api.container.
- The composer footer gains "Run unattended in a container…": one dialog
  that is the arming form (draft prefilled, budgets, model and reachable
  origin) before a run and the status/review view (phase, log tail,
  deferrals, commits and their ref, canary, teardown) during and after; a
  banner over the composer mirrors the phase and a toast announces the end.
- Visual evidence: browser-tier spec over a seeded finished run and an
  Electron spec through real IPC, with screenshots; unit tests for the
  provider plan and the service; the Docker integration test still passes
  with the moved runner.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NzkY3kYdcQvYk8EH3EW8uv
Four findings from the readiness review of the container run, each with the
test the review asked for.

**Snapshot the thread's checkout, not the project root.** A thread in an
isolated worktree has its own branch and its own uncommitted edits, so the
service resolved the wrong tree. It now resolves the checkout through
`resolveThreadExecutionContext` — the cold resolver the supervisor already
injects — refuses a root git cannot snapshot with a readable reason instead of
a raw `git rev-parse` failure mid-run, and never falls back to the project root
when a worktree is broken. `start` claims the thread's slot before its first
await so two clicks cannot start two containers, and releases it when
resolution fails. The record and the dialog name which checkout ran.

**Never report a clean finish when retrieval or cleanup failed.** The record
now carries `carryOut { expected, ref, error }` and `cleanupError` instead of a
bare ref, and `judgeRun` decides the phase: commits that were produced but
could not be fetched, a container that would not stop or reap, and a leaked
secret canary keep a run out of `finished` and surface as the failure reason or
a warning. The banner no longer says commits are back when no ref was fetched.

**Key image reuse to the worker build.** `copse-worker:local` survives app
upgrades, so tag existence alone kept running the previous guest — including
its permission behaviour. The image is labelled with a fingerprint of the guest
bundle, the Dockerfile, the entrypoint, the uid and the sandbox-runtime
version, and is rebuilt when the shipped worker differs.

**Bound the wall-clock deadline independently.** `docker stop` failing or
hanging while `docker wait` stayed open could leave a run pending forever,
never reaching its cleanup block. The stop has its own timeout and a bounded
grace period settles the wait either way; the abandoned wait is killed and the
failure is reported rather than swallowed, with `docker rm --force` in teardown
as the next line of defence. Both Docker calls are injectable so a failed and a
hung stop are covered by unit tests.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NzkY3kYdcQvYk8EH3EW8uv
The plan quoted `container:runThread` / `container:runChanged`; the merge
renamed both to the kebab-case wire names the frozen protocol derives from
the facade.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NzkY3kYdcQvYk8EH3EW8uv
`judgeRun` pushed the canary onto `warnings` and then tested the cleanup
branch first, so a run that both leaked the secret canary and failed to reap
its container reported "The container could not be removed" as the failure
and buried the leak in the warnings list. A container left behind is a chore;
a credential that escaped the run is an incident, and it should be the line
the user reads first.

Moves the canary verdict above the cleanup branch. The canary stays in
`warnings` too, so the dialog's warning strip still shows it either way.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NzkY3kYdcQvYk8EH3EW8uv
Reconstructs the adaptation the branch's `Merge origin/main` commit carried.
A rebase drops that merge, so the rename it performed has to land as its own
commit or the preload facade would invoke channels the main process never
registers.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BCcGaKPRL3fmRpLR3rK5WX
`thread-container.ts` carried its own `isRecord`, byte-identical to the one
#2407 consolidated into `@shared/unknown-value.ts`. #2419 then made the type
predicate inventory shrink-only, so the duplicate registered as a new
unlisted predicate and failed `check` on the merge with main.

The file already imports through `@shared`, so the alias resolves on every
path this module is built for, the guest bundle included.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NzkY3kYdcQvYk8EH3EW8uv
The composer footer gained "Run unattended in a container…", and two e2e
specs pin the overflow roster: `footer-overflow-bounds` by count and
`developer-mode` by exact labels, in both its default and developer-mode
cases. Only the unit-level roster in `input-bar.test.ts` was updated when
the action landed, so these three assertions still described a six-item
menu.

They went unnoticed because the e2e tier never ran on this PR: the `e2e`
job is gated on `draft == false` or the `ci-full` label, and this PR is a
draft. Labelling it `ci-full` dispatched the shards and they failed on
exactly these.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NzkY3kYdcQvYk8EH3EW8uv
Screenshot candidates rendered for parent PR
#2348 at
`bc51f9dad83792e849cb20e25a269806479c5f2b` by
[CI run
34031724395](https://github.com/copse-dev/agent-pane/actions/runs/34031724395).

Review GitHub's image diffs, then merge this PR (or enable auto-merge)
to apply the
accepted references to `claude/copse-container-execution-nw4ln8`. This
branch contains
only PNG candidates from the immutable
`reference-screenshot-candidates-34031724395` artifact and never targets
`main`.

If the parent branch has advanced beyond the source SHA above, do not
merge this PR;
the successful CI run for the new head will replace it.

Co-authored-by: jonathanKingston <338988+jonathanKingston@users.noreply.github.com>
Starting a run on macOS failed before the container existed:

  egress socket path is too long for a unix socket (104 > 100):
  /var/folders/r5/qll_28695_q_2qr2kk7lv5gm0000gn/T/copse-egress-run-mtpvs161-9a6506/openrouter.ai_443.sock

A unix socket path is capped by `sun_path` — 104 bytes on macOS, 108 on
Linux — and macOS gives each process a per-user temp root around 49 bytes
before we add anything. Both of our contributions grew without a bound:
the directory spelled the whole runtime id, and the file name spelled the
hostname, so an extra allowlisted origin on a long host could overflow on
Linux too.

Both are now digests: `copse-cx-<10 hex of the runtime id>` and
`<10 hex of host:port>.sock`, a flat 15 bytes per socket whatever the
origin. Worst case on that same macOS root is 83 bytes. The run id and the
origins stay readable in `run.json` and in the broker's log lines, which is
where they are actually looked up.

The guest can no longer derive the file name, so `COPSE_EGRESS_ORIGINS`
carries it: `host:port=socket`. The entrypoint refuses a name containing a
separator rather than pasting it into a path.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NzkY3kYdcQvYk8EH3EW8uv
The dialog asked for the task in an editable textarea prefilled from the
composer draft. Both ways into it — an existing thread, the new-thread
input — mean the user typed that sentence a moment ago, so the field was
asking them to confirm their own text in a second box.

It is now a confirmation. The task is the composer draft, shown read-only;
an editable field appears only when the composer is empty, because then
there is genuinely nothing to run, and Start stays disabled until it has
something (the IPC schema requires a non-empty prompt, so an empty start
only ever produced a validation error).

What the dialog is actually for is the part the composer cannot say: this
runs unwatched, on this model, until one of two budgets stops it. So the
model becomes a choice rather than a line of hint text — a select over the
provider-backed models (agents are filtered out; the guest runs a model
in-process, not an ACP session), defaulting to the thread's and applying to
this run only.

`ContainerRunProgress` gains `prompt`. It is part of what a run is: the
review record can now say what was asked, which it could not before, and
"Start another run" can offer the same task again — with the composer empty
it would otherwise reopen a blank form it refuses to submit.

Tests: `fillModelSelect` is extracted and unit-tested for the shapes the
option list arrives in — empty, grouped, and missing the current model —
because neither e2e fixture can populate it (the Electron project seeds no
provider keys and the demo API serves no LM Studio models), and asserting
otherwise there would have been an assertion that could never fail
honestly. The browser tier covers both faces of the sheet, quoted and
empty; the Electron tier covers the live preload path.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NzkY3kYdcQvYk8EH3EW8uv
…ot run

Three things, all one thread.

**The picker is the app's picker.** The run dialog had a hand-rolled
`<select>` with its own population code — a second, worse way to choose a
model in the same app, with no search. It now mounts
`mountModelSelectPicker` over a hidden native select, the way Settings does,
so the roster is the same searchable menu as the composer's and the default
is the thread's current model. The bespoke `fillModelSelect` and its test go
with it.

The picker has to mount after the field exists: `mountModelSelectPicker`
inserts its trigger with `select.after(...)`, which silently does nothing
while the select has no parent. That is what the first attempt got wrong.

**Agent models are shown, not hidden, and not offered.** ACP, remote and
plugin agents are separate programs that authenticate as the user — an OAuth
login in `$HOME` or their own vendor key — and an unattended container is
given neither by design (`unattended-runs.md` decision 3), with a secret
canary checking it stayed that way. So they cannot run in the guest.

Hiding them makes this picker quietly different from every other one;
offering them ends in the service refusing the run. They are listed disabled
and labelled "needs its own login" instead. The runnable set is derived from
the product's own `includeAgentModels: false`, not a hardcoded prefix list,
so it stays right as that set moves.

**The refusal explains itself.** `resolveContainerProvider` answered an
`acp:` id with "cannot resolve a provider for model …", which reads as an
internal miss. It now says the model runs as its own agent process signed in
as you, and that the container is not given your credentials.

The four container-run reference screenshots are refreshed here rather than
in a follow-up, and that placement is load-bearing. `filter-screenshots`
holds them as "contested: deliberately committed on this branch", so CI
never proposes them and they have to be re-rendered by hand — but a commit
touching *only* `tests/e2e/screenshots/` is a screenshot-only head, which
`ci.yml` skips e2e for, before the `ci-full` label is even consulted. Landed
separately they would have skipped the very e2e run that should check them.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NzkY3kYdcQvYk8EH3EW8uv
…t use

The greyed-out agent rows said "needs its own login", which was wrong twice
over. The agent is usually already signed in — that is what "on this device"
in its group heading means — and signing in again would change nothing,
because the container is given no credentials at all, not because a
credential is missing. It sent people off to fix the wrong thing.

The row now says "not available in a container", and a note under the model
field carries the part that actually unblocks them: agent models sign in as
you on this device, an unattended container is given no credentials, so pick
the same model from its provider instead. That last clause matters — the
provider-backed twin is usually sitting one group up in the same list, and it
runs.

The note appears only once the roster actually contains a disabled row, since
a disabled option cannot be clicked to ask why.

`loadRunModelOptions` now takes the bound fetcher rather than the api and the
current model, which drops a `{} as ModelOptionsApi` cast from the test and
lets it assert the real thing: the reason names the container, and never
implies a sign-in would help. The first version of that test asserted a
string literal it had written itself, which would have passed against any
implementation at all.

Screenshots are refreshed in this commit rather than after it, for the reason
recorded in e17ee4f: a screenshot-only head skips the e2e run that checks it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NzkY3kYdcQvYk8EH3EW8uv
`carry-in / carry-out over git bundles` asserted that `writeCarryInBundle`
and a fresh `createSnapshotCommit` produce the same commit sha. A commit sha
folds in the committer timestamp at one-second granularity, so the assertion
held only while both calls landed in the same second — it passed locally for
days and failed on a CI runner that crossed a second boundary between them.

The intent was that the bundle carries the same snapshot, which is a
statement about content: compare the trees, which are timestamp-independent.
Verified the mechanism directly — two commits of an identical tree a second
apart differ in sha and agree in tree.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NzkY3kYdcQvYk8EH3EW8uv
The run dialog offers agent-backed models greyed out as "not available in a
container". This records why, and what it would take to change that, as a
section of the plan with its own decisions (A1–A6), phases (A-0 to A-4) and
test rows.

Three facts make the guest unable to run one today, each with its file:
an agent authenticates as the user from its own store, and the guest is
given no credentials by design; the broker cannot express the wildcard
origins the catalogue declares, and two origins on 443 collide in the
entrypoint's per-origin socat scheme; and an agent runs its own tool loop,
so the deferral guarantee — the run's headline claim — would not hold.

The decisions: a vendor key scoped to the run and never the login (A1); one
broker socket behind a guest CONNECT proxy with a pattern allowlist, which
also fixes the 443 collision (A2); the record names the harness and outward
effects are denied rather than deferred, because an agent's own tool call
cannot be replayed from the host (A3); binaries baked, pinned and in the
image fingerprint (A4); the container is the sandbox, no nested seatbelt
(A5); scope is the key-capable agents only (A6).

Every phase before A-3 is inert behind the existing refusal. A-1, the egress
rework, is worth landing on its own for the collision it fixes.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NzkY3kYdcQvYk8EH3EW8uv
The nested AGENTS.md walk was cached for one second, so a turn re-walked
up to 10,000 directories before nearly every file tool call. A turn now
creates a discovery memo (`createNestedInstructionTurn`) that the system
prompt build seeds with one fresh walk; every `activateNestedInstructionSources`
call of that turn reuses it. Callers outside a turn (the composer estimate)
share a 30 s cache, and Settings still forces a reload.

An edit tool whose path is an AGENTS.md invalidates both the memo and the
shared cache after it runs, so the next file tool call sees the file the
agent wrote, moved, or removed. `run_shell` writes are not observed.

Each mid-turn activation now adds a one-line transcript notice naming the
file, emitted as a text chunk at the next step boundary rather than between
a tool call and its result (a text chunk there would start a new assistant
bubble and strand the tool card).

Discovery records when it stopped at a directory, file, or depth cap:
project sources carry `discoveryTruncated` and the walk logs a warning.
A nested file that repeats an already-listed source stays listed and is
marked `duplicateOf` instead of being dropped; its content is still
injected once. The latest-activation record for Settings is now keyed per
thread under the project root, so concurrent threads no longer overwrite
each other; Settings reads the most recent thread.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011jaQbed96h8aSZLijfiHNB
…urces

`instructions:list` passes `duplicateOf` and `discoveryTruncated` through
to the renderer. Sources → Instruction files marks a nested file whose text
repeats a listed one with a quiet "duplicate" badge and says which file
carries it, and appends a single note under the list when nested discovery
stopped at its cap, so a missing deep file no longer looks unwritten.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011jaQbed96h8aSZLijfiHNB
Name the built-in file tools that bring a path into context and say that
`run_shell` writes, ACP agents, and subagent reads do not activate a nested
AGENTS.md. Describe the once-per-turn discovery, the re-walk after the agent
writes an AGENTS.md, the transcript notice, the duplicate badge, and the
truncation note in Sources.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011jaQbed96h8aSZLijfiHNB
The container's egress was one unix socket and one guest-side socat
listener per allowlisted origin, bound by name through --add-host and a
sysctl for privileged ports. That could not express a wildcard, and two
origins on the same port both tried to bind it in the guest, so the second
listener died backgrounded and its origin silently blackholed.

Now the host mounts a single broker socket and the worker starts a small
HTTP proxy on its own loopback, advertised through HTTPS_PROXY/HTTP_PROXY
(Node's fetch honours them under NODE_USE_ENV_PROXY). Each request opens
the socket, writes `CONNECT host:port`, and the host answers OK or DENY
against a pattern allowlist — exact `host:port` and `*.suffix:port`,
matched on the dot boundary. TLS stays end to end; refusals are logged
with the target, so a destination the guest asked for and did not get is
in the review record. The provider path is unchanged: plain-HTTP origins
go absolute-form through the proxy, re-chunked and streamed so
server-sent events arrive as they are sent.

- egress-rules.ts: the grammar, pure, shared by both sides.
- egress-broker.ts: the socket, the one-line protocol, the log; a
  `resolve` remap may carry a port so a stand-in origin can play 443.
- guest-egress-proxy.ts: the loopback proxy, dependency-free, in the
  worker bundle.
- --add-host, the sysctl and socat are gone from the run and the image.
- The integration test now reaches the model on guest port 443 through a
  wildcard rule and checks the rule is named in the log.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NzkY3kYdcQvYk8EH3EW8uv
…s shape

`ProjectInstructionSummary` gains a required `trusted: boolean` alongside the
optional `scopePath` / `duplicateOf` / `discoveryTruncated`. A new *optional*
result field would have been additive, but a required one is not, so
`gen-api-protocol.mts --compare-ref` classifies `instructions:list` and
`client.instructions.list` as breaking and fails the gate at version 2.

The gate did not exist when this branch forked; it arrived on `main` with the
protocol freeze, so this is the first run that has had to answer for the shape
change. Bumping is the right side of the trade: `trusted` is a real property of
every instruction source, and weakening it to optional purely to stay additive
would put a lie in the type.

The manifest is regenerated with `gen:api-protocol`, not edited: only its
`version` moves, because the manifest records channel arity and binding names,
which this change leaves alone.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BCcGaKPRL3fmRpLR3rK5WX
The run dialog offered every agent model greyed out: an agent signs in as
the user on the desktop, and the guest is given no credentials. That is
still true of the login. It is not true of a vendor API key, which is the
one credential a run already carries, scoped to the run, by value. So the
agents with a documented key path — Claude, Codex, Gemini — can run in the
guest, on their own harness, under that key; the rest stay greyed out with
a reason that says what would make them run, or that nothing would.

Plumbing (A-0): the image takes an ACP_AGENTS build argument of pinned
package@version specs (src/shared/container-acp-agents.ts) and installs
them globally before dropping to the worker user; the specs join the
image fingerprint. The run spec carries the agent config; the worker
registers that one agent in its settings overlay so getAcpAgent resolves
it and nothing else. The result and record gain `harness` and `denials`.

Credentials and policy (A-2): the user's own env map and desktop command
path never cross; the guest gives the agent exactly one variable, the
run's key under the agent's own name. The container is the sandbox, so
the agent spawns without a nested seatbelt and the session host is not
staged. Inside a contained run the ACP permission responder treats the
agent as contained, and its shell requests run the contained gate with a
new option, outwardEffects: 'deny': an agent's own command cannot be
replayed by Copse, so an outward effect is refused and recorded rather
than queued. A host escape's throw is answered to the agent as a
rejection. Any other kind that would reach a dialog is refused and
recorded. The worker reads the refusals back from the decision log.

Resolver (A-3, code): resolveContainerProvider returns an `acp` plan for
a registered key-capable agent whose key is in Settings — the full
selection as the model, the harness, the key, and the catalogue's
allowedDomains on 443 as egress rules. Others are refused per agent.

Dialog (A-4): agent rows enable when the vendor key exists, with the
per-agent reason otherwise; the note names which agents run and on what;
the record shows the harness and the refusals. No new IPC — renderer and
resolver read one shared table.

Not run here: the image build with the real agents baked in (no npm
registry from a Docker build in this sandbox), the scripted-agent
integration test (needs Docker), and a real agent run (needs a vendor
key). The plan records each as the next thing to run where it can.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NzkY3kYdcQvYk8EH3EW8uv
Jonathan Kingston and others added 28 commits September 9, 2026 03:34
Main bumped the same three security pins (xmldom under a 0.8 range, the
merge keeps that spelling), refreshed reference screenshots this branch
had also re-rendered (main's are taken; the container feature is off by
default now, so the footer menus it re-rendered match main's), and moved
Electron to 44.1. Main's new design lint keeps the accent rail for
nesting alone, so the run dialog's warning box becomes a plate on the
danger hue, as the callout surfaces in styles/global/base.css prescribe.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NzkY3kYdcQvYk8EH3EW8uv
chore(models): sync intellect scores from Artificial Analysis
fix(ssh): combine recursive and force removal flags correctly
…dexing

fix(files): resolve explicit references before indexing completes
Fix callout glyphs in forced colors and correct prototype guidance
Add durable event automation admission and recovery
…parity

Show PR comments, reviews, and individual CI checks
…rch-failure

fix(search): fall back when indexed search exits unsuccessfully
Add bulk worktree cleanup without rescanning other checkouts
…paths

fix(files): treat recursive listing paths as literal directories
…t-extensions

fix(attachments): preserve extensions when shortening filenames
…lity

fix(sandbox): keep zsh heredoc files in allowed scratch
…econcile-worktrees post-mortem

Three findings from the 2026-09-09 post-mortem of /reconcile-worktrees stalling
inside Copse when driven by Codex over ACP.

1. Skill discovery ignored ~/.codex/skills. `.codex` joins the container list
   at both scopes, last in precedence; project-scope roots are now sorted by
   that precedence instead of readdir order so the documented order is true.
   `userSkillRoots()` and a test-only home override make the user scope
   testable without the developer's real skills leaking in.

2. run_shell could not read the invoked skill's directory or the primary
   checkout. Invoking a skill now grants that thread read-only sandbox access
   to the skill directory plus validated `paths` frontmatter entries
   (thread-read-roots.ts / skill-read-roots.ts); the seatbelt overlay adds them
   to allowRead only and the shell-scope classifier treats them as contained
   read roots, so read-only commands naming them auto-run in the sandbox. `sed`
   is admitted as structurally read-only in its provable filter shape. A
   linked-worktree thread can now read (never write) the shared primary
   checkout, with the shared .git, its own nested worktree and nested sibling
   worktrees carved out per child so Linux bind ordering cannot shadow write
   binds; the primary's HEAD, index and sibling admin dirs are readable so
   `git worktree list` and `git -C <primary>` work.

3. An abandoned bridged MCP call cancelled its pending approval. The bridge now
   aborts an abandoned call (socket close or notifications/cancelled) with an
   AbandonedCallAbort reason; the approval service parks the prompt instead of
   cancelling it, rejects the caller with ApprovalPendingError telling the
   agent to retry the same call, and replays the eventual verdict once to an
   identical request (same thread, execution root and prompt) within ten
   minutes. Turn end still dismisses parked prompts.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…rted

The experimental setting hides the "Run unattended in a container…"
entry by default, so the developer-mode and footer-overflow-bounds
specs seed it on, matching container-run-dialog. The developer-mode
helpers take an optional settings bag for this.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NzkY3kYdcQvYk8EH3EW8uv
refactor(container): share validated run and attestation contracts
refactor(container): report lifecycle phases as structured events
refactor(agent): share transcript content and tool updates
refactor(container): track external runs with the task supervisor
Support directory-scoped AGENTS.md instructions
…ion-nw4ln8

feat(security): run a thread unattended inside a hardened container
…stmortem

Fix skill discovery, sandbox reads and abandoned approvals (reconcile-worktrees post-mortem)
@copse-release-bot
copse-release-bot Bot merged commit 088565a into release Sep 9, 2026
73 of 76 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants