v0.2.0
Added
-
The tag-triggered release pipeline lands (plan 27 slice 3).
.github/workflows/release.ymlfires on av*tag and publishes with
GITHUB_TOKENalone, invoking only root-Makefile targets so the release's
executable source stays the Makefile:release-tag-check(the version must
be the changelog's newest released section — served by the tool's new
latestsubcommand — and the commit must sit onorigin/main; nothing
publishes otherwise),release-images(one multi-arch server build pushed
asghcr.io/opensdlc-dev/managed-agent-platform/{controlplane,brain,executor}:X.Y.Z
— same digest, three names, the coordinates the Helm chart composes — plus
…/gate:X.Y.Z; deliberately nolatesttag; withoutPUSH=1it builds
linux/amd64 into the local daemon and nothing leaves the machine),
release-chart(the chart, itsversionandappVersionboth checked
against the release PR's bump before anything publishes, to
oci://ghcr.io/opensdlc-dev/charts), andrelease-binaries
(version-stamped worker tarballs for linux/darwin × amd64/arm64 with
sha256sums). The GitHub Release's notes come frommake changelog-notes CAP=120000: thenotessubcommand now clamps an over-cap section to whole
leading Keep-a-Changelog groups plus a link to the full CHANGELOG.md
section — GitHub rejects a body over 125,000 characters, and the first
cut's absorbed legacy backlog (measured 443,570) exceeds it. Deploy docs go
live accordingly: the Helm values/README image guidance now points at the
published coordinates (from v0.2.0 onward) instead of "build and push your
own", README gains thehelm install oci://…path, and RELEASING.md's
"What the tag triggers" section replaces its "Not yet built" placeholder —
including the one-time note thatGITHUB_TOKEN-created GHCR packages
start private and need a public flip for anonymous pulls. -
Binaries know their version (plan 27 slice 2). New
internal/version
package — a singleVersionvariable,"dev"unless the build injects it
via-ldflags -X— wired into the Dockerfile as anARG VERSION=devon the
shared build stage, so both the server image and the gate image stamp their
binaries at release-build time. All five binaries log it on their existing
startup line (controlplane listening/brain running/executor running/worker running/gate listeninggain aversionattribute),
and the worker — the one binary users download and run standalone — answers
--version(and-version) with the bare version string before touching
any configuration. Deliberately no version API endpoint: that would be
net-new wire surface (plan 27 decision 3). -
Release management lands — the fragment half (plan 27 slice 1; the plan
startsin-progress). Changelog entries move out of CHANGELOG.md's
[Unreleased]section and intochangelog.d/fragments — one file per
PR per Keep-a-Changelog group, the body being the final entry verbatim (this
entry is the first one) — so parallel PRs stop contending for the same
top-of-file insertion point; only a release PR touches CHANGELOG.md, via the
newmake changelog VERSION=X.Y.Z. The assembler (tools/changelog, tested
with mutation evidence) folds fragments into a dated section in KaC group
order (entries newest-first by adding commit), moves any legacy
[Unreleased]body byte-identically below them — the one-time affordance
the 5,300-line backlog needs — leaves a pointer paragraph as the new
[Unreleased]body, advances the Keep-a-Changelog link references, and
refuses cleanly (empty release, existing version, malformed fragment names
so a typo'd section cannot silently drop an entry) leaving both files and
fragments untouched;make changelog-notesextracts a released section for
the coming release workflow. docs/RELEASING.md is the new ritual:
SemVer 0.x (Added/Changed → minor, fix-only → patch; 1.0 reserved for an
explicit stability promise), plan-archive-driven timing, annotated
vX.Y.Ztags, chart version/appVersion in lockstep — with the
tag-triggered publishing pipeline explicitly marked as arriving in plan 27
slice 3. CLAUDE.md/AGENTS.md step-2 wording and the verifier's
docs-consistency rung now require the fragment instead of a direct
CHANGELOG.md edit.
Added
-
github_repositorysession resources — the clone lands, and #55 closes
(plan 25 slice 2; the plan archives). A repository attached at session create
is now cloned into the sandbox before the agent's first tool runs. The clone
happens platform-side, in the executor, with go-git
(github.com/go-git/go-git/v5, a new primary dependency): the executor opens
the sealed token throughsecrets.Cipher, clones over HTTPS with the token in
theAuthorizationheader (x-access-tokenbasic auth, so the on-disk
.git/configcarries only the clean URL), packs the checkout to a tar, ships
it over the existingWriteFileStreampath, and extracts it. The token never
enters the sandbox — no credential helper, no remote URL rewrite, nothing for
the agent to read — and the egress gate is never involved, because no sandbox
process talks to GitHub.checkoutresolves here rather than at create: a
branch clones single-branch, a commit clones then checks out the sha, and
nulltakes the remote's default branch. Repositories materialize before
files, so a file mount may deliberately overlay into a checkout.
Idempotence is probe-only —<mount>/.gitpresent means materialized —
which is what lets a workspace restored from a plan-24 checkpoint keep the
agent's work instead of being re-cloned over; a marker file would have been
stripped at capture. Extraction stages into a sibling directory and renames,
so a<mount>/.gitthe probe trusts can only ever name a complete tree.
Failure is surfaced, not fatal: a repository that will not clone records a
session.errorof the new typegithub_repository_clone_error(reasons
auth,not_found,network,checkout,too_large,timeout,
internal, classified over go-git's typed sentinels rather than message
text — with a status go-git has no sentinel for, a 5xx or a 429, read off the
transport error and reported asnetwork, because a git-host outage told as
internalsends the operator to the wrong logs; a remote that answers 200
with an outage page and a checkout descriptor go-git will not build a refspec
from arenetworkandcheckoutfor the same reason, and a clone this
platform cancels — a lost lease, a shutdown — isinternal, because it is),
deduped per
(resource, reason) so a polling session cannot flood its own log, and carrying
retry_status: retryinglike every othersession.errorthe platform writes —
the next work item re-probes and clones again, so no clone failure is ever the
last attempt. The session runs on with its other repositories mounted, and a
repository that is already materialized is never reported as failed, even on an
executor whose cipher configuration has drifted away from the control plane's. Self-review found and
fixed one real leak on that path: go-git copies a failing response's body
into the errors it builds for 401/403/404, so a host that named the
credential it rejected — and a host that rejects one has already decoded it —
put the token into a line we log, on the likeliest clone failure there is.
Every error the clone returns is now scrubbed of the token verbatim and
of the base64 basic-auth blob it was sent as, through a wrapper that keeps
the error chain intact so the reason classification still reads the sentinel
through it. Two new
budgets bound the work per repository —EXECUTOR_REPO_CLONE_MAX_BYTES
(default 1 GiB, metered as the bytes land, symlink targets included, and
shared across the.gitchroot go-git takes, so an oversized repository is
abandoned mid-clone rather than after) andEXECUTOR_REPO_CLONE_TIMEOUT
(default 5m, covering the checkout and the packing as well as the fetch, which
is all go-git's own context reaches) — both exposed in the compose file and
the Helm chart. The executor judges a mount path again before building the
rm -rfthat lands a checkout there, refusing the sandbox workdir, the skills
tree materialized into it moments earlier, and another repository's staging
path — three things the control plane cannot know it is looking at. The brain
appends a "Mounted repositories" block to the system prompt naming each
path, url, and checkout, gated tocloudenvironments: BYOC workers
materialize nothing (deliberate, #322), so asserting a checkout there would be
a false statement to the model. Unit M of the plan's verification matrix
lands as executor and brain integration tests — a real git repository served
over real smart-HTTP by an in-package fixture, ten rows against real Docker
sandboxes, and a token sweep of the materialized.git— with red-run
mutation evidence for every new guard, the review rounds' included, and the
post-fetch context bound pinned as the bound it is rather than as three
separate guards (its re-checks are redundant by design — each downstream one
catches what the one before it would have — so no single one's removal is
observable)
(docs/HISTORY.md carries the running record). The end-to-endrepo-answereval
joins the opt-in suite (RUN_EVALS=1plusGITHUB_EVAL_REPO_URL/
GITHUB_EVAL_REPO_TOKENin.env): a passphrase reachable only through a
real cloned GitHub repository, asked for without naming the mount, so the
brain's block is the only way to find it. Divergences: the slice-2 clone
semantics registered INFERRED and the no-BYOC-materialization stance
CONFIRMED in docs/DIVERGENCES.md. -
github_repositorysession resources — the wire half lands (plan 25
slice 1; the git half of #55 starts, and the plan flipsin-progress).
POST /v1/sessionsresources[]now accepts the repo variant: an exact
canonicalhttps://github.com/{owner}/{repo}URL (userinfo/port/query/
fragment carriers all 400 — the url is a rendered field and must never
smuggle a credential), a write-onlyauthorization_token(non-empty, ≤8 KiB)
sealed throughsecrets.Cipherinto the newsession_resource_credentials
table (migration 0020, ciphertext + key id, ON DELETE CASCADE with the
session), and a strictbranch/commitcheckout union stored and rendered
as given (nullwhen omitted). The rendered resource is token-free by
construction — the echoed jsonb never holds the secret. Token rotation
(POST …/resources/{rid}) goes live: re-seals the ciphertext, bumps the
resource'supdated_at, returns the full rendered resource; file resources,
archived sessions, and cipher-less deployments keep their rejections.
Post-create immutability is total — the add endpoint stays file-only
(wire-faithful per the SDK's typed Add) and repo DELETE is rejected
(attached-for-lifetime). Mount rules: clean-form literal paths (unlike the
file arm, which #323 roots under the uploads directory — the cross-resource
uniqueness and nesting rules compare files by their resolved paths),
no resource above a repo mount — enforced at create and by the post-create
add alike — no nested repos,/and/tmpreserved, at most eight repos
per session; the derived default mount is validated like a supplied one,
with the URL grammar bounding both segments so the default is clean and
storable by construction. Tokens seal before the transaction opens
(create and rotation both, the vault-credential precedent), so the cipher's
network round trip never runs under the session row lock. The review round
also hardened the OpenBao client's error scrub for every sealed secret
(vault credentials included): a hostile transit endpoint reflecting the
request's plaintext decoded — not just verbatim — into its error body is
now redacted in both forms before the text can reach a log. Unit W of the plan's verification
matrix lands as pgtest integration tests — token-sweep probe across every
response surface included — with red-run mutation evidence recorded for each
new guard. Divergences: the create-rejection entry carved down
(memory_storealone stays rejected) and the repo validation/rotation
strictness registered INFERRED in docs/DIVERGENCES.md. -
Plan 25 authored (draft): git/repo mounting —
github_repositorysession
resources (docs/plan/25_git-repo-mounting.md,
the second half of #55; the Files half landed with plan 08). Deep-researched against
the reference on 2026-08-06 — the public managed-agents GitHub guide and
session-resources API pages, the anthropic-sdk-go typed schema (pinned v1.61.0,
byte-identical to the checkout tip), and theantCLI + SDK worker source, which
prove the clone is server-side (zero client-side repo handling) and that the
reference mounts nothing on self_hosted environments. Three user-settled decisions
(2026-08-06) shape the design: the clone is control-plane resource materialization
via go-git — the token never enters the sandbox and the egress gate is never
involved; BYOC stays reference-faithful (no worker materialization; #322 takes the
symmetric extension); and a failed clone surfaces as a
github_repository_clone_errorsession.errorvariant without failing the run.
Two slices: the wire + sealed token storage (secrets.Cipher, migration 0020,
token rotation goes live), then the executor clone + the brain's "Mounted
repositories" block + therepo-answereval. The plan carries an executable
verification matrix — fixtures + acts + observations at the wire / sandbox-filesystem
/ event-log surfaces, mandatory adversarial probes, a mutation duty on every new
guard, and PASS/FAIL/BLOCKED/SKIP verdicts — adapted from the verifiable-react
runtime-observation philosophy. -
Plan 26 drafted — verification hardening: checkers proven by what they
refuse, verdicts that travel with their evidence
(docs/plan/26_verification-hardening.md).
The plan comes out of a comparative study of Anthropic'show-we-claude-codeworkshop sample
(a "verifiable component architecture": runtime observation at the surface,
mandatory adversarial fixtures, a PASS/FAIL/BLOCKED/SKIP verdict taxonomy)
against this repo's regime. Most workshop ideas are already here in stronger
form; five gaps survived the adversarial comparison, and the plan closes them
in five independent slices: known-bad subjects the contract suite and eval
graders must be seen to refuse; aBLOCKED(environment)marker on
environment-provisioning failures (labeled, still red); a structured per-rung
JSON verdict from the verifier plus red-run evidence required by default for
the diff's own new guards; failure artifacts that carry their own rerun
command; and an acceptance harness that dumps its buffered wire transcript on
failure. Equally deliberate is what it refuses to build: no self-declared
introspection contract, no implementer/certifier unification, no
BLOCKED-as-skip, no replay video, no registry overgo test. -
The idle-TTL tier: an idle session's sandbox is checkpointed and reaped,
and the next message gets it back (plan 24 slice 5 — the final slice; #64,
closing the workspace-continuity half of #28). The reaper gains its fourth
tier: an idle cloud session whose last activity
(sessions.updated_at) is older than the newEXECUTOR_SANDBOX_IDLE_TTL
(default 24h;0disables; compose and Helm expose the knob — the Helm
template reads the value throughtoString, so the YAML integer0
disables the tier instead of being folded into "unset" and silently arming
the default) has its
workspace captured through the slice-4 engine and its sandbox destroyed —
the TTL tier is the engine's first production trigger. Three exclusions,
all evaluated under the session's advisory lock: a session owing work (a
queued/starting/activework item — a pending harvest or tool run
must find the tree it was enqueued against, read in the same snapshot as
the status and TTL-age predicates — or one stopped within the executor's
lease TTL: an interrupt cancels the row instantly but its physical
claimant only notices at the next lease renewal, so until a lease TTL has
passed the tool may still be running in the sandbox; normally-completed
items carry nostopped_atand no such grace), a session with an unanswered
tool-confirmation ask (HITL-idle is still mid-turn; the ask check is its
own, deliberately earlier read — ordered before the main criteria query,
because a
confirmation batch answers the ask, enqueues the tool's work and flips the
session running in one transaction — asks-first means that transaction can
never land between the two reads with both coming back permissive), and a
disabled tier (zero TTL, or an executor with no object store — which logs
the disablement once at startup rather than silently discarding
workspaces).user.interruptstill never reaps; an
interrupted-then-abandoned session falls to the TTL like any other. Only
the capture failures the sandbox itself causes degrade (loudly) to
reap-without-checkpoint — the workspace over the budget and an unreadable
sandbox, exactly the two plan 24 D8 sanctions (too_largeanderrorare
separate metric outcomes; a failed capture writes no marker, so the next
provision starts fresh) — while a failure outside the sandbox (the
executor's spool disk, the object store, the marker write) aborts the
reap: the sandbox stays owned, the TTL is a floor not a deadline, and
the next pass retries with the workspace intact (the review caught the
first cut degrading on every failure, turning a transient blob-store
outage into permanent workspace loss). Thesession_checkpoints
marker row's cleanup owner is the deleting transaction itself
(deleteSession) — the acceptance run caught the reaper being unable to
own it: a session whose sandbox the idle tier already reaped never
reappears inOwned, so a row left to the reaper's deleted tier would
linger forever — and the capture's marker write is guarded against the
reverse race: it inserts under aKEY SHARElock on the session row only
while that row still exists, so a DELETE landing mid-capture cannot have
its marker resurrected (the capture withdraws its just-uploaded blob and
the reap proceeds, deletion having wanted the data gone; a failed withdraw
aborts instead, leaving the sandbox owned so the next pass's deleted tier
retries the blob delete). Plan 24 archives with this slice. -
The checkpoint/restore engine: an idle-reaped session will resume with its
workspace (plan 24 slice 4; #64, the workspace-continuity half of #28).
sandbox.ProvidergrowsExport(sessionID, root)on both backends — one
directory root streamed out as a tar (Docker through the archive endpoint,
which reads a stopped container; K8s through an in-podtarexec) —
and the executor gains the engine over it. Capture combines the three
durable roots (the workdir, the persistent shell's cwd/env state, the
published deliverables;/tmpdeliberately not) into one gzipped tar in
object storage, stripping the workdir's materialization sentinels so a
restored workspace re-materializes skills and files instead of trusting an
agent-writable marker, validating every member (clean relative paths;
regular files, directories and symlinks only) and budgeting the framed tar
stream itself against the newEXECUTOR_CHECKPOINT_MAX_BYTES(default
2 GiB; compose and Helm expose the knob) — the same measure restore's
decompressed bound enforces, so an accepted capture is always restorable
under the same cap. Restore fires on the newsession_checkpoints
marker, never on "the container is fresh" — a mid-turn container death
still recreates fresh on the event log's truth — and runs inside the
provision lock: areadymarker makes provision replace whatever exists
(the half-restore replacement rule), re-validate the downloaded tar under
the same budget, stream it in, extract it with one in-sandboxtaras the
sandbox user (the transfer path that preserves modes and symlinks), and
flip the markerconsumedonly after the extraction, so a crash mid-restore
is retried, never adopted. Two new instrument pairs:
sandbox.checkpoint/sandbox.restorecounters{outcome} with duration
histograms. Failure modes fail closed: an executor deployed without an
object store refuses to provision a session holding a ready checkpoint
rather than silently handing the agent an empty workspace, executor startup
refuses a workdir that would alias the checkpoint's other roots
(ValidateWorkdir), bounds the cap at 1 PiB, and raises the connection
floor to 3 (provision lock + reaper lock + transient queries each pin one).
In this slice the engine's only trigger is its test suite — the idle-TTL
tier that will drive it in production is slice 5. -
The reaper runs: terminal sessions lose their sandboxes (plan 24 slice 3;
#64). Sandbox destruction has an owner for the first time: every executor's
Runnow starts a goroutine that sweeps its own endpoint once per
EXECUTOR_REAP_INTERVAL(default 1m; compose and Helm expose the knob) —
Ownedfor the candidates, the session's database lifecycle for the
verdict, never a caller's claim — and only for cloud sessions: a
self_hosted session's sandbox carries the same ownership label but belongs
to the customer's BYOC worker, so on a shared daemon it is skipped in every
tier (the tombstone records the environment kind because the row is gone by
the time the reaper asks). A deleted cloud session — its row gone and its
tombstone present in the newdeleted_sessionstable, written by the
delete in the same transaction — is reaped and its workspace-checkpoint blob
deleted (blob first, so a failed delete keeps the retry trigger); a holding
whose id this database never saw is skipped — a missing row alone also
describes another deployment's sandbox on a shared Docker daemon or K8s
namespace, or a contract suite's fixtures next to a compose stack, and none
of those are this reaper's to destroy; an archived or terminated session is
reaped with its checkpoint kept until the row goes; idle, running and
rescheduling sessions are untouchable (the idle-TTL tier is slice 5's).
Reap-versus-provision is serialized by a per-session Postgres advisory lock:
provisionSandboxholds it blocking around every provision, the reaper
try-locks (a provision in flight means the session is in use — skip, next
pass re-asks) and re-reads the criteria under the lock, on the lock's own
connection, so a session revived between classification and lock is left
alone and a deliberately tiny pool cannot self-deadlock (a one-connection
pool is refused at executor startup).DELETE /v1/sessions/{id}also drops
the checkpoint blob best-effort on a context detached from the request — the
reaper covers a session that still owns a sandbox, this covers one whose
sandbox is already gone, which no reap pass will visit again, and a client
hanging up must not skip it. One new metric:sandbox.sessions.reaped
counter by tier. This
closes #64's core: a session's containers no longer outlive it, and the
cumulative-sandbox ceiling stops being a function of uptime. -
The sandbox provider learns
OwnedandReapon both backends — the
reaper's hands, with no reaper yet (plan 24 slice 2; #64).
sandbox.Providergrows the teardown contract:Owned(ctx)lists the
distinct session ids behind every container/pod carrying the ownership label
on this endpoint — running or stopped, gates included, endpoint-local by
design so N executors shard the reap with no coordination — and
Reap(ctx, sessionID)destroys a session's whole holding from its id alone,
needing no live handle: on Docker it force-removes every labeled container
with its anonymous volumes, sandbox before its netns-owning gate, waiting out
the daemon's "removal already in progress" 409 — the window a racing reaper
actually lands in — rather than surfacing the racer as an error; on
Kubernetes it selects the session's pods by label (never the derived name)
and waits until each pod object is gone from the API (the force-deleting
Destroy's own bound: a partitioned node's kubelet may lag). Both revoke the
session's gate token
first through a new provider-levelGateTokenRevoker(Reaphas noSpec
to carry one; a failed revoke aborts with nothing removed, so the next pass
retries both halves — #197's ordering), whichcmd/executorwires from its
pool viabackend.Configand the database-less BYOC worker leaves nil. The
Docker API client gains its one missing endpoint
(GET /containers/json?all=1+ label filter) and the Helm executor Role the
matchinglistverb. Four new shared contract rows (owned-after-provision,
reap-removes-all, reap-idempotent, reap-unknown-noop) run against both real
backends; the gated-pair ordering, revoke-first, 404-as-removed and
label-not-name behaviors are pinned by backend-specific suites (scripted
daemon, fake clientset), each verified to fail against its target mutant. No
production caller yet — slice 3's reaper loop is the consumer. -
Plan 24 drafted — sandbox teardown: the reaper, and the workspace that
survives it — and its first slice: a running session refuses archive and
delete (docs/plan/24_sandbox-teardown.md;
#64, and the workspace-continuity half of #28). The plan builds the missing
destruction lifecycle: a reaper goroutine in the executor (the only process
holding both the sandbox provider and the pool) as the single owner —
sandboxes are invisible on the wire, so eventual teardown is
wire-indistinguishable from immediate — withOwned/Reapjoining
sandbox.Provider, reap criteria derived from the session's database
lifecycle only (deleted / archived / terminated now; idle-past-TTL with a
blob checkpoint/restore of the agent's durable state later), a per-session
advisory lock linearizing reap against provision, anduser.interrupt
deliberately not a reap trigger. Slice 1 is the structural precondition the
reference documents: archiving or deleting arunningsession now answers
400invalid_request_error(requireNotRunning, checked under the session
row lock in the same transaction as the mutation, so a confirmation flipping
the session to running cannot slip between check and commit) — the refusal's
status and wording are ours, recorded INFERRED in docs/DIVERGENCES.md. No
gate-token change rides along:gatetoken.Authenticatealready fails closed
on archived sessions. Tests:TestRunningSessionArchiveAndDeleteRejected
(red before the guard, green after — both mutations refused while running,
neither mutating the row, both succeeding once idle). -
Plan 23 drafted: a write the sandbox cannot land becomes the model's error,
not the platform's fault
(docs/plan/23_classified-unwritable-write.md,
docs/REFERENCE_PROJECTS.md; #306). The plan
records what the reference implementation does — the SDK's
tools/agenttoolset(the toolset the realantworker runs) answers every
write failure to the model as anis_errortool_result, worded by
fsErrorMessage's four-entry normalization table with raw-text passthrough,
and puts no error-code taxonomy on the wire — and designs the convergence:
a new internalErrNotWritablesentinel (exit 20), both backends deriving
the reason from the sandbox's own bashstrerrortext (k8s in-script,
docker via a classify-on-refusal probe — no daemon-text parsing), and a
fileFaultrow that normalizes wording the way the reference does.
REFERENCE_PROJECTS.md now namestools/agenttoolsetas the
behavior-and-wording authority foragent_toolset_20260401tools. Status
draft; implementation is a follow-on PR that flips itin-progress. -
A session override's replacement system prompt answers to the SDK's
documented 100,000-character ceiling
(internal/api/sessions.go, #291). The pinned SDK
bounds anagent_with_overridesreplacementsystemat 100,000 characters —
a bound documented on the session override params only; agents' own
create/updatesystemhas no documented ceiling — but nothing enforced it:
an arbitrarily large replacement prompt was stored and could fail at the
provider on every turn.resolveAgentnow rejects an over-cap override with
a 400 naming the limit, counted in runes (the filesupload.go precedent for
character-documented limits, shared with the #66/#289 metadata caps). The
check binds only what the override supplies: an over-cap stored
system still resolves via a plain reference or an omitted override field, and
system:null(clear) is never counted. The reject shape and counting unit
are ours — recorded in docs/DIVERGENCES.md (INFERRED). Surfaced by the #290
review pass. -
A session's resolved agent answers to the whole-spec agent caps
(internal/api/sessions.go, #287). The #66
validations ran on agent create/update only, so a session's
agent_with_overridescould assemble exactly the specs agent create refuses —
129 tools, a duplicate or unreferenced MCP server, a colliding tool name — and
every turn of that session failed at the provider instead of the create being
a 400.resolveAgentnow runswire.go'svalidateAgentSpecon the merged
resolved spec — on every resolve, so a plain reference to a stored spec that
predates #66's enforcement and violates a cap also 400s at create — and
session update validates the patch's merged result the way agent update does:
a patch that strands a storedmcp_serveror grows past a cap rejects, and a
rejected update leaves the stored snapshot intact. Whether
the reference rejects the same set on the session surface is unobserved —
recorded with the #66 INFERRED entry in docs/DIVERGENCES.md. -
Session create/update enforce the SDK's documented metadata caps
(internal/api/sessions.go, #289). The pinned SDK
bounds session metadata with the same sentence it uses for agents — at most
16 pairs, keys up to 64 chars, values up to 512 (betasession.go) — but
sessions enforced nothing: an over-cap bag was stored and echoed. Both call
sites now runwire.go's sharedvalidateMetadataCaps(rune-counted, same
messages as agents and vaults): create validates the parsed bag, update the
post-patch stored bag, closing the gap #66 recorded. Environments stay
unbounded — nothing documents caps for them (docs/DIVERGENCES.md). -
Agent create/update enforce the reference's documented caps — a malformed agent
is a 400 at create, not a stored agent whose every turn fails
(internal/api/wire.go, #66). The pinned SDK documents
them on the create/update params: at most 128 tools and 20 MCP servers, server
names unique and every server referenced by anmcp_toolsetin the agent's
resulting tools, and metadata at most 16 pairs with 64-char keys and 512-char
values (counted as Unicode code points, not bytes) — the same numbers vaults
already enforced, so the metadata check is now one shared
validateMetadataCaps(moved from vaults.go, messages unchanged).
Tool names must also be unique once theagent_toolsetexpands — resolved
against what the entry actually enables, so a custom tool may reuse a disabled
built-in's name — because a duplicate reaches the Messages API as a 400 on every
turn of the agent. Update validates the merged result (the SDK's "resulting
tools" / "stored bag" wording), so an update that clearstoolswhile keeping
stored servers rejects without bumping the version. The reject messages, the
entry-counting of the 128 cap, and the not-rejected danglingmcp_toolset
reference are ours — recorded in
docs/DIVERGENCES.md; sessionagent_with_overrides
applied none of these until #287 closed the gap (entry above). -
Sandbox pods can pull from a private registry —
SANDBOX_K8S_IMAGE_PULL_SECRETS
(internal/sandbox/k8s/pullsecrets.go,
deploy/helm, #199). The K8s
provider's pods carried noimagePullSecrets, so a privateexecutor.sandboxImage
failed every provision with ImagePullBackOff even though the platform's own pods
could pull it. The provider now takes comma-separated Secret names — validated at
startup as the DNS-1123 subdomains the API server requires, refusing empties and
duplicates, naming the variable (#65's rule, the placement precedent) — and puts
the references on every sandbox pod at the pod level, so one knob also covers a
limited session's net-setup image and a gated session's gate sidecar; nil unless
configured, leaving an
unconfigured deployment's pods byte-identical. Threaded through
backend.Configto bothcmd/executorandcmd/worker. The chart grows
sandboxImagePullSecrets(same{name: ...}shape asimagePullSecrets):
nullinherits the top-level list — the sandbox pods run in the release
namespace, where the same Secrets answer — an explicit[]opts out, and a name
that would corrupt the comma encoding, or a non-list root thatwithwould
silently read as the opt-out, fails the render. Tests: the parse contract
(accept/trim, reject bad name/empty/duplicate naming the env var), podSpec carries
the references on plain and gated pods and stays nil unconfigured,Newrefuses a
malformed value before reaching a cluster, and the CI helm job asserts the
default render stays clean, the encoding, the inheritance, the opt-out, and the
render-time refusals. -
/work/pollhonoursblock_ms— a true long poll on a work-items NOTIFY
(internal/api/workapi.go,
internal/queue/queue.go,
internal/events, #74). The poll used to return
immediately whateverblock_mssaid, leaving the protocol correct but chattier than
the reference's. Now a createdtool_execitem rides amap_work_itemsNOTIFY on the
enqueuer's own handle (inside a transaction it is delivered on commit, so a woken poll
can always see the row), the SSE broker's listener carries the third channel keyed by
environment id, andpollWorkholds an empty poll open — subscribe before the first
poll, then poll → wait on wake/deadline/disconnect, with one final re-poll at the
deadline. Window semantics follow the SDK's recorded contract: absent = non-blocking,
the server ceiling is 999ms (over-cap values clamp to it), and an explicit 0 — which
the reference rejects — is a 400, extended to negative, present-but-empty,
unparseable, and repeated values. The
worker'sEmptyPollSleepdeliberately stays: the reference client sleeps between
empty polls the same way, so an idle worker's cadence remains wire-identical
(block + sleep). Reclaim-driven availability has no NOTIFY and is found by the next
poll, at most one ≤999ms window late. Tests: mid-wait enqueue wakes the poll early
with the trace-context headers intact, the deadline answers null after the full
window, over-cap clamps, invalid values 400, the NOTIFY is commit-gated, and the
broker's wake is environment-keyed.
Fixed
-
A
mount_pathis rooted under the session's uploads directory, the way the
reference documents it (internal/api/sessionresources.go;
#323). The public docs state the rule outright — "amount_pathof
/data.csvplaces the file at/mnt/session/uploads/data.csvin the
sandbox", and "paths should be absolute" is that page's style advice, not
a filesystem root. The platform read the leading/literally and required
it, so it was wrong in both directions at once: the relative form Anthropic's
own SDK example and workshop sample use (data.csv,app.log) was rejected
with a 400 — the report that opened #323 — while the absolute form from the
docs' own worked example was accepted and mounted at the container's
filesystem root, silently putting the file somewhere no client expected
it. (The SDK example is not perfectly self-consistent — its prompt then names
/uploads/data.csv, a third spelling matching neither rule — so the docs, not
the samples, are what this change follows.)resolveMountPathnow resolves
both spellings to the one documented place:/data.csvanddata.csvalike
land at/mnt/session/uploads/data.csv. An already-rooted absolute path
passes through cleaned but not re-rooted — the reference's data-analyst
cookbook mounts at the full/mnt/session/uploads/<name>and prompts the
agent with that same path, so re-rooting it would break the reference's own
teaching code — and an omittedmount_pathstill defaults to
/mnt/session/uploads/<file_id>. A
path is cleaned before it is rooted, so two spellings of one path resolve
alike —/../../etc/passwdand/mnt/session/uploads/a/../../../../etc/passwd
both clean to/etc/passwdand both land at/mnt/session/uploads/etc/passwd.
Only a relative path can still climb out once cleaned (../etc/passwd),
and that, along with a path naming the root itself, is a 400. The length,
storable-text and per-session uniqueness checks now all apply to the
resolved path, so/data.csvanddata.csvcollide as one mount instead
of passing as two; the uniqueness check also cleans the stored side, so a
non-canonical literal written before this change (/mnt/session/uploads//x)
still counts as taken against a freshly resolved/xrather than letting a
second resource silently overwrite the first's bytes. Bounding the resolved
path also means a supplied path is effectively capped at 1004 bytes once
rooted, where 1024 used to be accepted. Both the create-timeresources[]and
thePOST …/resourcesadd route resolve identically. This changes where a
newly created mount lands: a client that mounts at/workspace/in.txtnow
gets/mnt/session/uploads/workspace/in.txt. That is the point — it is what
the reference does — and it narrows containment as a side effect: a resource
created from now on cannot name a path outside/mnt, the sandbox's
writable set. Two limits on that, stated because the previous wording of this
entry overclaimed them: resolution is create/add-time only — stored rows
are not backfilled (deliberately: re-rooting a live session's mount would move
a file the agent's system prompt has already named), so a pre-upgrade session
keeps its literal path and can still fail to materialize under a read-only
root; and containment is lexical, over the stored string — the uploads
directory is agent-writable, so a symlink planted there can still redirect a
mount's bytes, the same accepted single-tenant tampering residual the mount
sentinel carries. One unlooked-for benefit falls out of the same rooting: the
outputs directory the executor harvests (/mnt/session/outputs) is no longer
a nameablemount_path, so a caller can no longer inject a file into a
session's deliverables by mounting into it. The brain's "Mounted files"
system-prompt block renders the
resolved path, so the agent is always told where the file actually is. This
also settles an unresolved review thread from the original Files slice
(PR #157), which asked forpath.Cleannormalization before the uniqueness
comparison. -
A failed bulk write's payload is taken back too — the #310 fix, extended to
batches, still executing nothing (best effort, as #310's own is: the
emptying raises no error, and a daemon that refuses it leaves the residue
where the sandbox user already could not reach it)
(internal/sandbox/bulkwrite.go,
internal/sandbox/docker/docker.go,
internal/sandbox/k8s/k8s.go; #316, raised by
the verifier and the Claude reviewer on #310's PR). #310 covered the single
write. A batch lands the same way — the docker daemon extracts its members on
the host, as root — but its only sheds were the sandbox user'srm, in
__map_bulk_renameand in__map_bulk_discardalike, so a batch refused
under a parent that user cannot write left every member's full payload
behind, up to 10,000 of them. Measured on a real non-root image before the
fix: a two-member batch into/etcleft 8192 bytes the sandbox could neither
unlink nor read away
(TestBulkWriteIntoARootOwnedParentOnANonRootImage, red first).Both sheds now name what their own
rmcould not take — on stdout, a
member by its manifest index and the two bookkeeping files asm/d, two
builtin tests per member and no process — and the docker backend empties
exactly those through the daemon in one archive: a refused batch leaves
every member at once, and ten thousand HEAD-plus-PUT round trips is not a
cleanup. Naming rather than emptying blindly is what keeps the pass from
recreating a temporary thermalready removed. The report steers nothing:
the shell counts a manifest the sandbox can rewrite, but the index is
resolved against the platform's own list, so what is emptied is always one of
the paths that batch chose — never a target, never a file the batch did not
put there. A successful batch is asked too, its last act being thermof
the bookkeeping files the same root extraction landed in the workdir.Two ways the sandbox could have answered for itself are closed rather than
assumed, both found in review. An image owns this stream before the script
does —bash -csourcesBASH_ENV, the channel #310 measured — so a forged
map-bulk-left 0would have put an empty file back exactly where the cleanup
had just taken one away; only the lines after the shed's own opening marker
are read now, and a stream without one is not a report at all. And the
manifest the shed walks lives in the sandbox's workdir, delivered a round
trip before the exec that reads it (#206 named that window), so deleting one
file would have left the pass with nothing to name and stranded every
member — the shed now says when it lost its list, and the one branch that
knows the members were delivered answers by emptying the platform's own list
instead.Both of those answers were themselves wrong on the first attempt, and the
verifier measured both against a real daemon before merge. The opening marker
is printed with a newline in front of it, because the stream arrives as
frames concatenated with no separator: an image whose output does not end in
a newline absorbed the marker into its own last partial line and left its
forged copy as the last valid one — the framing handing an attacker exactly
what it was written to take away. And the list that answers a deleted
manifest carries the members only: it had carried the two bookkeeping files
too, which that same branch removes itself before reporting, so the
fallback recreated as zero-byte files precisely what the shed had just
deleted. Both are pinned by rows that go red without the fix.The branch that must not empty is the same one as in #310: a rename whose
exec failed may have left amvin flight, so it keepsrmand stops
there, and a fake-daemon row asserts no emptying archive is sent even when
the shed reports the whole payload still present. The one #310 called
"vacuous to fix" — the reported-fault return that sheds nothing because
__map_bulk_renamehas alreadyrm -f'd — turns out to be where the residue
actually is: under a root-owned parent every member'smvis refused and
everyrmwith it, so that return now empties rather than re-running a
discard whosermhas already failed.The k8s twin needed no emptying — its
tarruns inside the pod, as the
sandbox user, so nothing it leaves is unremovable — but itsdiscardBulk
now runs on a context detached from the caller's (context.WithoutCancel
plus a 10-second budget), which #310 gave the docker cleanups and left it
out of: a batch cancelled mid-transfer could strand up to 10,000 members'
bytes in the pod until it died. No clientset fake can observe that (an exec
is a SPDY stream over the REST config, not a typed call, so no reactor ever
fires), so the row stands up an API server and asserts the pod was asked at
all — red without the change, 0 requests against 1. Detaching alone would
not have covered the case it was written for, which review caught: a caller
that goes away mid-delivery returns fromdeliverBulk's error branch, which
shed nothing at all. Both of those branches now shed.One limit the batch has that the single write does not, stated rather than
closed:reclaimasks the daemon directly, so it still empties when the shed
exec could not run, while a batch's emptying is driven by that exec's report
and has none without it. Covering it would cost a HEAD per member — ten
thousand round trips for a sandbox already too broken to runrm. -
A refused write's payload no longer sits in the sandbox for the
container's life — and no privileged exec was introduced to remove it
(internal/sandbox/docker/docker.go,
internal/sandbox/docker/api.go; #310, the
verifier's finding on #306's PR). The docker daemon extracts a write's
archive as root, so when the rename is what fails — a root-owned parent
under a non-root uid, the route #306 classifies — the script's ownrm -f
runs as a user who cannot unlink from that directory, and the refused
payload stayed behind as a root-owned.map-write-*file, one per refused
write (measured on a real non-root image). The daemon is asked to take
back what it landed: after a HEAD that says something is still there, the
same archive endpoint extracts a zero-byte entry of the same name over it.
Where that succeeds the payload is gone and only an empty name remains; it
reports nothing of its own, so it is best effort and a daemon that refuses
leaves the residue the sandbox user already could not shed. A put that dies
mid-transfer gets the same treatment, its residue being a partial payload
rather than an empty name (a verifier finding on the first exec-free
iteration).Two boundaries came out of review. The emptying is not asked for on the
branch where the rename's exec itself failed: that error does not say the
script never started, so amvmay be in flight, and emptying the temporary
under it would land zero bytes on the very target the caller is being told
it did not touch. That branch unlinks with the sandbox user'srmand stops
— keeping a payload the container will take away beats destroying data the
write promised to leave alone (Codex, round 5; the fake-daemon row asserts
no HEAD and no archive on that route). And every cleanup on this backend
now runs on a context detached from the write's own (context.WithoutCancel
plus a 10-second budget) — the two single-write sheds, and the batch's
discardBulkalongside them — because a tool call that timed out
mid-transfer is exactly the write whose residue must still be shed:
inheriting its cancellation skipped the cleanup in the case that caused it,
and left a batch's members (up to 10,000 of them) behind for the container's
life. Sites that shed both ways can now hold a failed write for two budgets
before returning, which the code says out loud. Codex and CodeRabbit found
the single-write half; the verifier found that the batch had been left out
of it. The k8s twin keeps the caller's context for now — its temporaries are
the sandbox user's own, so nothing there is unremovable, which is what
#310 is about — and #316 carries the question for both backends.The route not taken is the point: the obvious fix — an exec as
User: "0"
runningrm -f— was written, measured against four escalation channels an
image controls, and abandoned, so the platform still runs nothing in a
sandbox as anyone but the sandbox's own user. What pins that is structural
(docker'sexecConfighas noUserfield to set); the real-image row
TestTheRootShedRunsNoAgentCodeOnANonRootImagecovers the shell-hook
channel behind it. The evidence, the four channels (over the premise they
all sit on) and the rejected design are recorded in
docs/HISTORY.md. The k8s backend needs nothing here:
its temporary is created by the sandbox user, so the same credential that
made it removes it. The docker bulk write is knowingly left out — its
shed is the sandbox user'srm, and what makes that enough is the caller
(the one batch the platform writes goes under the workdir), not the
directories it creates, sincemkdir -pleaves a root-owned parent that
already exists exactly as it found it — and #316 tracks closing that rather
than resting on the caller. Red observed first on every row: the real-image residue
(the refused payload's bytes in/etc, want 0), the fake-daemon emptying
archive that did not exist, the raw route's re-pin — where the script's own
rm worked, no emptying archive may be sent, or the cleanup would make the
litter it exists to remove — and round 5's three. -
A write the sandbox cannot land is the model's error, not the platform's
fault (internal/sandbox/sandbox.go,
internal/sandbox/filefault.go,
internal/sandbox/k8s/k8s.go,
internal/sandbox/docker/docker.go,
internal/toolset/file.go; #306,
plan 23, archived in this
PR). A replaceable target whose temporary file could not be created — a
read-only root outside the writable mounts, a root-owned parent under a
non-root uid, a full disk — or whose missing parent could not be made,
failed raw: k8s'sexit 1, docker's daemonhttp 400, both abandoned by
the executor to lease reclaim and retried until the lease ran out. The
reference's own toolset answers every write failure to the model
(tools/agenttoolset: each failure path returns througherrorf,
surfaced as anis_errortool_result; no error-code taxonomy on the
wire), so both backends now classify:ErrNotWritable(exit 20), carried
asPathNotWritableErrorwith the sandbox's own bash strerror text as
the reason — k8s's write script captures the failed create's ormkdir's
message in-script and exits 20 with the reason on stdout (a path fault
still wins at 15; the mid-streamteefailure stays a raw exit 1, a
failure of the transfer rather than the target); docker attempts the same
create in a classify-on-refusal probe exec when the daemon refuses the
PUT on a replaceable target, takesmkdir's own stderr tail in
mkdirAll, and asks the same probe after a failed rename — the daemon
extracts as root, so a root-owned parent under a non-root uid takes the
PUT and refuses only the move, and a probe that succeeds keeps the raw
error there, a failure of the transfer rather than the target — no
daemon-text parsing anywhere, and both backends' reasons come from the
same shell's strerror, which is what keeps #205's identical-answers
invariant. The toolset words the result the way the reference's
fsErrorMessagetable does: both permission spellings (EACCES's
Permission denied, EPERM'sOperation not permitted— the table
matchesfs.ErrPermission, which Go answers for both errnos) take the
reference'spermission denied, everything else passes through raw. Red observed first on every
new test: the host-bash exit-20 + reason rows, docker's probe
classification,mkdirAllstrerror, and rename-refusal tests (the last
measured against a real non-root image, whose rawmv … Permission deniedwas exactly the abandoned-to-reclaim error), the toolset table
rows, and the read-only-root contract cells tightened fromerr != nil
toErrNotWritable(buffered, quarter-megabyte streamed, and a
new-parent cell — green on both real backends in seconds). docs:
DIVERGENCES' #205 convergence entry gains theClosed (#306)record,
ARCHITECTURE's sandbox.go/toolset/contract rows name the sentinel and
its wording provenance, and plan 23 archives with its summary in
HISTORY.md. -
Every exit the k8s write script can take with the body unread now drains
it first, so a failed large write returns instead of deadlocking
(internal/sandbox/k8s/k8s.go,
internal/sandbox/k8s/k8s_internal_test.go,
internal/sandbox/sandboxtest/contract.go;
issue #304, the follow-on #303 filed rather than widened in). Three failure
branches exited with stdin unread: amkdir -pthe path refuses, a
temporary file that cannot be created (a read-only root outside the
writable mounts, a root-owned parent, ENOSPC), and ateedying part way.
A small body races the exit code home; past the exec stream's flow-control
window the stdin copy holds the framer lock the teardown needs, and the
call hangs until the pod dies — #304's measurement was a 64 MiB streamed
write onto a read-only root deadlocking three of three on the: >
branch. Each branch now drains (cat >/dev/null) before its exit, the
teebranch shedding its temporary first so a teardown mid-drain cannot
leave it, and themkdirbranch classifying first —__map_path_fault
runs in a subshell so its verdict is carried past the drain rather than
exiting ahead of it — because the classification describes the path that
mademkdirfail, and a drain's worth of sandbox activity later that
moment has passed (the same freshness the #305 review demanded of the
unreplaceable probe; raised for this branch by the #307 review pass). Unlike #303's refusal branches, all three can be staged from the
host's bash, so the drains are pinned by behavior rather than by literal:
a new script test feeds the body through a pipe whose producer is the
verdict —head -c 1Mfinishes when the script drains and dies of
SIGPIPE when it does not (red-proven: producer exit 141 on all three
branches against the undrained script) — with themkdirbranch staged
by a file blocking the path, the: >branch by a directory squatting
the temporary's name, and theteebranch by a PATH shim dying
mid-stream. Two quarter-megabyte cells pin the real transport, hanging
rather than passing on regression:WriteUnderNonDirectorystreams large
onto a blocked path (stillErrNotDirectory, now returned rather than
hung) and the read-only-root hardening row streams large onto/etc(an
error, not a hang — the raw refusal's classification is now its own
tracked defect, #306, deliberately out of this drain pass as it was out
of #303's). docker is untouched: its script reads no streamed stdin. -
The unreplaceable-target refusal no longer depends on the write being able
to start (internal/sandbox/docker/docker.go,
internal/sandbox/k8s/k8s.go, #303). The #205
probe lived inside the rename scripts, so it only ran when the write got far
enough to run them — and the very hardening that makes unreplaceable targets
common keeps it from getting there. Under a read-only root filesystem
(Hardening.ReadOnlyRootfs, the recommended posture), docker's daemon
refuses the archive PUT outright before any script (http 400: container rootfs is marked read-only), and k8s'steecannot create the temporary
file next to a target on the read-only root: writing onto/etc/hosts(its
bind mount's parent sits on the read-only root) surfaced the raw refusal on
both backends — the unclassified fault the executor abandons to lease
reclaim and the same doomed call retries until the lease runs out, exactly
what #205 closed for the writable-root case. The k8s write script now asks
the directory check and the__map_unreplaceableprobe before creating the
temporary file rather than only just before the move, and each refusal
drains the body (cat >/dev/null) before its exit: sitting ahead oftee,
the refusals must do its consuming themselves, because an exit that leaves a
larger-than-flow-control-window body unread deadlocks the exec stream
instead of returning — measured, and the same pre-existing hazard on the
script's other early exits is now #304. The unreplaceable probe is asked
again after the body lands, just before the move — the pre-move position
it held before this change, and the one docker's rename has always asked
from — so the early ask buys reachability without trading away freshness: a
target that becomes a device node while a large body streams is still
refused, not supplanted (no drain needed there;teehas consumed the body
by then). docker, whose script the daemon never ran, asks the same questions
in an exec of its own when the PUT fails, classifying the refusal after the
fact. The same reorder is what fixes
k8s's half of the non-root corner (a root-owned/etcor/devrefuses
the temporary's creation to the sandbox uid); docker's half needed no code —
its temporary is the daemon's, created as root, so an unwritable parent
never blocked it. Two new contract rows
(WriteOntoUnreplaceableTargetUnderReadOnlyRoot,
WriteOntoUnreplaceableTargetAsNonRoot) pin both hardened postures on both
backends — buffered, streamed, and quarter-megabyte bodies, the node still a
device after; the non-root row runs the classification end-to-end as uid
65534, though with the read-only root necessarily along (RunAsUsertravels
withReadOnlyRootfsso the entrypoint can make the workdir) the refusal
fires for both reasons at once, so it pins the posture rather than isolating
the uid mechanism — and the writable-root device-node row gains a large-body
cell that hangs rather than passes if a refusal ever stops draining; the
k8s script-pin test asserts both asks and their order aroundtee, the
race between them being nothing a contract row can stage deterministically.
Found by the #302 review pass (a CodeRabbit finding, verified real and
deferred to its own issue rather than widened in-PR); the independent
verifier's large-body measurement is what put the drains in, and the #305
review pass (a Codex bot finding, independently converged on by the Claude
review) is what put the pre-move ask back. -
A write onto a target a rename cannot replace is a classified refusal on
both backends, not an unclassified fault
(internal/sandbox/filefault.go,
internal/sandbox/docker/docker.go,
internal/sandbox/k8s/k8s.go,
internal/toolset/file.go, #205). The atomic
write lands bytes under a temporary name and renames them into place (#71),
and some targets cannot be renamed onto: a file bind-mounted into the
sandbox (/etc/hosts) fails themvwith EBUSY, and a device node is
worse — the k8smvsucceeded and supplanted/dev/nullwith a regular
file, while docker failed unclassified because the daemon extracts the
temporary file into the image's/devon the overlay, invisible under the
tmpfs mounted over it. Either way an unclassified error reached the
executor, which stopped the tool set and abandoned the work item to lease
reclaim — the same doomed call retried until the lease ran out. Both write
scripts now ask a shared__map_unreplaceableprobe (a device-node test
plus a/proc/self/mountinfomount-point walk, shell builtins only)
before the move and exit a new shared code (19,
sandbox.ExitPathNotReplaceable— 17/18 belong to the bulk namespace),
which both backends map to a newsandbox.ErrNotReplaceablesentinel and
the file tools hand to the model as an actionable result: the target cannot
be replaced, bash redirection writes through it. A symlink stays
replaceable even when it points at a device (the rename replaces the link —
the documented supplant behavior), and the probe misses into the old
unclassified error rather than ever misclassifying (a mount point whose
path carries a space, a sandbox without readable mountinfo). The two
backends now agree on both targets, pinned by two new contract rows
(WriteOntoBindMountedTarget,WriteOntoDeviceNode— target intact, node
still a device, no temporary-file residue the sandbox can see; docker's
daemon-side copy for a device target lands on the overlay under the tmpfs,
invisible either way) that failed red on both backends
before the classification landed; bulk writes (WriteFiles) keep today's
behavior — no built-in tool routes a bind-mounted or device target through
them. The docs/DIVERGENCES.md atomicity entry's standing residual closes
with this. -
The issue-triage bash guard no longer denies every command on hosts where
python3is the Microsoft Store stub
(.claude/hooks/issue-triage-bash-guard.sh,
#295). The guard's parse step piped the PreToolUse payload topython3 -c
with|| exit 2, so a host whosepython3is the WindowsApps alias stub —
which exits without reading stdin — denied every Bash call the triage
subagent made, allow-listed or not, before the allowlist ever ran; triage
silently degraded to a no-evidenceneeds_plandefault that looked like a
policy denial, not an environment fault. The parse now probespython3,
thenpython, with a known-good document, so with a working interpreter a
malformed payload still hard-denies exactly as before; only when neither
works does it fall back to a POSIXsedextraction that fails closed on
any value it cannot decode faithfully (a backslash in the raw match — an
escape it would have to interpret, or a truncated match at an escaped
quote — denies with a rephrase-without-quotes message) or attribute
unambiguously (anything but exactly one"command"key in the payload
denies — the greedy match would otherwise judge the last key while the
harness executestool_input.command). The allowlist,
metacharacter screen, and flag denials are untouched and stay fail-closed;
real-python hosts keep identical exit codes on every row, and the stub-host
change is that a broken interpreter no longer masquerades as policy.
Verified by a 19-row exit-code matrix on the stub host (allow rows now 0
— pretty-printed payloads included; deny rows still 2, among them a
second"command"key, a blank-line variant of it, and a zero-key
payload — except the one fallback row, a truncated payload whose
extractable command is clean and allow-listed, which passes the screens;
unreachable from the harness, which emits only valid JSON), a 19-row
old-vs-new exit-code diff under a real python3 (identical on every row,
malformed JSON included), and a live triage dispatch through the fixed
hook whose transcript shows zero guard denials. -
The NUL-sanitization test's preview drain is bounded by a sentinel frame,
not a coalesced wake (internal/brain/nul_test.go,
#294). One CI run drained zero preview frames from a turn that demonstrably
settled. The old drain's premise — "the settle commit's NOTIFY follows every
preview frame, so a single Wake bounds the drain" — misread the wake
contract: wakes are coalesced re-read signals, and the listener buffers a
coverage-healing wake the moment LISTEN activates — long before any turn
activity — so the drain's singleWake()consumed that token and its
non-blocking frame drain raced the dispatcher — losing under CI load. The
ordering guarantee is real but lives on the frames lane: the one listening
connection delivers notifications in commit order, so a sentinel frame
published afterrunOncereturns autocommits after the settle and arrives
after every frame the turn broadcast. The drain now reads the frames lane
blockingly until the sentinel — which also lets the all-NUL control prove
an empty set deterministically instead of by timing. The broker is
untouched: its wake contract is documented, its own tests flush pre-buffered
wakes (drainWakes) before asserting, and production consumers use wakes
correctly (re-read after every wake). Evidence: a mechanism probe over the
old drain observes a wake with zero commits since Subscribe, and the fixed
test holds green under-racewith full-core CPU load. -
Kubernetes pod adoption validates the pod's fixed-at-create spec, not just
its ownership label and gate shape
(internal/sandbox/k8s/k8s.go, #296 — the k8s
twin of #29, below). Both adoption paths — the existing-pod path and the
create-race loser — checked only the session's ownership label (ours) and
the gated-vs-ungated shape (hasGateSidecar), so a session re-provisioned
under a changed spec silently adopted the old pod: an ungatedlimited
request adopted anunrestrictedsession's pod — which lacks the netsetup
init container that enforceslimited, so the session kept open egress — and
a changed image or workdir was equally invisible. A pod spec is immutable, so
none of it could be reconciled by adopting. The k8sadoptablenow compares
the pod's fixed-at-create shape — netsetup init-container presence for the
ungated case (the gated egress path is already pinned by the sidecar), the
sandbox container's image, and its workdir — against the normalized requested
spec on both paths, before the pod is handed to a tool run. A mismatch fails
closed with the samesandbox.ErrSpecMismatchsentinel and deletes nothing,
the same contract as Docker's — including the same recovery: the session
keeps failing provision until the stale pod is removed by hand, with the
product-level alternative tracked in #297 (the rollout consequence —
drain before changing networking/image/workdir — is now spelled out in
docs/self-hosted-security.md, which previously described these settings as
silently kept). On the existing-pod path the check runs after the
readiness wait, so the #198 wedged-gated-pod reclaim stays reachable — a pod
that is wedged and mismatched is reclaimed, not stranded behind a refusal
that deletes nothing. Reachability matches the Docker
half: a gate-less deployment's unrestricted→limited flip reaches this path
today; a gated one is absorbed by thehasGateSidecarreplace. Tests:
fake-clientset rows for each mismatch (with a reactor proving no delete
touches the pod), the matching-limited adoption, the gated mismatch, and the
wedged-plus-mismatched reclaim, built frompodSpec's
own output so the fixtures cannot drift from real creates; a live
kind-cluster test proving the refusal deletes nothing (by pod UID — the
derived name would survive a recreate) and that the refused pod stays
adoptable under its original spec; and a new shared contract-suite row
(SpecMismatchRefusesAdoption) pinning the refuse-then-readopt behavior on
every backend. -
Docker sandbox adoption validates the container's fixed-at-create spec, not
just its ownership label
(internal/sandbox/docker/docker.go,
internal/sandbox/sandbox.go, #29). Both
adoption paths — the existing-container path and the create-race loser —
checked only that the session's ownership label (and, since #197, the gate
pairing) matched, so a session re-provisioned under a changed spec silently
reused the old container: alimitedrequest adopted anunrestricted
session'sbridgecontainer and kept its open egress, and a changed image or
workdir was equally invisible. A sharedadoptablecheck now compares the
container's fixed-at-create configuration — the effective ungated
HostConfig.NetworkMode(gated mode is already pinned bypairedWithGate),
image, and workdir — against the normalized requested spec on both paths,
before the container is started or anything runs in it. A mismatch fails
closed with the newsandbox.ErrSpecMismatchsentinel and deletes nothing:
replacement is an explicit lifecycle the platform does not have, per the
issue — so a session whose existing container mismatches keeps failing
provision until the stale container is removed by hand. Contrary to #29's own
severity framing (written pre-gate), the networking half is reachable today:
the control plane accepts an environment networking patch mid-session (only
thekindis immutable) and the executor reads the environment's config live
at each tool run, so in a gate-less Docker deployment
unrestricted→limited reached exactly this adoption path and silently kept
bridgeegress — now it fails closed. (A gated deployment absorbs the same
flip via the #197pairedWithGateremove-and-rebuild reshape.) Whether the
control plane should instead refuse the patch while sessions are live is
#297. The k8s provider's twin gap was #296, fixed above. Tests: fake-daemon rows for
each mismatch (structurally proving no start/remove/exec touches the
container) and the matching-noneadoption, plus a real-daemon test reading
the effectiveHostConfig.NetworkModeoffdocker inspectfor both the
refusal and the matching-adoption legs. -
A claim racing executor shutdown no longer escapes as a fatal error
(internal/executor/executor.go, #282).Run
recognised cancellation only aserrors.Is(err, context.Canceled), but aClaim
in flight when the context dies can surface a transport-level failure from the
dropped connection instead — an i/o timeout on a loaded host in #282's gate run —
and that error escapedRunwhere the test (and a supervisor) expect a clean nil
stop.Runnow checksctx.Err()after a step error, the same
liveness-after-failure conventionworker.Runalready follows; a genuine claim
failure with the context live stays fatal. Same load-induced-flake family as #265
(2a533ec), one layer up: the connection, not the container start. Tests: a stub
context whose cancellation lands exactly between the loop-top check and the error
inspection — the race window, made deterministic — over a closed pool (the stand-in
for the dropped connection) gets nil fromRun; the same closed pool with a live
context still returns the claim error. -
A dismantled egress gate's token is revoked, and an ungated re-provision no longer
adopts a still-gated sandbox (internal/gatetoken,
internal/sandbox,
internal/sandbox/docker,
internal/sandbox/k8s,
internal/executor, #197). Gate-token revocation
only ever happened as a side effect of minting a successor (Ensure's
revoke-on-re-mint), so the one transition that dismantles a session's gate without
replacing it — gated→ungated, when the session's vaults detach or its networking
stops beinglimited— left the old token live until the session archived. Both
backends now revoke it at the moment they observe that transition, through a new
sandbox.GateTokenRevokerseam carried onSpecitself (deliberately not on
GateSpec: the provision that needs it is precisely the one withGate == nil),
implemented by the executor overgatetoken.Revoke— idempotent, and ordered before
the teardown so a failed teardown retries both. The K8s call site is the existing
shape-mismatch pod replacement. The Docker provider had the deeper half of the bug:
pairedWithGatetreated an ungated session as trivially paired, so a gated→ungated
re-provision silently adopted the still-gate-networked sandbox and never observed the
transition at all — it now requires an ungated session's sandbox to not be
gate-networked, and the mismatch path removes the sandbox and — after the same
ownership-label check every adoption path makes, never by name alone — its gate
container before rebuilding directly networked. A gate orphaned with no re-provision
ever arriving remains the standalone teardown reaper's job (#64). Tests: a
revoked token stops authenticating and a second revoke is a clean no-op; each
backend's reshape test asserts exactly one revoke on the ungated direction — landing
before the teardown (the k8s revoke still sees the gated pod, the docker revoke sees
zero removals issued), the ordering that lets a failed teardown retry both — and
none on the gated one; the Docker dismantle test asserts sandbox and gate are both
removed. -
An uploaded file's extension-fallback mime no longer depends on the serving host
(internal/mimetab,
internal/api/filesupload.go, #277 — the #264 fix
carried to the registry's other writer).fileMimeTypestill fell back to
mime.TypeByExtensionwhen an upload part declared no (or a generic) Content-Type —
the same host-merged lookup #264 removed from the harvest path — so the same filename
could land differentfilesrows depending on which path created the row and which
host served the upload. The pinned table #264 built now lives in a shared package,
internal/mimetab(the harvest's table moved, not forked:internal/executorand
internal/apiboth consultmimetab.ByPath, and the grader-coupling and
host-independence tests moved with it), and the upload fallback consults it in place
of the process mime registry. Declared-Content-Type-wins, the octet-stream fallback,
and every table value are unchanged; a regression test uploads a.jsonlfile — in
the pinned table, absent from Go's builtin one — with no declared type and asserts
the row istext/plain; charset=utf-8on every host.
Changed
-
The GCP deployment holds no downloaded key material at all: the GCS HMAC pair is gone
(deploy/gcp, deploy/helm/managed-agent-platform,
#240, plan 22 slice 2 — the plan is archived with
this PR). Slice 1 built the backend that needs no credential; this slice moves the
deployment onto it and deletes what it replaced. The chart grows a third
object-storage mode,gcsObjectStorage.enabled+.bucket, which emitsBLOB_BACKEND
andBLOB_BUCKETand no credential key whatsoever — mutually exclusive with both the
bundled MinIO andexternalObjectStorage, each rejected at render with a namedfail
rather than by whichever one the template happened to read last, and refused outright
alongsideexistingSecret, where the chart writes no Secret and the value would select
nothing. (Precisely: no credential value exists in the release. The three Deployments
still declareoptional: truereferences to the S3 keys, because the env helper is one
uniform block; they resolve to unset because the Secret has no such keys.)deploy/gcp
follows:
themap-storageservice account, the two Secret Manager containers that held the key's
halves, and every trace of the pair inbootstrap.share removed, and the bucket is
bound to the identities the three workloads already run under.Three bucket grants, not the two the plan predicted, and read from the code rather than
assumed. The controlplane and executor Put, Get and Delete objects, so each takes
roles/storage.objectUser; the brain only ever Gets, so it takes
roles/storage.objectViewer.objectAdminwas considered and rejected, on a
measured difference rather than a remembered one: it adds exactly four permissions over
objectUser—objects.getIamPolicy/objects.setIamPolicy, which the bucket's uniform
bucket-level access turns off so nothing could exercise them, and
objects.setRetention/objects.overrideUnlockedRetention, which are live but govern
object retention the platform never sets. None of the four is anything the code calls.bootstrap.shdrops from 313 lines to 157: with no key to materialize, the whole
create-once-read-once dance goes, along with its rollback trap, itspython3
dependency, and the two secrets it fed. Its three-outcome version probe stays — the
distinction between "no version" and "could not tell" is what stops a second version
landing on top of a live one — and now says in its own comment that the second caller it
was shaped for retired here.check_split.py'sMIN_PROTECTEDdrops 10 → 7, which is
what its own failure message asks an operator to do when the foundation legitimately
shrinks;google_storage_hmac_keystays in its unrecoverable-kinds table as the guard
that stops one reappearing in the disposable half. One of the three stated reasons the
foundation/environment split exists — deleting a service account deletes its HMAC keys —
stopped being true, so the four passages that assert it now record it as retired instead
of claiming it; the split still stands on the KMS key ring, which cannot be deleted at
all, and on secrets as the reconciliation source.An existing mode-2 deployment migrates by runbook rather than automatically. The three
retired resources are in Terraform state, andfoundation/'s
deletion_policy = "PREVENT"is read from state rather than configuration, so simply
deleting the blocks makes a later apply fail — the guard working as designed.
docs/deploy-gcp.md gains a migration section: the three
terraform state rmaddresses, the deletion order (HMAC key before service account,
always, since deleting the account takes its keys with it and strands a secret nothing
can ever read again), and — before anything moves — the three bucket bindings added out
of band, becausemake gcp-env-applyadds the new grants and drops the oldmap-storage
ones in the same apply, so no ordering of the two Terraform states alone is gap-free.
It also says to remove the retiredblob-*keys from a hand-assembled Secret rather
than merely stop selecting them: the chart's env helper references every key optionally,
so one left behind is still injected into all three pods. Terraform'sremovedblocks
anddeletion_policy = "ABANDON"would each automate the state half, and the runbook
says why neither is used — a one-time migration would leave permanent configuration in
foundation/to serve it.The brain's Workload Identity annotation stops being optional and three passages that
said otherwise are corrected. It used to exist only for the Cloud SQL Auth Proxy, so the
direct-database path could skip it; the brain now reads the bucket, so mode 2 needs it
either way. Worth stating loudly because the failure is quiet: the brain's blob reads are
written to degrade rather than error, so a missing annotation shows up as an unreadable
rubric graded against the outcome description alone, not as an outage.Verified credential-free, which is the whole gate here:
make gcp-fmt gcp-validate gcp-split-check gcp-lintplus the three run-rather-than-read tooling tests, all four
chart object-storage modes rendered with every mutual-exclusion guard fired, and a new CI
step asserting the keyless mode reaches all three processes — both variables, not just
BLOB_BACKEND— while emitting no credential anywhere in the release, not merely in the
Secret.
Noterraform applywas run and the mode-2 acceptance was not re-run — both cost money
and are interactive by design, so they stay an operator action.
Fixed
-
A harvested deliverable's mime no longer depends on which executor host
harvested it (internal/executor/harvest.go, #264).
harvestMimeconsultedmime.TypeByExtension, which merges the Go builtin table
with the host's/etc/mime.types— so the same outputs tree yielded different
filesrows on different executor hosts (the plan-21 acceptance sawbuild_dcf.py
published asapplication/octet-streamfrom the compose executor image where a
desktop host saystext/x-python), and, through the grader's text-inline rule
(text/*,application/json), the host also decided whether a deliverable's
content reached the grader.harvestMimenow consults only a fixed in-code
extension table: a pinned copy of Go 1.26's builtin table (so a toolchain upgrade
cannot change registry rows either) plus a bounded set of textual deliverable
types it lacks (.py.md.yaml/.yml.tsv.log.sql.toml.ini
.jsonl.rst.tex), those deliberatelytext/*so the grader's inline rule
accepts them, and one binary addition (.tar,application/x-tar, alongside
the builtin.gz/.zip) — withapplication/octet-streamstill the
unknown-extension fallback on every host. A test seeds the process mime registry
and proves the seeded mapping cannot leak into a verdict, and pins the
grader-intended entries to the inline rule's accepted side. -
The per-test-binary Docker fixtures retry a dead container start instead of flaking
(internal/pgtest,
internal/secrets/secretstest,
internal/blob/blobtest, #265). On a crowded
Docker daemon a fixture's published port can come up dead — connection-refused for the
whole readiness budget while sibling fixtures in the same run work. Diagnosing this on
2026-08-04 showed waiting longer never heals it: pgtest's Postgres stayed refused to a
120-second ceiling and, with the budget raised as an experiment, to a 300-second one
(the store suite failing at 123s and then 302s on consecutive single-gate runs), and
the OpenBao fixture had flaked the same way under a concurrent gate. So each fixture's
Mainnow bounds one readiness wait at 150 seconds and, on failure, removes the
container and tries once more with a fresh one — a fresh port mapping is what actually
recovers. Around that retry, the failure paths stop eating evidence: the containers run
without--rm(a crashed entrypoint used to be auto-reaped before its state and logs
could be read), the failure message carries the dead container's state and last log
lines, the readiness wait fast-fails as soon as the container isexited/dead
instead of polling a corpse for the whole budget, a faileddocker rmis reported
instead of swallowed, and every forensic/cleanupdockercall is itself bounded so a
wedged daemon cannot hang the run. secretstest's transit-engine mount moves inside the
retried region, so a mount failure retries with a fresh container like any other dead
start. The per-probe timeouts in pgtest and secretstest also rise from 2 to 5 seconds
so a loaded dial+auth round trip is not cancelled mid-handshake. -
environment/main.tf's peering header no longer sells the manual
gcloud services vpc-peerings deleteas the way out of a stuck teardown
(deploy/gcp/environment/main.tf, #270). The comment
said "retry, and see docs/deploy-gcp.md for the manualgcloud services vpc-peerings deleteif a retry is not enough" — but the manual command is subject to the same
four-day producer-side wait the guide documents since #271, so it framed an escape
hatch that can itself be closed. The comment now states the four days, that no
retrying inside one session gets past them,
that what the failure strands is non-billable, and points at the guide's "Tearing it
down" section — which exists and covers exactly this. docs/HISTORY.md's "remaining
half" sentence flips to the past tense in the same change. Closes the last half of
#270; the guide half landed with #271.
Truncated: the full section is in CHANGELOG.md.