diff --git a/services/runner/src/engines/sandbox_agent/run-plan.ts b/services/runner/src/engines/sandbox_agent/run-plan.ts index 488240a9e5..f619376787 100644 --- a/services/runner/src/engines/sandbox_agent/run-plan.ts +++ b/services/runner/src/engines/sandbox_agent/run-plan.ts @@ -70,6 +70,25 @@ export const REMOTE_TOOLS_UNSUPPORTED_MESSAGE = "sandbox, use the Pi harness, or remove the tools. Tracked in " + "docs/design/agent-workflows/projects/in-sandbox-tool-mcp/."; +/** + * A non-Pi harness (Claude) on Daytona receives tools through the in-sandbox stdio MCP shim, + * which advertises only EXECUTABLE (gateway/callback) tools. Client tools are intentionally + * omitted: the shim's blocking relay call cannot pause for a browser round-trip yet. So a run + * whose ENTIRE tool set is client-kind has nothing deliverable to advertise on Daytona — it + * would otherwise proceed, drop every tool silently, and return `ok:true` (the F1 zero-tools + * drop). `run-plan.ts` is the documented refusal point for this (see `mcp.ts`); refuse it up + * front, exactly as the non-Daytona remote case is refused. A MIX of client + executable tools + * is fine (the executable ones are delivered; the client ones are dropped), so this fires only + * when NO executable tool remains. + */ +export const DAYTONA_CLIENT_ONLY_TOOLS_UNSUPPORTED_MESSAGE = + "Client tools are not deliverable to a non-Pi harness on Daytona: the in-sandbox stdio MCP " + + "shim advertises only executable (gateway/callback) tools, and a client tool cannot pause " + + "for a browser round-trip through its blocking relay yet. This run's tool set is entirely " + + "client-kind, so nothing would be advertised and the run would silently get zero tools. Add " + + "an executable tool, use the Pi harness, run on the local sandbox, or remove the tools. " + + "Tracked in docs/design/agent-workflows/projects/in-sandbox-tool-mcp/."; + /** * `runtime_provided` (subscription) auth means the harness authenticates from explicitly prepared * local runtime state (a mounted Pi/Claude login). That state lives only in the runner container @@ -414,6 +433,12 @@ export function buildRunPlan( if (!isDaytona) { return { ok: false, error: REMOTE_TOOLS_UNSUPPORTED_MESSAGE }; } + // On Daytona the shim advertises only executable tools; client tools are omitted. If the run + // carries tools but NONE are executable, nothing would be delivered — refuse instead of + // silently advertising an empty tool set (the F1 zero-tools drop mcp.ts's log warns about). + if (executableToolSpecsForRun.length === 0) { + return { ok: false, error: DAYTONA_CLIENT_ONLY_TOOLS_UNSUPPORTED_MESSAGE }; + } } // Layer 2: even on Daytona, code/gateway tools run on the RUNNER HOST via the relay, not diff --git a/services/runner/tests/unit/sandbox-agent-run-plan.test.ts b/services/runner/tests/unit/sandbox-agent-run-plan.test.ts index b4a59a3ad9..c4597c424c 100644 --- a/services/runner/tests/unit/sandbox-agent-run-plan.test.ts +++ b/services/runner/tests/unit/sandbox-agent-run-plan.test.ts @@ -704,7 +704,12 @@ describe("buildRunPlan", () => { ); }); - it("allows claude x daytona x client-only tools with no shim specs", () => { + it("refuses claude x daytona x client-ONLY tools (nothing deliverable to advertise)", () => { + // The shim advertises only executable tools; client tools are omitted. A run whose entire + // tool set is client-kind has nothing to deliver on Daytona, so it must fail CLOSED up + // front rather than proceed, drop every tool, and return ok:true (the F1 zero-tools drop + // that mcp.ts's "run-plan should have refused this run" log points at). + let created = false; const result = buildRunPlan( { harness: "claude", @@ -713,13 +718,26 @@ describe("buildRunPlan", () => { customTools: [{ name: "request_connection", kind: "client" }], } as AgentRunRequest, { - createDaytonaCwd: () => "/home/sandbox/agenta-fixed", + createDaytonaCwd: () => { + created = true; + return "/home/sandbox/agenta-fixed"; + }, }, ); - assert.equal(result.ok, true); - if (!result.ok) return; - assert.deepEqual(result.plan.executableToolSpecs, []); + assert.equal(result.ok, false); + if (result.ok) return; + assert.match(result.error, /Client tools are not deliverable/); + assert.match(result.error, /entirely\s+client-kind/); + assert.match( + result.error, + /docs\/design\/agent-workflows\/projects\/in-sandbox-tool-mcp\//, + ); + assert.equal( + created, + false, + "fails before any cwd is created (up-front gate)", + ); }); it("allows pi x daytona x client-only tools (Pi's extension + file relay deliver them)", () => {