From 089a2431c8fa294efd9a005c1fe0689674ef2f93 Mon Sep 17 00:00:00 2001 From: Mahmoud Mabrouk Date: Fri, 14 Aug 2026 18:33:58 +0200 Subject: [PATCH] fix(runner): deliver Pi tool specs via file, not env, to avoid spawn E2BIG --- .../src/engines/sandbox_agent/daytona.ts | 17 +- .../sandbox_agent/environment-setup.ts | 3 +- .../src/engines/sandbox_agent/pi-assets.ts | 121 +++++++++- .../src/environment/runtime-lifecycle.ts | 16 +- services/runner/src/extensions/agenta.ts | 86 +++++-- .../src/lifecycle/reconciliation-router.ts | 4 +- .../runner/tests/unit/extension-tools.test.ts | 132 +++++++++++ .../unit/sandbox-agent-pi-assets.test.ts | 209 ++++++++++++++++-- 8 files changed, 546 insertions(+), 42 deletions(-) diff --git a/services/runner/src/engines/sandbox_agent/daytona.ts b/services/runner/src/engines/sandbox_agent/daytona.ts index 0f128df811..973f5d3f91 100644 --- a/services/runner/src/engines/sandbox_agent/daytona.ts +++ b/services/runner/src/engines/sandbox_agent/daytona.ts @@ -2,7 +2,9 @@ import { join } from "node:path"; import { createAcpFetch } from "./acp-fetch.ts"; import { + resolvePiToolSpecsDelivery, uploadPiExtensionToSandbox, + uploadPiToolSpecsToSandbox, uploadSkillsToSandbox, uploadSystemPromptToSandbox, } from "./pi-assets.ts"; @@ -15,6 +17,7 @@ import { import { type RunPlan, type RunPlanPrompt, + type RunPlanTools, type RunPlanWorkspace, } from "./run-plan.ts"; @@ -194,7 +197,8 @@ export async function removePiModelsConfigFromSandbox( export interface PrepareDaytonaPiAssetsInput { sandbox: any; plan: Pick & { - workspace: Pick; + workspace: Pick; + tools: Pick; prompt: Pick< RunPlanPrompt, "hasSystemPrompt" | "systemPrompt" | "appendSystemPrompt" @@ -232,6 +236,17 @@ export async function prepareDaytonaPiAssets({ DAYTONA_PI_DIR, log, ); + // The run's tool specs. The sandbox env map was fixed at creation with the in-sandbox PATH of + // this file (`buildPiExtensionEnv`), so the bytes have to arrive here, before the session opens. + // They never rode the env itself: one env string holding every hydrated spec overflows Linux's + // per-string execve limit and the harness spawn dies with E2BIG (see `piToolSpecsFilePath`). + // A failure THROWS and is terminal in the engine's acquire try — a run whose tools silently + // vanished is worse than one that stops with a reason. + const toolSpecs = resolvePiToolSpecsDelivery( + plan.tools.toolSpecs, + plan.workspace.relayDir, + ); + if (toolSpecs) await uploadPiToolSpecsToSandbox(sandbox, toolSpecs, log); // A models.json plan (a custom provider, or a hand-entered model merged into a built-in one): // upload the exact file (overwriting stale) before the session starts. No plan: remove any stale // file so a reused sandbox keeps no earlier configuration. Upload failure THROWS here and is diff --git a/services/runner/src/engines/sandbox_agent/environment-setup.ts b/services/runner/src/engines/sandbox_agent/environment-setup.ts index 10ebecbfde..32483bd390 100644 --- a/services/runner/src/engines/sandbox_agent/environment-setup.ts +++ b/services/runner/src/engines/sandbox_agent/environment-setup.ts @@ -35,6 +35,7 @@ import { type PiModelsJsonPlan, } from "./pi-model-config.ts"; import { loadPiBuiltinRegistry } from "./pi-builtin-registry.ts"; +import { PUBLIC_SPECS_FILE_ENV } from "../../tools/tool-mcp-env.ts"; import { buildRunPlan } from "./run-plan.ts"; import { configFingerprint } from "./session-identity.ts"; import type { @@ -196,7 +197,7 @@ export async function prepareEnvironmentSetup( const strictModel = modelResolutionStrict(); logger( `tools=${plan.tools.toolSpecs.length} executableTools=${plan.tools.executableToolSpecs.length} ` + - `piPublicTools=${piExtEnv.AGENTA_AGENT_TOOLS_PUBLIC_SPECS ? "yes" : "no"}`, + `piPublicTools=${piExtEnv[PUBLIC_SPECS_FILE_ENV] ? "yes" : "no"}`, ); if (!plan.isPi && plan.isDaytona) { const clientTools = plan.tools.toolSpecs diff --git a/services/runner/src/engines/sandbox_agent/pi-assets.ts b/services/runner/src/engines/sandbox_agent/pi-assets.ts index 99149228a9..a567aab5dc 100644 --- a/services/runner/src/engines/sandbox_agent/pi-assets.ts +++ b/services/runner/src/engines/sandbox_agent/pi-assets.ts @@ -20,7 +20,11 @@ import { encodePiModelProviderOverride, PI_MODEL_PROVIDER_OVERRIDE_ENV, } from "../../extensions/model-provider-override.ts"; -import { advertisedToolSpecs } from "../../tools/public-spec.ts"; +import { + advertisedToolSpecs, + type AdvertisedToolSpec, +} from "../../tools/public-spec.ts"; +import { PUBLIC_SPECS_FILE_ENV } from "../../tools/tool-mcp-env.ts"; import type { MaterializedSkill } from "../skills.ts"; import { PKG_ROOT } from "./daemon.ts"; import { @@ -340,6 +344,111 @@ export function writePiModelsConfigLocal( } } +/** + * Thrown (via the engine's named-message pattern) when the run's tool specs could not be + * delivered to the harness. Fail loud rather than start a run whose tools the model never sees — + * the silent-tool-drop failure (F-042) is indistinguishable from a model that chose not to call + * them. Mirrors `TOOL_MCP_UNAVAILABLE_MESSAGE` for the non-Pi shim. Single line so + * `conciseError` surfaces it verbatim. + */ +export const PI_TOOL_SPECS_UNAVAILABLE_MESSAGE = + "The agent could not deliver its tool definitions to the harness, so none of its tools would " + + "have been available to the model. The run was stopped rather than run without them. Ask your " + + "deployment operator to check that the runner can write its relay directory."; + +/** + * The file the run's advertised tool specs ride to the Pi extension. + * + * A FILE, NEVER AN ENV VALUE. Linux caps a single argv/env string at `MAX_ARG_STRLEN` + * (131,072 bytes) and fails the whole `execve` with `E2BIG` when one exceeds it. Tool JSON + * Schemas are unbounded, so no size threshold is safe: a session with 44 hydrated Composio + * tools serialized to ~250 KB and every run died with "spawn E2BIG" before the harness + * started. The non-Pi stdio shim already rides a file for the same reason — see + * `PUBLIC_SPECS_FILE_ENV` in `tools/tool-mcp-env.ts`, whose name this reuses. + * + * A SIBLING of the relay dir, like the OTLP auth file, because `prepareWorkspace` clears and + * recreates the relay dir itself on every turn. It is keyed on the conversation (the relay dir + * is), non-secret, and rewritten in place per run, so it is left behind at teardown exactly as + * the relay dir is. + */ +export function piToolSpecsFilePath(relayDir: string): string { + return `${relayDir}.tool-specs.json`; +} + +/** The run's advertised specs plus the path the extension reads them from. */ +export interface PiToolSpecsDelivery { + /** A runner host path on local; the deterministic in-sandbox path on Daytona. */ + path: string; + /** The serialized `AdvertisedToolSpec` array — the exact bytes written to `path`. */ + contents: string; + specs: AdvertisedToolSpec[]; +} + +/** + * The tool specs this Pi run advertises, or `undefined` when there is nothing to advertise (no + * tools, or no relay dir to relay their execution back through). Takes the resolved specs so + * both sides derive from one source: the env builder from `request.customTools`, the Daytona + * upload from `plan.tools.toolSpecs` (the same array). + */ +export function resolvePiToolSpecsDelivery( + toolSpecs: ResolvedToolSpec[], + relayDir: string | undefined, +): PiToolSpecsDelivery | undefined { + const specs = advertisedToolSpecs(toolSpecs); + if (specs.length === 0 || !relayDir) return undefined; + return { + path: piToolSpecsFilePath(relayDir), + contents: JSON.stringify(specs), + specs, + }; +} + +/** + * Write the specs file for a LOCAL Pi run. THROWS the named message on failure: without the file + * the extension registers no tools, and a run whose tools silently vanished is worse than one + * that stops with a reason. + */ +export function writePiToolSpecsFileLocal( + delivery: PiToolSpecsDelivery, + log: Log = () => {}, +): void { + try { + mkdirSync(dirname(delivery.path), { recursive: true }); + writeFileSync(delivery.path, delivery.contents, "utf-8"); + } catch (err) { + log(`pi tool specs write failed: ${(err as Error).message}`); + throw new Error(PI_TOOL_SPECS_UNAVAILABLE_MESSAGE); + } + log( + `pi tool specs written path=${delivery.path} tools=${delivery.specs.length} ` + + `bytes=${Buffer.byteLength(delivery.contents, "utf-8")}`, + ); +} + +/** + * Upload the specs file into a Daytona sandbox: the runner's filesystem is not the sandbox's, and + * the sandbox env map is fixed at creation, so the env var names a deterministic in-sandbox path + * that this fills in before the session starts. THROWS the named message on failure, for the same + * reason the local write does. + */ +export async function uploadPiToolSpecsToSandbox( + sandbox: any, + delivery: PiToolSpecsDelivery, + log: Log = () => {}, +): Promise { + try { + await sandbox.mkdirFs({ path: dirname(delivery.path) }); + await sandbox.writeFsFile({ path: delivery.path }, delivery.contents); + } catch (err) { + log(`pi tool specs upload failed: ${(err as Error).message}`); + throw new Error(PI_TOOL_SPECS_UNAVAILABLE_MESSAGE); + } + log( + `pi tool specs uploaded path=${delivery.path} tools=${delivery.specs.length} ` + + `bytes=${Buffer.byteLength(delivery.contents, "utf-8")}`, + ); +} + /** * Env the Agenta Pi extension reads. Tool env contains only public metadata plus the * relay directory; private specs/auth stay in the runner. @@ -389,11 +498,15 @@ export function buildPiExtensionEnv( }); } - const specs = advertisedToolSpecs( + // The specs themselves ride a file whose PATH is all env carries: one env string holding every + // hydrated tool spec overflows Linux's per-string execve limit and kills the spawn with E2BIG. + // See `piToolSpecsFilePath`. The runner writes that file locally, or uploads it on Daytona. + const toolSpecs = resolvePiToolSpecsDelivery( (request.customTools as ResolvedToolSpec[]) ?? [], + opts.relayDir, ); - if (specs.length && opts.relayDir) { - env.AGENTA_AGENT_TOOLS_PUBLIC_SPECS = JSON.stringify(specs); + if (toolSpecs && opts.relayDir) { + env[PUBLIC_SPECS_FILE_ENV] = toolSpecs.path; env.AGENTA_AGENT_TOOLS_RELAY_DIR = opts.relayDir; // Hop-1 response-watch kill switch (event-driven-tool-relay plan, decision 7): the // in-sandbox writer defaults it to true, so it is only forwarded — verbatim — when diff --git a/services/runner/src/environment/runtime-lifecycle.ts b/services/runner/src/environment/runtime-lifecycle.ts index 1fa68ba212..322863bf63 100644 --- a/services/runner/src/environment/runtime-lifecycle.ts +++ b/services/runner/src/environment/runtime-lifecycle.ts @@ -39,7 +39,9 @@ import { buildPiExtensionEnv, configurePiSessionWorkspace, configurePiSkillSnapshot, + resolvePiToolSpecsDelivery, writeOtlpAuthFile, + writePiToolSpecsFileLocal, } from "../engines/sandbox_agent/pi-assets.ts"; import { applyClaudeConnectionEnv } from "../engines/sandbox_agent/runtime-policy.ts"; import type { AgentRunRequest } from "../protocol.ts"; @@ -207,7 +209,8 @@ export function buildRuntimeEnvironment( // specs, scoped env, callback endpoints, and callback auth in runner memory. const otlpAuthFilePath = p.isPi && !p.isDaytona ? `${p.workspace.relayDir}.otlp-auth` : undefined; - const otlpAuthorization = r.telemetry?.exporters?.otlp?.headers?.authorization; + const otlpAuthorization = + r.telemetry?.exporters?.otlp?.headers?.authorization; if (otlpAuthFilePath && otlpAuthorization) { writeOtlpAuthFile(otlpAuthFilePath, otlpAuthorization, input.log); } @@ -223,6 +226,17 @@ export function buildRuntimeEnvironment( skills: p.workspace.skillDirs.map((skill) => skill.name), }) : {}; + // The tool specs `buildPiExtensionEnv` just pointed the extension at ride a FILE (they are far + // too large for an env string — see `piToolSpecsFilePath`). A local run writes it here, beside + // the OTLP bearer file; a Daytona run cannot, because the runner's filesystem is not the + // sandbox's, so `prepareDaytonaPiAssets` uploads the same bytes to the same path instead. + if (p.isPi && !p.isDaytona) { + const toolSpecs = resolvePiToolSpecsDelivery( + p.tools.toolSpecs, + p.workspace.relayDir, + ); + if (toolSpecs) writePiToolSpecsFileLocal(toolSpecs, input.log); + } // Daytona builds its provider from `piExtEnv` rather than the local daemon env, so the // transcript location has to sit in BOTH slices or Pi and pi-acp disagree about the path. if (piSessionDir) piExtEnv.PI_CODING_AGENT_SESSION_DIR = piSessionDir; diff --git a/services/runner/src/extensions/agenta.ts b/services/runner/src/extensions/agenta.ts index a4caf7c8cb..e30e6f91cb 100644 --- a/services/runner/src/extensions/agenta.ts +++ b/services/runner/src/extensions/agenta.ts @@ -17,7 +17,11 @@ * the OTLP Authorization bearer (never a plain env * var — read once here, then deleted, see readOtlpAuthFile) * AGENTA_AGENT_CONTENT_CAPTURE_ENABLED "false" to drop prompt/completion/tool I/O from spans - * AGENTA_AGENT_TOOLS_PUBLIC_SPECS JSON [{ name, description, inputSchema }] + * AGENTA_AGENT_TOOLS_PUBLIC_SPECS_FILE path to a runner-written JSON file holding + * [{ name, description, inputSchema }] — a FILE because a + * large tool set exceeds the per-string execve limit and the + * harness spawn then fails with E2BIG (see loadPublicToolSpecs) + * AGENTA_AGENT_TOOLS_PUBLIC_SPECS the same JSON inline; the pre-file fallback, one release only * AGENTA_AGENT_TOOLS_RELAY_DIR relay tool calls through the runner via files here * AGENTA_AGENT_SKILLS_LOADED JSON [skillName] of skills that loaded this run (F-029) * @@ -47,6 +51,7 @@ import { requiredFields, specInputSchema, } from "../tools/spec-schema.ts"; +import { PUBLIC_SPECS_FILE_ENV } from "../tools/tool-mcp-env.ts"; import { buildPiGateEnvelope, PI_GATE_DIALOG_TITLE, @@ -80,6 +85,13 @@ function log(message: string): void { process.stderr.write(`[agenta-pi-ext] ${message}\n`); } +/** + * The pre-file inline delivery of the public tool specs. Kept readable for ONE release so a + * harness whose env was built by an older runner still sees its tools; the runner itself only + * writes `PUBLIC_SPECS_FILE_ENV` now. + */ +const LEGACY_PUBLIC_SPECS_ENV = "AGENTA_AGENT_TOOLS_PUBLIC_SPECS"; + /** The bundle cannot import the runner's identity table, so this copy is pinned against the * shared golden in `tests/unit/pi-builtin-tools-parity.test.ts`. */ export const PI_BUILTIN_TOOL_NAMES = [ @@ -270,19 +282,60 @@ function parseSkillsLoaded(raw: string | undefined): string[] { } } -/** Register public tool metadata as Pi tools whose execution relays to the runner. */ -function registerTools(pi: ExtensionAPI): void { - const raw = process.env.AGENTA_AGENT_TOOLS_PUBLIC_SPECS; - const relayDir = process.env.AGENTA_AGENT_TOOLS_RELAY_DIR; - if (!raw || !relayDir) return; +/** + * Load the run's public tool specs, preferring the runner-written FILE. + * + * The file is the delivery route: one env var holding every hydrated spec overflows Linux's + * per-string `execve` limit (131,072 bytes) and kills the harness spawn with `E2BIG` before any + * tool can be registered. The inline var remains a fallback for ONE release so a harness started + * by an older runner — or a warm session whose env predates this deploy — still finds its tools; + * remove it once no such process can be live. + * + * Returns `undefined` on any defect (unreadable file, bad JSON, non-array) after logging it: the + * caller then registers nothing, which is the same outcome as before, but the reason is on stderr. + */ +function loadPublicToolSpecs(): + { specs: ResolvedToolSpec[]; route: string } | undefined { + const path = process.env[PUBLIC_SPECS_FILE_ENV]; + const raw = process.env[LEGACY_PUBLIC_SPECS_ENV]; + let json: string; + let route: string; + if (path) { + route = `file ${path}`; + try { + json = readFileSync(path, "utf-8"); + } catch (err) { + log(`cannot read tool specs ${route}: ${(err as Error).message}`); + return undefined; + } + } else if (raw) { + route = `env ${LEGACY_PUBLIC_SPECS_ENV}`; + json = raw; + } else { + return undefined; + } - let specs: ResolvedToolSpec[] = []; + let parsed: unknown; try { - specs = JSON.parse(raw); + parsed = JSON.parse(json); } catch (err) { - log(`bad AGENTA_AGENT_TOOLS_PUBLIC_SPECS: ${(err as Error).message}`); - return; + log(`bad tool specs in ${route}: ${(err as Error).message}`); + return undefined; } + if (!Array.isArray(parsed)) { + log(`tool specs in ${route} must be a JSON array`); + return undefined; + } + return { specs: parsed as ResolvedToolSpec[], route }; +} + +/** Register public tool metadata as Pi tools whose execution relays to the runner. */ +function registerTools(pi: ExtensionAPI): void { + const relayDir = process.env.AGENTA_AGENT_TOOLS_RELAY_DIR; + if (!relayDir) return; + const loaded = loadPublicToolSpecs(); + if (!loaded) return; + const specs = loaded.specs; let registered = 0; for (const spec of specs) { @@ -352,13 +405,14 @@ function registerTools(pi: ExtensionAPI): void { } as any); registered += 1; } - log(`registered ${registered} tool(s) -> relay ${relayDir}`); + log( + `registered ${registered} tool(s) from ${loaded.route} -> relay ${relayDir}`, + ); } /** The Pi ExtensionFactory: tools + (env-driven) tracing + usage writeback. */ const factory = (pi: ExtensionAPI): void => { - const modelProviderOverrideRaw = - process.env[PI_MODEL_PROVIDER_OVERRIDE_ENV]; + const modelProviderOverrideRaw = process.env[PI_MODEL_PROVIDER_OVERRIDE_ENV]; const modelProviderOverride = modelProviderOverrideRaw === undefined ? undefined @@ -369,7 +423,11 @@ const factory = (pi: ExtensionAPI): void => { process.env.TRACEPARENT || process.env.OTEL_EXPORTER_OTLP_TRACES_ENDPOINT ); const relayDir = process.env.AGENTA_AGENT_TOOLS_RELAY_DIR; - const hasTools = !!(process.env.AGENTA_AGENT_TOOLS_PUBLIC_SPECS && relayDir); + const hasTools = !!( + (process.env[PUBLIC_SPECS_FILE_ENV] || + process.env[LEGACY_PUBLIC_SPECS_ENV]) && + relayDir + ); const hasBuiltinActivation = isTruthyFlag( process.env.AGENTA_AGENT_BUILTIN_ACTIVATION, ); diff --git a/services/runner/src/lifecycle/reconciliation-router.ts b/services/runner/src/lifecycle/reconciliation-router.ts index c5d6c9aa55..1c651ea2c0 100644 --- a/services/runner/src/lifecycle/reconciliation-router.ts +++ b/services/runner/src/lifecycle/reconciliation-router.ts @@ -43,7 +43,9 @@ export type HarnessKind = "pi" | "claude" | "codex" | "unknown"; * * The shelved components, with their insertion points, are: * - the untrusted best-effort acknowledgement (adapter-matrix.md section 4.3); - * - the Pi specs-file channel, replacing the `AGENTA_AGENT_TOOLS_PUBLIC_SPECS` env var; + * - re-reading the Pi specs file mid-session (the file channel itself now ships — the runner + * writes `AGENTA_AGENT_TOOLS_PUBLIC_SPECS_FILE` on every Pi run — but the extension reads it + * once, at session start); * - the MCP shim `tools.listChanged` capability plus its notification. */ export interface HarnessLifecycleCapabilities { diff --git a/services/runner/tests/unit/extension-tools.test.ts b/services/runner/tests/unit/extension-tools.test.ts index 94c40d2875..f5ba55740b 100644 --- a/services/runner/tests/unit/extension-tools.test.ts +++ b/services/runner/tests/unit/extension-tools.test.ts @@ -28,9 +28,11 @@ import factory, { } from "../../src/extensions/agenta.ts"; import { PI_MODEL_PROVIDER_OVERRIDE_ENV } from "../../src/extensions/model-provider-override.ts"; import { refusedAtGateText } from "../../src/tools/denial-text.ts"; +import { PUBLIC_SPECS_FILE_ENV } from "../../src/tools/tool-mcp-env.ts"; const TOOL_ENV = [ "AGENTA_AGENT_TOOLS_PUBLIC_SPECS", + PUBLIC_SPECS_FILE_ENV, "AGENTA_AGENT_TOOLS_RELAY_DIR", "TRACEPARENT", "OTEL_EXPORTER_OTLP_TRACES_ENDPOINT", @@ -355,6 +357,136 @@ describe("agenta extension tool registration", () => { }); }); +/** + * Regression: "Agent run failed: spawn E2BIG". The runner used to pack every hydrated tool spec + * into AGENTA_AGENT_TOOLS_PUBLIC_SPECS; Linux rejects an execve whose env holds a string over + * 131,072 bytes, so a large tool set killed the harness spawn. The specs now arrive in a file + * named by AGENTA_AGENT_TOOLS_PUBLIC_SPECS_FILE, and this is the read side of that contract. + */ +describe("agenta extension tool specs delivery", () => { + const specsDirs: string[] = []; + + function specsFile(contents: string): string { + const dir = mkdtempSync(join(tmpdir(), "agenta-ext-specs-")); + specsDirs.push(dir); + const path = join(dir, "relay.tool-specs.json"); + writeFileSync(path, contents, "utf-8"); + return path; + } + + afterEach(() => { + for (const dir of specsDirs.splice(0)) + rmSync(dir, { recursive: true, force: true }); + }); + + it("registers tools from the specs file the runner wrote", () => { + clearEnv(); + process.env[PUBLIC_SPECS_FILE_ENV] = specsFile( + JSON.stringify([ + { name: "from_file_one", description: "one" }, + { name: "from_file_two", description: "two" }, + ]), + ); + process.env.AGENTA_AGENT_TOOLS_RELAY_DIR = "/tmp/agenta-relay-test"; + + const pi = fakePi(); + factory(pi as any); + + assert.deepEqual( + pi.registered.map((t) => t.name), + ["from_file_one", "from_file_two"], + ); + }); + + it("carries a tool set far larger than a single env string can hold", () => { + clearEnv(); + const specs = Array.from({ length: 44 }, (_, i) => ({ + name: `composio_tool_${i}`, + description: `Tool ${i}. ${"description text ".repeat(60)}`, + inputSchema: { + type: "object", + properties: Object.fromEntries( + Array.from({ length: 40 }, (_, f) => [ + `field_${f}`, + { + type: "string", + description: `Field ${f}. ${"prose ".repeat(20)}`, + }, + ]), + ), + }, + })); + const json = JSON.stringify(specs); + assert.ok(Buffer.byteLength(json, "utf-8") > 300_000); + process.env[PUBLIC_SPECS_FILE_ENV] = specsFile(json); + process.env.AGENTA_AGENT_TOOLS_RELAY_DIR = "/tmp/agenta-relay-test"; + + const pi = fakePi(); + factory(pi as any); + + assert.equal(pi.registered.length, 44); + }); + + it("prefers the file over the pre-file inline env var", () => { + clearEnv(); + process.env[PUBLIC_SPECS_FILE_ENV] = specsFile( + JSON.stringify([{ name: "from_file", description: "file" }]), + ); + process.env.AGENTA_AGENT_TOOLS_PUBLIC_SPECS = JSON.stringify([ + { name: "from_env", description: "stale inline copy" }, + ]); + process.env.AGENTA_AGENT_TOOLS_RELAY_DIR = "/tmp/agenta-relay-test"; + + const pi = fakePi(); + factory(pi as any); + + assert.deepEqual( + pi.registered.map((t) => t.name), + ["from_file"], + ); + }); + + it("still reads the inline env var when no file is named (one-release fallback)", () => { + clearEnv(); + process.env.AGENTA_AGENT_TOOLS_PUBLIC_SPECS = JSON.stringify([ + { name: "from_env", description: "inline" }, + ]); + process.env.AGENTA_AGENT_TOOLS_RELAY_DIR = "/tmp/agenta-relay-test"; + + const pi = fakePi(); + factory(pi as any); + + assert.deepEqual( + pi.registered.map((t) => t.name), + ["from_env"], + ); + }); + + it("registers nothing when the named file is unreadable or malformed", () => { + clearEnv(); + process.env.AGENTA_AGENT_TOOLS_RELAY_DIR = "/tmp/agenta-relay-test"; + + process.env[PUBLIC_SPECS_FILE_ENV] = join( + tmpdir(), + "agenta-ext-specs-absent", + "relay.tool-specs.json", + ); + const missing = fakePi(); + factory(missing as any); + assert.equal(missing.registered.length, 0); + + process.env[PUBLIC_SPECS_FILE_ENV] = specsFile("{not json"); + const malformed = fakePi(); + factory(malformed as any); + assert.equal(malformed.registered.length, 0); + + process.env[PUBLIC_SPECS_FILE_ENV] = specsFile('{"name":"not-an-array"}'); + const notArray = fakePi(); + factory(notArray as any); + assert.equal(notArray.registered.length, 0); + }); +}); + describe("readOtlpAuthFile", () => { it("reads the bearer once, then deletes the file so it cannot be re-read", () => { const dir = mkdtempSync(join(tmpdir(), "agenta-otlp-auth-test-")); diff --git a/services/runner/tests/unit/sandbox-agent-pi-assets.test.ts b/services/runner/tests/unit/sandbox-agent-pi-assets.test.ts index 264f2ae8b9..379cbc3b37 100644 --- a/services/runner/tests/unit/sandbox-agent-pi-assets.test.ts +++ b/services/runner/tests/unit/sandbox-agent-pi-assets.test.ts @@ -27,15 +27,21 @@ import { materializeDaytonaPiSkillSnapshot, materializeLocalPiSkillSnapshot, PI_SKILL_SNAPSHOT_MARKER, + PI_TOOL_SPECS_UNAVAILABLE_MESSAGE, piSessionWorkspaceDir, + piToolSpecsFilePath, prepareLocalAgentDir, prepareLocalPiAssets, resolvePiSkillSnapshot, + resolvePiToolSpecsDelivery, uploadDirToSandbox, + uploadPiToolSpecsToSandbox, writeOtlpAuthFile, writePiModelsConfigLocal, + writePiToolSpecsFileLocal, writeSystemPromptLocal, } from "../../src/engines/sandbox_agent/pi-assets.ts"; +import { PUBLIC_SPECS_FILE_ENV } from "../../src/tools/tool-mcp-env.ts"; import type { PiModelConfigPlan } from "../../src/engines/sandbox_agent/pi-model-config.ts"; const MODEL_CONFIG_PLAN: PiModelConfigPlan = { @@ -211,11 +217,15 @@ describe("buildPiExtensionEnv", () => { ], } as AgentRunRequest; + const relayDir = join(tempDir("agenta-pi-specs-"), "relay"); const env = buildPiExtensionEnv(request, true, { - relayDir: "/tmp/relay", + relayDir, usageOutPath: "/tmp/usage.json", otlpAuthFilePath: "/tmp/otlp-auth", }); + writePiToolSpecsFileLocal( + resolvePiToolSpecsDelivery(request.customTools ?? [], relayDir)!, + ); assert.equal(env.TRACEPARENT, request.context?.propagation?.traceparent); assert.equal( @@ -226,10 +236,15 @@ describe("buildPiExtensionEnv", () => { assert.equal(env.AGENTA_AGENT_OTLP_AUTH_FILE, "/tmp/otlp-auth"); assert.equal(env.OTEL_EXPORTER_OTLP_HEADERS, undefined); assert.equal(env.AGENTA_AGENT_CONTENT_CAPTURE_ENABLED, "false"); - assert.equal(env.AGENTA_AGENT_TOOLS_RELAY_DIR, "/tmp/relay"); + assert.equal(env.AGENTA_AGENT_TOOLS_RELAY_DIR, relayDir); assert.equal(env.AGENTA_AGENT_USAGE_CAPTURE_PATH, "/tmp/usage.json"); - const specs = JSON.parse(env.AGENTA_AGENT_TOOLS_PUBLIC_SPECS ?? "[]"); + // The specs ride a file; env carries only its path (see the E2BIG regression below). + assert.equal(env[PUBLIC_SPECS_FILE_ENV], `${relayDir}.tool-specs.json`); + assert.equal(env.AGENTA_AGENT_TOOLS_PUBLIC_SPECS, undefined); + const specs = JSON.parse( + readFileSync(env[PUBLIC_SPECS_FILE_ENV] ?? "", "utf-8"), + ); assert.deepEqual(specs, [ { name: "safe_tool", @@ -270,7 +285,7 @@ describe("buildPiExtensionEnv", () => { ); assert.equal(env.TRACEPARENT, undefined); - assert.equal(env.AGENTA_AGENT_TOOLS_PUBLIC_SPECS, undefined); + assert.equal(env[PUBLIC_SPECS_FILE_ENV], undefined); assert.equal(env.AGENTA_AGENT_TOOLS_RELAY_DIR, undefined); assert.equal(env[PI_MODEL_PROVIDER_OVERRIDE_ENV], undefined); }); @@ -283,7 +298,7 @@ describe("buildPiExtensionEnv", () => { assert.equal(env.AGENTA_AGENT_BUILTIN_GATING, "1"); assert.equal(env.AGENTA_AGENT_TOOLS_RELAY_DIR, undefined); - assert.equal(env.AGENTA_AGENT_TOOLS_PUBLIC_SPECS, undefined); + assert.equal(env[PUBLIC_SPECS_FILE_ENV], undefined); }); it("always sets the builtin activation env and never a grant list", () => { @@ -300,25 +315,27 @@ describe("buildPiExtensionEnv", () => { }); it("accepts snake_case tool schemas from older Python wire payloads", () => { - const env = buildPiExtensionEnv( + const customTools = [ { - customTools: [ - { - name: "request_connection", - kind: "client", - input_schema: { - type: "object", - required: ["integration"], - properties: { integration: { type: "string" } }, - }, - }, - ], - } as unknown as AgentRunRequest, + name: "request_connection", + kind: "client", + input_schema: { + type: "object", + required: ["integration"], + properties: { integration: { type: "string" } }, + }, + }, + ]; + const env = buildPiExtensionEnv( + { customTools } as unknown as AgentRunRequest, false, { relayDir: "/tmp/relay" }, ); - const specs = JSON.parse(env.AGENTA_AGENT_TOOLS_PUBLIC_SPECS ?? "[]"); + assert.equal(env[PUBLIC_SPECS_FILE_ENV], "/tmp/relay.tool-specs.json"); + const specs = JSON.parse( + resolvePiToolSpecsDelivery(customTools as never, "/tmp/relay")!.contents, + ); assert.deepEqual(specs[0].inputSchema, { type: "object", required: ["integration"], @@ -420,6 +437,159 @@ describe("buildPiExtensionEnv", () => { }); }); +/** + * Regression: "Agent run failed: spawn E2BIG". Every hydrated tool spec used to be packed into + * ONE env var, and Linux refuses `execve` when any single env string exceeds MAX_ARG_STRLEN + * (131,072 bytes) — a session with 44 Composio tools (~250 KB of specs) failed before the harness + * process existed. The specs now ride a file whose path is all env carries. + */ +const MAX_ARG_STRLEN = 131_072; + +/** 44 tools with fat JSON Schemas: the shape that reproduced the failure in production. */ +function fatToolSpecs(count = 44) { + return Array.from({ length: count }, (_, i) => ({ + name: `composio_tool_${i}`, + description: `Tool ${i}. ${"description text ".repeat(60)}`, + kind: "callback" as const, + inputSchema: { + type: "object", + properties: Object.fromEntries( + Array.from({ length: 40 }, (_, f) => [ + `field_${f}`, + { + type: "string", + description: `Field ${f}. ${"schema prose ".repeat(20)}`, + }, + ]), + ), + }, + })); +} + +describe("Pi tool specs delivery", () => { + it("keeps a 300 KB tool set out of the env and round-trips it through the file", () => { + const relayDir = join(tempDir("agenta-pi-specs-e2big-"), "relay"); + const customTools = fatToolSpecs(); + const request = { customTools } as unknown as AgentRunRequest; + + const env = buildPiExtensionEnv(request, false, { relayDir }); + const delivery = resolvePiToolSpecsDelivery(customTools as never, relayDir); + assert.ok(delivery); + writePiToolSpecsFileLocal(delivery); + + assert.ok( + Buffer.byteLength(delivery.contents, "utf-8") > 300_000, + "the fixture must exceed the single-env-string limit several times over", + ); + for (const [key, value] of Object.entries(env)) { + assert.ok( + Buffer.byteLength(value, "utf-8") < MAX_ARG_STRLEN, + `env ${key} is ${Buffer.byteLength(value, "utf-8")} bytes; execve would fail with E2BIG`, + ); + } + assert.equal(env[PUBLIC_SPECS_FILE_ENV], piToolSpecsFilePath(relayDir)); + assert.equal(env.AGENTA_AGENT_TOOLS_PUBLIC_SPECS, undefined); + assert.deepEqual( + JSON.parse(readFileSync(env[PUBLIC_SPECS_FILE_ENV] ?? "", "utf-8")), + JSON.parse(delivery.contents), + ); + assert.equal(JSON.parse(delivery.contents).length, 44); + }); + + it("puts the file beside the relay dir, which is cleared every turn", () => { + assert.equal( + piToolSpecsFilePath("/tmp/agenta/relay/session-1"), + "/tmp/agenta/relay/session-1.tool-specs.json", + ); + assert.equal( + resolvePiToolSpecsDelivery([], "/tmp/relay"), + undefined, + "no tools, nothing to deliver", + ); + assert.equal( + resolvePiToolSpecsDelivery([{ name: "t" }] as never, undefined), + undefined, + "no relay dir means no way to execute a tool, so none is advertised", + ); + }); + + it("fails loud when the file cannot be written, rather than dropping the tools", () => { + const dir = tempDir("agenta-pi-specs-unwritable-"); + // A FILE where the parent directory must be: mkdir fails with ENOTDIR. + const blocked = join(dir, "blocker"); + writeFileSync(blocked, "not a directory", "utf-8"); + const delivery = resolvePiToolSpecsDelivery( + [{ name: "t" }] as never, + join(blocked, "relay"), + ); + assert.ok(delivery); + + assert.throws( + () => writePiToolSpecsFileLocal(delivery), + (err: Error) => err.message === PI_TOOL_SPECS_UNAVAILABLE_MESSAGE, + ); + }); + + it("uploads the same bytes to the deterministic in-sandbox path on Daytona", async () => { + const writes: Array<{ path: string; contents: string }> = []; + const madeDirs: string[] = []; + const sandbox = { + mkdirFs: async ({ path }: { path: string }) => { + madeDirs.push(path); + }, + writeFsFile: async ({ path }: { path: string }, contents: string) => { + writes.push({ path, contents }); + }, + }; + const customTools = fatToolSpecs(2); + const delivery = resolvePiToolSpecsDelivery( + customTools as never, + "/home/sandbox/agenta/relay/session-1", + ); + assert.ok(delivery); + + await uploadPiToolSpecsToSandbox(sandbox, delivery); + + assert.deepEqual(madeDirs, ["/home/sandbox/agenta/relay"]); + assert.deepEqual(writes, [ + { + path: "/home/sandbox/agenta/relay/session-1.tool-specs.json", + contents: delivery.contents, + }, + ]); + // The env the sandbox was created with names exactly this path. + assert.equal( + buildPiExtensionEnv( + { customTools } as unknown as AgentRunRequest, + false, + { + relayDir: "/home/sandbox/agenta/relay/session-1", + }, + )[PUBLIC_SPECS_FILE_ENV], + writes[0].path, + ); + }); + + it("fails loud when the sandbox upload fails", async () => { + const sandbox = { + mkdirFs: async () => {}, + writeFsFile: async () => { + throw new Error("sandbox is gone"); + }, + }; + const delivery = resolvePiToolSpecsDelivery( + [{ name: "t" }] as never, + "/home/sandbox/agenta/relay/session-1", + ); + assert.ok(delivery); + + await assert.rejects( + uploadPiToolSpecsToSandbox(sandbox, delivery), + (err: Error) => err.message === PI_TOOL_SPECS_UNAVAILABLE_MESSAGE, + ); + }); +}); + describe("writeOtlpAuthFile", () => { it("writes the bearer to a 0600 file, not env", () => { const dir = tempDir("agenta-pi-otlp-auth-test-"); @@ -853,7 +1023,6 @@ describe("prepareLocalPiAssets (runtime_provided runs out of the mount, read-wri assert.equal(env.PI_CODING_AGENT_DIR, runDir); dirs.push(runDir as string); }); - }); describe("sandbox uploads", () => {