Skip to content

feat(runner): client tools on Claude in remote sandboxes#5383

Merged
mmabrouk merged 2 commits into
release/v0.105.6from
plan/client-tools-daytona
Jul 18, 2026
Merged

feat(runner): client tools on Claude in remote sandboxes#5383
mmabrouk merged 2 commits into
release/v0.105.6from
plan/client-tools-daytona

Conversation

@mmabrouk

@mmabrouk mmabrouk commented Jul 18, 2026

Copy link
Copy Markdown
Member

Context

On the Claude harness in a Daytona remote sandbox, a browser-fulfilled client tool (like request_connection) did not work. Two wrong outcomes were possible:

The same tool already worked on Pi (any sandbox) and on Claude in the local sandbox. Three mechanisms blocked the Claude-plus-Daytona path: the in-sandbox stdio MCP shim was only ever advertised the executable subset of tools, its tools/call had no "pause" outcome, and when a client tool parked the relay wrote no answer, so the shim waited out its full per-tool timeout and then emitted a late error frame.

This reuses the delivery path Pi already uses on Daytona (the existing file relay). It adds no new network route between the runner and the sandbox.

Changes

Advertise client tools to the shim. The upload gate and the advertisement gate no longer filter client specs out.

  • environment.ts: upload advertisedToolSpecs(plan.toolSpecs) (was plan.executableToolSpecs), gated on plan.toolSpecs.length > 0.
  • mcp.ts: build the shim entry when toolSpecs.length > 0 (was executableToolSpecs(toolSpecs).length > 0), and count every advertised tool in the honest log.

Add one new "paused" relay answer. When a client tool parks, the runner writes a benign paused answer so the shim ends its tools/call cleanly while the runner ends the turn. The browser result returns on the cold-replay resume.

  • relay-protocol.ts: ExecuteRelayResponse gains an optional paused?: true. An answer is now one of three: success (ok: true, text), failure (ok: false, error), or pause (ok: true, paused: true).
  • relay.ts: on PAUSED, the loop writes { ok: true, paused: true } when writePausedAnswer is set (non-Pi shim path); Pi still writes nothing.
  • relay-client.ts: relayToolCall recognizes the paused answer and returns a RELAY_PAUSED sentinel (not a throw, not an empty string), and still unlinks the req/res pair.
  • tool-mcp-stdio.ts: the shim maps the sentinel to a benign, NON-error tool result that names the tool and tells the model not to retry.

Before, the shim's tools/call had two outcomes:

{ content: [{ type: "text", text }] }              // success
{ content: [{ type: "text", text }], isError: true } // error (also fired on the pause timeout)

After, a pause is a third, distinct, non-error outcome:

{ content: [{ type: "text", text: 'The user has been asked to complete "request_connection" ... do not retry ...' }] }

Delete the interim #5366 refusal. run-plan.ts no longer refuses a client-only Daytona run (DAYTONA_CLIENT_ONLY_TOOLS_UNSUPPORTED_MESSAGE is gone). The REMOTE_TOOLS_UNSUPPORTED_MESSAGE refusal stays for a non-Daytona remote provider, which is still unproven and must fail closed.

The cold/warm design record

