From 336afe6da661e160b5727bee1cb8dcb738d6211a Mon Sep 17 00:00:00 2001 From: Emmanuel Preiss Date: Tue, 6 Oct 2026 18:17:01 -0300 Subject: [PATCH] fix(sandbox): classify broker failures and bound transport --- .../execution/sandbox-shell-client.test.ts | 427 ++++++++++++++++ .../src/execution/sandbox-shell-client.ts | 261 ++++++++-- ...i-host.sandbox-shell-broker-faults.test.ts | 455 ++++++++++++++++++ .../message-list/tools/toolCallModel.test.ts | 56 +++ .../message-list/tools/toolCallModel.ts | 42 +- src/i18n/locales/de.json | 8 + src/i18n/locales/en.json | 8 + src/i18n/locales/es.json | 8 + src/protocol/host-transcript.test.ts | 83 ++++ src/protocol/host-transcript.ts | 4 + 10 files changed, 1319 insertions(+), 33 deletions(-) create mode 100644 packages/backend/src/execution/sandbox-shell-client.test.ts create mode 100644 packages/backend/src/host/opengui-host.sandbox-shell-broker-faults.test.ts diff --git a/packages/backend/src/execution/sandbox-shell-client.test.ts b/packages/backend/src/execution/sandbox-shell-client.test.ts new file mode 100644 index 00000000..3ca88018 --- /dev/null +++ b/packages/backend/src/execution/sandbox-shell-client.test.ts @@ -0,0 +1,427 @@ +import { createServer, type Server } from "node:http"; +import { mkdtemp, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, describe, expect, test } from "vite-plus/test"; +import type { ToolExecutionContext } from "@opengui/harness"; +import { + createSandboxShellExecutor, + parseBrokerEndpoint, + type SandboxBrokerFailure, +} from "./sandbox-shell-client.ts"; + +const servers: Server[] = []; +const socketDirectories: string[] = []; + +afterEach(async () => { + await Promise.all( + servers.splice(0).map((server) => new Promise((resolve) => server.close(resolve))), + ); + await Promise.all( + socketDirectories.splice(0).map((path) => rm(path, { recursive: true, force: true })), + ); +}); + +function context(signal?: AbortSignal): ToolExecutionContext { + return { + projectDirectory: "/workspace/site", + dataDirectory: "/tmp/opengui-test", + sessionId: "session", + toolCallId: "call", + shell: { executable: "/bin/sh", family: "posix" }, + signal: signal ?? new AbortController().signal, + executionPolicy: { + restricted: true, + revision: 4, + shellAllowed: true, + grants: [{ root: "/workspace/site", access: "write" }], + async authorizePath() { + return { allowed: false }; + }, + }, + }; +} + +const SECRET_TOKEN = "broker-secret-token-xyz"; +const SECRET_COMMAND = "echo customer-s3cret /tmp/customer-plans"; + +async function startServer( + handler: ( + request: import("node:http").IncomingMessage, + response: import("node:http").ServerResponse, + ) => void, +) { + const server = createServer(handler); + await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); + servers.push(server); + const address = server.address(); + const port = typeof address === "object" && address ? address.port : 0; + return `http://127.0.0.1:${port}/v1/execute`; +} + +function isFailure(value: unknown): value is SandboxBrokerFailure { + return ( + !!value && + typeof value === "object" && + (value as { status?: unknown }).status === "error" && + typeof (value as { reason?: unknown }).reason === "string" + ); +} + +describe("sandbox shell client fault classification", () => { + test("passes ordinary 200 payloads through untouched, including exit 125", async () => { + const endpoint = await startServer((_request, response) => { + response.writeHead(200, { "content-type": "application/json" }).end( + JSON.stringify({ + exitCode: 125, + output: "docker: image not found", + }), + ); + }); + const executor = createSandboxShellExecutor(endpoint); + const result = (await executor(context(), { command: "legit-command" })) as Record< + string, + unknown + >; + // No client-side classification: exit 125 is an ordinary command result. + expect(result).toEqual({ exitCode: 125, output: "docker: image not found" }); + expect(isFailure(result)).toBe(false); + }); + + test("unreachable broker returns broker_unreachable without leaking endpoint or command", async () => { + // Port 1 is never bound: connection refused. + const executor = createSandboxShellExecutor("http://127.0.0.1:1/v1/execute", SECRET_TOKEN); + const result = await executor(context(), { command: SECRET_COMMAND }); + expect(result).toEqual({ + status: "error", + reason: "broker_unreachable", + summary: expect.any(String), + }); + const serialized = JSON.stringify(result); + expect(serialized).not.toContain("127.0.0.1"); + expect(serialized).not.toContain(SECRET_TOKEN); + expect(serialized).not.toContain("customer-s3cret"); + expect(serialized).not.toContain("/tmp/customer-plans"); + }); + + test( + "hung broker returns broker_timeout once the socket outlives the requested timeout", + { timeout: 30_000 }, + async () => { + const endpoint = await startServer(() => { + // Never respond. + }); + const executor = createSandboxShellExecutor(endpoint); + const started = Date.now(); + const result = await executor(context(), { command: "sleep 60", timeout: 1 }); + expect(Date.now() - started).toBeLessThan(20_000); + expect(result).toMatchObject({ status: "error", reason: "broker_timeout" }); + }, + ); + + test("missing token against a locked broker returns broker_unauthorized", async () => { + const endpoint = await startServer((request, response) => { + if (request.headers.authorization !== `Bearer ${SECRET_TOKEN}`) { + response.writeHead(401, { "content-type": "application/json" }).end("{}"); + return; + } + response.writeHead(200, { "content-type": "application/json" }).end("{}"); + }); + const executor = createSandboxShellExecutor(endpoint); + const result = await executor(context(), { command: "pwd" }); + expect(result).toMatchObject({ status: "error", reason: "broker_unauthorized" }); + expect(JSON.stringify(result)).not.toContain(SECRET_TOKEN); + }); + + test("wrong token returns broker_unauthorized without echoing either token", async () => { + const endpoint = await startServer((request, response) => { + if (request.headers.authorization !== `Bearer ${SECRET_TOKEN}`) { + response.writeHead(401, { "content-type": "application/json" }).end("{}"); + return; + } + response.writeHead(200, { "content-type": "application/json" }).end("{}"); + }); + const executor = createSandboxShellExecutor(endpoint, "wrong-token"); + const result = await executor(context(), { command: "pwd" }); + expect(result).toMatchObject({ status: "error", reason: "broker_unauthorized" }); + const serialized = JSON.stringify(result); + expect(serialized).not.toContain(SECRET_TOKEN); + expect(serialized).not.toContain("wrong-token"); + }); + + test("broker rejection (400) returns broker_rejected", async () => { + const endpoint = await startServer((_request, response) => { + response + .writeHead(400, { "content-type": "application/json" }) + .end(JSON.stringify({ denied: true })); + }); + const executor = createSandboxShellExecutor(endpoint); + const result = await executor(context(), { command: "pwd" }); + expect(result).toMatchObject({ status: "error", reason: "broker_rejected" }); + }); + + test("broker outage (500) returns broker_unreachable, not a rejection", async () => { + const endpoint = await startServer((_request, response) => { + response.writeHead(500, { "content-type": "application/json" }).end("{}"); + }); + const executor = createSandboxShellExecutor(endpoint); + const result = await executor(context(), { command: "pwd" }); + expect(result).toMatchObject({ status: "error", reason: "broker_unreachable" }); + }); + + test("unreadable broker payload returns broker_unreachable", async () => { + const endpoint = await startServer((_request, response) => { + response.writeHead(200, { "content-type": "application/json" }).end("not-json{{{"); + }); + const executor = createSandboxShellExecutor(endpoint); + const result = await executor(context(), { command: "pwd" }); + expect(result).toMatchObject({ status: "error", reason: "broker_unreachable" }); + }); + + test("aborted context rejects so the Harness aborts the Run", async () => { + const endpoint = await startServer(() => { + // Never respond; the abort must win. + }); + const controller = new AbortController(); + const pending = createSandboxShellExecutor(endpoint)(context(controller.signal), { + command: "sleep 60", + }); + controller.abort(); + await expect(pending).rejects.toThrow(); + }); +}); + +describe("sandbox shell client stream and transport hardening", () => { + test.each([400, 401, 500])( + "closes an HTTP %s error response without draining an unbounded body", + async (status) => { + let closeResponse!: () => void; + const closed = new Promise((resolve) => { + closeResponse = () => resolve(true); + }); + const endpoint = await startServer((_request, response) => { + response.once("close", closeResponse); + response.writeHead(status).write("unfinished error body"); + // Bound fixture cleanup even when the client does not release its socket. + setTimeout(() => response.destroy(), 2_000).unref(); + }); + const result = await createSandboxShellExecutor(endpoint)(context(), { command: "pwd" }); + expect(isFailure(result)).toBe(true); + expect( + await Promise.race([ + closed, + new Promise((resolve) => setTimeout(() => resolve(false), 500)), + ]), + ).toBe(true); + }, + ); + test( + "partial JSON then destroyed connection resolves to a sanitized failure instead of hanging", + { timeout: 30_000 }, + async () => { + const endpoint = await startServer((_request, response) => { + response.writeHead(200, { "content-type": "application/json" }); + response.write('{"exitCode": 0, "outp'); + setImmediate(() => response.destroy()); + }); + const executor = createSandboxShellExecutor(endpoint); + const result = await executor(context(), { command: "pwd" }); + expect(result).toMatchObject({ status: "error", reason: "broker_unreachable" }); + expect((result as SandboxBrokerFailure).summary).toMatch(/check the project/i); + expect((result as SandboxBrokerFailure).summary).not.toMatch(/did not run/i); + }, + ); + + test( + "drip-feed response trips the wall-clock deadline, not the broker timeout", + { timeout: 30_000 }, + async () => { + const endpoint = await startServer((_request, response) => { + response.writeHead(200, { "content-type": "application/json" }); + const interval = setInterval(() => { + if (!response.writableEnded) response.write(" "); + }, 400); + response.on("close", () => clearInterval(interval)); + }); + const executor = createSandboxShellExecutor(endpoint); + const started = Date.now(); + const result = await executor(context(), { command: "slow", timeout: 1 }); + // Wall-clock deadline (1s requested + 5s grace) bounds a stream that + // never goes idle long enough to matter: well under the ~35s a + // drip-feed could otherwise consume, and far under broker maximums. + expect(Date.now() - started).toBeLessThan(20_000); + expect(result).toMatchObject({ status: "error", reason: "broker_timeout" }); + expect((result as SandboxBrokerFailure).summary).toMatch(/check the project/i); + }, + ); + + test( + "headers-only hang resolves to broker_timeout at the deadline", + { timeout: 30_000 }, + async () => { + const endpoint = await startServer((_request, response) => { + response.writeHead(200, { "content-type": "application/json" }); + // No body, no end: the deadline must settle this. + }); + const executor = createSandboxShellExecutor(endpoint); + const result = await executor(context(), { command: "slow", timeout: 1 }); + expect(result).toMatchObject({ status: "error", reason: "broker_timeout" }); + }, + ); + + test("uncertain faults never claim the command did not run", async () => { + const refused = createSandboxShellExecutor("http://127.0.0.1:1/v1/execute"); + const refusedResult = (await refused(context(), { command: "pwd" })) as SandboxBrokerFailure; + expect(refusedResult.reason).toBe("broker_unreachable"); + expect(refusedResult.summary).toMatch(/may have run/i); + expect(refusedResult.summary).toMatch(/check the project/i); + }); + + test("pre-execution faults keep the explicit did-not-run claim", async () => { + const locked = await startServer((request, response) => { + if (request.headers.authorization !== "Bearer token") { + response.writeHead(401, { "content-type": "application/json" }).end("{}"); + return; + } + response.writeHead(200, { "content-type": "application/json" }).end("{}"); + }); + const denied = await createSandboxShellExecutor(locked)(context(), { command: "pwd" }); + expect((denied as SandboxBrokerFailure).summary).toMatch(/did not run/i); + const rejectedEndpoint = await startServer((_request, response) => { + response.writeHead(400, { "content-type": "application/json" }).end("{}"); + }); + const rejected = await createSandboxShellExecutor(rejectedEndpoint)(context(), { + command: "pwd", + }); + expect((rejected as SandboxBrokerFailure).summary).toMatch(/did not run/i); + }); + + test("oversized responses are uncertainty, not pre-execution rejections", async () => { + const endpoint = await startServer((_request, response) => { + response.writeHead(200, { "content-type": "application/json" }); + response.write(`{"exitCode":0,"output":"${"x".repeat(2 * 1024 * 1024)}"}`); + response.end(); + }); + const executor = createSandboxShellExecutor(endpoint); + const result = (await executor(context(), { command: "huge" })) as SandboxBrokerFailure; + expect(result.reason).toBe("broker_unreachable"); + expect(result.summary).toMatch(/check the project/i); + expect(result.summary).not.toMatch(/did not run/i); + }); + + test("https endpoint against a plaintext server fails closed and sanitized", async () => { + const plain = await startServer((_request, response) => { + response.writeHead(200, { "content-type": "application/json" }).end("{}"); + }); + const httpsEndpoint = plain.replace("http://", "https://"); + const executor = createSandboxShellExecutor(httpsEndpoint, SECRET_TOKEN); + const result = (await executor(context(), { command: "pwd" })) as SandboxBrokerFailure; + // TLS handshake against plaintext fails: must not fall back, hang, or leak. + expect(result.reason).toBe("broker_unreachable"); + const serialized = JSON.stringify(result); + expect(serialized).not.toContain("127.0.0.1"); + expect(serialized).not.toContain(SECRET_TOKEN); + expect(serialized).not.toContain(plain); + }); + + test("endpoint userinfo is refused before dispatch with zero server hits", async () => { + let hits = 0; + const endpoint = await startServer((_request, response) => { + hits += 1; + response.writeHead(200, { "content-type": "application/json" }).end("{}"); + }); + const userinfoEndpoint = endpoint.replace("http://", "http://user:s3cret@"); + const result = (await createSandboxShellExecutor(userinfoEndpoint, SECRET_TOKEN)(context(), { + command: "pwd", + })) as SandboxBrokerFailure; + expect(result).toMatchObject({ status: "error", reason: "broker_rejected" }); + expect(result.summary).toMatch(/did not run/i); + expect(hits).toBe(0); + const serialized = JSON.stringify(result); + expect(serialized).not.toContain("user"); + expect(serialized).not.toContain("s3cret"); + expect(serialized).not.toContain(SECRET_TOKEN); + }); + + test("unexpected protocol never touches the network", async () => { + const result = (await createSandboxShellExecutor("ftp://127.0.0.1:21/v1/execute")(context(), { + command: "pwd", + })) as SandboxBrokerFailure; + expect(result).toMatchObject({ status: "error", reason: "broker_rejected" }); + expect(result.summary).toMatch(/did not run/i); + }); + + test("malformed endpoint becomes a sanitized rejection, not a throw", async () => { + const result = (await createSandboxShellExecutor("notaurl jarvis")(context(), { + command: "pwd", + })) as SandboxBrokerFailure; + expect(result).toMatchObject({ status: "error", reason: "broker_rejected" }); + expect(JSON.stringify(result)).not.toContain("notaurl"); + }); + + test("unix socket endpoints keep working end to end", async () => { + const directory = await mkdtemp(join(tmpdir(), "opengui-sock-")); + socketDirectories.push(directory); + const socketPath = join(directory, "broker.sock"); + const server = createServer((_request, response) => { + response + .writeHead(200, { "content-type": "application/json" }) + .end(JSON.stringify({ exitCode: 0, output: "socket-ok" })); + }); + await new Promise((resolve) => server.listen(socketPath, resolve)); + servers.push(server); + const result = await createSandboxShellExecutor(socketPath)(context(), { command: "pwd" }); + expect(result).toEqual({ exitCode: 0, output: "socket-ok" }); + }); +}); + +describe("parseBrokerEndpoint", () => { + test("maps http/https defaults and explicit ports, preserving paths", () => { + expect(parseBrokerEndpoint("http://broker:8080/v1/execute")).toEqual({ + kind: "tcp", + secure: false, + hostname: "broker", + port: 8080, + path: "/v1/execute", + }); + expect(parseBrokerEndpoint("http://broker/v1/execute")).toMatchObject({ + secure: false, + port: 80, + }); + expect(parseBrokerEndpoint("https://broker/v1/execute")).toMatchObject({ + secure: true, + port: 443, + }); + expect(parseBrokerEndpoint("https://broker:8443/v1/execute?x=1")).toMatchObject({ + secure: true, + port: 8443, + path: "/v1/execute?x=1", + }); + expect(parseBrokerEndpoint("/run/broker.sock")).toEqual({ + kind: "socket", + socketPath: "/run/broker.sock", + path: "/v1/execute", + }); + }); + + test("passes IPv6 literals without URL brackets to the Node transport", () => { + expect(parseBrokerEndpoint("https://[::1]:8443/v1/execute")).toMatchObject({ + hostname: "::1", + port: 8443, + secure: true, + }); + }); + + test("rejects userinfo, unknown protocols, and unparsable values", () => { + for (const endpoint of [ + "http://user:pass@broker/v1/execute", + "http://user@broker/v1/execute", + "ftp://broker/v1/execute", + "gopher://broker/v1/execute", + "notaurl", + "http://", + ]) { + expect(() => parseBrokerEndpoint(endpoint)).toThrow(); + } + }); +}); diff --git a/packages/backend/src/execution/sandbox-shell-client.ts b/packages/backend/src/execution/sandbox-shell-client.ts index 1e932705..8fccb87a 100644 --- a/packages/backend/src/execution/sandbox-shell-client.ts +++ b/packages/backend/src/execution/sandbox-shell-client.ts @@ -1,46 +1,252 @@ -import { request } from "node:http"; +import { request as httpRequest, type ClientRequestArgs } from "node:http"; +import { request as httpsRequest } from "node:https"; import type { ShellToolExecutor } from "@opengui/harness"; const MAX_RESPONSE_BYTES = 1024 * 1024; +const DEFAULT_BROKER_TIMEOUT_SECONDS = 30; +const MAX_BROKER_TIMEOUT_SECONDS = 5_000; +/** Grace above the broker-enforced command timeout so a healthy broker always + * answers first; only a hung broker trips the client deadline. */ +const BROKER_TIMEOUT_GRACE_SECONDS = 5; -function post(endpoint: string, token: string | undefined, body: string, signal: AbortSignal) { +/** + * Stable machine reasons for sandbox broker transport/auth failures. These + * are recorded as ordinary tool failure outputs (never thrown, except + * cancellation), so the Harness stores a `tool_result` instead of failing + * the Run, and the model sees a bounded summary. Summaries are static + * strings: they never carry the broker endpoint, tokens, commands, or + * customer paths. + * + * Only authenticated (401/403) and rejected (4xx) pre-execution responses + * may claim the command did not run. Every other fault happens after a + * possible dispatch, so its summary states the uncertainty and tells the + * reader to check the project before retrying. + */ +export type SandboxBrokerFailureReason = + | "broker_unreachable" + | "broker_timeout" + | "broker_unauthorized" + | "broker_rejected"; + +export interface SandboxBrokerFailure { + status: "error"; + reason: SandboxBrokerFailureReason; + summary: string; +} + +const SUMMARY_UNAUTHORIZED = + "Sandbox shell broker refused authentication. The command did not run."; +const SUMMARY_REJECTED = "Sandbox shell broker rejected the request. The command did not run."; +const SUMMARY_NOT_DISPATCHED = + "Sandbox shell broker request was rejected before dispatch. The command did not run."; +const SUMMARY_UNREACHABLE = + "Sandbox shell broker is unreachable. The command may have run: check the project before retrying."; +const SUMMARY_TIMEOUT = + "Sandbox shell broker timed out. The command may have completed: check the project before retrying."; + +function failure(reason: SandboxBrokerFailureReason, summary: string): SandboxBrokerFailure { + return { status: "error", reason, summary }; +} + +/** Internal: endpoint configuration faults never reach the network. */ +class BrokerConfigError extends Error { + constructor() { + super("Sandbox shell broker endpoint is misconfigured"); + } +} + +export type BrokerTransport = + | { kind: "socket"; socketPath: string; path: string } + | { kind: "tcp"; secure: boolean; hostname: string; port: number; path: string }; + +/** + * Maps an endpoint to its transport. Loopback paths stay Unix sockets; + * `http:`/`https:` select the matching TLS behavior and default port. + * Anything else (unknown protocols, embedded userinfo, unparsable values) + * throws BrokerConfigError so credentials are never sent over the wrong + * transport and no endpoint text leaks into tool outputs. + */ +export function parseBrokerEndpoint(endpoint: string): BrokerTransport { + if (endpoint.startsWith("/")) { + return { kind: "socket", socketPath: endpoint, path: "/v1/execute" }; + } + let remote: URL; + try { + remote = new URL(endpoint); + } catch { + throw new BrokerConfigError(); + } + if (remote.protocol !== "http:" && remote.protocol !== "https:") { + throw new BrokerConfigError(); + } + if (remote.username || remote.password) { + throw new BrokerConfigError(); + } + const secure = remote.protocol === "https:"; + const port = remote.port ? Number(remote.port) : secure ? 443 : 80; + if (!remote.hostname || !Number.isFinite(port) || port <= 0) { + throw new BrokerConfigError(); + } + return { + kind: "tcp", + secure, + // URL.hostname retains brackets around IPv6 literals; Node's hostname + // option expects the address itself, without URL delimiters. + hostname: remote.hostname.startsWith("[") ? remote.hostname.slice(1, -1) : remote.hostname, + port, + path: `${remote.pathname}${remote.search}`, + }; +} + +/** Mirrors the broker's timeout formula so the deadline outlives a healthy call. */ +function effectiveTimeoutSeconds(input: unknown): number { + const timeout = (input as { timeout?: unknown } | null)?.timeout; + if (typeof timeout !== "number" || !(timeout > 0)) return DEFAULT_BROKER_TIMEOUT_SECONDS; + return Math.min(MAX_BROKER_TIMEOUT_SECONDS, timeout); +} + +function post( + endpoint: string, + token: string | undefined, + input: unknown, + body: string, + signal: AbortSignal, +): Promise { + let transport: BrokerTransport; + try { + transport = parseBrokerEndpoint(endpoint); + } catch { + return Promise.resolve(failure("broker_rejected", SUMMARY_NOT_DISPATCHED)); + } return new Promise((resolve, reject) => { - const remote = endpoint.startsWith("/") ? null : new URL(endpoint); - const req = request( - { - ...(remote - ? { hostname: remote.hostname, port: remote.port, path: remote.pathname } - : { socketPath: endpoint, path: "/v1/execute" }), + const deadlineMs = (effectiveTimeoutSeconds(input) + BROKER_TIMEOUT_GRACE_SECONDS) * 1000; + let req: ReturnType; + let settled = false; + let timedOut = false; + // Wall-clock deadline: socket inactivity timeouts alone let a drip-feed + // hang past the broker timeout. Cleared on every settlement path. + let timer: ReturnType | undefined; + const resolveOnce = (value: unknown) => { + if (settled) return; + settled = true; + if (timer !== undefined) clearTimeout(timer); + resolve(value); + }; + const rejectOnce = (error: unknown) => { + if (settled) return; + settled = true; + if (timer !== undefined) clearTimeout(timer); + reject(error); + }; + try { + const headers: Record = { + "content-type": "application/json", + "content-length": Buffer.byteLength(body), + ...(token ? { authorization: `Bearer ${token}` } : {}), + }; + const options: ClientRequestArgs = { method: "POST", - headers: { - "content-type": "application/json", - "content-length": Buffer.byteLength(body), - ...(token ? { authorization: `Bearer ${token}` } : {}), - }, + headers, signal, - }, - (response) => { + ...(transport.kind === "socket" + ? { socketPath: transport.socketPath, path: transport.path } + : { + hostname: transport.hostname, + port: transport.port, + path: transport.path, + }), + }; + const impl = transport.kind !== "socket" && transport.secure ? httpsRequest : httpRequest; + req = impl(options, (response) => { + // Register stream handlers before every status/settlement branch. + response.on("error", () => { + if (signal.aborted || settled) return; + resolveOnce( + timedOut + ? failure("broker_timeout", SUMMARY_TIMEOUT) + : failure("broker_unreachable", SUMMARY_UNREACHABLE), + ); + }); + response.on("close", () => { + if (!settled && !response.complete && !signal.aborted) { + resolveOnce( + timedOut + ? failure("broker_timeout", SUMMARY_TIMEOUT) + : failure("broker_unreachable", SUMMARY_UNREACHABLE), + ); + } + }); + if (settled) { + response.destroy(); + return; + } + const status = response.statusCode ?? 500; + if (status >= 400) { + resolveOnce( + status === 401 || status === 403 + ? failure("broker_unauthorized", SUMMARY_UNAUTHORIZED) + : status < 500 + ? failure("broker_rejected", SUMMARY_REJECTED) + : failure("broker_unreachable", SUMMARY_UNREACHABLE), + ); + // The error body is deliberately unused. Do not drain an unbounded + // stream after settling and clearing its wall-clock deadline. + response.destroy(); + return; + } const chunks: Buffer[] = []; let bytes = 0; + let oversized = false; response.on("data", (chunk: Buffer) => { + if (settled || oversized) return; bytes += chunk.length; - if (bytes > MAX_RESPONSE_BYTES) req.destroy(new Error("Sandbox response is too large")); - else chunks.push(chunk); + if (bytes > MAX_RESPONSE_BYTES) { + oversized = true; + response.destroy(); + // The response is unusable and dispatch already happened, so + // this is uncertainty, not a pre-execution rejection. + resolveOnce(failure("broker_unreachable", SUMMARY_UNREACHABLE)); + } else chunks.push(chunk); }); response.on("end", () => { + if (settled) return; try { - const value = JSON.parse(Buffer.concat(chunks).toString("utf8")) as unknown; - if ((response.statusCode ?? 500) >= 400) { - reject(new Error("Sandbox shell broker rejected execution")); - } else resolve(value); - } catch (error) { - reject(error); + // Ordinary 200 payloads (including nonzero command exits such as + // docker exit 125) pass through untouched: only the broker may + // classify execution faults, never this client. + resolveOnce(JSON.parse(Buffer.concat(chunks).toString("utf8")) as unknown); + } catch { + resolveOnce(failure("broker_unreachable", SUMMARY_UNREACHABLE)); } }); - }, - ); - req.on("error", reject); - req.end(body); + }); + } catch { + resolveOnce(failure("broker_rejected", SUMMARY_NOT_DISPATCHED)); + return; + } + timer = setTimeout(() => { + timedOut = true; + req.destroy(); + }, deadlineMs); + req.on("error", (error) => { + // Cancellation keeps existing semantics: reject so the Harness aborts + // the Run instead of recording a failure output. + if (signal.aborted) { + rejectOnce(error); + return; + } + if (timedOut) { + resolveOnce(failure("broker_timeout", SUMMARY_TIMEOUT)); + return; + } + resolveOnce(failure("broker_unreachable", SUMMARY_UNREACHABLE)); + }); + try { + req.end(body); + } catch { + resolveOnce(failure("broker_rejected", SUMMARY_NOT_DISPATCHED)); + req.destroy(); + } }); } @@ -49,6 +255,7 @@ export function createSandboxShellExecutor(endpoint: string, token?: string): Sh return await post( endpoint, token, + input, JSON.stringify({ projectDirectory: context.projectDirectory, grants: context.executionPolicy.grants ?? [], diff --git a/packages/backend/src/host/opengui-host.sandbox-shell-broker-faults.test.ts b/packages/backend/src/host/opengui-host.sandbox-shell-broker-faults.test.ts new file mode 100644 index 00000000..8eb3a55d --- /dev/null +++ b/packages/backend/src/host/opengui-host.sandbox-shell-broker-faults.test.ts @@ -0,0 +1,455 @@ +/** + * Regression tests for #153: authenticated restricted Account -> Host -> + * Harness -> sandbox executor, built through createBackendHost with real + * identity, path grants, and a fixture model. A fake broker HTTP server + * stands in for Docker (no Docker, no production, no real models). + * + * Broker transport/auth faults are recorded as ordinary tool failure outputs + * with stable machine reasons; ordinary command exits (including exit 125) + * pass through untouched; revocation still fails closed before any effect. + */ +import { createServer, type IncomingMessage, type Server, type ServerResponse } from "node:http"; +import { mkdtemp, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { DatabaseSync } from "node:sqlite"; +import { afterEach, describe, expect, test } from "vite-plus/test"; +import type { ModelTransport } from "@opengui/harness"; +import { createBackendHost, type BackendHost } from "../create-backend-host.ts"; +import { readBackendHostEnv } from "../host/env.ts"; +import { OpenGuiHost } from "./opengui-host.ts"; + +const backends: BackendHost[] = []; +const temporaryDirectories: string[] = []; +const servers: Server[] = []; +const savedEndpoint = process.env.OPENGUI_SHELL_SANDBOX_ENDPOINT; +const savedToken = process.env.OPENGUI_SHELL_SANDBOX_TOKEN; + +afterEach(async () => { + for (const backend of backends.splice(0)) { + await (await backend.hostReady).close(); + backend.identity?.database.close(); + } + await Promise.all( + servers.splice(0).map((server) => new Promise((resolve) => server.close(resolve))), + ); + await Promise.all(temporaryDirectories.splice(0).map((path) => rm(path, { recursive: true }))); + if (savedEndpoint === undefined) delete process.env.OPENGUI_SHELL_SANDBOX_ENDPOINT; + else process.env.OPENGUI_SHELL_SANDBOX_ENDPOINT = savedEndpoint; + if (savedToken === undefined) delete process.env.OPENGUI_SHELL_SANDBOX_TOKEN; + else process.env.OPENGUI_SHELL_SANDBOX_TOKEN = savedToken; +}); + +type BrokerMode = + | { kind: "ok" } + | { kind: "exit125" } + | { kind: "unauthorized"; token: string } + | { kind: "always400" } + | { kind: "hang" }; + +interface BrokerTap { + endpoint: string; + hits: () => number; + lastBody: () => Record | null; +} + +async function startFakeBroker(mode: BrokerMode): Promise { + let hits = 0; + let lastBody: Record | null = null; + const respond = (response: ServerResponse, status: number, value: unknown) => { + response.writeHead(status, { "content-type": "application/json" }).end(JSON.stringify(value)); + }; + const handler = (request: IncomingMessage, response: ServerResponse) => { + if (request.method !== "POST" || request.url !== "/v1/execute") { + response.writeHead(404).end(); + return; + } + void (async () => { + const chunks: Buffer[] = []; + for await (const chunk of request) chunks.push(chunk as Buffer); + hits += 1; + try { + lastBody = JSON.parse(Buffer.concat(chunks).toString("utf8")) as Record; + } catch { + lastBody = null; + } + if (mode.kind === "hang") return; // Never respond. + if (mode.kind === "unauthorized") { + if (request.headers.authorization !== `Bearer ${mode.token}`) { + respond(response, 401, { denied: true }); + return; + } + respond(response, 200, { exitCode: 0, output: "sandboxed-ok" }); + return; + } + if (mode.kind === "always400") { + respond(response, 400, { denied: true, error: "Sandbox shell request was rejected" }); + return; + } + if (mode.kind === "exit125") { + respond(response, 200, { + exitCode: 125, + output: "docker: Error response from daemon: image not found.", + }); + return; + } + respond(response, 200, { exitCode: 0, output: "sandboxed-ok" }); + })(); + }; + const server = createServer(handler); + await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); + servers.push(server); + const address = server.address(); + const port = typeof address === "object" && address ? address.port : 0; + return { + endpoint: `http://127.0.0.1:${port}/v1/execute`, + hits: () => hits, + lastBody: () => lastBody, + }; +} + +/** Fixture model: issues one shell tool call per prompt budget, then answers. */ +function countingModel(budget: { calls: number }, toolInput: Record) { + let emitted = 0; + const model: ModelTransport = { + async *stream(request) { + const results = request.context.filter( + (item) => (item as { type?: string }).type === "tool_result", + ).length; + if (emitted === results && emitted < budget.calls) { + emitted += 1; + yield { + type: "tool_call", + id: `shell-${emitted}`, + name: "shell", + input: toolInput, + }; + } else { + yield { type: "text_delta", delta: "Finished" }; + } + yield { type: "completed" }; + }, + }; + return model; +} + +interface MemberBackend { + backend: BackendHost; + host: OpenGuiHost; + member: { type: "user"; id: string; displayName: string }; + ownerToken: string; + projectDirectory: string; + budget: { calls: number }; +} + +async function setupMemberBackend(options: { + broker: BrokerTap | null; + token?: string; + toolInput?: Record; + grantAccess?: "write" | "none"; +}): Promise { + const root = await mkdtemp(join(tmpdir(), "opengui-broker-faults-")); + temporaryDirectories.push(root); + const projectDirectory = join(root, "project"); + const { mkdir } = await import("node:fs/promises"); + await mkdir(projectDirectory, { recursive: true }); + + if (options.broker) process.env.OPENGUI_SHELL_SANDBOX_ENDPOINT = options.broker.endpoint; + else delete process.env.OPENGUI_SHELL_SANDBOX_ENDPOINT; + if (options.token === undefined) delete process.env.OPENGUI_SHELL_SANDBOX_TOKEN; + else process.env.OPENGUI_SHELL_SANDBOX_TOKEN = options.token; + + const budget = { calls: 0 }; + const backend = createBackendHost({ + dataDirectory: join(root, "host-data"), + env: { + ...readBackendHostEnv(), + identityMode: "remote", + pathGrantsMode: "enforced", + allowedRoots: [root], + authToken: "", + servesFrontend: false, + }, + identityDatabase: new DatabaseSync(":memory:"), + identitySecret: "broker-faults-test-secret-at-least-32-characters", + identityBaseURL: "http://localhost", + model: countingModel(budget, options.toolInput ?? { command: "pwd" }), + }); + backends.push(backend); + await backend.ready; + + const request = (path: string, token: string, method = "GET", body?: unknown) => + backend.app.request(path, { + method, + headers: { authorization: `Bearer ${token}`, "content-type": "application/json" }, + ...(body === undefined ? {} : { body: JSON.stringify(body) }), + }); + const setup = await request("/api/identity/setup", "", "POST", { + username: "owner", + email: "owner@example.com", + password: "a sufficiently long owner password", + }); + expect(setup.status).toBe(201); + const ownerToken = ((await setup.json()) as { value: { token: string } }).value.token; + await request("/api/identity/host-policy", ownerToken, "PUT", { registrationMode: "open" }); + const register = await request("/api/identity/register", "", "POST", { + username: "member", + email: "member@example.com", + password: "a sufficiently long member password", + }); + expect(register.status).toBe(201); + const memberId = ((await register.json()) as { value: { actor: { id: string } } }).value.actor.id; + if (options.grantAccess !== "none") { + const grant = await request( + `/api/identity/members/${memberId}/path-grants`, + ownerToken, + "PUT", + { + grants: [{ root: projectDirectory, access: "write" }], + }, + ); + expect(grant.status).toBe(200); + } + const host = await backend.hostReady; + return { + backend, + host, + member: { type: "user", id: memberId, displayName: "member" }, + ownerToken, + projectDirectory, + budget, + }; +} + +async function promptShell( + fixture: MemberBackend, + text: string, +): Promise<{ sessionId: string; entries: unknown[]; dump: string }> { + const { host, member, projectDirectory, budget } = fixture; + const session = await host.createSession( + { + projectDirectory, + model: { connectionId: "fake", modelId: "fake" }, + reasoning: "none", + }, + member, + ); + budget.calls += 1; + await host.prompt(session.id, { text, actor: member }); + await host.waitForIdle(session.id, member); + const snapshot = await host.readSession(session.id, member); + return { sessionId: session.id, entries: snapshot.entries, dump: JSON.stringify(snapshot) }; +} + +function toolResultOutputs(entries: unknown[]) { + return (entries as Array<{ kind?: string; payload?: { output?: unknown } }>) + .filter((entry) => entry.kind === "tool_result") + .map((entry) => entry.payload?.output); +} + +describe("authenticated restricted shell broker faults (#153)", () => { + test("healthy broker executes through the restricted Account path and forwards grants", async () => { + const broker = await startFakeBroker({ kind: "ok" }); + const fixture = await setupMemberBackend({ broker }); + const { dump } = await promptShell(fixture, "Run pwd"); + expect(dump).toContain("sandboxed-ok"); + expect(broker.hits()).toBe(1); + expect((broker.lastBody() as { grants?: unknown[] } | null)?.grants).toHaveLength(1); + }); + + test("wrong token records broker_unauthorized and the Run still completes", async () => { + const broker = await startFakeBroker({ kind: "unauthorized", token: "correct-token" }); + const fixture = await setupMemberBackend({ broker, token: "wrong-token" }); + const { entries, dump } = await promptShell(fixture, "Run pwd"); + expect(toolResultOutputs(entries)).toEqual([ + expect.objectContaining({ status: "error", reason: "broker_unauthorized" }), + ]); + expect(entries.map((entry) => (entry as { kind: string }).kind)).not.toContain("run_failed"); + expect(dump).not.toContain("sandboxed-ok"); + }); + + test("missing token against a locked broker records broker_unauthorized", async () => { + const broker = await startFakeBroker({ kind: "unauthorized", token: "correct-token" }); + const fixture = await setupMemberBackend({ broker }); + const { entries } = await promptShell(fixture, "Run pwd"); + expect(toolResultOutputs(entries)).toEqual([ + expect.objectContaining({ status: "error", reason: "broker_unauthorized" }), + ]); + }); + + test("broker outage records broker_unreachable without provider misclassification", async () => { + const broker = await startFakeBroker({ kind: "ok" }); + await Promise.all( + servers.splice(0).map((server) => new Promise((resolve) => server.close(resolve))), + ); + const fixture = await setupMemberBackend({ broker }); + const { entries, dump } = await promptShell(fixture, "Run pwd"); + expect(toolResultOutputs(entries)).toEqual([ + expect.objectContaining({ status: "error", reason: "broker_unreachable" }), + ]); + expect(entries.map((entry) => (entry as { kind: string }).kind)).not.toContain("run_failed"); + expect(dump).not.toContain("sandboxed-ok"); + }); + + test("hung broker records broker_timeout", { timeout: 30_000 }, async () => { + const broker = await startFakeBroker({ kind: "hang" }); + const fixture = await setupMemberBackend({ + broker, + toolInput: { command: "sleep 60", timeout: 1 }, + }); + const { entries } = await promptShell(fixture, "Run a slow command"); + expect(toolResultOutputs(entries)).toEqual([ + expect.objectContaining({ status: "error", reason: "broker_timeout" }), + ]); + }); + + test("broker rejection records broker_rejected", async () => { + const broker = await startFakeBroker({ kind: "always400" }); + const fixture = await setupMemberBackend({ broker }); + const { entries } = await promptShell(fixture, "Run pwd"); + expect(toolResultOutputs(entries)).toEqual([ + expect.objectContaining({ status: "error", reason: "broker_rejected" }), + ]); + }); + + test("failed sandbox requests never fall back to native shell", async () => { + const broker = await startFakeBroker({ kind: "ok" }); + await Promise.all( + servers.splice(0).map((server) => new Promise((resolve) => server.close(resolve))), + ); + const fixture = await setupMemberBackend({ + broker, + toolInput: { command: "echo NATIVE_SHELL_MARKER_9f31" }, + }); + const { entries } = await promptShell(fixture, "Run the command"); + expect(toolResultOutputs(entries)).toEqual([ + expect.objectContaining({ status: "error", reason: "broker_unreachable" }), + ]); + // The command text is durable in the tool_call input (pre-existing + // behavior), but no execution output exists: the tool_result carries + // only the classified failure, proving native shell never ran. + expect(JSON.stringify(toolResultOutputs(entries))).not.toContain("NATIVE_SHELL_MARKER_9f31"); + }); + + test("legitimate command exit 125 passes through with no failure reason", async () => { + const broker = await startFakeBroker({ kind: "exit125" }); + const fixture = await setupMemberBackend({ broker }); + const { entries, dump } = await promptShell(fixture, "Run pwd"); + expect(dump).toContain("125"); + const outputs = toolResultOutputs(entries) as Array>; + expect(outputs).toHaveLength(1); + expect(outputs[0]?.exitCode).toBe(125); + expect(outputs[0]).not.toHaveProperty("reason"); + }); + + test("failure outputs carry no tokens, endpoints, or customer paths", async () => { + const secretToken = "super-secret-broker-token-42"; + const broker = await startFakeBroker({ kind: "unauthorized", token: "correct-token" }); + const fixture = await setupMemberBackend({ + broker, + token: secretToken, + toolInput: { command: "cat /tmp/customer-plans/secret.txt" }, + }); + const { dump } = await promptShell(fixture, "Read the file"); + expect(dump).not.toContain(secretToken); + expect(dump).not.toContain(broker.endpoint); + }); + + test("member without grants fails closed at Session creation", async () => { + const broker = await startFakeBroker({ kind: "ok" }); + const fixture = await setupMemberBackend({ broker, grantAccess: "none" }); + await expect( + fixture.host.createSession( + { + projectDirectory: fixture.projectDirectory, + model: { connectionId: "fake", modelId: "fake" }, + reasoning: "none", + }, + fixture.member, + ), + ).rejects.toThrow(/not authorized/i); + expect(broker.hits()).toBe(0); + }); + + test("revoked grants fail the next prompt closed with no further broker call", async () => { + const broker = await startFakeBroker({ kind: "ok" }); + const fixture = await setupMemberBackend({ broker }); + const { host, member, projectDirectory, budget } = fixture; + const session = await host.createSession( + { + projectDirectory, + model: { connectionId: "fake", modelId: "fake" }, + reasoning: "none", + }, + member, + ); + budget.calls += 1; + await host.prompt(session.id, { text: "Run pwd", actor: member }); + await host.waitForIdle(session.id, member); + expect(broker.hits()).toBe(1); + // Revoke every grant through the real owner route, then re-prompt. + const revoked = await fixture.backend.app.request( + `/api/identity/members/${member.id}/path-grants`, + { + method: "PUT", + headers: { + authorization: `Bearer ${fixture.ownerToken}`, + "content-type": "application/json", + }, + body: JSON.stringify({ grants: [] }), + }, + ); + expect(revoked.status).toBe(200); + budget.calls += 1; + await expect(host.prompt(session.id, { text: "Run pwd again", actor: member })).rejects.toThrow( + /Session not found/, + ); + expect(broker.hits()).toBe(1); + }); + + test("missing executor still reports sandbox_not_configured (stale wiring contract)", async () => { + // Unreachable through createBackendHost (endpoint and executor move + // together there); documents the execute-tool contract directly: a + // restricted policy that allows shell with no executor wired fails + // closed with sandbox_not_configured instead of touching native shell. + const root = await mkdtemp(join(tmpdir(), "opengui-no-executor-")); + temporaryDirectories.push(root); + const { mkdir } = await import("node:fs/promises"); + await mkdir(join(root, "project"), { recursive: true }); + const dataDirectory = await mkdtemp(join(tmpdir(), "opengui-no-executor-data-")); + temporaryDirectories.push(dataDirectory); + const budget = { calls: 0 }; + const host = new OpenGuiHost(dataDirectory, { + model: countingModel(budget, { command: "pwd" }), + resolveExecutionPolicy: async () => ({ + restricted: true, + revision: 7, + shellAllowed: true, + grants: [{ root: join(root, "project"), access: "write" }], + async authorizePath(target: string) { + return { allowed: true, canonicalPath: target }; + }, + }), + shellExecutor: undefined, + }); + await host.start(); + try { + const session = await host.createSession({ + projectDirectory: join(root, "project"), + model: { connectionId: "fake", modelId: "fake" }, + reasoning: "none", + }); + budget.calls += 1; + await host.prompt(session.id, { + text: "Run pwd", + actor: { type: "user", id: "member", displayName: "Member" }, + }); + await host.waitForIdle(session.id); + expect(JSON.stringify(await host.readSession(session.id))).toContain( + "sandbox_not_configured", + ); + } finally { + await host.close(); + } + }); +}); diff --git a/src/components/message-list/tools/toolCallModel.test.ts b/src/components/message-list/tools/toolCallModel.test.ts index afa7cc70..22b0c25c 100644 --- a/src/components/message-list/tools/toolCallModel.test.ts +++ b/src/components/message-list/tools/toolCallModel.test.ts @@ -217,4 +217,60 @@ describe("getToolCallViewModel", () => { ]); expect(vm.rawOutput).toBe(null); }); + + test("maps classified sandbox shell failures to localized labels", () => { + const t = ((key: string) => `t:${key}`) as unknown as Parameters< + typeof getToolCallViewModel + >[2]; + const vm = getToolCallViewModel( + toolPart({ + tool: "shell", + state: { + status: "error", + error: "Sandbox shell broker is unreachable. The command may have run.", + metadata: { reason: "broker_unreachable" }, + }, + }), + null, + t, + ); + + expect(vm.status).toBe("error"); + expect(vm.resultLabel).toBe("t:sandboxShell.broker_unreachable"); + }); + + test("ignores unknown shell reasons instead of using them as translation keys", () => { + const seen: string[] = []; + const t = ((key: string) => { + seen.push(key); + return key; + }) as unknown as Parameters[2]; + const vm = getToolCallViewModel( + toolPart({ + tool: "shell", + state: { + status: "error", + error: "Something novel broke", + metadata: { reason: "future_reason_from_newer_backend" }, + }, + }), + null, + t, + ); + + expect(vm.status).toBe("error"); + expect(vm.resultLabel).toBe(null); + expect(seen.filter((key) => key.startsWith("sandboxShell."))).toEqual([]); + }); + + test("keeps exit codes for ordinary shell failures without a reason", () => { + const vm = getToolCallViewModel( + toolPart({ + tool: "shell", + state: { status: "completed", output: "nope", metadata: { exitCode: 125 } }, + }), + ); + + expect(vm.resultLabel).toBe("Exit 125"); + }); }); diff --git a/src/components/message-list/tools/toolCallModel.ts b/src/components/message-list/tools/toolCallModel.ts index 23278dd8..5a570bd3 100644 --- a/src/components/message-list/tools/toolCallModel.ts +++ b/src/components/message-list/tools/toolCallModel.ts @@ -49,6 +49,35 @@ export interface ToolCallViewModel { expandable: boolean; } +/** + * Classified shell failure reasons recorded by the Host (policy denials + * and sandbox broker faults). The transcript projection carries `reason` + * into shell metadata; known reasons render a localized message instead of + * raw backend text. Unknown reasons fall through to existing behavior so a + * backend string can never become a translation key. + */ +export const SANDBOX_SHELL_FAILURE_REASONS = new Set([ + "broker_unreachable", + "broker_timeout", + "broker_unauthorized", + "broker_rejected", + "shell_not_allowed", + "sandbox_not_configured", +]); + +function sandboxFailureLabel( + kind: ToolCallKind, + status: ToolCallStatus, + state: ToolCallState, + t?: TFunction, +): string | null { + if (kind !== "bash" || status !== "error" || !("metadata" in state)) return null; + const metadata = state.metadata; + if (!isRecord(metadata) || typeof metadata.reason !== "string") return null; + if (!SANDBOX_SHELL_FAILURE_REASONS.has(metadata.reason)) return null; + return t?.(`sandboxShell.${metadata.reason}`) ?? metadata.reason; +} + const KNOWN_TOOLS: Record = { read: "read", bash: "bash", @@ -226,12 +255,13 @@ export function getToolCallViewModel( const images = extractImageAttachments(state, serverUrl); const resultLabel = kind === "bash" && status !== "running" && "metadata" in state && isRecord(state.metadata) - ? typeof state.metadata.exitCode === "number" - ? (t?.("toolLabels.bash.exitCode", { code: state.metadata.exitCode }) ?? - `Exit ${state.metadata.exitCode}`) - : typeof state.metadata.signal === "string" - ? state.metadata.signal - : null + ? (sandboxFailureLabel(kind, status, state, t) ?? + (typeof state.metadata.exitCode === "number" + ? (t?.("toolLabels.bash.exitCode", { code: state.metadata.exitCode }) ?? + `Exit ${state.metadata.exitCode}`) + : typeof state.metadata.signal === "string" + ? state.metadata.signal + : null)) : null; const output: ToolOutputBlock[] = []; const rawContent = meaningfulText(rawCandidate); diff --git a/src/i18n/locales/de.json b/src/i18n/locales/de.json index cc210997..16a52262 100644 --- a/src/i18n/locales/de.json +++ b/src/i18n/locales/de.json @@ -434,6 +434,14 @@ "rawDescription": "Ursprüngliche Textausgabe des Werkzeugs.", "copyRaw": "Rohdaten kopieren" }, + "sandboxShell": { + "broker_unreachable": "Sandbox unerreichbar — zuerst Projekt prüfen", + "broker_timeout": "Sandbox-Timeout — zuerst Projekt prüfen", + "broker_unauthorized": "Sandbox-Zugriff verweigert, kontaktiere deinen Admin", + "broker_rejected": "Sandbox hat die Anfrage abgelehnt", + "shell_not_allowed": "Shell ist für dieses Konto nicht zulässig", + "sandbox_not_configured": "Shell ist nicht konfiguriert, kontaktiere deinen Admin" + }, "workspace": { "addTitle": "Workspace hinzufügen", "editTitle": "Workspace bearbeiten", diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index 23de4e6a..e10d22c6 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -434,6 +434,14 @@ "rawDescription": "Original textual output returned by the tool.", "copyRaw": "Copy raw" }, + "sandboxShell": { + "broker_unreachable": "Sandbox unreachable — check project first", + "broker_timeout": "Sandbox timed out — check project first", + "broker_unauthorized": "Sandbox access refused, contact your admin", + "broker_rejected": "Sandbox rejected the request", + "shell_not_allowed": "Shell is not permitted for this account", + "sandbox_not_configured": "Shell is not configured, contact your admin" + }, "workspace": { "addTitle": "Add workspace", "editTitle": "Edit workspace", diff --git a/src/i18n/locales/es.json b/src/i18n/locales/es.json index 2789cde6..17153d5a 100644 --- a/src/i18n/locales/es.json +++ b/src/i18n/locales/es.json @@ -434,6 +434,14 @@ "rawDescription": "Salida textual original devuelta por la herramienta.", "copyRaw": "Copiar sin procesar" }, + "sandboxShell": { + "broker_unreachable": "Sandbox no disponible — revisa el proyecto", + "broker_timeout": "Sandbox sin respuesta — revisa el proyecto", + "broker_unauthorized": "Acceso a la sandbox denegado, contacta a tu admin", + "broker_rejected": "La sandbox rechazó la solicitud", + "shell_not_allowed": "Shell no está permitido para esta cuenta", + "sandbox_not_configured": "Shell no está configurado, contacta a tu admin" + }, "workspace": { "addTitle": "Añadir espacio de trabajo", "editTitle": "Editar espacio de trabajo", diff --git a/src/protocol/host-transcript.test.ts b/src/protocol/host-transcript.test.ts index 357e94a6..22743c12 100644 --- a/src/protocol/host-transcript.test.ts +++ b/src/protocol/host-transcript.test.ts @@ -3,6 +3,7 @@ import type { HostSessionSnapshot } from "./host-types"; import { applyHostTranscriptEvent, createHostTranscriptStream, + projectHostSnapshotToMessages, projectHostTranscriptStream, } from "./host-transcript"; @@ -688,3 +689,85 @@ describe("Host transcript streaming", () => { expect(streamed?.info.modelID).toBe("muse-spark-1.3-contributor-free"); }); }); + +describe("Host transcript shell failure reasons", () => { + test("carries the recorded machine reason into shell metadata for the tool view", () => { + const input = snapshot(); + input.entries = [ + { + id: "call-1", + sessionId: input.id, + sequence: 1, + kind: "tool_call", + payload: { runId: "run-1", toolCallId: "tool-1", name: "shell", input: {} }, + createdAt: "2026-07-10T00:00:01.000Z", + }, + { + id: "result-1", + sessionId: input.id, + sequence: 2, + kind: "tool_result", + payload: { + runId: "run-1", + toolCallId: "tool-1", + output: { + status: "error", + reason: "broker_unreachable", + summary: "Sandbox shell broker is unreachable. The command did not run.", + }, + }, + createdAt: "2026-07-10T00:00:02.000Z", + }, + ]; + + const parts = projectHostSnapshotToMessages(input).flatMap((message) => message.parts); + const tool = parts.find((part) => part.type === "tool"); + expect(tool).toMatchObject({ + type: "tool", + state: { + status: "error", + metadata: expect.objectContaining({ reason: "broker_unreachable" }), + }, + }); + }); + + test("carries policy denial reasons into shell metadata as well", () => { + const input = snapshot(); + input.entries = [ + { + id: "call-1", + sessionId: input.id, + sequence: 1, + kind: "tool_call", + payload: { runId: "run-1", toolCallId: "tool-1", name: "shell", input: {} }, + createdAt: "2026-07-10T00:00:01.000Z", + }, + { + id: "result-1", + sessionId: input.id, + sequence: 2, + kind: "tool_result", + payload: { + runId: "run-1", + toolCallId: "tool-1", + output: { + denied: true, + error: "Execution policy denied shell", + reason: "sandbox_not_configured", + }, + }, + createdAt: "2026-07-10T00:00:02.000Z", + }, + ]; + + const parts = projectHostSnapshotToMessages(input).flatMap((message) => message.parts); + const tool = parts.find((part) => part.type === "tool"); + expect(tool).toMatchObject({ + type: "tool", + state: { + status: "error", + metadata: expect.objectContaining({ reason: "sandbox_not_configured" }), + }, + }); + }); +}); diff --git a/src/protocol/host-transcript.ts b/src/protocol/host-transcript.ts index 81a41098..0bc3eb9c 100644 --- a/src/protocol/host-transcript.ts +++ b/src/protocol/host-transcript.ts @@ -409,6 +409,10 @@ export function projectHostSnapshotToMessages(snapshot: HostSessionSnapshot): Me truncated: (structuredOutput as { truncated?: unknown }).truncated, timedOut: (structuredOutput as { timedOut?: unknown }).timedOut, aborted: (structuredOutput as { aborted?: unknown }).aborted, + // Machine reason for classified shell failures + // (policy denials and sandbox broker faults). The tool + // view maps known reasons to localized messages. + reason: (structuredOutput as { reason?: unknown }).reason, } : undefined; return {