Found by cubic on the third review pass over the same catch in #13. It is not a defect #13 introduced — git diff origin/main...feat/define-agent-api is empty for both files involved. It has been on main since e245fc1 (#7), and defineAgent only made it reachable from a new entry point.
Reproduction
- Process A holds a live session on
sessionId = "x". Its container is running.
- Process B calls
createSession({ sessionId: "x" }). B onCreate runs an exec, which acquires the sandbox.
docker/container.ts acquire() finds a container with that name and adopts it rather than failing — adopt() returns immediately for status === "running".
- B
onCreate rejects (a corepack enable that exits non-zero, a proxy that is unreachable).
- B
catch calls destroy(). A live session dies.
A second, quieter consequence of the same step 3: when B onCreate succeeds, A and B are now running two sessions inside one container, sharing a filesystem, with neither aware of the other.
Why no reap policy fixes it
Three rounds on #13 produced three positions, each correct against the one before it:
| Policy |
Fixes |
Breaks |
| unconditional reap |
never leaks |
destroys a container this call adopted |
| reap only a generated id |
keeps the adopted container |
leaks a caller-named container that did not exist |
| unconditional reap (shipped) |
never leaks |
can destroy another process live session |
By the time the catch runs the damage is upstream. packages/core/src/agent/define.ts is backend-agnostic by design and cannot see whether the acquisition adopted or created.
Where the fix belongs
HarnessV1SandboxProvider separates the two operations — createSession is the fresh path, and resumeSession "reattach[es] to an existing sandbox previously created with the same sessionId". packages/core/src/sandbox/harness/provider.ts routes both to the same session(id) and says so:
The contract cannot report whether session(id) found a sandbox or minted one, so createSession is taken as the fresh path and resumeSession as the returning one.
The primitive a real split needs already exists: ContainerHandle.peek() — "the container name if it is already running, without creating or starting anything."
Why this is a decision, not just a patch
The same file documents adoption as the feature:
Adoption is what makes a sandbox id resumable across processes: a second process asking for the same id finds the running container instead of colliding with it.
So removing adoption from the create path removes intended behaviour. The decision is where adoption moves to and what createSession does with a name that is taken — refuse, or create under a different one:
- Should
createSession peek() first and throw on a live name?
- Does
resumeSession become the only adopting path, and does our SandboxProvider need to distinguish them at all, or only the harness layer?
- What happens to a stopped container of the same name — adopt (today) or recreate?
Independent of #16: this holds even if defineAgent never supports resume, because createSession adopting a live container is wrong on its own.
Found by cubic on the third review pass over the same
catchin #13. It is not a defect #13 introduced —git diff origin/main...feat/define-agent-apiis empty for both files involved. It has been onmainsince e245fc1 (#7), anddefineAgentonly made it reachable from a new entry point.Reproduction
sessionId = "x". Its container is running.createSession({ sessionId: "x" }). BonCreateruns an exec, which acquires the sandbox.docker/container.tsacquire()finds a container with that name and adopts it rather than failing —adopt()returns immediately forstatus === "running".onCreaterejects (acorepack enablethat exits non-zero, a proxy that is unreachable).catchcallsdestroy(). A live session dies.A second, quieter consequence of the same step 3: when B
onCreatesucceeds, A and B are now running two sessions inside one container, sharing a filesystem, with neither aware of the other.Why no reap policy fixes it
Three rounds on #13 produced three positions, each correct against the one before it:
By the time the
catchruns the damage is upstream.packages/core/src/agent/define.tsis backend-agnostic by design and cannot see whether the acquisition adopted or created.Where the fix belongs
HarnessV1SandboxProviderseparates the two operations —createSessionis the fresh path, andresumeSession"reattach[es] to an existing sandbox previously created with the samesessionId".packages/core/src/sandbox/harness/provider.tsroutes both to the samesession(id)and says so:The primitive a real split needs already exists:
ContainerHandle.peek()— "the container name if it is already running, without creating or starting anything."Why this is a decision, not just a patch
The same file documents adoption as the feature:
So removing adoption from the create path removes intended behaviour. The decision is where adoption moves to and what
createSessiondoes with a name that is taken — refuse, or create under a different one:createSessionpeek()first and throw on a live name?resumeSessionbecome the only adopting path, and does ourSandboxProviderneed to distinguish them at all, or only the harness layer?Independent of #16: this holds even if
defineAgentnever supports resume, becausecreateSessionadopting a live container is wrong on its own.