The pause message plus cold-replay resume is the cold-path contract, and it must exist regardless because a cold session has no live process to hold anything open. A warm-path native hold (keep the shim's call open inside the live turn, the way an ACP approval already holds on a warm session) is a real future extension, coupled to the relay's missing turn-boundary model (S5.2) and pending-interaction records.

The code is shaped for that future without building it. The client-tool pause disposition is a closed set at the client-tool boundary (ClientToolPauseDisposition = "pi-native" | "cold-acknowledge" | "warm-hold" in client-tools.ts), with warm-hold reserved and the mapping to the relay's writePausedAnswer switch made exhaustive, so adding the warm hold is a local change rather than a rework. The follow-up issue captures the user-facing problem and the research.

Tests

  • services/runner: pnpm run typecheck clean, pnpm test green at 1214 (1209 baseline + 5 new for the paused path and the disposition mapping).
  • Live QA on the EE dev stack (Claude + Daytona, vault Anthropic key), driving the product /services/agent/v0/invoke endpoint and asserting on the SSE wire:
    • C2 (Claude + Daytona) client journey: PASS. Turn 1 renders the widget on the wire (a data-render frame, finish=other); the answer resumes and the run completes with no re-park and no error. This is the release gate for the feature.
    • C1 (Claude + local) client journey: PASS. No regression on the local path.
    • Pi local client journey: PASS. The Pi path is untouched.
    • C2 regression sweep: chat, tool, approve, deny all PASS. The deny journey (PR feat(sdk): emit tool-output-denied so the UI can tell a denial from a failure #5381's fix) is green. No late error frame appears on the pause.
    • Identical-arguments case: two identical request_connection('slack') calls across turns each parked their own widget and resumed with their own distinct token, no misattachment (the per-key FIFO correlation holds). The concurrent same-turn case could not be provoked (the model deduplicates identical connection requests), so no id plumbing was added, per the plan's decision.

Scope / risk

  • Gated to the non-Pi Daytona shim path. Pi (any sandbox) and Claude local are unchanged; the paused answer is never written for Pi.
  • No /run wire-contract change: the relay files are internal to the runner, so no golden fixture in sdks/python moves.
  • Each step is independently revertable (see the plan's rollback section).
  • The release-gate client journey lives in the local-only agent-release-gate skill, so it is not in this diff.

What to QA

  • In the playground, build a Claude agent on a Daytona sandbox with a request_connection client tool, and prompt it to connect an integration. The connect widget should appear in the chat, the run should pause, and answering it should resume the agent to completion. Before this change the run either refused up front or produced no tool at all.
  • Regression: the same client tool on Claude local and on Pi still parks and resumes.

Plans #5383. Closes #5256. Closes #4984.

Plan-only workspace for delivering browser-fulfilled client tools to the
Claude harness in remote Daytona sandboxes. Reuses the existing file relay
(the Pi-on-Daytona pattern); adds no new runner-to-sandbox network path.

Plans #5256 and closes the residual mixed-set silent-drop from #4984.

Claude-Session: https://claude.ai/code/session_01DnWRxU3dCJ11hgDidm26vW
@vercel

vercel Bot commented Jul 18, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
agenta-documentation Ready Ready Preview, Comment Jul 18, 2026 10:22pm

Request Review

@coderabbitai

coderabbitai Bot commented Jul 18, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

This change enables Claude client tools on Daytona by advertising them through the in-sandbox shim, adding paused relay responses, returning benign MCP results, and preserving cold-replay resumption. It removes the Daytona client-only refusal and adds documentation and unit-test coverage.

Changes

Daytona client-tool delivery

Layer / File(s) Summary
Design and delivery contract
docs/design/agent-workflows/projects/client-tools-daytona/*, docs/design/agent-workflows/documentation/tools.md, docs/design/agent-workflows/interfaces/*
Documents Daytona client-tool delivery, paused answers, resume behavior, validation, and interface semantics.
Tool advertisement and run planning
services/runner/src/engines/sandbox_agent/{client-tools.ts,environment.ts,environment-setup.ts,mcp.ts,run-plan.ts,run-turn.ts,tool-mcp-assets.ts}
Advertises executable and client tools through the Daytona shim, removes the client-only refusal, and selects paused-answer behavior by harness.
Paused relay protocol and shim flow
services/runner/src/tools/{relay-protocol.ts,relay.ts,relay-client.ts,dispatch.ts,tool-mcp-stdio.ts}
Adds the paused relay response and sentinel, writes paused answers when enabled, and maps them to a non-error MCP result.
Behavioral validation
services/runner/tests/unit/*
Tests pause dispositions, relay cleanup and responses, Daytona run-plan behavior, shim advertisement, and paused MCP results.

Estimated code review effort: 3 (Moderate) | ~25 minutes

Possibly related issues

Possibly related PRs

Sequence Diagram(s)

sequenceDiagram
  participant Claude
  participant DaytonaShim
  participant RunnerRelay
  participant Browser
  Claude->>DaytonaShim: Call advertised client tool
  DaytonaShim->>RunnerRelay: Submit relay request
  RunnerRelay-->>DaytonaShim: Write paused relay answer
  DaytonaShim-->>Claude: Return non-error wait result
  Browser->>RunnerRelay: Complete client interaction
  RunnerRelay-->>Claude: Resume with browser result
Loading
🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Linked Issues check ✅ Passed The code adds Daytona client-tool delivery, paused relay handling, cleanup, resume flow, and preserves fail-closed behavior for unsupported remotes.
Out of Scope Changes check ✅ Passed The added docs, tests, and runner updates all support the client-tool Daytona work, with no unrelated changes standing out.
Docstring Coverage ✅ Passed Docstring coverage is 62.50% which is sufficient. The required threshold is 60.00%.
Title check ✅ Passed The title is concise and accurately summarizes the main change: enabling client tools for Claude in remote sandboxes.
Description check ✅ Passed The description is directly related to the changeset and explains the problem, approach, and tests in detail.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch plan/client-tools-daytona

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@mmabrouk

Copy link
Copy Markdown
Member Author

@coderabbitai review

@coderabbitai

coderabbitai Bot commented Jul 18, 2026

Copy link
Copy Markdown
✅ Action performed

Review finished.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

@mmabrouk mmabrouk left a comment

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Plan review: approved with four decisions on the open questions, binding for the implementation.

  1. The pause-answer gate: name the capability, not the harness. Use a writePausedAnswer flag on the plan (set true for non-Pi today) rather than branching on !plan.isPi. A third Daytona harness then changes one assignment, not a scattered condition.

  2. Correlation of identical calls: the live QA MUST force the case of two client tools with identical name and arguments in one turn. Implementation uses first-in-first-out matching per (name, arguments) and documents that limitation in the code. Do not build extra id plumbing preemptively; if the forced test shows a widget attaching to the wrong call, add the shim's request id as the correlating hint then, as its own small commit.

  3. The paused tool result text: a fixed template that includes the tool name, along the lines of 'The request is waiting for the user's response in the app. Do not retry it; continue or end your reply.' The model reasons more coherently when it knows which call is parked, and instructing it not to retry avoids loop-shaped transcripts.

  4. Relay file cleanup: explicit delete of the request/response pair at the moment the paused answer is written. Deterministic cleanup beats relying on the sweep; the existing stale-file sweep stays as the backstop for crashes.

Also noting for the record: the planner's two corrections to the research (the spec-file assembly lives in environment.ts, and the Daytona pause seam is already armed) were verified claims, and the plan is better for them. Implementation may start once CodeRabbit's pass is in and Mahmoud has had his look.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2


ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 4bae63cd-3ff9-42c4-ac61-f1be0f9fa782

📥 Commits

Reviewing files that changed from the base of the PR and between 3b0c0cb and 2e59e7c.

📒 Files selected for processing (2)
  • docs/design/agent-workflows/projects/client-tools-daytona/PLAN.md
  • docs/design/agent-workflows/projects/client-tools-daytona/README.md

Comment on lines +235 to +238
- **Paused relay answer (Step 3)**: when `executeRelayedTool` returns `PAUSED` on the non-Pi
Daytona path, the loop writes `{ ok: true, paused: true }`; on the Pi path it writes no answer
file (unchanged). `relayToolCall` maps a paused answer to its paused result, not a throw. The
shim's `tools/call` maps a paused result to a benign, non-error content result.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

Add regression assertions for late timeout frames and stale relay files.

The proposed tests verify that a paused answer exists and that resume succeeds, but they do not prove the core #5256 requirement: no late timeout/error frame arrives after teardown, and a paused .req.json/.res.json pair cannot satisfy a fresh call. Add these assertions to the unit and release-gate coverage.

Also applies to: 245-259

Comment on lines +285 to +295
combination. Each step is revertable on its own:

- **Step 1** is revertable: restore the `executableToolSpecs` filter at the upload and
advertisement gates. Client tools stop being advertised again.
- **Step 2** adds no code (or a small correlation refinement), so there is nothing to roll back
beyond that refinement.
- **Step 3** is the load-bearing change and is revertable: remove the `paused` field, restore
`if (text === PAUSED) return;`, and revert the shim's benign-result branch. Behavior returns
to Block B and Block C.
- **Step 4** adds no code.
- **Step 5** is revertable: restore the `DAYTONA_CLIENT_ONLY_TOOLS_UNSUPPORTED_MESSAGE` refusal.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Replace “revertable” with “revertible” or “reversible”.

The rollback section repeats the misspelling at Lines 285, 291, and 295.

🧰 Tools
🪛 LanguageTool

[grammar] ~285-~285: Ensure spelling is correct
Context: ...ches no other combination. Each step is revertable on its own: - Step 1 is revertable...

(QB_NEW_EN_ORTHOGRAPHY_ERROR_IDS_1)


[grammar] ~291-~291: Ensure spelling is correct
Context: ...p 3** is the load-bearing change and is revertable: remove the paused field, restore `i...

(QB_NEW_EN_ORTHOGRAPHY_ERROR_IDS_1)

Source: Linters/SAST tools


## 9. Open questions for review

1. **The paused answer's gating flag.** The plan gates the paused-answer write on `!plan.isPi`.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think we need to solve this today, no?

identical arguments, the lookup could attach a widget to the wrong bubble. The seam already
consumes stored outputs first-in-first-out per key; confirm the live test exercises the
identical-args case, and decide whether the shim should pass a correlating hint if it does not.
3. **The benign wait text.** The shim returns a short "waiting for the user" text result to

@mmabrouk mmabrouk Jul 18, 2026

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I thought we really paused. I didn't think that we would send a message to Claude Code in this case. Why can't we use the native weighting behavior here im lost?

@mmabrouk

Copy link
Copy Markdown
Member Author

Design record from Mahmoud's review, binding for the implementation.

The question he raised: why return a synthetic waiting-for-the-user message to Claude and end the turn, instead of the native pattern where the tool call simply stays open and the harness waits, as a warm session naturally could?

The answer we settled on: the message-plus-replay pattern is the cold-path contract, inherited from the Pi relay model, and it must exist regardless because a cold session has no process to hold anything open. The native hold is real and already exists in this codebase for approvals on warm sessions (the ACP permission request stays pending inside the live turn). Client tools do not get it yet because the relay has no turn-boundary model (open issue S5.2), the frontend answers by posting a new request rather than into an open turn, and a blocking MCP call faces Claude Code's client timeout (escape hatch identified: MCP progress notifications reset that timeout).

The decision: ship this plan as the cold path that works everywhere, and treat the warm-path native hold as an explicit planned extension, coupled to the S5.2 turn-boundary work and the pending-interaction-records direction. THE IMPLEMENTATION MUST BE SHAPED FOR THAT FUTURE: there are two paths, cold and warm, and the code must say so. Concretely: the relay's pause handling models a closed set of outcomes (answered, error, paused-cold today; paused-hold reserved for the warm extension), the writePausedAnswer capability flag stays the single switch point, and the shim's pause branch carries a comment pointing at the extension issue. No behavior for the warm hold gets built now, but no door gets closed either. An extension issue with the user-facing problem and this research will be filed when this ships.

@mmabrouk mmabrouk changed the title docs(plan): client tools on Claude in remote sandboxes feat(runner): client tools on Claude in remote sandboxes Jul 18, 2026
@mmabrouk

Copy link
Copy Markdown
Member Author

Live walkthrough: Claude on Daytona calls the client tool, the widget renders, the user answers, the run resumes

On the video: I could not capture a GIF/MP4 of the playground. A video needs a browser driving the live app, but the only browser connected to the extension is on a remote machine that cannot reach this dev box's localhost:8280, and there is no ffmpeg here to render one. So this is the sanctioned fallback: an annotated frame-by-frame recording of the same product endpoint the playground drives (/services/agent/v0/invoke), on the wire, for a Claude + Daytona agent with a request_connection client tool. Note turn 1 line where Claude discovers the tool via ToolSearch for mcp__agenta-tools__request_connection (the advertisement working), then calls it and the data-render widget frame appears.

Client tools on Claude in a remote (Daytona) sandbox — frame-by-frame walkthrough

FALLBACK EVIDENCE (not a GIF): the only browser connected to the extension is on a remote
machine that cannot reach this dev box's localhost:8280, so the playground could not be
driven visually. This is the same product endpoint the playground drives, recorded on the
wire. Cell: Claude harness + Daytona sandbox + a request_connection client tool. Session b30460ff-5e7a-45de-8a15-10d85a7d71cd.

Turn 1 — the agent calls the client tool and the widget PARKS

t+ms frame detail narration
156 start turn starts
156 start-step model step begins
8870 reasoning-start model begins reasoning
8870 reasoning-delta model reasoning (streamed)
9335 reasoning-end model reasoning ends
9335 tool-input-start model begins a tool call
9336 tool-input-available ToolSearch {} tool call arguments (streamed)
9345 tool-input-available ToolSearch {"query": "select:mcp__agenta-tools__request_connection"} tool call arguments (streamed)
9370 tool-output-available "Tool: mcp__agenta-tools__request_connection" a tool returned a result
13664 reasoning-start model begins reasoning
13664 reasoning-delta model reasoning (streamed)
14005 reasoning-end model reasoning ends
14005 tool-input-start model begins a tool call
14005 tool-input-available mcp__agenta-tools__request_connection {} tool call arguments (streamed)
14014 tool-input-available mcp__agenta-tools__request_connection {"integration": "slack"} tool call arguments (streamed)
14192 tool-input-available request_connection {"integration": "slack"} tool call arguments (streamed)
14192 data-render render={"kind": "connect"} toolCallId=toolu_01GmY7 >>> CLIENT-TOOL WIDGET rendered on the wire (the browser widget the user sees)
15656 finish-step model step ends
15657 finish finishReason=other turn ends

The user answers the widget in their browser with output {"connected": true, "integration": "slack", "slug": "slack-qa-abdf94109bd7"} (unguessable token slack-qa-abdf94109bd7), and the frontend re-posts the history in-band.

Turn 2 — the session RESUMES with the browser output and finishes

t+ms frame detail narration
48 start turn starts
48 start-step model step begins
10264 reasoning-start model begins reasoning
10264 reasoning-delta model reasoning (streamed)
10696 reasoning-delta model reasoning (streamed)
10701 reasoning-end model reasoning ends
10701 text-start model begins its reply text
10701 text-delta "Slack connected (`slack" model reply text (streamed)
10921 text-delta "-qa-abdf94109bd7`). What would you like model reply text (streamed)
12085 text-end model reply text ends
12291 finish-step model step ends
12291 finish finishReason=stop turn ends

Result

  • Turn 1 parked the client-tool widget on the wire (a data-render frame), finish=other, the client call did not terminate.
  • Turn 2 resumed and finished (finish=stop, errors=[]), did not re-park, and consumed the browser output.
  • Final reply: Slack connected (slack-qa-abdf94109bd7). What would you like to do?
  • Unguessable browser token surfaced on the resume: True.

// the non-Pi in-sandbox shim gets a benign paused answer so it ends its blocking
// `tools/call` at once rather than waiting out the per-tool timeout and emitting a late
// error frame. The browser result returns on the cold-replay resume turn.
if (!writePausedAnswer) return;

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reviewer note (pause/teardown ordering): onPause fires pause.pause() before this write, which tears down the ACP session (cold-replay model), not the sandbox. stop() drains this in-flight write, and the worst case is the shim being killed cleanly before it reads the answer — no late error frame either way. So the paused answer removes the old full-timeout error; it is a best-effort improvement, not a race removed "by construction". I deliberately did NOT add a shim-ack-before-cancel handshake (Codex suggested it): the shim is the thing being cancelled, so waiting on its ack would risk hanging teardown. Live QA confirmed no late error frame.

* new disposition (the warm hold) cannot silently fall through to the cold behavior; it forces a
* decision here, which is what keeps the warm extension a local change rather than a rework.
*/
export function relayWritesPausedAnswer(

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reviewer note (the closed set): the warm hold is a lifecycle decision, so the closed set lives here at the client-tool boundary, not in the relay's response shape. relayWritesPausedAnswer is exhaustive — adding warm-hold behavior forces a decision here rather than silently falling through to the cold path. The relay only consumes the derived writePausedAnswer boolean.

* dispatch. A pause reaching here is a contract violation, so fail loud instead of laundering the
* sentinel into a string. The cold-pause protocol is handled only by the in-sandbox shim.
*/
function assertNotPaused(

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reviewer note: this throw is defensive, not a live path. relayToolCall can now return the pause sentinel, but the Pi extension (writePausedAnswer=false) and the local loopback MCP (parks client tools in-process) never receive it. Failing loud beats laundering the sentinel into a string if that invariant ever breaks.

@mmabrouk

Copy link
Copy Markdown
Member Author

Reviewer note on identical-argument correlation (the pre-existing FIFO seam in client-tools.ts onClientTool, not changed by this PR): the parked widget correlates to Claude's tool-call id by (name, arguments) with per-key FIFO consumption, exactly as the already-shipped local Claude path does. Live QA forced two identical request_connection('slack') calls across turns and each parked its own widget and resumed with its own distinct token, no misattachment. The concurrent same-turn case could not be provoked (the model deduplicates identical connection requests), so per the plan's decision no stable-id plumbing was added. If a real concurrent identical-args swap ever surfaces, the fix is to plumb a stable call id at that seam.

@mmabrouk

Copy link
Copy Markdown
Member Author

@coderabbitai review

@coderabbitai

coderabbitai Bot commented Jul 18, 2026

Copy link
Copy Markdown
✅ Action performed

Review finished.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1


ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: d54477c1-129b-450a-831d-fba4257d9fa6

📥 Commits

Reviewing files that changed from the base of the PR and between 2e59e7c and 78f5b72.

📒 Files selected for processing (21)
  • docs/design/agent-workflows/documentation/tools.md
  • docs/design/agent-workflows/interfaces/README.md
  • docs/design/agent-workflows/interfaces/cross-service/runner-to-mcp-server.md
  • services/runner/src/engines/sandbox_agent/client-tools.ts
  • services/runner/src/engines/sandbox_agent/environment-setup.ts
  • services/runner/src/engines/sandbox_agent/environment.ts
  • services/runner/src/engines/sandbox_agent/mcp.ts
  • services/runner/src/engines/sandbox_agent/run-plan.ts
  • services/runner/src/engines/sandbox_agent/run-turn.ts
  • services/runner/src/engines/sandbox_agent/tool-mcp-assets.ts
  • services/runner/src/tools/dispatch.ts
  • services/runner/src/tools/relay-client.ts
  • services/runner/src/tools/relay-protocol.ts
  • services/runner/src/tools/relay.ts
  • services/runner/src/tools/tool-mcp-stdio.ts
  • services/runner/tests/unit/client-tools.test.ts
  • services/runner/tests/unit/relay-client.test.ts
  • services/runner/tests/unit/relay-loop.test.ts
  • services/runner/tests/unit/sandbox-agent-run-plan.test.ts
  • services/runner/tests/unit/session-mcp-layering.test.ts
  • services/runner/tests/unit/tool-mcp-stdio.test.ts

Comment on lines +844 to +895
it("non-Pi shim path (writePausedAnswer) writes { ok: true, paused: true } when a client tool parks", async () => {
const { host, files } = fakeHost();
const relay = startToolRelay(
host,
DIR,
[clientSpec],
undefined,
undefined,
parkingRelay(),
undefined,
{ writePausedAnswer: true },
);

putClientRequest(files, "call-1");
await until(() => files.has(`${DIR}/call-1.res.json`), "the paused answer");
const res = JSON.parse(files.get(`${DIR}/call-1.res.json`) ?? "{}");
assert.deepEqual(
res,
{ ok: true, paused: true },
"the shim path gets a benign paused answer, not an error or an empty success",
);

await relay.stop();
});

it("Pi path (no writePausedAnswer) writes NO answer file when a client tool parks", async () => {
const { host, files } = fakeHost();
let parked = false;
const relay = startToolRelay(
host,
DIR,
[clientSpec],
undefined,
undefined,
parkingRelay(() => {
parked = true;
}),
undefined,
{}, // writePausedAnswer defaults OFF -> Pi's native pause, no answer file
);

putClientRequest(files, "call-1");
await until(() => parked, "the client tool parked");
// Settle long enough that a (wrong) answer write would have landed.
await sleep(RELAY_POLL_MS + 50);
assert.ok(
!files.has(`${DIR}/call-1.res.json`),
"Pi parks through its own extension; the loop writes no answer file",
);

await relay.stop();
});

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win

Stop the relay in finally blocks.

Any failed wait or assertion skips relay.stop(), leaving its polling loop active and potentially hanging the test suite. Wrap each test body after relay creation in try/finally.

Proposed structure
     const relay = startToolRelay(/* ... */);
-
-    putClientRequest(files, "call-1");
-    // assertions...
-    await relay.stop();
+    try {
+      putClientRequest(files, "call-1");
+      // assertions...
+    } finally {
+      await relay.stop();
+    }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
it("non-Pi shim path (writePausedAnswer) writes { ok: true, paused: true } when a client tool parks", async () => {
const { host, files } = fakeHost();
const relay = startToolRelay(
host,
DIR,
[clientSpec],
undefined,
undefined,
parkingRelay(),
undefined,
{ writePausedAnswer: true },
);
putClientRequest(files, "call-1");
await until(() => files.has(`${DIR}/call-1.res.json`), "the paused answer");
const res = JSON.parse(files.get(`${DIR}/call-1.res.json`) ?? "{}");
assert.deepEqual(
res,
{ ok: true, paused: true },
"the shim path gets a benign paused answer, not an error or an empty success",
);
await relay.stop();
});
it("Pi path (no writePausedAnswer) writes NO answer file when a client tool parks", async () => {
const { host, files } = fakeHost();
let parked = false;
const relay = startToolRelay(
host,
DIR,
[clientSpec],
undefined,
undefined,
parkingRelay(() => {
parked = true;
}),
undefined,
{}, // writePausedAnswer defaults OFF -> Pi's native pause, no answer file
);
putClientRequest(files, "call-1");
await until(() => parked, "the client tool parked");
// Settle long enough that a (wrong) answer write would have landed.
await sleep(RELAY_POLL_MS + 50);
assert.ok(
!files.has(`${DIR}/call-1.res.json`),
"Pi parks through its own extension; the loop writes no answer file",
);
await relay.stop();
});
it("non-Pi shim path (writePausedAnswer) writes { ok: true, paused: true } when a client tool parks", async () => {
const { host, files } = fakeHost();
const relay = startToolRelay(
host,
DIR,
[clientSpec],
undefined,
undefined,
parkingRelay(),
undefined,
{ writePausedAnswer: true },
);
try {
putClientRequest(files, "call-1");
await until(() => files.has(`${DIR}/call-1.res.json`), "the paused answer");
const res = JSON.parse(files.get(`${DIR}/call-1.res.json`) ?? "{}");
assert.deepEqual(
res,
{ ok: true, paused: true },
"the shim path gets a benign paused answer, not an error or an empty success",
);
} finally {
await relay.stop();
}
});
it("Pi path (no writePausedAnswer) writes NO answer file when a client tool parks", async () => {
const { host, files } = fakeHost();
let parked = false;
const relay = startToolRelay(
host,
DIR,
[clientSpec],
undefined,
undefined,
parkingRelay(() => {
parked = true;
}),
undefined,
{}, // writePausedAnswer defaults OFF -> Pi's native pause, no answer file
);
try {
putClientRequest(files, "call-1");
await until(() => parked, "the client tool parked");
// Settle long enough that a (wrong) answer write would have landed.
await sleep(RELAY_POLL_MS + 50);
assert.ok(
!files.has(`${DIR}/call-1.res.json`),
"Pi parks through its own extension; the loop writes no answer file",
);
} finally {
await relay.stop();
}
});

@mmabrouk mmabrouk left a comment

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

My commit-level review of the feature commit (91260c9) after the plan-level review and Codex's pass. Verdict: this is the design we agreed, correctly built, and it moves to Mahmoud's queue.

What I verified line by line:

  • The closed set is real, not decorative. ClientToolPauseDisposition lives at the client-tool boundary with an exhaustive switch (a compile-time never-check), so the reserved warm-hold variant cannot silently fall through to cold behavior; when #5384 gets built, the compiler forces the decision. This is exactly the extendability condition from Mahmoud's review, honored in the type system rather than in a comment.
  • The pause sentinel is a Symbol, not a string. A legitimate tool result can be any string, including empty, so a string sentinel would have been a collision waiting to happen. The Symbol makes the pause structurally unmistakable, and the shim checks it before the ok/text path so a paused answer can never read as an empty success.
  • Pi's behavior is preserved by default. writePausedAnswer defaults off; only the non-Pi shim path flips it. The relay's Pi contract (write no answer, park through the extension) is untouched, which the unchanged Pi gate journeys confirm.
  • Cleanup is deterministic with the sweep as backstop, per the plan decision: the shim removes both relay files for every answer variant including the pause, and the turn-start sweep only covers a torn-down shim.
  • Test surface matches the risk: 488 added/151 removed lines across five new or extended unit suites (relay loop pause, relay client sentinel, shim mapping, run-plan gate removal, correlation), plus the live evidence and the new gate journey.

Recorded disagreement with Codex finding #1 (ack-handshake on teardown) reviewed and endorsed: the handshake would couple teardown to a process being killed; the live evidence shows the worst case is a clean shim shutdown with no late error frame.

One expectation for the merge sequence: this PR and #5381 (the denied frame) were verified together on one tree; merge them in the same release window so the gate's client and deny journeys stay green together.

* warm session, instead of writing an answer and tearing the session down.
* NOT built; nothing produces it yet. See the warm-hold extension #5384.
*/
export type ClientToolPauseDisposition =

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Honestly, quite weird structuring of this. I can't say why, but it feels very weird to have pi-native, cold-acknowledge, and warm-hold as pose this position. Looks to me the right way is to have maybe port and then a version for pi version for others. I don't know, but this is weird. Maybe it's very hard to kind of improve, and that's all right, but I just say I wouldn't call this good code.

throw new Error(PI_PERMISSION_EXTENSION_UNAVAILABLE_MESSAGE);
}
if (!plan.isPi && plan.executableToolSpecs.length > 0) {
if (!plan.isPi && plan.toolSpecs.length > 0) {

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I mean, is pi and is at each place not very good programming? I know it's maybe outside of scope here, but just maybe add it to your memory. I don't think this is the right way to have the logic. I think the problem is there is some sort of port adapter for dealing with the logic, where you don't have to have if-then-else for the harness type within the code. It does not look like the environment TS should know about that. It looks like the responsibility is all over the place.

Advertise browser-fulfilled client tools to the in-sandbox stdio MCP shim and add a
paused relay answer so a parked client tool ends the shim's tools/call cleanly while
the runner ends the turn. The browser result returns on the cold-replay resume. Deletes
the interim #5366 client-only Daytona refusal. Closes #5256; closes the residual mixed-set
drop of #4984.

The client-tool pause disposition is a closed set (pi-native / cold-acknowledge, with
warm-hold reserved) at the client-tool boundary; the relay consumes the derived
writePausedAnswer switch.

Claude-Session: https://claude.ai/code/session_01DnWRxU3dCJ11hgDidm26vW
@mmabrouk
mmabrouk force-pushed the plan/client-tools-daytona branch from 91260c9 to b2e8419 Compare July 18, 2026 22:20
@mmabrouk
mmabrouk marked this pull request as ready for review July 18, 2026 22:21
@mmabrouk mmabrouk added the needs-review Agent updated; awaiting Mahmoud's review label Jul 18, 2026
@dosubot dosubot Bot added size:XL This PR changes 500-999 lines, ignoring generated files. Backend Feature Request New feature or request labels Jul 18, 2026
@github-actions

github-actions Bot commented Jul 18, 2026

Copy link
Copy Markdown
Contributor

Railway Preview Environment

Status Destroyed (PR closed)

Updated at 2026-07-18T23:40:20.977Z

@mmabrouk
mmabrouk changed the base branch from main to release/v0.105.6 July 18, 2026 23:39
@mmabrouk
mmabrouk merged commit 0ad8de4 into release/v0.105.6 Jul 18, 2026
53 of 54 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Backend Feature Request New feature or request needs-review Agent updated; awaiting Mahmoud's review size:XL This PR changes 500-999 lines, ignoring generated files.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[bug] Claude harness gets no tools on Daytona sandboxes (and fails silently) (feat) Support client tools on Claude in remote sandboxes

1 participant