Skip to content

fix(tools): a sandbox's third-party tools are the item's, not the waker's - #708

Merged
HYChou0515 merged 4 commits into
masterfrom
worktree-ext-tools-item-property
Aug 12, 2026
Merged

fix(tools): a sandbox's third-party tools are the item's, not the waker's#708
HYChou0515 merged 4 commits into
masterfrom
worktree-ext-tools-item-property

Conversation

@HYChou0515

Copy link
Copy Markdown
Owner

Follow-up to #707, which fixed half the problem and made the other half worse.

What #707 got wrong

It threaded the turn's resolved bundles into one of the four things that
create a sandbox. The other three carry nothing:

creates a sandbox carried tools before this PR
agent turn (turn_context) ✅ (#707)
the human terminal, POST /a/{slug}/items/{id}/exec
a workflow's deterministic node
registry.rebuild_io_handle (file op after a reaped sandbox)

A sandbox mounts its bundles inside create and never again. So once a
restart clears the in-memory sessions, whichever entry point happens to wake
an item first decides whether it has third-party tools for that sandbox's
entire life
. Open the terminal before your first message and they are gone
until the sandbox is recycled.

#707's second commit then made that louder instead of rarer: the session
recorded the tool-less sandbox as known-empty, so the next turn refused the
tools rather than offering them. No such file or directory became "the tool
isn't there at all" — which is what a redeploy looked like from outside.

The model was wrong, not just incomplete

What a sandbox mounts is a property of the item — its App's declared
external_tools. A turn is only special in that it has already resolved, so
its shas are pinned to the schemas the model was handed. Everyone else should
ask the same question rather than answer it by omission.

  • InvestigationRegistry.tools_for answers "what does this item mount", used
    whenever a caller states nothing.
  • A caller's {} stays an answer, not a gap, so an app with no
    third-party tools pays for no resolve.
  • The lookup is written once (resolve_item_tools) and read by both callers.
    Two places answering "which bundles does this item get" is precisely the
    rule that drifts, and its way of drifting is a tool that exists in some
    sandboxes and not others depending on who opened them.
  • Resolving here is best effort: an artifact store that is down must not stop
    someone opening a terminal. The sandbox then comes up without that tool and
    the session records known-empty, so the next turn still says why instead
    of handing over a launcher that isn't there.

Docs

New docs/deployment.md §15.3 states the fact everything else depends on and
that §15 never said: resolve (per turn, fills the host's cache, mounts
nothing) is not mount (once, at create). It names the four creators and
records the symptom of getting this wrong, since we shipped it. §15.4 now also
says a live sandbox keeps serving the older build deliberately, and what that
costs. Subsections renumbered from 15.3 down, cross-references followed.

Verification

Targeted: test_turn_external_tools (2 new), test_registry,
tooling/test_external, plus app-construction smoke tests
(test_api_namespace, test_app_version_header, test_apps_routes,
test_turn_queue) — 93 passed. ruff check + format --check, ty check,
and mkdocs build --strict all pass. Full suite left to CI.

Deploying

App-side only, as before — no sandbox-host or mcp-runner rebuild.

🤖 Generated with Claude Code

https://claude.ai/code/session_01KVqSrLupJQTJ4Wp1exBKnJ

HYChou0515 and others added 4 commits August 11, 2026 17:23
…he wake

My P1 threaded the turn's bundles into one of the FOUR things that create a
sandbox. The other three carry nothing: the human terminal
(`POST …/exec`), a workflow's deterministic node, and `rebuild_io_handle`.
A sandbox mounts once, at create, and is never repaired — so after a restart
wipes the sessions, whichever entry point happens to wake an item first
decides whether it has third-party tools for that sandbox's whole life. Open
a terminal before your first message and the tools are gone until recycle.

P2 then made that failure louder rather than rarer: the session recorded the
tool-less sandbox as known-empty, so the next turn refused the tools instead
of offering them. `No such file or directory` became "the tool is not there
at all", which is what a redeploy looked like from the outside.

The model was wrong, not just incomplete. What to mount is a property of the
ITEM — its App's declared `external_tools` — and a turn is only special in
that it has already resolved, so its shas are pinned to the schemas the model
was handed. Everyone else should ask the same question rather than answer it
by omission. So the registry gains `tools_for`, used when a caller states
nothing; a caller's `{}` stays an ANSWER, so an app with no third-party tools
pays for no resolve.

The lookup itself is now written once (`resolve_item_tools`) and read by both
callers. Two places answering "which bundles does this item get" is exactly
the rule that drifts, and its way of drifting is a tool that exists in some
sandboxes and not others depending on who opened them.

Resolving is best effort here: an artifact store that is down must not stop
someone opening a terminal. The sandbox then comes up without that tool and
the session records known-empty, so the next turn still says WHY rather than
handing over a launcher that isn't there.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KVqSrLupJQTJ4Wp1exBKnJ
§15 described registering a tool and changing its version, and never said the
one fact both of those depend on: mounting happens inside `create` and never
again. Without it, "the bundle is cached on the host" reads as "the tool is
available", and every consequence below looks arbitrary — why a newly
registered tool does nothing for an item already open, why a release only
reaches the next sandbox, why a workspace can hold a tool at a version the
resolve no longer returns.

New §15.3 separates resolve (per turn, fills the host's cache, mounts
nothing) from mount (once, at create), names the four things that create a
sandbox, and states that all four must carry the item's tools — with the
symptom of getting that wrong, because we shipped it: whichever entry point
woke the item first decided whether it had third-party tools at all.

§15.4 now also says that a live sandbox keeps serving the older build on
purpose, and what that costs.

Subsections renumbered from 15.3 down; cross-references followed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KVqSrLupJQTJ4Wp1exBKnJ
Renumbering §15's subsections updated every cross-reference inside
`deployment.md` and none of the one in `extending-the-platform.md`, which
still sent readers to §15.6 for issuing a certificate — now the disk section.
The kind of break that stays correct-looking: the number resolves to a real
heading, just the wrong one.

Also points at the new §15.3 from there, since "when does a tool actually get
into a sandbox" is the question that page's third-party section raises and
does not answer.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KVqSrLupJQTJ4Wp1exBKnJ
CI caught what my local targeted run did not: three assertions about the
certificate runbook sliced `docs/deployment.md` between `### 15.6` and
`### 15.7`. Inserting one subsection above moved that window onto the disk
section, so they failed reporting that the runbook no longer says the name is
ours to choose — true of the text they were reading, and nothing to do with
the text they are about.

A section number is not part of what a runbook says. The heading is the handle
a reader would use, so the tests use it too: `_section(doc, "工具憑證")`.
`####` children do not terminate the slice, and the result stays bounded to
one section rather than silently widening to the whole file, which would make
every one of these assertions pass for the wrong reason.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KVqSrLupJQTJ4Wp1exBKnJ
@HYChou0515
HYChou0515 merged commit 0d471d9 into master Aug 12, 2026
9 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.

1 participant