diff --git a/services/agent/src/tools/direct.ts b/services/agent/src/tools/direct.ts new file mode 100644 index 0000000000..581ba1a17f --- /dev/null +++ b/services/agent/src/tools/direct.ts @@ -0,0 +1,272 @@ +/** + * Direct-call tool transport (direct-call tools, Phase 2). + * + * A resolved callback tool can carry a `call` descriptor instead of a `callRef`. When it does, + * the runner calls that Agenta endpoint DIRECTLY — reference tools (a stored workflow invoked as + * a tool) and platform tools (an existing Agenta endpoint exposed to the harness) — instead of + * routing through the shared `/tools/call` gateway. Only gateway (Composio) tools still route + * through `/tools/call`, because only the server can read the Composio secret. + * + * This module owns the three pieces of a direct call so both dispatch paths share one + * implementation: + * - `assembleBody` — merge the model's args with the server-fixed `body` (and, in Phase 3, the + * run-context `context` binding) per the body-assembly rules in the design. + * - `directCallUrl` — the SSRF guard: validate the method + path and bind the origin to the run's + * own Agenta, so the descriptor (untrusted input) can never reach a non-Agenta host. + * - `callDirect` — the actual HTTP round-trip, reusing the run's caller credential. + * + * In this phase it is called only from `tools/relay.ts` `executeRelayedTool` — the live host path + * for both local and Daytona, because both call sites relay every tool call to the host. The + * symmetric `tools/dispatch.ts` `runResolvedTool` host-direct branch is deferred until the + * gateway-refactor lane lands (see the PR notes); the in-sandbox child never makes the call. + */ +import type { ResolvedToolSpec } from "../protocol.ts"; +import { TOOL_CALL_TIMEOUT_MS } from "./callback.ts"; + +/** The resolved `call` descriptor (see `ResolvedToolSpec.call`). */ +export type DirectCall = NonNullable; + +/** Methods a direct call may use. The descriptor is untrusted, so this is an allowlist. */ +const DIRECT_CALL_METHODS = new Set(["GET", "POST"]); + +/** + * Object keys that must never be written through a dotted path or a merge: assigning to them + * mutates the prototype chain (prototype pollution). Rejected in `deepSet` and skipped in + * `deepMerge`. + */ +const UNSAFE_KEYS = new Set(["__proto__", "constructor", "prototype"]); + +/** A non-null, non-array object. Used so merges/sets only recurse into real maps. */ +function isPlainObject(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +/** + * Deep-set `value` at a dotted `path` in `target`, creating intermediate objects. Each segment is + * validated: empty segments and prototype-polluting keys (`__proto__`/`constructor`/`prototype`) + * are rejected. An intermediate that is not a plain object is replaced with one. + */ +export function deepSet( + target: Record, + path: string, + value: unknown, +): void { + const parts = path.split("."); + for (const part of parts) { + if (!part) throw new Error(`invalid empty segment in path '${path}'`); + if (UNSAFE_KEYS.has(part)) { + throw new Error(`unsafe path segment '${part}' in '${path}'`); + } + } + let cursor = target; + for (let i = 0; i < parts.length - 1; i++) { + const key = parts[i]; + if (!isPlainObject(cursor[key])) cursor[key] = {}; + cursor = cursor[key] as Record; + } + cursor[parts[parts.length - 1]] = value; +} + +/** + * Recursively merge `overlay` onto `base`. `overlay` WINS on every conflict (so server-fixed + * fields override the model's args); two plain objects at the same key merge, anything else + * replaces. Prototype-polluting keys in `overlay` are skipped. Returns a new object; inputs are + * not mutated. + */ +export function deepMerge( + base: Record, + overlay: Record, +): Record { + const out: Record = isPlainObject(base) ? { ...base } : {}; + for (const [key, value] of Object.entries(overlay)) { + if (UNSAFE_KEYS.has(key)) continue; + if (isPlainObject(value) && isPlainObject(out[key])) { + out[key] = deepMerge(out[key] as Record, value); + } else { + out[key] = value; + } + } + return out; +} + +/** + * Build the request body for a direct call from the model's `params` and the descriptor. + * + * Merge order (later wins): + * 1. The model's args, placed at `call.args_into` (a dotted deep-set path, e.g. `data.inputs` + * for a reference invoke) or, when absent, merged at the body root. Non-object args with no + * `args_into` have nowhere safe to land at the root, so they are dropped. + * 2. `call.body` — static server-fixed fields baked at resolve time (e.g. a reference's + * `references.workflow_revision.id`). These OVERLAY the model's args, so the model can never + * retarget or override a fixed field. + * 3. `call.context` — the run-context binding ($ctx. from the run's `runContext`), filled + * LAST so a bound field always wins. THIS IS A PHASE 3 SEAM: `runContext` is not wired yet, + * so Phase 2 does not apply it (and nothing emits `context` yet). See the TODO below. + */ +export function assembleBody( + call: DirectCall, + params: unknown, +): Record { + // 1. Model args, at args_into (deep-set) or the root. + let body: Record = {}; + const args = params ?? {}; + if (call.args_into) { + deepSet(body, call.args_into, args); + } else if (isPlainObject(args)) { + body = { ...args }; + } + // 2. Server-fixed fields win over the model's args. + if (call.body) body = deepMerge(body, call.body); + // 3. Run-context binding (`call.context`) is Phase 3. It depends on the `runContext` payload on + // `/run`, which is not wired yet, so it is intentionally NOT applied here and no resolver + // emits it. Filling it last (context wins) is the documented merge rule. + // TODO(Phase 3): for each [bodyPath, "$ctx."] in call.context, resolve against + // the run's runContext blob and deepSet(body, bodyPath, value) — context overrides all. + return body; +} + +/** + * Validate the descriptor and build the absolute URL to call. The `call` is untrusted input, so + * this is the SSRF guard, and it makes NO assumption about where the Agenta API is mounted: + * - `method` must be on the allowlist (GET/POST); + * - `path` must be a single absolute-path reference — a string starting with exactly one `/` + * (no scheme, no protocol-relative `//host`, no backslashes, no whitespace/CRLF, no literal + * `..` traversal); + * - the path is RESOLVED against the origin of the run's own `callbackEndpoint` (the `/tools/call` + * URL the gateway already uses), and the resolved origin must equal that origin — a true + * host-lock, so a tool can never reach a non-Agenta host even via a percent-encoded escape + * (`/api/%2e%2e/...`) that URL-normalizes to another path; + * - the resolved path must stay under the callback's MOUNT — the callback path minus its trailing + * `/tools/call` (e.g. `/api` for `https://host/api/tools/call`, or `` for an OSS self-host at + * `http://host:8000/tools/call`). A non-empty mount must contain the path, so a normalized + * escape out of the API surface is rejected; an empty mount (API at the origin root) relies on + * the host-lock alone. Deriving the mount instead of hard-coding `/api` is what lets this work + * on a self-host where the API is not under `/api`. + */ +export function directCallUrl(callbackEndpoint: string, call: DirectCall): string { + if (!DIRECT_CALL_METHODS.has(call.method)) { + throw new Error( + `direct-call method '${call.method}' is not allowed (GET/POST only)`, + ); + } + const path = call.path; + // A single absolute-path reference: a string starting with exactly one `/`. Rejects non-strings, + // scheme-qualified URLs (`https://…` does not start with `/`) and protocol-relative `//host`. + if (typeof path !== "string" || path[0] !== "/" || path[1] === "/") { + throw new Error( + `direct-call path '${path}' must be an absolute path starting with a single '/'`, + ); + } + // Reject the obvious traversal/encoding tricks up front (defense in depth; the host-lock and + // mount check below also catch a normalized escape). + if (path.includes("..") || path.includes("\\") || /\s/.test(path)) { + throw new Error(`direct-call path '${path}' is not a safe relative path`); + } + let base: URL; + try { + base = new URL(callbackEndpoint); + } catch { + throw new Error( + `cannot derive Agenta origin from callback endpoint '${callbackEndpoint}'`, + ); + } + if (base.origin === "null") { + throw new Error( + `callback endpoint '${callbackEndpoint}' has no usable origin`, + ); + } + // Resolve against the callback origin and host-lock: the resolved origin must equal it. This + // binds every direct call to the run's own Agenta, whatever the path normalizes to. + let resolved: URL; + try { + resolved = new URL(path, base.origin); + } catch { + throw new Error(`direct-call path '${path}' is not a valid path`); + } + if (resolved.origin !== base.origin) { + throw new Error( + `direct-call path '${path}' resolves outside the run's Agenta origin`, + ); + } + // Confine to the callback's mount (the callback path minus a trailing `/tools/call`). An empty + // mount (API at the root) imposes no prefix; a non-empty mount must contain the resolved path, + // so a normalized escape like `/api/%2e%2e/admin` -> `/admin` is rejected. + const CALLBACK_PATH_SUFFIX = "/tools/call"; + const mount = base.pathname.endsWith(CALLBACK_PATH_SUFFIX) + ? base.pathname.slice(0, -CALLBACK_PATH_SUFFIX.length) + : ""; + if ( + mount && + resolved.pathname !== mount && + !resolved.pathname.startsWith(`${mount}/`) + ) { + throw new Error( + `direct-call path '${path}' is outside the Agenta API mount '${mount}'`, + ); + } + return resolved.toString(); +} + +/** + * One direct call to an Agenta endpoint. Reuses the run's caller credential (`authorization`), + * combines an optional caller `signal` with the per-tool timeout, and returns the response text + * verbatim for the model. Throws on a transport error or a non-2xx status; callers turn the throw + * into a tool-error result so the model loop continues. + * + * The response is returned as-is (the body text). Endpoint-specific result shaping — e.g. lifting + * a reference invoke's `data.outputs` + `trace_id` — is Phase 4, when the reference resolver + * starts emitting `call`. + */ +export async function callDirect( + method: "GET" | "POST", + url: string, + authorization: string | undefined, + body: Record, + signal?: AbortSignal, +): Promise { + const headers: Record = { + "content-type": "application/json", + }; + if (authorization) headers["authorization"] = authorization; + + const timeoutSignal = AbortSignal.timeout(TOOL_CALL_TIMEOUT_MS); + const anyOf = (AbortSignal as any).any; + const combined = + signal && typeof anyOf === "function" + ? anyOf([signal, timeoutSignal]) + : timeoutSignal; + + let response: Response; + try { + response = await fetch(url, { + method, + headers, + // GET carries no body (fetch forbids it); POST sends the assembled JSON body. + body: method === "POST" ? JSON.stringify(body) : undefined, + signal: combined, + // Do not auto-follow redirects: a 3xx to another host would defeat the origin lock in + // directCallUrl (SSRF-via-redirect). A 3xx surfaces here as a non-ok response and fails + // closed below — we never chase it. + redirect: "manual", + }); + } catch (err) { + // Log the detail server-side; the model gets a generic message so the resolved internal URL + // and the transport error never leak into the tool result. + console.error( + `direct tool call ${method} ${url} transport error:`, + err instanceof Error ? err.message : String(err), + ); + throw new Error("direct tool call failed"); + } + + const bodyText = await response.text(); + if (!response.ok) { + // Keep the internal URL and the upstream response body server-side; the model gets only the + // status code. (`redirect: "manual"` makes a 3xx a non-ok response, so it lands here too.) + console.error( + `direct tool call ${method} ${url} returned HTTP ${response.status}: ${bodyText.slice(0, 500)}`, + ); + throw new Error(`direct tool call failed: HTTP ${response.status}`); + } + return bodyText; +} diff --git a/services/agent/src/tools/relay.ts b/services/agent/src/tools/relay.ts index 28bc3c1302..97ec8d0365 100644 --- a/services/agent/src/tools/relay.ts +++ b/services/agent/src/tools/relay.ts @@ -19,6 +19,7 @@ import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from import { callAgentaTool } from "./callback.ts"; import { runCodeTool } from "./code.ts"; +import { assembleBody, callDirect, directCallUrl } from "./direct.ts"; import type { ResolvedToolSpec, ToolCallbackContext } from "../protocol.ts"; import type { PermissionPolicy } from "../responder.ts"; @@ -141,6 +142,16 @@ async function executeRelayedTool( if (!callback?.endpoint) { throw new Error(`missing toolCallback endpoint for '${spec.name}'`); } + // Direct-call tools (reference / platform): the host makes the call directly so the sandbox + // child still sends only name + args. The origin is bound to the run's own callback endpoint + // and the run's authorization is reused (see tools/direct.ts). A spec carries `call` XOR + // `callRef`, so this is checked before the gateway fallback. + if (spec.call) { + const url = directCallUrl(callback.endpoint, spec.call); + const body = assembleBody(spec.call, req.args); + return callDirect(spec.call.method, url, callback.authorization, body); + } + // Gateway (Composio): POST back through Agenta's /tools/call so the secret stays server-side. return callAgentaTool( callback.endpoint, callback.authorization, diff --git a/services/agent/tests/unit/tool-direct.test.ts b/services/agent/tests/unit/tool-direct.test.ts new file mode 100644 index 0000000000..d3f97142a0 --- /dev/null +++ b/services/agent/tests/unit/tool-direct.test.ts @@ -0,0 +1,388 @@ +/** + * Unit tests for direct-call tools (tools/direct.ts) and the two dispatch branches that use it + * (tools/dispatch.ts `runResolvedTool`, tools/relay.ts `startToolRelay` -> `executeRelayedTool`). + * + * A resolved callback tool can carry a `call` descriptor; when it does the runner calls the + * Agenta endpoint directly instead of routing through /tools/call. These tests cover: + * - assembleBody: args_into deep-set, the fixed-wins overlay, the root merge, and + * prototype-pollution-safe assignment. + * - directCallUrl (the SSRF guard): method allowlist, the /api-relative path rule, traversal / + * protocol-relative / absolute-URL rejection, and origin binding to the run's callback endpoint. + * - the dispatch branch (runResolvedTool, host-direct) and the relay branch (startToolRelay, + * Daytona host) with FAKE `call` specs and a mocked global fetch. + * + * No network and no harness: `globalThis.fetch` is stubbed per test and restored after. + * + * Run: pnpm test (or: pnpm exec vitest run tests/unit/tool-direct.test.ts) + */ +import { afterEach, describe, it } from "vitest"; +import assert from "node:assert/strict"; +import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import { + assembleBody, + deepMerge, + deepSet, + directCallUrl, + type DirectCall, +} from "../../src/tools/direct.ts"; +import { + localRelayHost, + startToolRelay, + type RelayResponse, +} from "../../src/tools/relay.ts"; +import type { ResolvedToolSpec } from "../../src/protocol.ts"; + +const ENDPOINT = "https://agenta.example/api/tools/call"; + +/** One captured fetch call. */ +interface CapturedFetch { + url: string; + init: RequestInit; +} + +const realFetch = globalThis.fetch; +afterEach(() => { + globalThis.fetch = realFetch; +}); + +/** Replace global fetch with a stub that records the call and returns `body`. */ +function stubFetch(body: string, ok = true, status = 200): CapturedFetch[] { + const calls: CapturedFetch[] = []; + globalThis.fetch = (async (url: any, init: any) => { + calls.push({ url: String(url), init: init ?? {} }); + return new Response(body, { status: ok ? status : status >= 400 ? status : 500 }); + }) as typeof fetch; + return calls; +} + +// --------------------------------------------------------------------------- +// assembleBody +// --------------------------------------------------------------------------- + +describe("assembleBody", () => { + it("deep-sets the model args at args_into", () => { + const call: DirectCall = { + method: "POST", + path: "/api/workflows/invoke", + args_into: "data.inputs", + }; + const body = assembleBody(call, { city: "Paris" }); + assert.deepEqual(body, { data: { inputs: { city: "Paris" } } }); + }); + + it("merges the model args at the root when args_into is absent", () => { + const call: DirectCall = { method: "POST", path: "/api/workflows/query" }; + const body = assembleBody(call, { flags: { is_draft: true } }); + assert.deepEqual(body, { flags: { is_draft: true } }); + }); + + it("overlays the reference invoke: args at data.inputs, fixed revision at the root", () => { + const call: DirectCall = { + method: "POST", + path: "/api/workflows/invoke", + body: { references: { workflow_revision: { id: "rev_abc123" } } }, + args_into: "data.inputs", + }; + const body = assembleBody(call, { city: "Paris" }); + assert.deepEqual(body, { + data: { inputs: { city: "Paris" } }, + references: { workflow_revision: { id: "rev_abc123" } }, + }); + }); + + it("lets the server-fixed body win over a colliding model arg (no retargeting)", () => { + const call: DirectCall = { + method: "POST", + path: "/api/workflows/revisions/commit", + // self-targeting fixed field: the model passes a different id but cannot override it. + body: { workflow_variant_id: "own-variant" }, + }; + const body = assembleBody(call, { + workflow_variant_id: "someone-elses", + parameters: { temperature: 0.2 }, + }); + assert.equal(body.workflow_variant_id, "own-variant"); + assert.deepEqual(body.parameters, { temperature: 0.2 }); + }); + + it("fixed body wins on a nested collision, too", () => { + const call: DirectCall = { + method: "POST", + path: "/api/workflows/invoke", + body: { data: { inputs: { locked: "server" } } }, + args_into: "data.inputs", + }; + const body = assembleBody(call, { locked: "model", extra: 1 }); + assert.deepEqual(body, { + data: { inputs: { locked: "server", extra: 1 } }, + }); + }); + + it("drops non-object args when there is no args_into (nowhere safe at the root)", () => { + const call: DirectCall = { method: "POST", path: "/api/x" }; + assert.deepEqual(assembleBody(call, "a string"), {}); + assert.deepEqual(assembleBody(call, undefined), {}); + }); + + it("does NOT apply context (Phase 3 seam): a $ctx binding is ignored for now", () => { + const call: DirectCall = { + method: "POST", + path: "/api/x", + context: { "trace.trace_id": "$ctx.trace.trace_id" }, + }; + const body = assembleBody(call, { a: 1 }); + // The bound field is NOT set: context binding depends on runContext (Phase 3). + assert.deepEqual(body, { a: 1 }); + }); + + it("is prototype-pollution-safe via args_into", () => { + const call: DirectCall = { + method: "POST", + path: "/api/x", + args_into: "__proto__.polluted", + }; + assert.throws(() => assembleBody(call, true), /unsafe path segment '__proto__'/); + assert.equal(({} as any).polluted, undefined); + }); + + it("is prototype-pollution-safe via a body key", () => { + // JSON.parse makes a real OWN "__proto__" key (an object literal would set the prototype). + const call: DirectCall = { + method: "POST", + path: "/api/x", + body: JSON.parse('{"__proto__": {"polluted": true}}'), + }; + const body = assembleBody(call, { a: 1 }); + assert.equal((body as any).polluted, undefined); + assert.equal(({} as any).polluted, undefined); + }); +}); + +// --------------------------------------------------------------------------- +// deepSet / deepMerge primitives +// --------------------------------------------------------------------------- + +describe("deepSet / deepMerge", () => { + it("deepSet rejects an empty path segment", () => { + assert.throws(() => deepSet({}, "a..b", 1), /invalid empty segment/); + }); + + it("deepSet replaces a non-object intermediate", () => { + const target: Record = { a: 5 }; + deepSet(target, "a.b", 1); + assert.deepEqual(target, { a: { b: 1 } }); + }); + + it("deepMerge has the overlay win and does not mutate inputs", () => { + const base = { a: { x: 1 }, keep: 1 }; + const overlay = { a: { y: 2 }, keep: 9 }; + const out = deepMerge(base, overlay); + assert.deepEqual(out, { a: { x: 1, y: 2 }, keep: 9 }); + assert.deepEqual(base, { a: { x: 1 }, keep: 1 }, "base is untouched"); + }); +}); + +// --------------------------------------------------------------------------- +// directCallUrl (SSRF guard) +// --------------------------------------------------------------------------- + +describe("directCallUrl", () => { + it("joins the callback origin with the /api path", () => { + const url = directCallUrl(ENDPOINT, { + method: "POST", + path: "/api/workflows/invoke", + }); + assert.equal(url, "https://agenta.example/api/workflows/invoke"); + }); + + it("preserves a non-default port from the callback origin", () => { + const url = directCallUrl("http://127.0.0.1:8000/api/tools/call", { + method: "GET", + path: "/api/workflows/abc", + }); + assert.equal(url, "http://127.0.0.1:8000/api/workflows/abc"); + }); + + it("keeps a query string", () => { + const url = directCallUrl(ENDPOINT, { + method: "GET", + path: "/api/tools/catalog/integrations?search=github", + }); + assert.equal( + url, + "https://agenta.example/api/tools/catalog/integrations?search=github", + ); + }); + + it("rejects a disallowed method", () => { + assert.throws( + () => directCallUrl(ENDPOINT, { method: "DELETE" as any, path: "/api/x" }), + /method 'DELETE' is not allowed/, + ); + }); + + it("accepts a path on a non-/api mount (OSS self-host at the origin root)", () => { + // The callback carries no /api prefix, so the mount is empty and the host-lock is the only + // boundary — the API lives at the origin root on this deployment. + const url = directCallUrl("http://host:8000/tools/call", { + method: "POST", + path: "/workflows/invoke", + }); + assert.equal(url, "http://host:8000/workflows/invoke"); + }); + + it("rejects a same-origin path outside the callback's mount", () => { + assert.throws( + () => directCallUrl(ENDPOINT, { method: "POST", path: "/secrets" }), + /is outside the Agenta API mount '\/api'/, + ); + }); + + it("rejects a percent-encoded traversal that normalizes out of the mount", () => { + // `/api/%2e%2e/admin` URL-normalizes to `/admin`: the literal `..` check misses it, but the + // mount confinement (after resolution) rejects it. + assert.throws( + () => directCallUrl(ENDPOINT, { method: "POST", path: "/api/%2e%2e/admin" }), + /is outside the Agenta API mount '\/api'/, + ); + }); + + it("rejects an absolute URL as the path", () => { + assert.throws( + () => + directCallUrl(ENDPOINT, { + method: "POST", + path: "https://evil.example/api/x", + }), + /must be an absolute path starting with a single '\/'/, + ); + }); + + it("rejects a protocol-relative path", () => { + assert.throws( + () => directCallUrl(ENDPOINT, { method: "POST", path: "//evil.example/api/x" }), + /must be an absolute path starting with a single '\/'/, + ); + }); + + it("rejects a literal traversal path", () => { + assert.throws( + () => directCallUrl(ENDPOINT, { method: "POST", path: "/api/../admin" }), + /is not a safe relative path/, + ); + }); + + it("rejects a callback endpoint with no usable origin", () => { + assert.throws( + () => directCallUrl("not a url", { method: "POST", path: "/api/x" }), + /cannot derive Agenta origin/, + ); + }); +}); + +// The reference-tool spec reused by the live dispatch tests below: a stored workflow invoked as +// a tool (args at data.inputs, the resolved revision baked into the fixed body). +const refSpec: ResolvedToolSpec = { + name: "get_weather", + kind: "callback", + call: { + method: "POST", + path: "/api/workflows/invoke", + body: { references: { workflow_revision: { id: "rev_abc123" } } }, + args_into: "data.inputs", + }, +}; + +// --------------------------------------------------------------------------- +// dispatch branch (startToolRelay -> executeRelayedTool, the LIVE host path) +// +// Both live call sites (the Pi extension and the internal tool MCP server) relay tool calls to +// the runner, so executeRelayedTool is where a direct call actually happens — on local and on +// Daytona. The sandbox sends only name + args; the host assembles the body, applies the SSRF +// guard, and makes the call. (The dispatch.ts `runResolvedTool` host-direct branch is the +// symmetric non-relay path; it is deferred — see the PR notes — because dispatch.ts is being +// rewritten by a parallel lane.) +// --------------------------------------------------------------------------- + +/** Drive one tool call through the host relay loop and return the response the runner wrote. */ +async function relayOnce( + spec: ResolvedToolSpec, + callback: { endpoint: string; authorization?: string }, + args: unknown, +): Promise { + const dir = mkdtempSync(join(tmpdir(), "agenta-direct-relay-")); + try { + const id = "call-1"; + writeFileSync( + join(dir, `${id}.req.json`), + JSON.stringify({ toolName: spec.name, toolCallId: id, args }), + ); + const relay = startToolRelay(localRelayHost(), dir, [spec], callback, "auto"); + const resPath = join(dir, `${id}.res.json`); + const deadline = Date.now() + 5000; + while (Date.now() < deadline && !existsSync(resPath)) { + await new Promise((r) => setTimeout(r, 20)); + } + await relay.stop(); + assert.ok(existsSync(resPath), "the relay wrote a response file"); + return JSON.parse(readFileSync(resPath, "utf-8")) as RelayResponse; + } finally { + rmSync(dir, { recursive: true, force: true }); + } +} + +describe("startToolRelay direct branch (host makes the call for the sandbox)", () => { + it("makes the direct call host-side from a relayed name + args", async () => { + const calls = stubFetch("relayed-direct-result"); + const res = await relayOnce( + refSpec, + { endpoint: ENDPOINT, authorization: "ApiKey secret" }, + { city: "Berlin" }, + ); + + assert.equal(res.ok, true); + assert.equal(res.text, "relayed-direct-result"); + assert.equal(calls.length, 1); + assert.equal(calls[0].url, "https://agenta.example/api/workflows/invoke"); + // Redirects are not auto-followed: a 3xx to another host would defeat the origin lock. + assert.equal((calls[0].init as { redirect?: string }).redirect, "manual"); + assert.deepEqual(JSON.parse(calls[0].init.body as string), { + data: { inputs: { city: "Berlin" } }, + references: { workflow_revision: { id: "rev_abc123" } }, + }); + }); + + it("surfaces the SSRF-guard rejection as a relay error", async () => { + stubFetch("never"); + const badSpec: ResolvedToolSpec = { + name: "bad", + kind: "callback", + call: { method: "POST", path: "/secrets" }, + }; + const res = await relayOnce( + badSpec, + { endpoint: ENDPOINT, authorization: "ApiKey secret" }, + {}, + ); + assert.equal(res.ok, false); + assert.match(res.error ?? "", /is outside the Agenta API mount/); + }); + + it("returns a generic error on a non-2xx (no internal URL or upstream body leaks)", async () => { + // The upstream responds 500 with a detailed body; the model must see only the status code, + // never the resolved internal URL or the response body (those stay in the server log). + stubFetch("INTERNAL stack trace + secret detail", false, 500); + const res = await relayOnce( + refSpec, + { endpoint: ENDPOINT, authorization: "ApiKey secret" }, + { city: "Berlin" }, + ); + assert.equal(res.ok, false); + assert.equal(res.error, "direct tool call failed: HTTP 500"); + assert.doesNotMatch(res.error ?? "", /agenta\.example/); + assert.doesNotMatch(res.error ?? "", /stack trace|secret detail/); + }); +});