From 21a6ece83501c6cee063cf52f822c43d3c274ae3 Mon Sep 17 00:00:00 2001 From: debajyoti-truefoundry Date: Fri, 14 Aug 2026 18:14:56 +0530 Subject: [PATCH 01/10] Add stdlib UDS mcp_client_local with harness-style script codegen. Replace the pipe fixture with a product-shaped local client, inline it via build:gen, and point smoke at call-tool with TFY_MCP_SERVERS. Co-authored-by: Cursor --- .gitignore | 1 + .../local-sandbox/fixtures/mcp_pipe_client.py | 144 --------- packages/local-sandbox/package.json | 15 +- .../scripts/generate-sandbox-scripts.mjs | 22 ++ packages/local-sandbox/src/core/hostRun.ts | 28 +- .../src/scripts/mcp_client_local.py | 277 ++++++++++++++++++ packages/local-sandbox/test/smoke.test.ts | 41 ++- 7 files changed, 345 insertions(+), 183 deletions(-) delete mode 100644 packages/local-sandbox/fixtures/mcp_pipe_client.py create mode 100644 packages/local-sandbox/scripts/generate-sandbox-scripts.mjs create mode 100644 packages/local-sandbox/src/scripts/mcp_client_local.py diff --git a/.gitignore b/.gitignore index eb5ff3960..f0ccbdbba 100644 --- a/.gitignore +++ b/.gitignore @@ -14,6 +14,7 @@ packages/trueforge/src/catalog/modelCatalog.gen.ts packages/trueforge/src/catalog/mcpCatalog.gen.ts packages/trueforge/src/catalog/skillCatalog.gen.ts packages/trueforge/src/catalog/sandboxCatalog.gen.ts +packages/local-sandbox/src/sandboxScripts.gen.ts data/ # Helm subchart deps are fetched via `helm dependency build` (pinned by the # committed Chart.lock); the downloaded .tgz/dirs are not committed. diff --git a/packages/local-sandbox/fixtures/mcp_pipe_client.py b/packages/local-sandbox/fixtures/mcp_pipe_client.py deleted file mode 100644 index 8c55b77ef..000000000 --- a/packages/local-sandbox/fixtures/mcp_pipe_client.py +++ /dev/null @@ -1,144 +0,0 @@ -#!/usr/bin/env python3 -"""Code Mode UDS client: connect to host socket, one JSON request/response per call.""" - -from __future__ import annotations - -import argparse -import asyncio -import json -import os -import socket -import sys -import time -from typing import Any - -MAX_MESSAGE_BYTES = 64 * 1024 * 1024 - - -def _sock_path() -> str: - path = os.environ.get("TFY_MCP_SOCK") - if not path: - raise RuntimeError("TFY_MCP_SOCK is not set") - return path - - -def _request_timeout() -> float: - return float(os.environ.get("TFY_CM_REQUEST_TIMEOUT_SECONDS", "60")) - - -def _read_message(sock: socket.socket) -> Any: - body = b"" - while True: - chunk = sock.recv(65536) - if not chunk: - break - body += chunk - if len(body) > MAX_MESSAGE_BYTES: - raise RuntimeError(f"message exceeds max {MAX_MESSAGE_BYTES} bytes") - return json.loads(body.decode("utf-8")) - - -def _write_message(sock: socket.socket, value: Any) -> None: - sock.sendall(json.dumps(value).encode("utf-8")) - - -def _request_sync(payload: dict[str, Any]) -> Any: - """Connect → JSON request → write-close → JSON reply → close (no request_id).""" - sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) - try: - sock.settimeout(_request_timeout()) - sock.connect(_sock_path()) - _write_message(sock, payload) - sock.shutdown(socket.SHUT_WR) - reply = _read_message(sock) - finally: - sock.close() - if not isinstance(reply, dict): - raise RuntimeError(f"Code Mode reply is not an object: {reply!r}") - if not reply.get("ok"): - source = reply.get("source", "internal") - error = reply.get("error", "unknown error") - if source == "caller": - raise RuntimeError(f"Invalid MCP request: {error}") - if source == "transport": - raise RuntimeError(f"Code Mode transport error: {error}") - raise RuntimeError(f"Internal MCP error: {error}") - return reply.get("result") - - -async def list_tools(server: str) -> Any: - return await asyncio.to_thread( - _request_sync, - {"op": "list_tools", "server": server}, - ) - - -async def call_tool(server: str, tool: str, body: dict[str, Any]) -> Any: - return await asyncio.to_thread( - _request_sync, - { - "op": "call_tool", - "server": server, - "tool": tool, - "arguments": body, - }, - ) - - -async def _cmd_list_tools(server: str) -> None: - await list_tools(server) - print("list-tools-ok") - - -async def _cmd_call_tool(server: str, tool: str, args: dict[str, Any]) -> None: - result = await call_tool(server, tool, args) - print("call-tool-ok", json.dumps(result, default=str)) - - -async def _cmd_multiplex(server: str, count: int) -> None: - coros = [ - call_tool(server, "ping", {"message": f"m{i}", "delay_ms": 150}) - for i in range(count) - ] - started = time.monotonic() - results = await asyncio.gather(*coros) - elapsed_ms = int((time.monotonic() - started) * 1000) - print("multiplex-ok", elapsed_ms, json.dumps(results, default=str)) - - -async def _async_main() -> None: - parser = argparse.ArgumentParser(prog="mcp_pipe_client.py") - sub = parser.add_subparsers(dest="cmd", required=True) - - list_p = sub.add_parser("list-tools", help="Invoke list_tools()") - list_p.add_argument("--server", default="demo") - - call_p = sub.add_parser("call-tool", help="Invoke call_tool()") - call_p.add_argument("--server", default="demo") - call_p.add_argument("--tool", default="ping") - call_p.add_argument("--args-json", default='{"message":"poc"}', type=json.loads) - - multi_p = sub.add_parser("multiplex", help="Concurrent call_tool via parallel UDS connects") - multi_p.add_argument("--server", default="demo") - multi_p.add_argument("--count", type=int, default=2) - - args = parser.parse_args() - try: - if args.cmd == "list-tools": - await _cmd_list_tools(args.server) - return - if args.cmd == "multiplex": - await _cmd_multiplex(args.server, args.count) - return - await _cmd_call_tool(args.server, args.tool, args.args_json) - except RuntimeError as e: - print(str(e), file=sys.stderr) - raise SystemExit(2) from e - - -def main() -> None: - asyncio.run(_async_main()) - - -if __name__ == "__main__": - main() diff --git a/packages/local-sandbox/package.json b/packages/local-sandbox/package.json index 2e683c2a3..2115a489d 100644 --- a/packages/local-sandbox/package.json +++ b/packages/local-sandbox/package.json @@ -16,19 +16,20 @@ "./package.json": "./package.json" }, "files": [ - "dist", - "fixtures" + "dist" ], "engines": { "node": ">=22" }, "scripts": { - "build": "tsc -p tsconfig.build.json", - "build:smoke": "tsc -p tsconfig.smoke.json", - "typecheck": "tsc -p tsconfig.json --noEmit", + "build:gen": "node scripts/generate-sandbox-scripts.mjs", + "build:gen:watch": "node --watch-path=src/scripts scripts/generate-sandbox-scripts.mjs", + "build": "pnpm run build:gen && tsc -p tsconfig.build.json", + "build:smoke": "pnpm run build:gen && tsc -p tsconfig.smoke.json", + "typecheck": "pnpm run build:gen && tsc -p tsconfig.json --noEmit", "lint": "eslint src scripts --max-warnings 0 --config ../../eslint.config.mjs", - "test": "NODE_OPTIONS='--conditions=trueforge-dev' jest --config jest.config.cjs --testPathIgnorePatterns=smoke\\.test\\.ts$", - "smoke": "NODE_OPTIONS='--conditions=trueforge-dev' jest --config jest.config.cjs --runInBand --forceExit test/smoke.test.ts", + "test": "pnpm run build:gen && NODE_OPTIONS='--conditions=trueforge-dev' jest --config jest.config.cjs --testPathIgnorePatterns=smoke\\.test\\.ts$", + "smoke": "pnpm run build:gen && NODE_OPTIONS='--conditions=trueforge-dev' jest --config jest.config.cjs --runInBand --forceExit test/smoke.test.ts", "smoke:lima": "bash scripts/smoke-lima.sh", "probe:loopback": "pnpm build:smoke && node dist/scripts/probe-loopback.js" }, diff --git a/packages/local-sandbox/scripts/generate-sandbox-scripts.mjs b/packages/local-sandbox/scripts/generate-sandbox-scripts.mjs new file mode 100644 index 000000000..24f1e0906 --- /dev/null +++ b/packages/local-sandbox/scripts/generate-sandbox-scripts.mjs @@ -0,0 +1,22 @@ +/** + * Inlines the local Code Mode MCP client into a generated TS module so + * install paths can self-serve the script with no loose fixture files. + * A plain generated `.ts` file works unchanged across tsc and @swc/jest. + * + * Runs via the `build:gen` script before build/typecheck/test/smoke. The + * generated output (src/sandboxScripts.gen.ts) is gitignored. + */ +import { readFileSync, writeFileSync } from 'node:fs'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const root = join(dirname(fileURLToPath(import.meta.url)), '..'); +const scriptsDir = join(root, 'src/scripts'); +const read = f => readFileSync(join(scriptsDir, f), 'utf-8'); + +const out = `// AUTO-GENERATED by scripts/generate-sandbox-scripts.mjs — do not edit. +export const sandboxScripts = { + mcpClientLocal: ${JSON.stringify(read('mcp_client_local.py'))}, +} as const; +`; +writeFileSync(join(root, 'src/sandboxScripts.gen.ts'), out); diff --git a/packages/local-sandbox/src/core/hostRun.ts b/packages/local-sandbox/src/core/hostRun.ts index ae469bd39..14efeeeea 100644 --- a/packages/local-sandbox/src/core/hostRun.ts +++ b/packages/local-sandbox/src/core/hostRun.ts @@ -9,32 +9,14 @@ import { getDefaultWritePaths, SandboxManager } from '@anthropic-ai/sandbox-runtime'; import { execFile, spawn, type ChildProcess } from 'node:child_process'; import { randomUUID } from 'node:crypto'; -import { existsSync } from 'node:fs'; -import { copyFile, mkdir, rm } from 'node:fs/promises'; +import { mkdir, rm, writeFile } from 'node:fs/promises'; import { createRequire } from 'node:module'; import { dirname, isAbsolute, join } from 'node:path'; -import { fileURLToPath } from 'node:url'; import { promisify } from 'node:util'; +import { sandboxScripts } from '../sandboxScripts.gen.js'; const execFileAsync = promisify(execFile); -const HERE = dirname(fileURLToPath(import.meta.url)); -/** Package root from src/ or dist/src/ (Jest runs TypeScript source). */ -function packageRoot(startDir: string): string { - let dir = startDir; - for (;;) { - if (existsSync(join(dir, 'package.json')) && existsSync(join(dir, 'fixtures'))) { - return dir; - } - const parent = dirname(dir); - if (parent === dir) { - throw new Error(`local-sandbox package root not found from ${startDir}`); - } - dir = parent; - } -} -const ROOT = packageRoot(HERE); -const FIXTURES = join(ROOT, 'fixtures'); /** SRT ships Linux helpers (e.g. apply-seccomp) under vendor/; the wrapped command must read them. */ // Package-root resolve (not app-module loading): Jest's CJS transform breaks import.meta.resolve. const SRT_VENDOR = join( @@ -513,9 +495,9 @@ export async function runSupervisorSession(params: { }); } -/** Copy the MCP client fixture into the sandbox (isolation: only sandbox root is writable). */ +/** Install the local Code Mode MCP client into the sandbox (only sandbox root is writable). */ export async function installMcpFixture(sandboxRootPath: string): Promise { - const dest = join(sandboxRootPath, 'mcp_pipe_client.py'); - await copyFile(join(FIXTURES, 'mcp_pipe_client.py'), dest); + const dest = join(sandboxRootPath, 'mcp_client_local.py'); + await writeFile(dest, sandboxScripts.mcpClientLocal, 'utf8'); return dest; } diff --git a/packages/local-sandbox/src/scripts/mcp_client_local.py b/packages/local-sandbox/src/scripts/mcp_client_local.py new file mode 100644 index 000000000..8c163b368 --- /dev/null +++ b/packages/local-sandbox/src/scripts/mcp_client_local.py @@ -0,0 +1,277 @@ +#!/usr/bin/env python3 +"""Code Mode UDS client — same public surface as product mcp_client.py, stdlib only. + +Authoritative reference (do not diverge on API/CLI/policy semantics): + packages/harness/src/core/sandbox/scripts/mcp_client.py + +Inlined into TypeScript via scripts/generate-sandbox-scripts.mjs (sandboxScripts.gen.ts), +same pattern as harness sandboxScripts. +""" + +from __future__ import annotations + +import argparse +import asyncio +import base64 +import json +import logging +import os +import socket +import sys +import time +from pathlib import Path +from typing import Any, TypedDict + +logger = logging.getLogger(__name__) + +MAX_MESSAGE_BYTES = 64 * 1024 * 1024 +_TOOLS_CACHE_TTL_SECONDS = 600 + + +class _ServerConfig(TypedDict): + allowed_tools: list[str] + + +_inflight_list_tools: dict[str, asyncio.Task[list[dict[str, Any]]]] = {} + +_raw_servers = os.environ.get("TFY_MCP_SERVERS") +_servers_map: dict[str, _ServerConfig] = ( + json.loads(base64.b64decode(_raw_servers).decode()) if _raw_servers else {} +) +_enable_agent_approvals = os.environ.get("TFY_ENABLE_AGENT_APPROVALS", "true").lower() == "true" + + +def _sock_path() -> str: + path = os.environ.get("TFY_MCP_SOCK") + if not path: + raise RuntimeError("TFY_MCP_SOCK is not set") + return path + + +def _request_timeout() -> float: + return float(os.environ.get("TFY_CM_REQUEST_TIMEOUT_SECONDS", "60")) + + +def _check_tool_allowed(server: str, tool_name: str) -> None: + server_config = _servers_map.get(server) + if server_config is None: + raise RuntimeError(f"Access denied: MCP server '{server}' is not available for this agent") from None + server_tools = server_config.get("allowed_tools") or [] + if len(server_tools) > 0 and tool_name not in server_tools: + raise RuntimeError(f"Access denied: tool '{tool_name}' is not enabled on server '{server}'") from None + + +def _cache_path(server: str) -> Path: + return Path(__file__).parent / f"{server}.tools.json" + + +def _read_tools_cache(server: str) -> list[dict[str, Any]] | None: + p = _cache_path(server) + if not p.exists(): + return None + try: + raw = json.loads(p.read_text(encoding="utf-8")) + if not isinstance(raw, dict): + raise ValueError("cache root must be object") + fetched_at = raw.get("fetched_at") + tools = raw.get("tools") + if not isinstance(fetched_at, (int, float)) or not isinstance(tools, list): + raise ValueError("cache shape invalid") + if time.time() - float(fetched_at) > _TOOLS_CACHE_TTL_SECONDS: + p.unlink(missing_ok=True) + return None + return [t for t in tools if isinstance(t, dict)] + except Exception: + logger.exception("_read_tools_cache") + p.unlink(missing_ok=True) + return None + + +def _write_tools_cache(server: str, tools: list[dict[str, Any]]) -> None: + try: + payload = {"fetched_at": time.time(), "tools": tools} + _cache_path(server).write_text(json.dumps(payload), encoding="utf-8") + except Exception: + logger.exception("_write_tools_cache") + + +def _read_message(sock: socket.socket) -> Any: + body = b"" + while True: + chunk = sock.recv(65536) + if not chunk: + break + body += chunk + if len(body) > MAX_MESSAGE_BYTES: + raise RuntimeError(f"message exceeds max {MAX_MESSAGE_BYTES} bytes") + return json.loads(body.decode("utf-8")) + + +def _write_message(sock: socket.socket, value: Any) -> None: + sock.sendall(json.dumps(value).encode("utf-8")) + + +def _uds_request_sync(payload: dict[str, Any]) -> Any: + """Connect → JSON request → write-close → JSON reply (no request_id).""" + sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) + try: + sock.settimeout(_request_timeout()) + sock.connect(_sock_path()) + _write_message(sock, payload) + sock.shutdown(socket.SHUT_WR) + reply = _read_message(sock) + finally: + sock.close() + if not isinstance(reply, dict): + raise RuntimeError(f"Code Mode reply is not an object: {reply!r}") + ok = reply.get("ok") + if ok is not True: + source = reply.get("source", "internal") + error = reply.get("error", "unknown error") + if source == "caller": + raise RuntimeError(f"Invalid MCP request: {error}") + if source == "transport": + raise RuntimeError(f"Code Mode transport error: {error}") + raise RuntimeError(f"Internal MCP error: {error}") + return reply.get("result") + + +async def _uds_request(payload: dict[str, Any]) -> Any: + return await asyncio.to_thread(_uds_request_sync, payload) + + +async def _fetch_tools(server: str) -> list[dict[str, Any]]: + result = await _uds_request({"op": "list_tools", "server": server}) + if not isinstance(result, dict): + raise RuntimeError(f"list_tools '{server}' returned unexpected shape: {result!r}") from None + tools = result.get("tools", []) + if not isinstance(tools, list): + raise RuntimeError(f"list_tools '{server}' tools is not a list: {tools!r}") from None + return [t for t in tools if isinstance(t, dict)] + + +async def _fetch_and_cache_tools(server: str) -> list[dict[str, Any]]: + tools = await _fetch_tools(server) + _write_tools_cache(server, tools) + return tools + + +async def _get_tools(server: str) -> list[dict[str, Any]]: + cached = _read_tools_cache(server) + if cached is not None: + return cached + task = _inflight_list_tools.get(server) + if task is None: + task = asyncio.create_task(_fetch_and_cache_tools(server)) + _inflight_list_tools[server] = task + task.add_done_callback(lambda _t: _inflight_list_tools.pop(server, None)) + return await task + + +async def _get_tool(server: str, tool_name: str) -> dict[str, Any] | None: + for t in await _get_tools(server): + if t.get("name") == tool_name: + return t + return None + + +def _is_destructive(tool: dict[str, Any]) -> bool: + annotations = tool.get("annotations") + if annotations is None: + return False + if not isinstance(annotations, dict): + return False + destructive = annotations.get("destructiveHint") + read_only = annotations.get("readOnlyHint") + return bool(destructive) or (not read_only and read_only is not None) + + +async def _ensure_non_destructive(server: str, tool_name: str) -> None: + tool = await _get_tool(server, tool_name) + if tool is None: + raise RuntimeError(f"Tool '{tool_name}' not found on MCP server '{server}'") from None + if _is_destructive(tool): + raise RuntimeError( + f"Tool '{tool_name}' on MCP server '{server}' is destructive and cannot be called in Code Mode; " + f"call it directly so it can go through the user approval flow" + ) from None + + +def _project_call_tool_result(server: str, tool: str, result: Any) -> Any: + """Project an MCP-wire CallToolResult-shaped object into the user-facing Python value.""" + if not isinstance(result, dict): + raise RuntimeError(f"call_tool reply for '{server}/{tool}' is malformed: expected object") from None + + if result.get("isError"): + content = result.get("content") + text_parts: list[str] = [] + if isinstance(content, list): + for c in content: + if isinstance(c, dict) and c.get("type") == "text": + text = c.get("text") + if isinstance(text, str) and text: + text_parts.append(text) + msg = "; ".join(text_parts) if text_parts else "tool returned an error" + raise RuntimeError(f"MCP tool error (server={server}, tool={tool}): {msg}") from None + + if result.get("structuredContent") is not None: + return result["structuredContent"] + + content = result.get("content") + if isinstance(content, list) and len(content) == 1: + first = content[0] + if isinstance(first, dict) and first.get("type") == "text": + text = first.get("text") + if isinstance(text, str) and text: + try: + return json.loads(text) + except Exception: + pass + if isinstance(content, list) and content: + return content + return None + + +async def call_tool(server: str, tool: str, body: dict[str, Any]) -> Any: + _check_tool_allowed(server, tool) + if _enable_agent_approvals: + await _ensure_non_destructive(server, tool) + + raw = await _uds_request( + { + "op": "call_tool", + "server": server, + "tool": tool, + "arguments": body, + }, + ) + return _project_call_tool_result(server, tool, raw) + + +_USAGE = "mcp_client_local.py call-tool " + + +def _build_arg_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser(prog="mcp_client_local.py", usage=_USAGE) + sub = parser.add_subparsers(dest="cmd", required=True) + + call_tool_p = sub.add_parser("call-tool", help="Invoke an MCP tool") + call_tool_p.add_argument("server") + call_tool_p.add_argument("tool") + call_tool_p.add_argument("args_json", metavar="args-json", type=json.loads) + + return parser + + +async def _main() -> None: + args = _build_arg_parser().parse_args() + try: + if args.cmd == "call-tool": + result = await call_tool(args.server, args.tool, args.args_json) + print(json.dumps(result, default=str)) + except RuntimeError as e: + sys.exit(str(e)) + + +if __name__ == "__main__": + asyncio.run(_main()) diff --git a/packages/local-sandbox/test/smoke.test.ts b/packages/local-sandbox/test/smoke.test.ts index eeab2a57e..265aaa8fe 100644 --- a/packages/local-sandbox/test/smoke.test.ts +++ b/packages/local-sandbox/test/smoke.test.ts @@ -41,6 +41,10 @@ const ENV_INHERIT_MARKER = 'TFY_SMOKE_INHERIT'; const ENV_INHERIT_VALUE = `inherit-${randomUUID()}`; const ENV_PEER_MARKER = 'TFY_SMOKE_PEER_ENV'; const ENV_PEER_VALUE = `peer-secret-${randomUUID()}`; +/** Product-shaped allowlist for smoke Code Mode client (demo/ping only). */ +const TFY_MCP_SERVERS_DEMO = Buffer.from(JSON.stringify({ demo: { allowed_tools: ['ping'] } }), 'utf8').toString( + 'base64', +); // Package-root resolve: Jest's CJS transform breaks import.meta.resolve. const SRT_VENDOR = join( dirname(createRequire(import.meta.url).resolve('@anthropic-ai/sandbox-runtime/package.json')), @@ -232,7 +236,11 @@ async function withCodeModeTransport(params: { sandboxId: 'smoke', requestTimeoutSeconds: 60, }); - await params.run(env); + await params.run({ + ...env, + TFY_MCP_SERVERS: TFY_MCP_SERVERS_DEMO, + TFY_ENABLE_AGENT_APPROVALS: 'true', + }); } finally { dispatcher.close(); await transport.stop(); @@ -266,18 +274,20 @@ async function smokeCodeMode(params: { ); console.log('ok: Code Mode UDS parent 0700 + sock 0600'); - const list = await runSupervisorSession({ + const call = await runSupervisorSession({ sandboxRootPath: params.sandboxRootPath, shell: params.shell, platform: params.platform, - command: 'python3 mcp_pipe_client.py list-tools --server demo', + command: `python3 mcp_client_local.py call-tool demo ping '${JSON.stringify({ message: 'poc' })}'`, env, timeoutMs: 15_000, }); - assert.equal(list.protocolError, undefined, list.protocolError); - assert.equal(list.exitCode, 0, list.stderrText); - assert.match(list.stdoutText, /list-tools-ok/); - console.log('ok: Code Mode list-tools (UDS)'); + assert.equal(call.protocolError, undefined, call.protocolError); + assert.equal(call.exitCode, 0, call.stderrText); + const callJson: unknown = JSON.parse(call.stdoutText.trim().split(/\r?\n/).filter(Boolean).at(-1) ?? ''); + assert.ok(callJson !== null && typeof callJson === 'object'); + assert.ok('echo' in callJson); + console.log('ok: Code Mode call-tool (UDS)'); }, }); @@ -355,7 +365,20 @@ async function smokeCodeMode(params: { sandboxRootPath: params.sandboxRootPath, shell: params.shell, platform: params.platform, - command: 'python3 mcp_pipe_client.py multiplex --server demo --count 2', + command: [ + "python3 - <<'PY'", + 'import asyncio, json, time', + 'from mcp_client_local import call_tool', + 'async def main():', + ' started = time.monotonic()', + ' results = await asyncio.gather(', + ' call_tool("demo", "ping", {"message": "m0", "delay_ms": 150}),', + ' call_tool("demo", "ping", {"message": "m1", "delay_ms": 150}),', + ' )', + ' print("multiplex-ok", int((time.monotonic() - started) * 1000), json.dumps(results))', + 'asyncio.run(main())', + 'PY', + ].join('\n'), env, timeoutMs: 15_000, }); @@ -383,7 +406,7 @@ async function smokeCodeMode(params: { command: [ 'set -euo pipefail', 'unset TFY_MCP_SOCK', - 'if python3 mcp_pipe_client.py list-tools --server demo; then', + `if python3 mcp_client_local.py call-tool demo ping '${JSON.stringify({ message: 'x' })}'; then`, ' echo "expected missing-sock failure" >&2', ' exit 1', 'fi', From afce05e5e5d5becc1b1d5482e6ffa52e9584790c Mon Sep 17 00:00:00 2001 From: debajyoti-truefoundry Date: Fri, 14 Aug 2026 20:33:31 +0530 Subject: [PATCH 02/10] Move MCP client install onto CodeModeTransport so Sandbox uploads via provider. Transports own script content and remotePath; Sandbox derives PYTHONPATH/PATH layout and skips install when Code Mode is unset. Co-authored-by: Cursor --- .../src/core/CodeModeUdsTransport.ts | 29 ++++- packages/local-sandbox/src/core/hostRun.ts | 25 ++--- packages/local-sandbox/src/index.ts | 2 +- .../src/provider/LocalSandboxProvider.ts | 21 ++-- packages/local-sandbox/test/smoke.test.ts | 20 ++-- packages/trueforge-core/src/core/index.ts | 2 +- .../src/core/sandbox/Sandbox.ts | 102 ++++++++++++------ .../sandbox/codeMode/CodeModeTransport.ts | 18 ++++ .../codeMode/nats/CodeModeNatsTransport.ts | 16 ++- .../core/sandbox/sandboxBridgeTimeout.test.ts | 5 + 10 files changed, 171 insertions(+), 69 deletions(-) diff --git a/packages/local-sandbox/src/core/CodeModeUdsTransport.ts b/packages/local-sandbox/src/core/CodeModeUdsTransport.ts index 03d20badb..beadaafe5 100644 --- a/packages/local-sandbox/src/core/CodeModeUdsTransport.ts +++ b/packages/local-sandbox/src/core/CodeModeUdsTransport.ts @@ -8,6 +8,7 @@ * The caller owns that parent directory's lifetime; this transport unlinks the sock it creates. */ import type { + CodeModeClientInstall, CodeModeDispatcher, CodeModeReply, CodeModeRequest, @@ -15,10 +16,11 @@ import type { } from '@truefoundry/trueforge-core/core'; import { CodeModeRequestSchema, validateNoPathTraversal } from '@truefoundry/trueforge-core/core'; import { chmodSync, existsSync, realpathSync, statSync } from 'node:fs'; -import { chmod, unlink } from 'node:fs/promises'; +import { chmod, mkdir, rm, symlink, unlink, writeFile } from 'node:fs/promises'; import { createServer, type Server, type Socket } from 'node:net'; -import { isAbsolute, join, resolve } from 'node:path'; +import { dirname, isAbsolute, join, resolve } from 'node:path'; import { ulid } from 'ulid'; +import { sandboxScripts } from '../sandboxScripts.gen.js'; import { encodeJsonMessage, JsonMessageReader, MAX_MESSAGE_BYTES } from './frame.js'; import { registerCodeModeSocketPath, unregisterCodeModeSocketPath } from './hostRun.js'; @@ -26,6 +28,22 @@ const MAX_CODE_MODE_SOCKET_PARENT_BYTES = 60; const CODE_MODE_SOCKET_PARENT_MODE = 0o700; const CODE_MODE_SOCKET_MODE = 0o600; +/** Install layout for local Code Mode MCP client (sandboxId = absolute sandbox root). */ +export function localMcpClientRemotePath(sandboxId: string): string { + return join(sandboxId, 'mcp-client', 'mcp_client.py'); +} + +/** Install the local Code Mode MCP client into the sandbox (same layout as Sandbox.init). */ +export async function installMcpFixture(sandboxRootPath: string): Promise<{ remotePath: string }> { + const remotePath = localMcpClientRemotePath(sandboxRootPath); + const binLink = join(dirname(remotePath), 'bin', 'mcp-client'); + await mkdir(dirname(binLink), { recursive: true, mode: 0o700 }); + await writeFile(remotePath, sandboxScripts.mcpClientLocal, { encoding: 'utf8', mode: 0o555 }); + await rm(binLink, { force: true }); + await symlink(remotePath, binLink); + return { remotePath }; +} + export interface CodeModeUdsTransportOptions { /** * Absolute existing directory for Code Mode UDS files (≤60 bytes, mode 0700). @@ -81,6 +99,13 @@ export class CodeModeUdsTransport implements CodeModeTransport { this.onProtocolError = options.onProtocolError; } + getClientInstall(params: { sandboxId: string }): CodeModeClientInstall { + return { + content: sandboxScripts.mcpClientLocal, + remotePath: localMcpClientRemotePath(params.sandboxId), + }; + } + start(params: { codeModeDispatcher: CodeModeDispatcher; sandboxId: string; diff --git a/packages/local-sandbox/src/core/hostRun.ts b/packages/local-sandbox/src/core/hostRun.ts index 14efeeeea..ce9fab816 100644 --- a/packages/local-sandbox/src/core/hostRun.ts +++ b/packages/local-sandbox/src/core/hostRun.ts @@ -9,11 +9,11 @@ import { getDefaultWritePaths, SandboxManager } from '@anthropic-ai/sandbox-runtime'; import { execFile, spawn, type ChildProcess } from 'node:child_process'; import { randomUUID } from 'node:crypto'; -import { mkdir, rm, writeFile } from 'node:fs/promises'; +import { realpathSync } from 'node:fs'; +import { mkdir, rm } from 'node:fs/promises'; import { createRequire } from 'node:module'; import { dirname, isAbsolute, join } from 'node:path'; import { promisify } from 'node:util'; -import { sandboxScripts } from '../sandboxScripts.gen.js'; const execFileAsync = promisify(execFile); @@ -170,7 +170,13 @@ function commandEnv(params: { TMPDIR: tmp, TMP: tmp, TEMP: tmp, - PATH: commandPath(params.platform), + // Local MCP client CLI lives at /mcp-client/bin (see CodeModeUdsTransport install layout). + PATH: (() => { + const base = commandPath(params.platform); + const ours = `${join(params.sandboxRootPath, 'mcp-client', 'bin')}:${base}`; + const theirs = params.extra?.['PATH']; + return theirs !== undefined && theirs.length > 0 ? `${ours}:${theirs}` : ours; + })(), }; return { ...params.extra, @@ -277,9 +283,11 @@ export async function createSandbox(sandboxRootPath: string): Promise { await mkdir(sandboxRootPath, { recursive: true, mode: 0o700 }); await mkdir(join(sandboxRootPath, '.tmp'), { recursive: true, mode: 0o700 }); await mkdir(join(sandboxRootPath, '.home'), { recursive: true, mode: 0o700 }); - darwinUnixSocketSandboxRoots.add(sandboxRootPath); + // Seatbelt allowWrite matches real paths (/private/var/... on macOS). + const realRoot = realpathSync(sandboxRootPath); + darwinUnixSocketSandboxRoots.add(realRoot); syncDarwinUnixSockets(); - return sandboxRootPath; + return realRoot; } export async function removeSandbox(sandboxRootPath: string): Promise { @@ -494,10 +502,3 @@ export async function runSupervisorSession(params: { }); }); } - -/** Install the local Code Mode MCP client into the sandbox (only sandbox root is writable). */ -export async function installMcpFixture(sandboxRootPath: string): Promise { - const dest = join(sandboxRootPath, 'mcp_client_local.py'); - await writeFile(dest, sandboxScripts.mcpClientLocal, 'utf8'); - return dest; -} diff --git a/packages/local-sandbox/src/index.ts b/packages/local-sandbox/src/index.ts index 6837f1448..145849cc5 100644 --- a/packages/local-sandbox/src/index.ts +++ b/packages/local-sandbox/src/index.ts @@ -1,4 +1,4 @@ -export { CodeModeUdsTransport } from './core/CodeModeUdsTransport.js'; +export { CodeModeUdsTransport, installMcpFixture, localMcpClientRemotePath } from './core/CodeModeUdsTransport.js'; export type { CodeModeUdsTransportOptions } from './core/CodeModeUdsTransport.js'; export type { LocalSandboxPlatform } from './core/hostRun.js'; export { LocalSandboxProvider } from './provider/LocalSandboxProvider.js'; diff --git a/packages/local-sandbox/src/provider/LocalSandboxProvider.ts b/packages/local-sandbox/src/provider/LocalSandboxProvider.ts index 53e82e315..a9aa6a007 100644 --- a/packages/local-sandbox/src/provider/LocalSandboxProvider.ts +++ b/packages/local-sandbox/src/provider/LocalSandboxProvider.ts @@ -17,7 +17,7 @@ import { } from '@truefoundry/trueforge-core/core'; import { mkdir, mkdtemp } from 'node:fs/promises'; import { tmpdir } from 'node:os'; -import { dirname, isAbsolute, join, resolve, sep } from 'node:path'; +import { isAbsolute, join, relative, resolve, sep } from 'node:path'; import { ulid } from 'ulid'; import { CodeModeUdsTransport, assertCodeModeSocketParentPath } from '../core/CodeModeUdsTransport.js'; import { @@ -64,8 +64,9 @@ export interface LocalSandboxProviderOptions { } /** Sandbox-relative path for sandboxed commands (avoids /var vs /private/var seatbelt mismatches). */ -function sandboxRelativePath(userPath: string): string { - return userPath.replace(/^\.\/+/, ''); +function toSandboxRelativePath(params: { sandboxRootPath: string; absolutePath: string }): string { + const rel = relative(params.sandboxRootPath, params.absolutePath); + return rel === '' ? '.' : rel; } export class LocalSandboxProvider implements SandboxProvider { @@ -338,8 +339,8 @@ export class LocalSandboxProvider implements SandboxProvider { async downloadFile(params: { sandboxId: string; path: string }): Promise { await this.ensureSrt(); const sandboxRootPath = params.sandboxId; - this.resolveInSandboxRoot(sandboxRootPath, params.path); - const relPath = sandboxRelativePath(params.path); + const absolutePath = this.resolveInSandboxRoot(sandboxRootPath, params.path); + const relPath = toSandboxRelativePath({ sandboxRootPath, absolutePath }); const info = await this.getFileInfo({ sandboxRootPath, relPath, userPath: params.path }); if (info.isDir) { throw new SandboxPathIsDirectoryError(params.path); @@ -361,7 +362,7 @@ export class LocalSandboxProvider implements SandboxProvider { return buf; } - /** Payload on stdin so large uploads stay off argv. */ + /** Payload on stdin so large uploads stay off argv. Parent dirs must already exist. */ async uploadFile(params: { sandboxId: string; remotePath: string; content: Buffer }): Promise { await this.ensureSrt(); if (params.content.length > this.fileMaxBytesForDownload) { @@ -370,13 +371,11 @@ export class LocalSandboxProvider implements SandboxProvider { const sandboxRootPath = params.sandboxId; // Resolve for traversal checks, but pass sandbox-relative paths to the shell. // Absolute /var/folders/... paths lose quoting under SRT and become mkdir /var. - this.resolveInSandboxRoot(sandboxRootPath, params.remotePath); - const remotePath = sandboxRelativePath(params.remotePath); - const parent = dirname(remotePath); - const mkdirPart = parent === '.' ? '' : `mkdir -p ${shellEscape(parent)} && `; + const absolutePath = this.resolveInSandboxRoot(sandboxRootPath, params.remotePath); + const remotePath = toSandboxRelativePath({ sandboxRootPath, absolutePath }); const result = await this.runSandboxCommand({ sandboxRootPath, - command: `${mkdirPart}cat > ${shellEscape(remotePath)}`, + command: `cat > ${shellEscape(remotePath)}`, stdin: params.content, }); if (result.exitCode !== 0) { diff --git a/packages/local-sandbox/test/smoke.test.ts b/packages/local-sandbox/test/smoke.test.ts index 265aaa8fe..e32276246 100644 --- a/packages/local-sandbox/test/smoke.test.ts +++ b/packages/local-sandbox/test/smoke.test.ts @@ -14,11 +14,10 @@ import { tmpdir } from 'node:os'; import { dirname, join } from 'node:path'; import { fileURLToPath } from 'node:url'; import { ulid } from 'ulid'; -import { CodeModeUdsTransport } from '../src/core/CodeModeUdsTransport.js'; +import { CodeModeUdsTransport, installMcpFixture } from '../src/core/CodeModeUdsTransport.js'; import { commandPath, createSandbox, - installMcpFixture, MAX_OUTPUT_BYTES, platformAllowRead, registerCodeModeSocketPath, @@ -216,6 +215,7 @@ function makeDemoToolSet(params: { onRequest?: () => void }): IToolSet { async function withCodeModeTransport(params: { codeModeSocketParentPath: string; + sandboxRootPath: string; maxMessageBytes?: number; onProtocolError?: (message: string) => void; onRequest?: () => void; @@ -230,14 +230,16 @@ async function withCodeModeTransport(params: { toolSets: [makeDemoToolSet({ onRequest: params.onRequest })], logger: makeSilentCodeModeLogger(), }); + const install = transport.getClientInstall({ sandboxId: params.sandboxRootPath }); try { const { env } = await transport.start({ codeModeDispatcher: dispatcher, - sandboxId: 'smoke', + sandboxId: params.sandboxRootPath, requestTimeoutSeconds: 60, }); await params.run({ ...env, + PYTHONPATH: dirname(install.remotePath), TFY_MCP_SERVERS: TFY_MCP_SERVERS_DEMO, TFY_ENABLE_AGENT_APPROVALS: 'true', }); @@ -258,6 +260,7 @@ async function smokeCodeMode(params: { await withCodeModeTransport({ codeModeSocketParentPath: params.codeModeSocketParentPath, + sandboxRootPath: params.sandboxRootPath, onRequest: () => { toolRequests += 1; }, @@ -278,7 +281,7 @@ async function smokeCodeMode(params: { sandboxRootPath: params.sandboxRootPath, shell: params.shell, platform: params.platform, - command: `python3 mcp_client_local.py call-tool demo ping '${JSON.stringify({ message: 'poc' })}'`, + command: `mcp-client call-tool demo ping '${JSON.stringify({ message: 'poc' })}'`, env, timeoutMs: 15_000, }); @@ -295,6 +298,7 @@ async function smokeCodeMode(params: { let oversizeError: string | undefined; await withCodeModeTransport({ codeModeSocketParentPath: params.codeModeSocketParentPath, + sandboxRootPath: params.sandboxRootPath, maxMessageBytes: oversizeCap, onProtocolError: message => { oversizeError = message; @@ -327,6 +331,7 @@ async function smokeCodeMode(params: { let badJsonError: string | undefined; await withCodeModeTransport({ codeModeSocketParentPath: params.codeModeSocketParentPath, + sandboxRootPath: params.sandboxRootPath, onProtocolError: message => { badJsonError = message; }, @@ -357,6 +362,7 @@ async function smokeCodeMode(params: { await withCodeModeTransport({ codeModeSocketParentPath: params.codeModeSocketParentPath, + sandboxRootPath: params.sandboxRootPath, onRequest: () => { toolRequests += 1; }, @@ -368,7 +374,7 @@ async function smokeCodeMode(params: { command: [ "python3 - <<'PY'", 'import asyncio, json, time', - 'from mcp_client_local import call_tool', + 'from mcp_client import call_tool', 'async def main():', ' started = time.monotonic()', ' results = await asyncio.gather(', @@ -395,6 +401,7 @@ async function smokeCodeMode(params: { const beforeMissing = toolRequests; await withCodeModeTransport({ codeModeSocketParentPath: params.codeModeSocketParentPath, + sandboxRootPath: params.sandboxRootPath, onRequest: () => { toolRequests += 1; }, @@ -406,7 +413,7 @@ async function smokeCodeMode(params: { command: [ 'set -euo pipefail', 'unset TFY_MCP_SOCK', - `if python3 mcp_client_local.py call-tool demo ping '${JSON.stringify({ message: 'x' })}'; then`, + `if mcp-client call-tool demo ping '${JSON.stringify({ message: 'x' })}'; then`, ' echo "expected missing-sock failure" >&2', ' exit 1', 'fi', @@ -426,6 +433,7 @@ async function smokeCodeMode(params: { let holdPid: number | undefined; await withCodeModeTransport({ codeModeSocketParentPath: params.codeModeSocketParentPath, + sandboxRootPath: params.sandboxRootPath, onRequest: () => { hostInjected += 1; }, diff --git a/packages/trueforge-core/src/core/index.ts b/packages/trueforge-core/src/core/index.ts index 03be317a9..bfe518ff1 100644 --- a/packages/trueforge-core/src/core/index.ts +++ b/packages/trueforge-core/src/core/index.ts @@ -139,7 +139,7 @@ export { PromiseTimeoutError, withTimeout } from './util/promiseUtils'; // Sandbox (concrete implementation; provider details exported for composition) export { CodeModeDispatcher } from './sandbox/codeMode/CodeModeDispatcher'; export type { CodeModeLogger } from './sandbox/codeMode/CodeModeDispatcher'; -export type { CodeModeTransport } from './sandbox/codeMode/CodeModeTransport'; +export type { CodeModeClientInstall, CodeModeTransport } from './sandbox/codeMode/CodeModeTransport'; export { CodeModeErrorSourceSchema, CodeModeReplySchema, CodeModeRequestSchema } from './sandbox/codeMode/types'; export type { CodeModeErrorSource, CodeModeReply, CodeModeRequest } from './sandbox/codeMode/types'; export { DaytonaSandboxProvider } from './sandbox/provider/DaytonaProvider'; diff --git a/packages/trueforge-core/src/core/sandbox/Sandbox.ts b/packages/trueforge-core/src/core/sandbox/Sandbox.ts index fa4696cae..40462eb4f 100644 --- a/packages/trueforge-core/src/core/sandbox/Sandbox.ts +++ b/packages/trueforge-core/src/core/sandbox/Sandbox.ts @@ -16,15 +16,25 @@ import { import type { AgentTracing } from '../tracing/AgentTracing'; import { extractErrorLogFields } from '../util/errorLogFields'; import { CodeModeDispatcher } from './codeMode/CodeModeDispatcher'; -import type { CodeModeTransport } from './codeMode/CodeModeTransport'; +import { type CodeModeClientInstall, type CodeModeTransport } from './codeMode/CodeModeTransport'; import { SANDBOX_FILE_UPLOADS_DIR } from './constants'; import { ensureExecSuccess, shellEscape, type SandboxProvider } from './provider/Provider'; import { validateNoPathTraversal, validateSandboxOwnedByTenant } from './SandboxErrors'; -import { sandboxScripts } from './sandboxScripts.gen'; // Import submodules, not the ./skills barrel, to avoid a cycle (the mounters import from Sandbox). +import { dirname, join } from 'node:path'; import { SKILLS_DIR } from './skills/constants'; import type { ISkillMounter } from './skills/ISkillMounter'; +/** Layout derived from install remotePath (always `…/mcp_client.py`). */ +function mcpClientLayout(remotePath: string): { pythonPath: string; binDir: string; binLink: string } { + const pythonPath = dirname(remotePath); + const binDir = join(pythonPath, 'bin'); + return { pythonPath, binDir, binLink: join(binDir, 'mcp-client') }; +} + +/** Fallback PATH tail when the provider does not pass PATH (Daytona image defaults). */ +const DEFAULT_SANDBOX_PATH = '/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin'; + export interface SandboxInfo { sandbox_id: string; } @@ -105,15 +115,6 @@ const SANDBOX_FILE_OUTPUT_TAG = 'sandbox-file-output'; export const SANDBOX_SCHEMA_INFER_TAG = 'sandbox-schema-infer'; const SANDBOX_FILE_UPLOADS_TAG = 'sandbox-file-uploads'; -// Stable directory inside the sandbox where the MCP client library is uploaded. -// MCP client upload dir. Two lookup channels: -// - Python imports → PYTHONPATH points here (set by injectMCPClientEnv). -// - Shell `mcp-client` → symlinked into MCP_CLIENT_BIN_SYMLINK (on default PATH); -// basename drops `.py` so the agent doesn't reflexively prefix with `python`. -const MCP_CLIENT_DIR = '/opt/tfy/mcp-client'; -const MCP_CLIENT_PATH = `${MCP_CLIENT_DIR}/mcp_client.py`; -const MCP_CLIENT_BIN_SYMLINK = '/usr/local/bin/mcp-client'; - // Per-server config sent to mcp_client.py via TFY_MCP_SERVERS. interface SandboxMcpServerConfig { allowed_tools: string[]; @@ -139,19 +140,28 @@ function injectTraceContextEnv(): Record { /** * Injects environment variables required for mcp_client.py into sandbox_exec arguments. * Transport-specific vars (NATS URL/prefix/timeout) come from `codeModeEnv` via transport.start(). + * PYTHONPATH / layout bin PATH come from transport.getClientInstall remotePath (Sandbox-derived). */ function injectMCPClientEnv(params: { env?: Record | undefined; mcpServers: Record; execExtraEnv?: Readonly> | undefined; codeModeEnv?: Record | undefined; + mcpClientInstall?: CodeModeClientInstall | undefined; }): Record { const codeModeEnv = params.codeModeEnv ?? {}; const hasCodeModeTransport = Object.keys(codeModeEnv).length > 0; + const install = params.mcpClientInstall; + const layout = install === undefined ? undefined : mcpClientLayout(install.remotePath); + const pathTail = params.env?.['PATH'] ?? params.execExtraEnv?.['PATH'] ?? DEFAULT_SANDBOX_PATH; return { ...(params.execExtraEnv ?? {}), ...(params.env ?? {}), - PYTHONPATH: MCP_CLIENT_DIR, + ...(layout !== undefined && { + PYTHONPATH: layout.pythonPath, + // Prepend layout bin; Daytona also keeps pathBinSymlink on /usr/local/bin. + PATH: `${layout.binDir}:${pathTail}`, + }), ...(Object.keys(params.mcpServers).length > 0 && { TFY_MCP_SERVERS: Buffer.from(JSON.stringify(params.mcpServers)).toString('base64'), }), @@ -210,12 +220,13 @@ export class Sandbox extends LocalToolMCP { private readonly execExtraEnv?: Readonly> | undefined; private readonly skillMounter?: ISkillMounter | undefined; private cachedSkillsSection?: string | undefined; - private readonly mcpClientScriptBase64: string; private readonly logger: Logger; // Pre-resolved credential-store file content (null = clear / no git auth). private readonly resolvedGitCredentialsContent: string | null; private codeModeDispatcher: CodeModeDispatcher | undefined; private codeModeTransport: CodeModeTransport | undefined; + /** Cached from transport.getClientInstall after sandbox init (when Code Mode is configured). */ + private mcpClientInstall: CodeModeClientInstall | undefined; private tools = [ defineTool({ @@ -236,10 +247,6 @@ export class Sandbox extends LocalToolMCP { const mcpBoundTimeoutMs = options.mcpRequestTimeoutMs + options.mcpConnectTimeoutMs; this.requestTimeoutSeconds = Math.ceil(mcpBoundTimeoutMs / 1000) + NATS_REQUEST_TIMEOUT_BUFFER_SECONDS; this.execExtraEnv = options.execExtraEnv; - // Scripts are internal to Sandbox: the upload paths, env contract, and prompt - // text are all hardcoded here, so injecting different content was never a - // real extension point. Consumers don't see or provide them. - this.mcpClientScriptBase64 = Buffer.from(sandboxScripts.mcpClient, 'utf-8').toString('base64'); this.logger = options.logger.child({ module: 'Sandbox' }); this.resolvedGitCredentialsContent = options.resolvedGitCredentialsContent ?? null; @@ -492,6 +499,7 @@ export class Sandbox extends LocalToolMCP { mcpServers: this.buildMcpServersEnvelope(), execExtraEnv: this.execExtraEnv, codeModeEnv, + mcpClientInstall: this.mcpClientInstall, }); const gitAuthEnv = this.resolvedGitCredentialsContent !== null @@ -593,18 +601,37 @@ export class Sandbox extends LocalToolMCP { } private async initSandboxEnvironment(): Promise { - const toolResultDumpDir = this.provider.getToolResultDumpDir(this.requiredSandboxInfo.sandbox_id); + const sandboxId = this.requiredSandboxInfo.sandbox_id; + const toolResultDumpDir = this.provider.getToolResultDumpDir(sandboxId); this.logger.info('Uploading MCP client script and preparing skills directory in sandbox'); - const initSteps = [ - `mkdir -p ${MCP_CLIENT_DIR} ${Sandbox.FILE_UPLOADS_DIR} ${toolResultDumpDir} ${SKILLS_DIR}`, - `rm -f ${MCP_CLIENT_PATH}`, - `echo '${this.mcpClientScriptBase64}' | base64 -d > ${MCP_CLIENT_PATH}`, - // Make executable + symlink onto PATH so `mcp-client` runs by name. - `chmod 0555 ${MCP_CLIENT_PATH}`, - `ln -sf ${MCP_CLIENT_PATH} ${MCP_CLIENT_BIN_SYMLINK}`, - ]; + this.mcpClientInstall = this.codeModeTransport?.getClientInstall({ sandboxId }); + const install = this.mcpClientInstall; + + const dirs = [shellEscape(Sandbox.FILE_UPLOADS_DIR), shellEscape(toolResultDumpDir), shellEscape(SKILLS_DIR)]; + if (install !== undefined) { + const { pythonPath, binDir } = mcpClientLayout(install.remotePath); + dirs.push(shellEscape(pythonPath), shellEscape(binDir)); + } + ensureExecSuccess(await this.provider.exec({ sandboxId, command: `mkdir -p ${dirs.join(' ')}` })); + + const initSteps: string[] = []; + if (install !== undefined) { + const { binLink } = mcpClientLayout(install.remotePath); + await this.provider.uploadFile({ + sandboxId, + remotePath: install.remotePath, + content: Buffer.from(install.content, 'utf-8'), + }); + initSteps.push( + `chmod 0555 ${shellEscape(install.remotePath)}`, + `ln -sf ${shellEscape(install.remotePath)} ${shellEscape(binLink)}`, + ); + if (install.pathBinSymlink !== undefined) { + initSteps.push(`ln -sf ${shellEscape(install.remotePath)} ${shellEscape(install.pathBinSymlink)}`); + } + } // Fold skill installation into this single init exec. The mounter returns a declarative // command + env + timeout; runs even when empty so its downloader can prune a reused sandbox. @@ -613,16 +640,21 @@ export class Sandbox extends LocalToolMCP { initSteps.push(skillInit.command); } - const script = initSteps.join(' && '); - const result = await this.provider.exec({ - sandboxId: this.requiredSandboxInfo.sandbox_id, - command: script, - env: skillInit?.env, - timeoutSeconds: skillInit?.timeoutSeconds, - }); - ensureExecSuccess(result); + if (initSteps.length > 0) { + ensureExecSuccess( + await this.provider.exec({ + sandboxId, + command: initSteps.join(' && '), + env: skillInit?.env, + timeoutSeconds: skillInit?.timeoutSeconds, + }), + ); + } + this.logger.info( - `Sandbox initialized: MCP client at ${MCP_CLIENT_PATH} (symlinked to ${MCP_CLIENT_BIN_SYMLINK}); skills dir ${SKILLS_DIR}`, + install === undefined + ? `Sandbox initialized: skills dir ${SKILLS_DIR}` + : `Sandbox initialized: MCP client at ${install.remotePath}; skills dir ${SKILLS_DIR}`, ); await this.writeGitCredentials(); diff --git a/packages/trueforge-core/src/core/sandbox/codeMode/CodeModeTransport.ts b/packages/trueforge-core/src/core/sandbox/codeMode/CodeModeTransport.ts index 3a144b7b9..e6fd4940e 100644 --- a/packages/trueforge-core/src/core/sandbox/codeMode/CodeModeTransport.ts +++ b/packages/trueforge-core/src/core/sandbox/codeMode/CodeModeTransport.ts @@ -1,10 +1,28 @@ import type { CodeModeDispatcher } from './CodeModeDispatcher'; +/** Payload for installing the Code Mode MCP client into a sandbox filesystem. */ +export interface CodeModeClientInstall { + /** UTF-8 Python source (installed as mcp_client.py). */ + content: string; + /** Absolute path for the Python module file inside the sandbox FS. */ + remotePath: string; + /** + * Optional extra CLI symlink on the default PATH (Daytona: `/usr/local/bin/mcp-client`). + * Layout bin link is always `dirname(remotePath)/bin/mcp-client` (Sandbox-derived). + */ + pathBinSymlink?: string | undefined; +} + /** * Code Mode channel. Construct eagerly with the dispatcher; connect/listen in `start`. * `start` may be called again after failure; `stop` is always valid after construct. */ export interface CodeModeTransport { + /** + * MCP client script + install path for this transport (sync; no listen required). + * Sandbox applies the install during init; channel env still comes from `start`. + */ + getClientInstall(params: { sandboxId: string }): CodeModeClientInstall; /** * Lazy connect/listen; may be called again after a failed attempt (non-sticky). * Idempotent once successfully started. diff --git a/packages/trueforge-core/src/core/sandbox/codeMode/nats/CodeModeNatsTransport.ts b/packages/trueforge-core/src/core/sandbox/codeMode/nats/CodeModeNatsTransport.ts index 6ef7d9698..cf8fb756e 100644 --- a/packages/trueforge-core/src/core/sandbox/codeMode/nats/CodeModeNatsTransport.ts +++ b/packages/trueforge-core/src/core/sandbox/codeMode/nats/CodeModeNatsTransport.ts @@ -7,10 +7,16 @@ import { WebSocket } from 'ws'; import { extractErrorLogFields } from '../../../util/errorLogFields'; import { withTimeout } from '../../../util/promiseUtils'; import { DEFAULT_SANDBOX_NATS_WS_PORT } from '../../constants'; +import { sandboxScripts } from '../../sandboxScripts.gen'; import type { CodeModeDispatcher } from '../CodeModeDispatcher'; -import type { CodeModeTransport } from '../CodeModeTransport'; +import type { CodeModeClientInstall, CodeModeTransport } from '../CodeModeTransport'; import { CodeModeRequestSchema, type CodeModeReply, type CodeModeRequest } from '../types'; +/** Stable install path inside Daytona / TFY sandbox images. */ +const MCP_CLIENT_PATH = '/opt/tfy/mcp-client/mcp_client.py'; +/** Product CLI entry already on the image PATH. */ +const MCP_CLIENT_PATH_BIN_SYMLINK = '/usr/local/bin/mcp-client'; + // `wsconnect` from @nats-io/nats-core relies on a global WebSocket constructor; in Node we // provide it from `ws`. Same pattern as `src/services/NatsService.ts`. Object.assign(global, { WebSocket }); @@ -46,6 +52,14 @@ export class CodeModeNatsTransport implements CodeModeTransport { this.logger = params.logger.child({ module: 'CodeModeNatsTransport' }); } + getClientInstall(_params: { sandboxId: string }): CodeModeClientInstall { + return { + content: sandboxScripts.mcpClient, + remotePath: MCP_CLIENT_PATH, + pathBinSymlink: MCP_CLIENT_PATH_BIN_SYMLINK, + }; + } + start(params: { codeModeDispatcher: CodeModeDispatcher; sandboxId: string; diff --git a/packages/trueforge-core/tests/core/sandbox/sandboxBridgeTimeout.test.ts b/packages/trueforge-core/tests/core/sandbox/sandboxBridgeTimeout.test.ts index 94209fcee..8d3d2d4b7 100644 --- a/packages/trueforge-core/tests/core/sandbox/sandboxBridgeTimeout.test.ts +++ b/packages/trueforge-core/tests/core/sandbox/sandboxBridgeTimeout.test.ts @@ -64,6 +64,11 @@ describe('Code Mode timeouts', () => { it('derives the Code Mode wait from MCP request + connect plus a buffer', async () => { let capturedTimeoutSeconds: number | undefined; const transport: CodeModeTransport = { + getClientInstall: () => ({ + content: '#!/usr/bin/env python3\nprint("mock")\n', + remotePath: '/opt/tfy/mcp-client/mcp_client.py', + pathBinSymlink: '/usr/local/bin/mcp-client', + }), start: params => { capturedTimeoutSeconds = params.requestTimeoutSeconds; return Promise.resolve({ From a5cd6721ce9addcfe750cb1316ed4c4712f19927 Mon Sep 17 00:00:00 2001 From: debajyoti-truefoundry Date: Fri, 14 Aug 2026 21:41:27 +0530 Subject: [PATCH 03/10] Add local sandbox server integration plan. Documents standalone fallback, v1 sandbox ids, and copy-into-server sequencing before package removal. Co-authored-by: Cursor --- .../local_sandbox_server_0829652e.plan.md | 238 ++++++++++++++++++ 1 file changed, 238 insertions(+) create mode 100644 .cursor/plans/local_sandbox_server_0829652e.plan.md diff --git a/.cursor/plans/local_sandbox_server_0829652e.plan.md b/.cursor/plans/local_sandbox_server_0829652e.plan.md new file mode 100644 index 000000000..730be695d --- /dev/null +++ b/.cursor/plans/local_sandbox_server_0829652e.plan.md @@ -0,0 +1,238 @@ +--- +name: Local sandbox server +overview: "Fold local-sandbox into packages/server and wire it as standalone in-memory fallback (capabilities on, no settings GET/DB). Fancy sandbox ids v1:type:raw; SandboxProvider.type; cross-type not carried forward." +todos: + - id: move-into-server + content: "Copy local-sandbox into server; wire + verify green; ask developer before deleting packages/local-sandbox" + status: pending + - id: schema-catalog + content: "Settings/catalog API stay Daytona-only (no local wire type); no synthetic local GET" + status: pending + - id: remove-tenant-ownership + content: "Delete validateSandboxOwnedByTenant + SandboxTenantMismatchError + all call sites (Sandbox x2, turns download) + core export; fix OpenAPI 403 copy" + status: pending + - id: local-ids + content: "Path sandboxIds; {data}/sandboxes + {tmpdir}/tf_cms; Sandbox owns v1:type:raw id helpers; SandboxProvider.type" + status: pending + - id: server-factory + content: "Runtime LocalSandboxProvider fallback; capabilities enabled when fallback; GET 404 if no row; carry-forward by type" + status: pending + - id: recreate-missing + content: "Same-type missing sandbox → recreate; cross-type → omit existing id (new create). Prefer type gate over blind restore" + status: pending + - id: ui-adapter + content: "UI: capabilities sandbox on with empty settings → Daytona still Available to configure; no local settings row" + status: pending + - id: tests + content: "Capabilities on without DB row; GET 404 empty; PUT Daytona; v1 id + carry-forward; same-type recreate" + status: pending +isProject: false +--- + +# Local sandbox server integration + +## Product rules (locked) + +- **Local only when `STANDALONE=true`** (from [`packages/server/src/config.ts`](packages/server/src/config.ts)). When `STANDALONE=false`, there is no local fallback path — only a Daytona DB row can enable sandbox (same as today). +- **No DB upsert for local.** The `sandbox_provider` table stays empty until the user configures Daytona. Local is an **in-memory runtime fallback** when there is no row and the host supports it. +- **GET settings does not return local** — no row → **404** as today. Local is invisible on the settings API. +- **Capabilities:** when local fallback applies, report sandbox (and skills) **enabled** even with no DB row. +- **PUT stays Daytona-only.** First Daytona PUT upgrades off implicit local. +- **Session continuity:** persisted `sandbox_id` uses `v1:provider_type:raw_id`. When starting a turn, if the id is `v1:`-prefixed and `provider_type` ≠ current provider → **do not carry forward** (create fresh). If `v1:` is absent (legacy) → carry forward as today. Same-type missing remote/local root → recreate (below). + +## Current gaps + +```mermaid +flowchart TD + Cap[GET capabilities] --> Check[checkSnapshotStatus] + Check --> Row{sandbox_provider row?} + Row -->|no| Disabled[sandbox disabled] + Row -->|yes| DaytonaOnly[always toDaytonaSandboxProvider] + Turn[Turn sandboxProvider] --> Resolve[resolveSandboxProvider] + Resolve --> DaytonaOnly +``` + +- Manifest/schema/catalog are Daytona-only ([`sandboxProvider.ts`](packages/server/src/schemas/sandboxProvider.ts), [`sandbox-catalog.yaml`](packages/server/catalog/sandbox-catalog.yaml)). +- [`resolveSandboxProvider`](packages/server/src/runtime/sessionResources.ts) always builds Daytona. +- [`LocalSandboxProvider.createSandbox`](packages/local-sandbox/src/provider/LocalSandboxProvider.ts) returns an absolute path as `sandboxId`. Tenant-prefix ownership checks are not needed for local (or Daytona session reattach): ids are not client-supplied. +- Settings UI hides Available once any provider is configured — with GET 404 + capabilities enabled, Available (Daytona) must remain visible so users can upgrade. + +## Design — interface / method surface + +### Move `@truefoundry/local-sandbox` into `packages/server` + +Local sandbox is server-only (standalone). **Copy first, delete later:** + +1. **Copy** `packages/local-sandbox` sources into [`packages/server/src/sandbox/local/`](packages/server/src/sandbox/local/) (provider, core, schemas, local Python client, codegen). Tests → [`packages/server/tests/sandbox/local/`](packages/server/tests/sandbox/local/). Scripts/smoke/lima → under server scripts. +2. Add needed deps to [`packages/server/package.json`](packages/server/package.json); wire resolve/capabilities/main to the **server copy**. +3. Prove green: server typecheck/tests + local smoke via server scripts; standalone fallback works. +4. **Stop and ask the developer** before deleting `packages/local-sandbox`. Do **not** remove the top-level package until explicitly confirmed. +5. After confirmation: remove `packages/local-sandbox`, drop root `typecheck` filter / lockfile entries, and any remaining `@truefoundry/local-sandbox` imports. + +Do not delete the top-level package in the same step as the first copy — keep it until the in-server path is verified **and** the developer approves removal. + +Local UDS `mcp_client` stays under `server/src/sandbox/local/` (tightly coupled); not merged with product NATS `mcp_client.py`. + +### New: sandbox ref helpers — [`packages/harness/src/core/sandbox/sandboxRef.ts`](packages/harness/src/core/sandbox/sandboxRef.ts) (name OK to adjust) + +```ts +export interface SandboxRefParts { + /** Provider kind from `SandboxProvider.type` (e.g. `daytona`, `local`) — plain string, not a closed union. */ + providerType: string; + rawId: string; +} + +/** `v1:type:raw` — raw may contain `:` (split only on first two colons after version). */ +export function formatSandboxId(parts: SandboxRefParts): string; + +/** + * Parse fancy id. No `v1:` prefix → `{ kind: 'legacy', rawId: fullString }`. + * Malformed `v1:` (too few segments) → `{ kind: 'legacy', rawId: fullString }` so carry-forward stays safe. + */ +export function parseSandboxId( + sandboxId: string, +): { kind: 'v1'; parts: SandboxRefParts } | { kind: 'legacy'; rawId: string }; + +/** Carry-forward gate for turn admit / download. */ +export function existingSandboxIdForProvider(params: { + existingSandboxId: string | undefined; + currentProviderType: string; +}): string | undefined; +``` + +Export from [`packages/harness/src/core/index.ts`](packages/harness/src/core/index.ts). + +### [`SandboxProvider`](packages/harness/src/core/sandbox/provider/Provider.ts) + +```ts +export interface SandboxProvider { + /** Stable provider kind used in fancy sandbox ids and carry-forward (plain string). */ + readonly type: string; + // ...existing methods unchanged; createSandbox still returns raw id only +} +``` + +- [`DaytonaSandboxProvider`](packages/harness/src/core/sandbox/provider/DaytonaProvider.ts): `readonly type = 'daytona'` +- [`LocalSandboxProvider`](packages/server/src/sandbox/local/provider/LocalSandboxProvider.ts): `readonly type = 'local'` +- Any other `SandboxProvider` impls (e.g. TFY) must set `type` as well + +`createSandbox(): Promise<{ sandboxId: string }>` still returns **raw** id only. + +### [`Sandbox` / `SandboxOptions`](packages/harness/src/core/sandbox/Sandbox.ts) + +No separate `providerType` / `providerName` on options — read `provider.type`. + +Behavior changes (methods unchanged externally): + +- Constructor: drop `validateSandboxOwnedByTenant`; if `existingSandboxId` set, store fancy or legacy as session id; compute **raw** via `parseSandboxId` for provider calls. +- `ensureSandboxCreated`: on create, `formatSandboxId({ providerType: provider.type, rawId })` before `SANDBOX_CREATED` / `SandboxInfo`. +- All `provider.*` calls use **raw** id only. +- Same-type missing: on `SandboxNotAvailableError` from provider while reattaching, clear existing, `createSandbox()`, emit new fancy id (recreate path). + +Remove dead: ownership-only `tenantName` usage (keep `TFY_TENANT_NAME` in `execExtraEnv` only if still needed for Daytona/agent env). + +### Server resolve / build + +[`resolveSandboxProvider`](packages/server/src/runtime/sessionResources.ts) return type becomes: + +```ts +Promise; +// provider.type discriminates daytona vs local +``` + +- DB Daytona row → `DaytonaSandboxProvider` (`type: 'daytona'`) +- No row + STANDALONE + `LocalSandboxProvider.isSupported()` → `LocalSandboxProvider` (`type: 'local'`, no store write) +- Else → `undefined` + +[`buildTurnSandbox`](packages/server/src/runtime/sessionResources.ts): + +```ts +export function buildTurnSandbox(input: { + provider: SandboxProvider; + logger: Logger; + gitSkills: readonly GitSkill[]; + fileDownloadEnabled: boolean; + existingSandboxId?: string | undefined; // gate with existingSandboxIdForProvider({ ..., currentProviderType: provider.type }) + tracing: AgentTracing; + tenantName: string; // DaytonaSandboxProvider construction / optional TFY_TENANT_NAME for Daytona only +}): Sandbox; +``` + +Call sites ([`turns.ts`](packages/server/src/apis/turns.ts) factory + download): + +- `existingSandboxIdForProvider({ existingSandboxId, currentProviderType: provider.type })` before `buildTurnSandbox`. +- Download: `parseSandboxId` → raw → `provider.downloadFile({ sandboxId: raw, path })`. + +### Schemas — [`sandboxProvider.ts`](packages/server/src/schemas/sandboxProvider.ts) + +- **No `type: 'local'` on the wire.** GET/PUT/catalog stay Daytona-only (current schemas). +- Local exists only as runtime `SandboxProvider.type === 'local'`, not as a settings manifest. + +### Settings / capabilities / status helpers + +- [`sandboxProviders.ts` GET](packages/server/src/apis/sandboxProviders.ts): **unchanged** — no row → 404 (do **not** synthesize local). +- PUT: Daytona-only; first PUT inserts Daytona row (upgrades off implicit local). +- [`capabilities.ts`](packages/server/src/apis/capabilities.ts) / status helper: no row + STANDALONE + `isSupported` → sandbox/skills **enabled** (`ready`) without Daytona SDK or store write. +- [`validateAgentSpec`](packages/server/src/runtime/sessionResources.ts): sandbox/skills OK when resolve would return a provider (row **or** local fallback). + +### Config + process lifecycle + +[`config.ts`](packages/server/src/config.ts) — **derived only** (no new user-facing env vars for now): + +- `LOCAL_SANDBOX_ROOT_PARENT` = `join(envPaths('trueforge').data, 'sandboxes')` +- `CODE_MODE_SOCKET_PARENT` = `join(os.tmpdir(), 'tf_cms')` + +[`main.ts`](packages/server/src/main.ts): + +- `prepareCodeModeSocketParent()` at startup: exists → warn; `rm` + `mkdir 0700` +- shutdown hook: `rm` `tf_cms` +- `mkdir` sandboxes parent as needed (no delete on shutdown) + +### Local provider (in server) + +[`LocalSandboxProvider`](packages/server/src/sandbox/local/provider/LocalSandboxProvider.ts) (after move): + +- Options unchanged shape (no `tenantName`); construct with derived paths + `support`. +- Ops on missing/nonexistent root → `SandboxNotAvailableError` (enables Sandbox recreate). +- `createSandbox` still returns absolute path raw id. +- No separate `@truefoundry/local-sandbox` dependency. + +### UI / adapter + +- Settings GET 404 + capabilities `sandbox.enabled` → no configured provider row in UI; **keep Daytona in Available** so users can configure/upgrade. +- After Daytona PUT → normal Daytona configured UI. +- No adapter mapping for `type: 'local'` settings payload (none returned). +- Composer/agent UI already keys off capabilities for sandbox/skills enablement — that path lights up without a settings row. + +### Removals — delete `validateSandboxOwnedByTenant` from the codebase + +Full delete (no shim, no “Daytona-only” keep): + +| Location | Action | +|---|---| +| [`SandboxErrors.ts`](packages/harness/src/core/sandbox/SandboxErrors.ts) | Delete `validateSandboxOwnedByTenant` and `SandboxTenantMismatchError` | +| [`core/index.ts`](packages/harness/src/core/index.ts) | Remove export | +| [`Sandbox.ts`](packages/harness/src/core/sandbox/Sandbox.ts) constructor | Remove call + import | +| [`Sandbox.ts`](packages/harness/src/core/sandbox/Sandbox.ts) `ensureSandboxCreated` | Remove call after `createSandbox` | +| [`turns.ts`](packages/server/src/apis/turns.ts) download handler | Remove call + import | +| [`turnRoutes.ts`](packages/server/src/routes/turnRoutes.ts) download 403 description | Drop “sandbox belongs to another tenant” wording | + +Rationale: `sandbox_id` is never client-supplied; download already authorizes via session tenant + `checkTurnAccess` + turn loaded through that session. + +## Tests + +- Assert no remaining references to `validateSandboxOwnedByTenant` / `SandboxTenantMismatchError`. +- Empty store + standalone + supported → capabilities sandbox/skills **enabled**; GET settings still **404**; store still empty. +- PUT Daytona on empty works; GET then Daytona; capabilities still enabled via row. +- Fancy id helpers + Sandbox wrap/unwrap; carry-forward drops on type mismatch; legacy non-`v1:` still carried. +- Same-type missing → recreate + new id in snapshot. +- Download unwraps fancy id to raw before `provider.downloadFile` (import helpers). +- UI: empty settings + capabilities on → Daytona Available; after Daytona → configured. + +## Out of scope + +- Merging product NATS `mcp_client.py` with the local UDS client — keep the local Python client **under** `server/src/sandbox/local/` (tightly coupled to UDS transport); do not unify modules. +- Multi-provider rows (still singleton per tenant). +- Persisting or returning `type: 'local'` on settings/catalog API. +- Local upsert / local PUT / synthetic local GET. +- Removing `packages/local-sandbox` without an explicit developer go-ahead after the server copy is green. From 01a371aa89b0e4fdf65964fef976ff4b38645323 Mon Sep 17 00:00:00 2001 From: Chirag Jain Date: Mon, 17 Aug 2026 16:49:41 +0530 Subject: [PATCH 04/10] WIP: local sandbox impl --- ...10000-local-sandbox-standalone-fallback.md | 7 + .../local_sandbox_server_0829652e.plan.md | 153 +- .gitignore | 1 + .../src/provider/LocalSandboxProvider.ts | 1 + packages/trueforge-core/src/core/index.ts | 3 +- .../src/core/sandbox/Sandbox.ts | 183 +- .../src/core/sandbox/SandboxErrors.ts | 20 +- .../core/sandbox/provider/DaytonaProvider.ts | 1 + .../src/core/sandbox/provider/Provider.ts | 2 + .../sandbox/provider/TFYSandboxProvider.ts | 1 + .../src/core/sandbox/sandboxRef.ts | 55 + .../trueforge-core/tests/core/harnessMocks.ts | 1 + .../tests/core/sandbox/Sandbox.ids.test.ts | 107 + .../core/sandbox/ownershipRemoved.test.ts | 8 + .../core/sandbox/sandboxBridgeTimeout.test.ts | 1 + .../tests/core/sandbox/sandboxRef.test.ts | 47 + .../trueforge/jest.local-contract.config.cjs | 10 + .../trueforge/jest.local-smoke.config.cjs | 10 + packages/trueforge/jest.unit.config.cjs | 2 + packages/trueforge/lima/local-sandbox.yaml | 50 + packages/trueforge/package.json | 10 +- packages/trueforge/scripts/generate-dev.mjs | 14 + .../generate-local-sandbox-scripts.mjs | 22 + .../scripts/local-sandbox/probe-loopback.ts | 232 ++ .../scripts/local-sandbox/smoke-lima.sh | 36 + packages/trueforge/src/apis/capabilities.ts | 3 +- packages/trueforge/src/apis/turns.ts | 20 +- packages/trueforge/src/config.ts | 23 + packages/trueforge/src/main.ts | 19 + packages/trueforge/src/routes/turnRoutes.ts | 2 +- .../trueforge/src/runtime/sessionResources.ts | 41 +- .../local/core/CodeModeUdsTransport.ts | 271 +++ .../trueforge/src/sandbox/local/core/frame.ts | 36 + .../src/sandbox/local/core/hostRun.ts | 504 +++++ packages/trueforge/src/sandbox/local/index.ts | 5 + .../local/provider/LocalSandboxProvider.ts | 409 ++++ .../src/sandbox/local/schemas/jsonMessage.ts | 4 + .../src/sandbox/local/schemas/xferFileInfo.ts | 8 + .../sandbox/local/scripts/mcp_client_local.py | 277 +++ .../trueforge/src/sandbox/localLifecycle.ts | 22 + .../trueforge/src/sandbox/localRuntime.ts | 21 + .../tests/sandbox/local/smoke.test.ts | 1975 +++++++++++++++++ .../tests/unit/apis/capabilities.test.ts | 28 + .../unit/runtime/sessionResources.test.ts | 27 + .../codeModeUdsTransport.contract.test.ts | 148 ++ .../sandbox/local/provider/contract.test.ts | 33 + .../local/provider/missingRoot.test.ts | 25 + packages/trueforge/tests/unit/tsconfig.json | 3 +- packages/trueforge/tsup.config.ts | 2 + pnpm-lock.yaml | 3 + 50 files changed, 4728 insertions(+), 158 deletions(-) create mode 100644 .changeset/20260817110000-local-sandbox-standalone-fallback.md create mode 100644 packages/trueforge-core/src/core/sandbox/sandboxRef.ts create mode 100644 packages/trueforge-core/tests/core/sandbox/Sandbox.ids.test.ts create mode 100644 packages/trueforge-core/tests/core/sandbox/ownershipRemoved.test.ts create mode 100644 packages/trueforge-core/tests/core/sandbox/sandboxRef.test.ts create mode 100644 packages/trueforge/jest.local-contract.config.cjs create mode 100644 packages/trueforge/jest.local-smoke.config.cjs create mode 100644 packages/trueforge/lima/local-sandbox.yaml create mode 100644 packages/trueforge/scripts/generate-dev.mjs create mode 100644 packages/trueforge/scripts/generate-local-sandbox-scripts.mjs create mode 100644 packages/trueforge/scripts/local-sandbox/probe-loopback.ts create mode 100755 packages/trueforge/scripts/local-sandbox/smoke-lima.sh create mode 100644 packages/trueforge/src/sandbox/local/core/CodeModeUdsTransport.ts create mode 100644 packages/trueforge/src/sandbox/local/core/frame.ts create mode 100644 packages/trueforge/src/sandbox/local/core/hostRun.ts create mode 100644 packages/trueforge/src/sandbox/local/index.ts create mode 100644 packages/trueforge/src/sandbox/local/provider/LocalSandboxProvider.ts create mode 100644 packages/trueforge/src/sandbox/local/schemas/jsonMessage.ts create mode 100644 packages/trueforge/src/sandbox/local/schemas/xferFileInfo.ts create mode 100644 packages/trueforge/src/sandbox/local/scripts/mcp_client_local.py create mode 100644 packages/trueforge/src/sandbox/localLifecycle.ts create mode 100644 packages/trueforge/src/sandbox/localRuntime.ts create mode 100644 packages/trueforge/tests/sandbox/local/smoke.test.ts create mode 100644 packages/trueforge/tests/unit/sandbox/local/codeModeUdsTransport.contract.test.ts create mode 100644 packages/trueforge/tests/unit/sandbox/local/provider/contract.test.ts create mode 100644 packages/trueforge/tests/unit/sandbox/local/provider/missingRoot.test.ts diff --git a/.changeset/20260817110000-local-sandbox-standalone-fallback.md b/.changeset/20260817110000-local-sandbox-standalone-fallback.md new file mode 100644 index 000000000..40ede7996 --- /dev/null +++ b/.changeset/20260817110000-local-sandbox-standalone-fallback.md @@ -0,0 +1,7 @@ +--- +'@truefoundry/trueforge-core': patch +'@truefoundry/trueforge': patch +'@truefoundry/trueforge-ui': patch +--- + +Enable a standalone in-memory local sandbox fallback (no settings row), persist fancy `v1:type:raw` sandbox ids, and drop tenant-prefix ownership checks. diff --git a/.cursor/plans/local_sandbox_server_0829652e.plan.md b/.cursor/plans/local_sandbox_server_0829652e.plan.md index 730be695d..860fc03ca 100644 --- a/.cursor/plans/local_sandbox_server_0829652e.plan.md +++ b/.cursor/plans/local_sandbox_server_0829652e.plan.md @@ -1,31 +1,34 @@ --- name: Local sandbox server -overview: "Fold local-sandbox into packages/server and wire it as standalone in-memory fallback (capabilities on, no settings GET/DB). Fancy sandbox ids v1:type:raw; SandboxProvider.type; cross-type not carried forward." +overview: Fold local-sandbox into packages/trueforge and wire it as standalone in-memory fallback (capabilities on, no settings GET/DB). Fancy sandbox ids v1:type:raw; SandboxProvider.type; cross-type not carried forward. todos: - id: move-into-server - content: "Copy local-sandbox into server; wire + verify green; ask developer before deleting packages/local-sandbox" - status: pending + content: Copy current local-sandbox into trueforge; wire + verify green; ask developer before deleting packages/local-sandbox + status: completed - id: schema-catalog - content: "Settings/catalog API stay Daytona-only (no local wire type); no synthetic local GET" - status: pending + content: Settings/catalog API stay Daytona-only (no local wire type); no synthetic local GET + status: completed - id: remove-tenant-ownership - content: "Delete validateSandboxOwnedByTenant + SandboxTenantMismatchError + all call sites (Sandbox x2, turns download) + core export; fix OpenAPI 403 copy" - status: pending + content: Delete validateSandboxOwnedByTenant + SandboxTenantMismatchError + all call sites (Sandbox x2, turns download) + core export; fix OpenAPI 403 copy + status: completed - id: local-ids - content: "Path sandboxIds; {data}/sandboxes + {tmpdir}/tf_cms; Sandbox owns v1:type:raw id helpers; SandboxProvider.type" - status: pending + content: Path sandboxIds; {data}/sandboxes + {tmpdir}/tf_cms; Sandbox owns v1:type:raw id helpers; SandboxProvider.type + status: completed - id: server-factory - content: "Runtime LocalSandboxProvider fallback; capabilities enabled when fallback; GET 404 if no row; carry-forward by type" - status: pending + content: Runtime LocalSandboxProvider fallback; cache isSupported at boot; capabilities enabled when fallback; GET 404 if no row; carry-forward by type + status: completed - id: recreate-missing - content: "Same-type missing sandbox → recreate; cross-type → omit existing id (new create). Prefer type gate over blind restore" - status: pending + content: Same-type missing sandbox → recreate; cross-type → omit existing id (new create). Prefer type gate over blind restore + status: completed - id: ui-adapter - content: "UI: capabilities sandbox on with empty settings → Daytona still Available to configure; no local settings row" - status: pending + content: "UI test: capabilities sandbox on with empty settings → Daytona still Available; no local settings row" + status: completed - id: tests - content: "Capabilities on without DB row; GET 404 empty; PUT Daytona; v1 id + carry-forward; same-type recreate" - status: pending + content: Capabilities on without DB row; GET 404 empty; PUT Daytona; v1 id + carry-forward; same-type recreate + status: completed + - id: changeset + content: Add .changeset for trueforge-core + trueforge (+ trueforge-ui if UI tests/adapter change) + status: completed isProject: false --- @@ -33,7 +36,7 @@ isProject: false ## Product rules (locked) -- **Local only when `STANDALONE=true`** (from [`packages/server/src/config.ts`](packages/server/src/config.ts)). When `STANDALONE=false`, there is no local fallback path — only a Daytona DB row can enable sandbox (same as today). +- **Local only when `STANDALONE=true`** (from [`packages/trueforge/src/config.ts`](packages/trueforge/src/config.ts)). When `STANDALONE=false`, there is no local fallback path — only a Daytona DB row can enable sandbox (same as today). - **No DB upsert for local.** The `sandbox_provider` table stays empty until the user configures Daytona. Local is an **in-memory runtime fallback** when there is no row and the host supports it. - **GET settings does not return local** — no row → **404** as today. Local is invisible on the settings API. - **Capabilities:** when local fallback applies, report sandbox (and skills) **enabled** even with no DB row. @@ -52,28 +55,28 @@ flowchart TD Resolve --> DaytonaOnly ``` -- Manifest/schema/catalog are Daytona-only ([`sandboxProvider.ts`](packages/server/src/schemas/sandboxProvider.ts), [`sandbox-catalog.yaml`](packages/server/catalog/sandbox-catalog.yaml)). -- [`resolveSandboxProvider`](packages/server/src/runtime/sessionResources.ts) always builds Daytona. +- Manifest/schema/catalog are Daytona-only ([`sandboxProvider.ts`](packages/trueforge/src/schemas/sandboxProvider.ts), [`sandbox-catalog.yaml`](packages/trueforge/catalog/sandbox-catalog.yaml)). +- [`resolveSandboxProvider`](packages/trueforge/src/runtime/sessionResources.ts) always builds Daytona. - [`LocalSandboxProvider.createSandbox`](packages/local-sandbox/src/provider/LocalSandboxProvider.ts) returns an absolute path as `sandboxId`. Tenant-prefix ownership checks are not needed for local (or Daytona session reattach): ids are not client-supplied. -- Settings UI hides Available once any provider is configured — with GET 404 + capabilities enabled, Available (Daytona) must remain visible so users can upgrade. +- Settings UI already maps GET 404 → empty list and shows Available when `providers.length === 0`. Keep that: capabilities-on + empty settings must still show Daytona in Available so users can upgrade. UI work is a regression test, not a new hide/show rule. ## Design — interface / method surface -### Move `@truefoundry/local-sandbox` into `packages/server` +### Move `@truefoundry/local-sandbox` into `packages/trueforge` -Local sandbox is server-only (standalone). **Copy first, delete later:** +Local sandbox is server-only (standalone). **Copy first, delete later.** Copy the **current** `packages/local-sandbox` tree (already has `build:gen` / `sandboxScripts.gen.ts`, `mcp_client_local.py`, `getClientInstall`) — not an older snapshot. -1. **Copy** `packages/local-sandbox` sources into [`packages/server/src/sandbox/local/`](packages/server/src/sandbox/local/) (provider, core, schemas, local Python client, codegen). Tests → [`packages/server/tests/sandbox/local/`](packages/server/tests/sandbox/local/). Scripts/smoke/lima → under server scripts. -2. Add needed deps to [`packages/server/package.json`](packages/server/package.json); wire resolve/capabilities/main to the **server copy**. -3. Prove green: server typecheck/tests + local smoke via server scripts; standalone fallback works. +1. **Copy** sources into [`packages/trueforge/src/sandbox/local/`](packages/trueforge/src/sandbox/local/) (provider, core, schemas, local Python client, codegen). Unit/contract tests → [`packages/trueforge/tests/unit/sandbox/local/`](packages/trueforge/tests/unit/sandbox/local/) so [`jest.unit.config.cjs`](packages/trueforge/jest.unit.config.cjs) (`tests/unit/**/*.test.ts`) picks them up. Smoke/lima/probe stay as **scripts** on `@truefoundry/trueforge` (`smoke:local`, `smoke:local:lima`) — do not put `smoke.test.ts` on the unit Jest run. +2. Add `@anthropic-ai/sandbox-runtime` (and any other local-sandbox deps) to [`packages/trueforge/package.json`](packages/trueforge/package.json). Wire local script codegen into trueforge `build:gen` (or a sibling `build:gen:local-sandbox` that `build` / `typecheck` / `test` invoke). Gitignore the generated `sandboxScripts.gen.ts` under trueforge (same as today’s local-sandbox gitignore). +3. Wire resolve / capabilities / main to the **trueforge copy**. Prove green: trueforge typecheck + unit tests + local smoke via the new scripts; standalone fallback works. 4. **Stop and ask the developer** before deleting `packages/local-sandbox`. Do **not** remove the top-level package until explicitly confirmed. -5. After confirmation: remove `packages/local-sandbox`, drop root `typecheck` filter / lockfile entries, and any remaining `@truefoundry/local-sandbox` imports. +5. After confirmation: remove `packages/local-sandbox`, drop the root [`package.json`](package.json) `typecheck` filter for `@truefoundry/local-sandbox`, refresh the lockfile, and drop any remaining `@truefoundry/local-sandbox` imports. `local-sandbox` is not on the CI test matrix today (only root typecheck); after the fold, unit tests run as part of the `trueforge` package job. Do not delete the top-level package in the same step as the first copy — keep it until the in-server path is verified **and** the developer approves removal. -Local UDS `mcp_client` stays under `server/src/sandbox/local/` (tightly coupled); not merged with product NATS `mcp_client.py`. +Local UDS `mcp_client` stays under `packages/trueforge/src/sandbox/local/` (tightly coupled); not merged with product NATS `mcp_client.py`. -### New: sandbox ref helpers — [`packages/harness/src/core/sandbox/sandboxRef.ts`](packages/harness/src/core/sandbox/sandboxRef.ts) (name OK to adjust) +### New: sandbox ref helpers — [`packages/trueforge-core/src/core/sandbox/sandboxRef.ts`](packages/trueforge-core/src/core/sandbox/sandboxRef.ts) (name OK to adjust) ```ts export interface SandboxRefParts { @@ -100,9 +103,9 @@ export function existingSandboxIdForProvider(params: { }): string | undefined; ``` -Export from [`packages/harness/src/core/index.ts`](packages/harness/src/core/index.ts). +Export from [`packages/trueforge-core/src/core/index.ts`](packages/trueforge-core/src/core/index.ts). -### [`SandboxProvider`](packages/harness/src/core/sandbox/provider/Provider.ts) +### [`SandboxProvider`](packages/trueforge-core/src/core/sandbox/provider/Provider.ts) ```ts export interface SandboxProvider { @@ -112,13 +115,13 @@ export interface SandboxProvider { } ``` -- [`DaytonaSandboxProvider`](packages/harness/src/core/sandbox/provider/DaytonaProvider.ts): `readonly type = 'daytona'` -- [`LocalSandboxProvider`](packages/server/src/sandbox/local/provider/LocalSandboxProvider.ts): `readonly type = 'local'` -- Any other `SandboxProvider` impls (e.g. TFY) must set `type` as well +- [`DaytonaSandboxProvider`](packages/trueforge-core/src/core/sandbox/provider/DaytonaProvider.ts): `readonly type = 'daytona'` +- [`LocalSandboxProvider`](packages/trueforge/src/sandbox/local/provider/LocalSandboxProvider.ts): `readonly type = 'local'` +- [`TFYSandboxProvider`](packages/trueforge-core/src/core/sandbox/provider/TFYSandboxProvider.ts) must set `type` as well `createSandbox(): Promise<{ sandboxId: string }>` still returns **raw** id only. -### [`Sandbox` / `SandboxOptions`](packages/harness/src/core/sandbox/Sandbox.ts) +### [`Sandbox` / `SandboxOptions`](packages/trueforge-core/src/core/sandbox/Sandbox.ts) No separate `providerType` / `providerName` on options — read `provider.type`. @@ -127,13 +130,13 @@ Behavior changes (methods unchanged externally): - Constructor: drop `validateSandboxOwnedByTenant`; if `existingSandboxId` set, store fancy or legacy as session id; compute **raw** via `parseSandboxId` for provider calls. - `ensureSandboxCreated`: on create, `formatSandboxId({ providerType: provider.type, rawId })` before `SANDBOX_CREATED` / `SandboxInfo`. - All `provider.*` calls use **raw** id only. -- Same-type missing: on `SandboxNotAvailableError` from provider while reattaching, clear existing, `createSandbox()`, emit new fancy id (recreate path). +- Same-type missing: on `SandboxNotAvailableError` from provider while reattaching, clear existing, `createSandbox()`, emit new fancy id (recreate path). This path does **not** exist today — `ensureSandboxCreated` just reuses `existingSandboxInfo`. Remove dead: ownership-only `tenantName` usage (keep `TFY_TENANT_NAME` in `execExtraEnv` only if still needed for Daytona/agent env). ### Server resolve / build -[`resolveSandboxProvider`](packages/server/src/runtime/sessionResources.ts) return type becomes: +[`resolveSandboxProvider`](packages/trueforge/src/runtime/sessionResources.ts) return type becomes: ```ts Promise; @@ -141,10 +144,12 @@ Promise; ``` - DB Daytona row → `DaytonaSandboxProvider` (`type: 'daytona'`) -- No row + STANDALONE + `LocalSandboxProvider.isSupported()` → `LocalSandboxProvider` (`type: 'local'`, no store write) +- No row + `STANDALONE` + cached support probe is supported → `LocalSandboxProvider` (`type: 'local'`, no store write) - Else → `undefined` -[`buildTurnSandbox`](packages/server/src/runtime/sessionResources.ts): +**Cache `LocalSandboxProvider.isSupported()` once** (process start or first use). The probe inits SRT and creates a temp sandbox — do not call it on every GET `/capabilities`, `validateAgentSpec`, or turn. + +[`buildTurnSandbox`](packages/trueforge/src/runtime/sessionResources.ts): ```ts export function buildTurnSandbox(input: { @@ -158,51 +163,59 @@ export function buildTurnSandbox(input: { }): Sandbox; ``` -Call sites ([`turns.ts`](packages/server/src/apis/turns.ts) factory + download): +Call sites ([`turns.ts`](packages/trueforge/src/apis/turns.ts) factory + download): - `existingSandboxIdForProvider({ existingSandboxId, currentProviderType: provider.type })` before `buildTurnSandbox`. - Download: `parseSandboxId` → raw → `provider.downloadFile({ sandboxId: raw, path })`. -### Schemas — [`sandboxProvider.ts`](packages/server/src/schemas/sandboxProvider.ts) +### Schemas — [`sandboxProvider.ts`](packages/trueforge/src/schemas/sandboxProvider.ts) - **No `type: 'local'` on the wire.** GET/PUT/catalog stay Daytona-only (current schemas). - Local exists only as runtime `SandboxProvider.type === 'local'`, not as a settings manifest. ### Settings / capabilities / status helpers -- [`sandboxProviders.ts` GET](packages/server/src/apis/sandboxProviders.ts): **unchanged** — no row → 404 (do **not** synthesize local). +- [`sandboxProviders.ts` GET](packages/trueforge/src/apis/sandboxProviders.ts): **unchanged** — no row → 404 (do **not** synthesize local). - PUT: Daytona-only; first PUT inserts Daytona row (upgrades off implicit local). -- [`capabilities.ts`](packages/server/src/apis/capabilities.ts) / status helper: no row + STANDALONE + `isSupported` → sandbox/skills **enabled** (`ready`) without Daytona SDK or store write. -- [`validateAgentSpec`](packages/server/src/runtime/sessionResources.ts): sandbox/skills OK when resolve would return a provider (row **or** local fallback). +- [`capabilities.ts`](packages/trueforge/src/apis/capabilities.ts) / status helper: no row + `STANDALONE` + cached support → sandbox/skills **enabled** (`ready`) without Daytona SDK or store write. +- [`validateAgentSpec`](packages/trueforge/src/runtime/sessionResources.ts): sandbox/skills OK when resolve would return a provider (row **or** local fallback). Existing unit tests that require a DB row must be updated for the standalone+supported case. ### Config + process lifecycle -[`config.ts`](packages/server/src/config.ts) — **derived only** (no new user-facing env vars for now): +[`config.ts`](packages/trueforge/src/config.ts) — **derived only** (no new user-facing env vars). Fields live on **`StandaloneServerConfiguration` only** (local fallback is `STANDALONE=true`-only): -- `LOCAL_SANDBOX_ROOT_PARENT` = `join(envPaths('trueforge').data, 'sandboxes')` +- `LOCAL_SANDBOX_ROOT_PARENT` = `join(envPaths('trueforge', { suffix: '' }).data, 'sandboxes')` — same `{ suffix: '' }` as `SQLITE_PATH` so sandboxes sit next to the DB, not under `trueforge-nodejs`. - `CODE_MODE_SOCKET_PARENT` = `join(os.tmpdir(), 'tf_cms')` -[`main.ts`](packages/server/src/main.ts): +Reads go through `configuration`, not `process.env`. + +[`main.ts`](packages/trueforge/src/main.ts) (standalone only): + +- `prepareCodeModeSocketParent()` at **every** standalone startup (including `tsx watch` restarts): exists → warn; `rm` + `mkdir 0700`. This is the reliability path for leftover sockets. +- `mkdir` sandboxes parent as needed (no delete on shutdown). +- Shutdown `rm` of `tf_cms` only in the **existing** production drain hook (`NODE_ENV !== 'development'`). Do **not** add a special watch-mode shutdown — watch already skips drain so `tsx` can restart; the next start’s `prepare` cleans leftovers. -- `prepareCodeModeSocketParent()` at startup: exists → warn; `rm` + `mkdir 0700` -- shutdown hook: `rm` `tf_cms` -- `mkdir` sandboxes parent as needed (no delete on shutdown) +Probe cache: run `LocalSandboxProvider.isSupported()` once during standalone boot (after socket-parent prepare) and pass the result into resolve/capabilities. If unsupported, local fallback is off (capabilities stay disabled until a Daytona row exists). -### Local provider (in server) +### Local provider (in trueforge) -[`LocalSandboxProvider`](packages/server/src/sandbox/local/provider/LocalSandboxProvider.ts) (after move): +[`LocalSandboxProvider`](packages/trueforge/src/sandbox/local/provider/LocalSandboxProvider.ts) (after move): -- Options unchanged shape (no `tenantName`); construct with derived paths + `support`. -- Ops on missing/nonexistent root → `SandboxNotAvailableError` (enables Sandbox recreate). +- `readonly type = 'local'`. +- Options unchanged shape (no `tenantName`); construct with derived paths + cached `support`. +- **New work:** ops on a missing/nonexistent root must throw `SandboxNotAvailableError` (today they become `SandboxFileNotFoundError` or generic errors). That is what enables Sandbox recreate. - `createSandbox` still returns absolute path raw id. -- No separate `@truefoundry/local-sandbox` dependency. +- No separate `@truefoundry/local-sandbox` dependency after the copy is the runtime path. ### UI / adapter -- Settings GET 404 + capabilities `sandbox.enabled` → no configured provider row in UI; **keep Daytona in Available** so users can configure/upgrade. +Adapter already maps GET 404 → `[]` ([`sandboxProviderCatalog.ts`](packages/trueforge-ui/src/plugins/trueforge-agent-server-adapter/catalogs/sandboxProviderCatalog.ts)); Available stays visible when the list is empty. + +- Settings GET 404 + capabilities `sandbox.enabled` → no configured provider row; **Daytona stays in Available**. - After Daytona PUT → normal Daytona configured UI. - No adapter mapping for `type: 'local'` settings payload (none returned). -- Composer/agent UI already keys off capabilities for sandbox/skills enablement — that path lights up without a settings row. +- Composer/agent UI already keys off capabilities for sandbox/skills enablement. +- Add a UI test for empty settings + capabilities on → Daytona Available; after Daytona → configured. ### Removals — delete `validateSandboxOwnedByTenant` from the codebase @@ -210,29 +223,35 @@ Full delete (no shim, no “Daytona-only” keep): | Location | Action | |---|---| -| [`SandboxErrors.ts`](packages/harness/src/core/sandbox/SandboxErrors.ts) | Delete `validateSandboxOwnedByTenant` and `SandboxTenantMismatchError` | -| [`core/index.ts`](packages/harness/src/core/index.ts) | Remove export | -| [`Sandbox.ts`](packages/harness/src/core/sandbox/Sandbox.ts) constructor | Remove call + import | -| [`Sandbox.ts`](packages/harness/src/core/sandbox/Sandbox.ts) `ensureSandboxCreated` | Remove call after `createSandbox` | -| [`turns.ts`](packages/server/src/apis/turns.ts) download handler | Remove call + import | -| [`turnRoutes.ts`](packages/server/src/routes/turnRoutes.ts) download 403 description | Drop “sandbox belongs to another tenant” wording | +| [`SandboxErrors.ts`](packages/trueforge-core/src/core/sandbox/SandboxErrors.ts) | Delete `validateSandboxOwnedByTenant` and `SandboxTenantMismatchError` | +| [`core/index.ts`](packages/trueforge-core/src/core/index.ts) | Remove export | +| [`Sandbox.ts`](packages/trueforge-core/src/core/sandbox/Sandbox.ts) constructor | Remove call + import | +| [`Sandbox.ts`](packages/trueforge-core/src/core/sandbox/Sandbox.ts) `ensureSandboxCreated` | Remove call after `createSandbox` | +| [`turns.ts`](packages/trueforge/src/apis/turns.ts) download handler | Remove call + import | +| [`turnRoutes.ts`](packages/trueforge/src/routes/turnRoutes.ts) download 403 description | Drop “sandbox belongs to another tenant” wording | Rationale: `sandbox_id` is never client-supplied; download already authorizes via session tenant + `checkTurnAccess` + turn loaded through that session. +### Changeset + +Published-package change (`trueforge-core`, `trueforge`, and `trueforge-ui` if the adapter/test lands there). Add a `.changeset/*.md` via `pnpm changeset`. + ## Tests - Assert no remaining references to `validateSandboxOwnedByTenant` / `SandboxTenantMismatchError`. -- Empty store + standalone + supported → capabilities sandbox/skills **enabled**; GET settings still **404**; store still empty. +- Empty store + standalone + cached support → capabilities sandbox/skills **enabled**; GET settings still **404**; store still empty. - PUT Daytona on empty works; GET then Daytona; capabilities still enabled via row. - Fancy id helpers + Sandbox wrap/unwrap; carry-forward drops on type mismatch; legacy non-`v1:` still carried. -- Same-type missing → recreate + new id in snapshot. +- Same-type missing → recreate + new id in snapshot (`SandboxNotAvailableError` from provider). - Download unwraps fancy id to raw before `provider.downloadFile` (import helpers). - UI: empty settings + capabilities on → Daytona Available; after Daytona → configured. +- Local contract tests under `packages/trueforge/tests/unit/sandbox/local/`. Smoke stays on `pnpm --filter @truefoundry/trueforge smoke:local` (not unit Jest). ## Out of scope -- Merging product NATS `mcp_client.py` with the local UDS client — keep the local Python client **under** `server/src/sandbox/local/` (tightly coupled to UDS transport); do not unify modules. +- Merging product NATS `mcp_client.py` with the local UDS client — keep the local Python client **under** `packages/trueforge/src/sandbox/local/` (tightly coupled to UDS transport); do not unify modules. - Multi-provider rows (still singleton per tenant). - Persisting or returning `type: 'local'` on settings/catalog API. - Local upsert / local PUT / synthetic local GET. -- Removing `packages/local-sandbox` without an explicit developer go-ahead after the server copy is green. +- Removing `packages/local-sandbox` without an explicit developer go-ahead after the trueforge copy is green. +- Special-casing watch-mode shutdown for `tf_cms` (startup `prepare` covers leftovers). diff --git a/.gitignore b/.gitignore index f0ccbdbba..09aa30d98 100644 --- a/.gitignore +++ b/.gitignore @@ -14,6 +14,7 @@ packages/trueforge/src/catalog/modelCatalog.gen.ts packages/trueforge/src/catalog/mcpCatalog.gen.ts packages/trueforge/src/catalog/skillCatalog.gen.ts packages/trueforge/src/catalog/sandboxCatalog.gen.ts +packages/trueforge/src/sandbox/local/sandboxScripts.gen.ts packages/local-sandbox/src/sandboxScripts.gen.ts data/ # Helm subchart deps are fetched via `helm dependency build` (pinned by the diff --git a/packages/local-sandbox/src/provider/LocalSandboxProvider.ts b/packages/local-sandbox/src/provider/LocalSandboxProvider.ts index a9aa6a007..92db0445f 100644 --- a/packages/local-sandbox/src/provider/LocalSandboxProvider.ts +++ b/packages/local-sandbox/src/provider/LocalSandboxProvider.ts @@ -70,6 +70,7 @@ function toSandboxRelativePath(params: { sandboxRootPath: string; absolutePath: } export class LocalSandboxProvider implements SandboxProvider { + readonly type = 'local'; private readonly sandboxRootPathParent: string; private readonly codeModeSocketParentPath: string; private readonly support: LocalSandboxSupported; diff --git a/packages/trueforge-core/src/core/index.ts b/packages/trueforge-core/src/core/index.ts index bfe518ff1..52bc61c85 100644 --- a/packages/trueforge-core/src/core/index.ts +++ b/packages/trueforge-core/src/core/index.ts @@ -166,9 +166,10 @@ export { SandboxNotAvailableError, SandboxPathIsDirectoryError, validateNoPathTraversal, - validateSandboxOwnedByTenant, } from './sandbox/SandboxErrors'; export { SANDBOX_IMAGE_URI } from './sandbox/sandboxImage'; +export { existingSandboxIdForProvider, formatSandboxId, parseSandboxId, rawSandboxId } from './sandbox/sandboxRef'; +export type { SandboxRefParts } from './sandbox/sandboxRef'; // Skills: the ISkillMounter seam lets hosts plug in their own skill sources export { InstructionBuilder } from './InstructionBuilder'; diff --git a/packages/trueforge-core/src/core/sandbox/Sandbox.ts b/packages/trueforge-core/src/core/sandbox/Sandbox.ts index 40462eb4f..a875689ea 100644 --- a/packages/trueforge-core/src/core/sandbox/Sandbox.ts +++ b/packages/trueforge-core/src/core/sandbox/Sandbox.ts @@ -19,7 +19,8 @@ import { CodeModeDispatcher } from './codeMode/CodeModeDispatcher'; import { type CodeModeClientInstall, type CodeModeTransport } from './codeMode/CodeModeTransport'; import { SANDBOX_FILE_UPLOADS_DIR } from './constants'; import { ensureExecSuccess, shellEscape, type SandboxProvider } from './provider/Provider'; -import { validateNoPathTraversal, validateSandboxOwnedByTenant } from './SandboxErrors'; +import { SandboxNotAvailableError, validateNoPathTraversal } from './SandboxErrors'; +import { formatSandboxId, rawSandboxId } from './sandboxRef'; // Import submodules, not the ./skills barrel, to avoid a cycle (the mounters import from Sandbox). import { dirname, join } from 'node:path'; import { SKILLS_DIR } from './skills/constants'; @@ -209,7 +210,6 @@ export class Sandbox extends LocalToolMCP { private readonly provider: SandboxProvider; private readonly existingSandboxId?: string | undefined; - private readonly tenantName: string; private existingSandboxInfo: SandboxInfo | undefined; // Cached promise to prevent concurrent sub-agents from creating duplicate sandboxes. private sandboxCreationPromise?: Promise | undefined; @@ -241,7 +241,6 @@ export class Sandbox extends LocalToolMCP { super({ tracing: options.tracing }); this.provider = options.provider; this.existingSandboxId = options.existingSandboxId; - this.tenantName = options.execExtraEnv?.['TFY_TENANT_NAME'] ?? ''; this.skillMounter = options.skillMounter; this.fileDownloadEnabled = options.fileDownloadEnabled ?? false; const mcpBoundTimeoutMs = options.mcpRequestTimeoutMs + options.mcpConnectTimeoutMs; @@ -251,11 +250,15 @@ export class Sandbox extends LocalToolMCP { this.resolvedGitCredentialsContent = options.resolvedGitCredentialsContent ?? null; if (this.existingSandboxId) { - validateSandboxOwnedByTenant(this.existingSandboxId, this.tenantName); this.existingSandboxInfo = { sandbox_id: this.existingSandboxId }; } } + /** Provider-facing id (unwraps `v1:type:raw`; legacy ids pass through). */ + private providerSandboxId(sessionId: string): string { + return rawSandboxId(sessionId); + } + private buildMcpServersEnvelope(): Record { const out: Record = {}; for (const server of this.codeExecToolSets) { @@ -448,6 +451,13 @@ export class Sandbox extends LocalToolMCP { return this.existingSandboxInfo; } + private clearExistingSandbox(): void { + this.existingSandboxInfo = undefined; + this.sandboxCreationPromise = undefined; + this.sandboxInitPromise = undefined; + this.mcpClientInstall = undefined; + } + private async ensureSandboxCreated(): Promise<{ sandboxInfo: SandboxInfo; sandboxCreated: SandboxInfo | undefined; @@ -455,15 +465,12 @@ export class Sandbox extends LocalToolMCP { if (this.existingSandboxInfo) { return { sandboxInfo: this.existingSandboxInfo, sandboxCreated: undefined }; } - // Provider returns camelCase `{ sandboxId }`; rename to the snake_case - // `SandboxInfo` shape used everywhere downstream (Redis JSON, wire event, - // in-memory state). + // Provider returns a raw id; persist the fancy `v1:type:raw` session id. this.sandboxCreationPromise ??= this.provider .createSandbox() - .then(({ sandboxId }) => { - validateSandboxOwnedByTenant(sandboxId, this.tenantName); - return { sandbox_id: sandboxId }; - }) + .then(({ sandboxId }) => ({ + sandbox_id: formatSandboxId({ providerType: this.provider.type, rawId: sandboxId }), + })) .catch((e: unknown) => { this.sandboxCreationPromise = undefined; throw e; @@ -471,29 +478,76 @@ export class Sandbox extends LocalToolMCP { const sandboxInfo = await this.sandboxCreationPromise; // Concurrent callers may both await the same creation promise; only the first - // should report sandboxCreated. Read via a local to avoid control-flow narrowing - // that treats existingSandboxInfo as always undefined after the early return above. - const prior = this.existingSandboxInfo as SandboxInfo | undefined; - const isNew = prior === undefined; + // should report sandboxCreated. Read through a method so TS does not keep the + // pre-await "undefined" narrowing on the field. + const raced = this.peekExistingSandboxInfo(); + if (raced !== undefined) { + return { sandboxInfo: raced, sandboxCreated: undefined }; + } this.existingSandboxInfo = sandboxInfo; - return { sandboxInfo, sandboxCreated: isNew ? sandboxInfo : undefined }; + return { sandboxInfo, sandboxCreated: sandboxInfo }; } - private async handleExec(input: SandboxExecInput): Promise { - const { sandboxInfo, sandboxCreated } = await this.ensureSandboxCreated(); - const sandboxCreatedFlag = Boolean(sandboxCreated); + private peekExistingSandboxInfo(): SandboxInfo | undefined { + return this.existingSandboxInfo; + } + + /** Same-type missing remote/local root: drop the stale id and create a new fancy id. */ + private async recreateAfterUnavailable(): Promise<{ + sandboxInfo: SandboxInfo; + sandboxCreated: SandboxInfo; + }> { + this.clearExistingSandbox(); + const created = await this.ensureSandboxCreated(); + if (created.sandboxCreated === undefined) { + throw new Error('Sandbox recreate did not create a new sandbox'); + } + return { sandboxInfo: created.sandboxInfo, sandboxCreated: created.sandboxCreated }; + } + + /** + * Create or reattach, then init. A missing same-type sandbox throws + * `SandboxNotAvailableError` from the provider; reattach then recreates. + */ + private async ensureReadySandbox(): Promise<{ + sandboxInfo: SandboxInfo; + sandboxCreated: SandboxInfo | undefined; + }> { + const first = await this.ensureSandboxCreated(); try { await this.ensureSandboxInitialized(); + return first; + } catch (error) { + if (!(error instanceof SandboxNotAvailableError) || first.sandboxCreated !== undefined) { + throw error; + } + const recreated = await this.recreateAfterUnavailable(); + await this.ensureSandboxInitialized(); + return recreated; + } + } + + private async handleExec(input: SandboxExecInput): Promise { + let sandboxInfo: SandboxInfo; + let sandboxCreated: SandboxInfo | undefined; + try { + ({ sandboxInfo, sandboxCreated } = await this.ensureReadySandbox()); } catch (e) { this.logger.error('Sandbox initialization failed', extractErrorLogFields(e)); const message = e instanceof Error ? e.message : 'Sandbox initialization failed'; + const fallback = this.existingSandboxInfo; return toolResultResponse({ text: `Sandbox initialization failed: ${message}`, isError: true, - overrides: { sandboxCreated: sandboxCreatedFlag, sandboxInfo }, + overrides: { + sandboxCreated: Boolean(fallback), + ...(fallback !== undefined ? { sandboxInfo: fallback } : {}), + }, }); } - const codeModeEnv = await this.ensureCodeModeStarted(sandboxInfo.sandbox_id); + const sandboxCreatedFlag = Boolean(sandboxCreated); + const rawId = this.providerSandboxId(sandboxInfo.sandbox_id); + const codeModeEnv = await this.ensureCodeModeStarted(rawId); const mcpClientEnv = injectMCPClientEnv({ env: input.env, mcpServers: this.buildMcpServersEnvelope(), @@ -503,31 +557,68 @@ export class Sandbox extends LocalToolMCP { }); const gitAuthEnv = this.resolvedGitCredentialsContent !== null - ? buildGitCredentialHelperEnv(this.provider.getGitCredentialsPath(sandboxInfo.sandbox_id)) + ? buildGitCredentialHelperEnv(this.provider.getGitCredentialsPath(rawId)) : {}; const env = { ...mcpClientEnv, ...gitAuthEnv }; - const result = await this.provider.exec({ - sandboxId: sandboxInfo.sandbox_id, - command: input.command, - cwd: input.cwd, - env, - }); + try { + const result = await this.provider.exec({ + sandboxId: rawId, + command: input.command, + cwd: input.cwd, + env, + }); - return { - result: { - content: [{ type: 'text', text: JSON.stringify(result) }], - isError: !result.success, - }, - wasInitialized: undefined, - sandboxCreated: sandboxCreatedFlag, - sandboxInfo, - }; + return { + result: { + content: [{ type: 'text', text: JSON.stringify(result) }], + isError: !result.success, + }, + wasInitialized: undefined, + sandboxCreated: sandboxCreatedFlag, + sandboxInfo, + }; + } catch (error) { + if (!(error instanceof SandboxNotAvailableError) || sandboxCreated !== undefined) { + throw error; + } + const recreated = await this.recreateAfterUnavailable(); + await this.ensureSandboxInitialized(); + const recreatedRawId = this.providerSandboxId(recreated.sandboxInfo.sandbox_id); + const retryCodeModeEnv = await this.ensureCodeModeStarted(recreatedRawId); + const retryEnv = { + ...injectMCPClientEnv({ + env: input.env, + mcpServers: this.buildMcpServersEnvelope(), + execExtraEnv: this.execExtraEnv, + codeModeEnv: retryCodeModeEnv, + mcpClientInstall: this.mcpClientInstall, + }), + ...(this.resolvedGitCredentialsContent !== null + ? buildGitCredentialHelperEnv(this.provider.getGitCredentialsPath(recreatedRawId)) + : {}), + }; + const retryResult = await this.provider.exec({ + sandboxId: recreatedRawId, + command: input.command, + cwd: input.cwd, + env: retryEnv, + }); + return { + result: { + content: [{ type: 'text', text: JSON.stringify(retryResult) }], + isError: !retryResult.success, + }, + wasInitialized: undefined, + sandboxCreated: true, + sandboxInfo: recreated.sandboxInfo, + }; + } } async getToolResultDumpDir(): Promise<{ dir: string; sandboxCreated: SandboxInfo | undefined }> { - const { sandboxCreated } = await this.ensureSandboxCreated(); - const dir = this.provider.getToolResultDumpDir(this.requiredSandboxInfo.sandbox_id).replace(/\/+$/, ''); + const { sandboxCreated, sandboxInfo } = await this.ensureSandboxCreated(); + const dir = this.provider.getToolResultDumpDir(this.providerSandboxId(sandboxInfo.sandbox_id)).replace(/\/+$/, ''); return { dir, sandboxCreated }; } @@ -536,13 +627,15 @@ export class Sandbox extends LocalToolMCP { fileName: string; content: Buffer; }): Promise<{ filePath: string; sandboxCreated: SandboxInfo | undefined }> { - const { sandboxCreated } = await this.ensureSandboxCreated(); - await this.ensureSandboxInitialized(); - const { sandbox_id: sandboxId } = this.requiredSandboxInfo; + const { sandboxCreated, sandboxInfo } = await this.ensureReadySandbox(); const fileName = params.fileName.replace(/^\/+/, ''); const targetDir = params.targetDir.replace(/\/+$/, ''); const filePath = `${targetDir}/${fileName}`; - await this.provider.uploadFile({ sandboxId, remotePath: filePath, content: params.content }); + await this.provider.uploadFile({ + sandboxId: this.providerSandboxId(sandboxInfo.sandbox_id), + remotePath: filePath, + content: params.content, + }); return { filePath, sandboxCreated }; } @@ -591,7 +684,7 @@ export class Sandbox extends LocalToolMCP { } private async writeGitCredentials(): Promise { - const sandboxId = this.requiredSandboxInfo.sandbox_id; + const sandboxId = this.providerSandboxId(this.requiredSandboxInfo.sandbox_id); const credentialsPath = this.provider.getGitCredentialsPath(sandboxId); const result = await this.provider.exec({ sandboxId, @@ -601,7 +694,7 @@ export class Sandbox extends LocalToolMCP { } private async initSandboxEnvironment(): Promise { - const sandboxId = this.requiredSandboxInfo.sandbox_id; + const sandboxId = this.providerSandboxId(this.requiredSandboxInfo.sandbox_id); const toolResultDumpDir = this.provider.getToolResultDumpDir(sandboxId); this.logger.info('Uploading MCP client script and preparing skills directory in sandbox'); diff --git a/packages/trueforge-core/src/core/sandbox/SandboxErrors.ts b/packages/trueforge-core/src/core/sandbox/SandboxErrors.ts index 5b1dd5388..22c84bcad 100644 --- a/packages/trueforge-core/src/core/sandbox/SandboxErrors.ts +++ b/packages/trueforge-core/src/core/sandbox/SandboxErrors.ts @@ -1,7 +1,7 @@ export abstract class SandboxError extends Error { /** - * Every sandbox failure is caused by the request itself — a bad path, another tenant's sandbox, - * a missing or oversized file — so each subclass carries the status a host should reply with. + * Every sandbox failure is caused by the request itself — a bad path, a missing + * or oversized file — so each subclass carries the status a host should reply with. * Listing the codes in use rather than `number` lets a typed route return this directly; adding * a subclass with a new code means widening this union and declaring it on the route. */ @@ -48,15 +48,6 @@ export class SandboxFileTooLargeError extends SandboxError { } } -class SandboxTenantMismatchError extends SandboxError { - readonly statusCode = 403; - - constructor(requestTenant: string) { - super(`Sandbox does not belong to tenant ${requestTenant}`); - this.name = 'SandboxTenantMismatchError'; - } -} - class SandboxPathTraversalError extends SandboxError { readonly statusCode = 400; @@ -66,13 +57,6 @@ class SandboxPathTraversalError extends SandboxError { } } -export function validateSandboxOwnedByTenant(sandboxId: string, tenantName: string): void { - const dotIndex = sandboxId.indexOf('.'); - if (dotIndex === -1 || sandboxId.substring(0, dotIndex) !== tenantName) { - throw new SandboxTenantMismatchError(tenantName); - } -} - const PATH_TRAVERSAL_RE = /(?:^|[\\/])\.\.(?:[\\/]|$)/; export function validateNoPathTraversal(path: string): void { diff --git a/packages/trueforge-core/src/core/sandbox/provider/DaytonaProvider.ts b/packages/trueforge-core/src/core/sandbox/provider/DaytonaProvider.ts index e07273a62..2279c9857 100644 --- a/packages/trueforge-core/src/core/sandbox/provider/DaytonaProvider.ts +++ b/packages/trueforge-core/src/core/sandbox/provider/DaytonaProvider.ts @@ -100,6 +100,7 @@ export interface DaytonaSandboxProviderOptions { } export class DaytonaSandboxProvider implements SandboxProvider { + readonly type = 'daytona'; private readonly tenantName: string; /** Release-owned sandbox image reference; built into a Daytona snapshot and cloned per sandbox. */ private readonly imageUri: string; diff --git a/packages/trueforge-core/src/core/sandbox/provider/Provider.ts b/packages/trueforge-core/src/core/sandbox/provider/Provider.ts index b2325359c..b5c35e4bb 100644 --- a/packages/trueforge-core/src/core/sandbox/provider/Provider.ts +++ b/packages/trueforge-core/src/core/sandbox/provider/Provider.ts @@ -65,6 +65,8 @@ export interface SandboxBuild { } export interface SandboxProvider { + /** Stable provider kind used in fancy sandbox ids and carry-forward (plain string). */ + readonly type: string; /** * Ensures the release image is being built into the provider's backing store and * returns its current status. Idempotent: an already-built image reports `ready`; diff --git a/packages/trueforge-core/src/core/sandbox/provider/TFYSandboxProvider.ts b/packages/trueforge-core/src/core/sandbox/provider/TFYSandboxProvider.ts index 7b51354fe..f176345dc 100644 --- a/packages/trueforge-core/src/core/sandbox/provider/TFYSandboxProvider.ts +++ b/packages/trueforge-core/src/core/sandbox/provider/TFYSandboxProvider.ts @@ -42,6 +42,7 @@ interface StatResult { } export class TFYSandboxProvider implements SandboxProvider { + readonly type = 'tfy'; private readonly serverUrl: string; private readonly natsBridgeUrl: string; private readonly tenantName: string; diff --git a/packages/trueforge-core/src/core/sandbox/sandboxRef.ts b/packages/trueforge-core/src/core/sandbox/sandboxRef.ts new file mode 100644 index 000000000..d85127a1b --- /dev/null +++ b/packages/trueforge-core/src/core/sandbox/sandboxRef.ts @@ -0,0 +1,55 @@ +export interface SandboxRefParts { + /** Provider kind from `SandboxProvider.type` (e.g. `daytona`, `local`) — plain string, not a closed union. */ + providerType: string; + rawId: string; +} + +const SANDBOX_ID_VERSION = 'v1'; +const SANDBOX_ID_PREFIX = `${SANDBOX_ID_VERSION}:`; + +/** `v1:type:raw` — raw may contain `:` (split only on the first two colons after version). */ +export function formatSandboxId(parts: SandboxRefParts): string { + return `${SANDBOX_ID_PREFIX}${parts.providerType}:${parts.rawId}`; +} + +/** + * Parse a fancy id. No `v1:` prefix, or a malformed `v1:` (too few segments), + * → `{ kind: 'legacy', rawId: fullString }` so carry-forward stays safe. + */ +export function parseSandboxId( + sandboxId: string, +): { kind: 'v1'; parts: SandboxRefParts } | { kind: 'legacy'; rawId: string } { + if (!sandboxId.startsWith(SANDBOX_ID_PREFIX)) { + return { kind: 'legacy', rawId: sandboxId }; + } + const rest = sandboxId.slice(SANDBOX_ID_PREFIX.length); + const colon = rest.indexOf(':'); + if (colon <= 0 || colon === rest.length - 1) { + return { kind: 'legacy', rawId: sandboxId }; + } + return { + kind: 'v1', + parts: { providerType: rest.slice(0, colon), rawId: rest.slice(colon + 1) }, + }; +} + +/** Raw id for provider calls. Legacy ids pass through unchanged. */ +export function rawSandboxId(sandboxId: string): string { + const parsed = parseSandboxId(sandboxId); + return parsed.kind === 'v1' ? parsed.parts.rawId : parsed.rawId; +} + +/** Carry-forward gate for turn admit / download. Type mismatch drops the id (create fresh). */ +export function existingSandboxIdForProvider(params: { + existingSandboxId: string | undefined; + currentProviderType: string; +}): string | undefined { + if (params.existingSandboxId === undefined) { + return undefined; + } + const parsed = parseSandboxId(params.existingSandboxId); + if (parsed.kind === 'v1' && parsed.parts.providerType !== params.currentProviderType) { + return undefined; + } + return params.existingSandboxId; +} diff --git a/packages/trueforge-core/tests/core/harnessMocks.ts b/packages/trueforge-core/tests/core/harnessMocks.ts index 65cc0b19c..9b6379a72 100644 --- a/packages/trueforge-core/tests/core/harnessMocks.ts +++ b/packages/trueforge-core/tests/core/harnessMocks.ts @@ -57,6 +57,7 @@ export function makeMockIMCPServer(params: { export function makeStubPublicSandbox(tenantName = 'test-tenant'): Sandbox { const provider: SandboxProvider = { + type: 'test', buildImage: jest.fn(), getImageBuildStatus: jest.fn(), createSandbox: jest.fn(), diff --git a/packages/trueforge-core/tests/core/sandbox/Sandbox.ids.test.ts b/packages/trueforge-core/tests/core/sandbox/Sandbox.ids.test.ts new file mode 100644 index 000000000..39eb08ff6 --- /dev/null +++ b/packages/trueforge-core/tests/core/sandbox/Sandbox.ids.test.ts @@ -0,0 +1,107 @@ +import { isCallToolResponseResult } from '../../../src/core/mcp/IMCPServer'; +import type { ExecResult, SandboxExecParams, SandboxProvider } from '../../../src/core/sandbox/provider/Provider'; +import { SANDBOX_EXEC_TOOL_NAME, Sandbox } from '../../../src/core/sandbox/Sandbox'; +import { SandboxNotAvailableError } from '../../../src/core/sandbox/SandboxErrors'; +import { NOOP_AGENT_TRACING } from '../../../src/core/tracing/NoopAgentTracing'; +import { makeSilentLogger } from '../harnessMocks'; + +function readyExec(): Promise { + return Promise.resolve({ success: true, response: { exitCode: 0, result: 'ok' } }); +} + +function makeProvider( + overrides: Partial & Pick, +): SandboxProvider { + return { + type: 'local', + buildImage: () => Promise.resolve({ status: 'ready', reason: null, metadata: null }), + getImageBuildStatus: () => Promise.resolve({ status: 'ready', reason: null, metadata: null }), + getAdditionalInstructions: () => undefined, + getToolResultDumpDir: sandboxId => `${sandboxId}/tool-results`, + getGitCredentialsPath: sandboxId => `${sandboxId}/.git-credentials`, + downloadFile: jest.fn(), + uploadFile: jest.fn().mockResolvedValue(undefined), + createCodeModeTransport: jest.fn(), + ...overrides, + }; +} + +function makeSandbox(provider: SandboxProvider, existingSandboxId?: string): Sandbox { + return new Sandbox({ + provider, + existingSandboxId, + blockDestructiveToolsInCodeMode: true, + mcpRequestTimeoutMs: 60_000, + mcpConnectTimeoutMs: 5_000, + logger: makeSilentLogger(), + tracing: NOOP_AGENT_TRACING, + }); +} + +describe('Sandbox fancy ids', () => { + it('wraps createSandbox raw id as v1:type:raw and calls the provider with raw', async () => { + const execCalls: SandboxExecParams[] = []; + const provider = makeProvider({ + createSandbox: () => Promise.resolve({ sandboxId: '/tmp/raw-1' }), + exec: params => { + execCalls.push(params); + return readyExec(); + }, + }); + const sandbox = makeSandbox(provider); + const result = await sandbox.callTool({ + name: SANDBOX_EXEC_TOOL_NAME, + arguments: { intent: 'pwd', command: 'pwd' }, + }); + if (!isCallToolResponseResult(result)) { + throw new Error('expected tool result'); + } + expect(result.sandboxInfo?.sandbox_id).toBe('v1:local:/tmp/raw-1'); + expect(result.sandboxCreated).toBe(true); + expect(execCalls.every(call => call.sandboxId === '/tmp/raw-1')).toBe(true); + }); + + it('reattaches a fancy id using the raw id and recreates when the provider reports missing', async () => { + let execCount = 0; + const provider = makeProvider({ + createSandbox: jest.fn().mockResolvedValue({ sandboxId: '/tmp/raw-2' }), + exec: params => { + execCount += 1; + if (params.sandboxId === '/tmp/gone') { + throw new SandboxNotAvailableError(params.sandboxId); + } + return readyExec(); + }, + }); + const sandbox = makeSandbox(provider, 'v1:local:/tmp/gone'); + const result = await sandbox.callTool({ + name: SANDBOX_EXEC_TOOL_NAME, + arguments: { intent: 'pwd', command: 'pwd' }, + }); + if (!isCallToolResponseResult(result)) { + throw new Error('expected tool result'); + } + expect(provider.createSandbox).toHaveBeenCalled(); + expect(result.sandboxInfo?.sandbox_id).toBe('v1:local:/tmp/raw-2'); + expect(result.sandboxCreated).toBe(true); + expect(execCount).toBeGreaterThan(1); + }); + + it('passes a legacy existing id through to the provider unchanged', async () => { + const execCalls: SandboxExecParams[] = []; + const provider = makeProvider({ + createSandbox: jest.fn(), + exec: params => { + execCalls.push(params); + return readyExec(); + }, + }); + const sandbox = makeSandbox(provider, 'tenant.legacy-id'); + await sandbox.callTool({ + name: SANDBOX_EXEC_TOOL_NAME, + arguments: { intent: 'pwd', command: 'pwd' }, + }); + expect(provider.createSandbox).not.toHaveBeenCalled(); + expect(execCalls.every(call => call.sandboxId === 'tenant.legacy-id')).toBe(true); + }); +}); diff --git a/packages/trueforge-core/tests/core/sandbox/ownershipRemoved.test.ts b/packages/trueforge-core/tests/core/sandbox/ownershipRemoved.test.ts new file mode 100644 index 000000000..e27810f9a --- /dev/null +++ b/packages/trueforge-core/tests/core/sandbox/ownershipRemoved.test.ts @@ -0,0 +1,8 @@ +import * as core from '../../../src/core/index'; + +describe('sandbox tenant ownership helpers', () => { + it('no longer exports validateSandboxOwnedByTenant or SandboxTenantMismatchError', () => { + expect('validateSandboxOwnedByTenant' in core).toBe(false); + expect('SandboxTenantMismatchError' in core).toBe(false); + }); +}); diff --git a/packages/trueforge-core/tests/core/sandbox/sandboxBridgeTimeout.test.ts b/packages/trueforge-core/tests/core/sandbox/sandboxBridgeTimeout.test.ts index 8d3d2d4b7..670365984 100644 --- a/packages/trueforge-core/tests/core/sandbox/sandboxBridgeTimeout.test.ts +++ b/packages/trueforge-core/tests/core/sandbox/sandboxBridgeTimeout.test.ts @@ -17,6 +17,7 @@ function makeSandbox(options: { const execCalls: SandboxExecParams[] = []; const transport = options.transport; const provider: SandboxProvider = { + type: 'test', buildImage: () => Promise.resolve({ status: 'ready', reason: null, metadata: null }), getImageBuildStatus: () => Promise.resolve({ status: 'ready', reason: null, metadata: null }), createSandbox: () => Promise.resolve({ sandboxId: 'test-tenant.sandbox-1' }), diff --git a/packages/trueforge-core/tests/core/sandbox/sandboxRef.test.ts b/packages/trueforge-core/tests/core/sandbox/sandboxRef.test.ts new file mode 100644 index 000000000..0afc83820 --- /dev/null +++ b/packages/trueforge-core/tests/core/sandbox/sandboxRef.test.ts @@ -0,0 +1,47 @@ +import { + existingSandboxIdForProvider, + formatSandboxId, + parseSandboxId, + rawSandboxId, +} from '../../../src/core/sandbox/sandboxRef'; + +describe('sandboxRef', () => { + it('formats v1:type:raw and parses it back (raw may contain colons)', () => { + const formatted = formatSandboxId({ providerType: 'local', rawId: '/tmp/a:b:c' }); + expect(formatted).toBe('v1:local:/tmp/a:b:c'); + expect(parseSandboxId(formatted)).toEqual({ + kind: 'v1', + parts: { providerType: 'local', rawId: '/tmp/a:b:c' }, + }); + expect(rawSandboxId(formatted)).toBe('/tmp/a:b:c'); + }); + + it('treats missing v1 prefix and malformed v1 ids as legacy', () => { + expect(parseSandboxId('tenant.uuid')).toEqual({ kind: 'legacy', rawId: 'tenant.uuid' }); + expect(parseSandboxId('v1:')).toEqual({ kind: 'legacy', rawId: 'v1:' }); + expect(parseSandboxId('v1:daytona')).toEqual({ kind: 'legacy', rawId: 'v1:daytona' }); + expect(parseSandboxId('v1:daytona:')).toEqual({ kind: 'legacy', rawId: 'v1:daytona:' }); + expect(rawSandboxId('tenant.uuid')).toBe('tenant.uuid'); + }); + + it('carries legacy and same-type v1 ids; drops cross-type v1 ids', () => { + expect( + existingSandboxIdForProvider({ existingSandboxId: undefined, currentProviderType: 'local' }), + ).toBeUndefined(); + expect(existingSandboxIdForProvider({ existingSandboxId: 'tenant.uuid', currentProviderType: 'local' })).toBe( + 'tenant.uuid', + ); + expect( + existingSandboxIdForProvider({ + existingSandboxId: 'v1:local:/tmp/s', + currentProviderType: 'local', + }), + ).toBe('v1:local:/tmp/s'); + expect( + existingSandboxIdForProvider({ + existingSandboxId: 'v1:daytona:abc', + currentProviderType: 'local', + }), + ).toBeUndefined(); + }); +}); diff --git a/packages/trueforge/jest.local-contract.config.cjs b/packages/trueforge/jest.local-contract.config.cjs new file mode 100644 index 000000000..2bab17595 --- /dev/null +++ b/packages/trueforge/jest.local-contract.config.cjs @@ -0,0 +1,10 @@ +/** @type {import('jest').Config} */ +const unit = require('./jest.unit.config.cjs'); + +module.exports = { + ...unit, + testPathIgnorePatterns: [], + testMatch: ['/tests/unit/sandbox/local/**/*.contract.test.ts'], + testTimeout: 120_000, + maxWorkers: 1, +}; diff --git a/packages/trueforge/jest.local-smoke.config.cjs b/packages/trueforge/jest.local-smoke.config.cjs new file mode 100644 index 000000000..155eea5a4 --- /dev/null +++ b/packages/trueforge/jest.local-smoke.config.cjs @@ -0,0 +1,10 @@ +/** @type {import('jest').Config} */ +const unit = require('./jest.unit.config.cjs'); + +module.exports = { + ...unit, + roots: ['/tests/sandbox/local'], + testMatch: ['/tests/sandbox/local/smoke.test.ts'], + testTimeout: 120_000, + maxWorkers: 1, +}; diff --git a/packages/trueforge/jest.unit.config.cjs b/packages/trueforge/jest.unit.config.cjs index f2331ae7e..408d637e8 100644 --- a/packages/trueforge/jest.unit.config.cjs +++ b/packages/trueforge/jest.unit.config.cjs @@ -39,4 +39,6 @@ module.exports = { maxWorkers: '50%', roots: ['/tests/unit'], testMatch: ['/tests/unit/**/*.test.ts'], + // SRT contract suites need a real host sandbox; run via `pnpm test:local-contract`. + testPathIgnorePatterns: ['contract\\.test\\.ts$'], }; diff --git a/packages/trueforge/lima/local-sandbox.yaml b/packages/trueforge/lima/local-sandbox.yaml new file mode 100644 index 000000000..109f495b4 --- /dev/null +++ b/packages/trueforge/lima/local-sandbox.yaml @@ -0,0 +1,50 @@ +# Minimal Lima guest for local-sandbox Linux SRT smoke. +# Mounts the package root at the same absolute host path. +cpus: 1 +memory: '2GiB' +disk: '20GiB' + +images: + - location: 'https://cloud-images.ubuntu.com/releases/24.04/release/ubuntu-24.04-server-cloudimg-arm64.img' + arch: 'aarch64' + - location: 'https://cloud-images.ubuntu.com/releases/24.04/release/ubuntu-24.04-server-cloudimg-amd64.img' + arch: 'x86_64' + +# `location` is rewritten to an absolute host path by scripts/smoke-lima.sh +mounts: + - location: '__LOCAL_SANDBOX_ROOT__' + writable: true + +containerd: + system: false + user: false + +provision: + - mode: system + script: | + #!/bin/bash + set -euxo pipefail + export DEBIAN_FRONTEND=noninteractive + # Ubuntu 24.04 blocks capability-bearing user namespaces by default; SRT/bwrap needs them. + if [[ -e /proc/sys/kernel/apparmor_restrict_unprivileged_userns ]]; then + sysctl -w kernel.apparmor_restrict_unprivileged_userns=0 + printf '%s\n' 'kernel.apparmor_restrict_unprivileged_userns=0' \ + >/etc/sysctl.d/99-local-sandbox-userns.conf + fi + apt-get update -y + apt-get install -y --no-install-recommends \ + bubblewrap \ + socat \ + ripgrep \ + python3 \ + python3-pip \ + python3-setuptools \ + ca-certificates \ + curl \ + gnupg + if ! command -v node >/dev/null 2>&1; then + curl -fsSL https://deb.nodesource.com/setup_22.x | bash - + apt-get install -y --no-install-recommends nodejs + fi + corepack enable + corepack prepare pnpm@9.15.9 --activate diff --git a/packages/trueforge/package.json b/packages/trueforge/package.json index d8dfbafd9..6af0925ba 100644 --- a/packages/trueforge/package.json +++ b/packages/trueforge/package.json @@ -29,8 +29,9 @@ }, "scripts": { "build:frontend-assets": "node scripts/copy-frontend.mjs", - "build:gen:watch": "node --watch-path=catalog/model-catalog.yaml --watch-path=catalog/mcp-catalog.yaml --watch-path=catalog/skill-catalog.yaml --watch-path=catalog/sandbox-catalog.yaml scripts/generate-catalog.mjs", - "build:gen": "node scripts/generate-catalog.mjs", + "build:gen:local-sandbox": "node scripts/generate-local-sandbox-scripts.mjs", + "build:gen:watch": "node --watch-path=catalog/model-catalog.yaml --watch-path=catalog/mcp-catalog.yaml --watch-path=catalog/skill-catalog.yaml --watch-path=catalog/sandbox-catalog.yaml --watch-path=src/sandbox/local/scripts scripts/generate-dev.mjs", + "build:gen": "pnpm run build:gen:local-sandbox && node scripts/generate-catalog.mjs", "build:clean": "rm -rf dist", "build": "pnpm run build:gen && tsc --noEmit && pnpm run build:clean && tsup && chmod +x dist/cli.js && pnpm run build:frontend-assets", "dev:serve": "NODE_OPTIONS='--conditions=trueforge-dev' NODE_ENV=development tsx watch --env-file=.env src/main.ts", @@ -48,9 +49,14 @@ "test:store:postgres": "jest --config jest.store.postgres.config.cjs", "test:store:sqlite": "jest --config jest.store.sqlite.config.cjs", "test": "pnpm run build:gen && NODE_OPTIONS='--conditions=trueforge-dev' node --env-file=.env.test ./node_modules/jest/bin/jest.js --config jest.unit.config.cjs", + "test:local-contract": "pnpm run build:gen && NODE_OPTIONS='--conditions=trueforge-dev' node --env-file=.env.test ./node_modules/jest/bin/jest.js --config jest.local-contract.config.cjs", + "smoke:local": "pnpm run build:gen && NODE_OPTIONS='--conditions=trueforge-dev' jest --config jest.local-smoke.config.cjs --runInBand --forceExit tests/sandbox/local/smoke.test.ts", + "smoke:local:lima": "bash scripts/local-sandbox/smoke-lima.sh", + "probe:loopback": "pnpm exec tsx scripts/local-sandbox/probe-loopback.ts", "typecheck": "pnpm run build:gen && tsc --noEmit && tsc --noEmit -p tests/db/tsconfig.json && tsc --noEmit -p tests/unit/tsconfig.json" }, "dependencies": { + "@anthropic-ai/sandbox-runtime": "0.0.71", "@daytona/sdk": "^0.204.1", "@hono/node-server": "^2.0.11", "@hono/swagger-ui": "^0.2.1", diff --git a/packages/trueforge/scripts/generate-dev.mjs b/packages/trueforge/scripts/generate-dev.mjs new file mode 100644 index 000000000..be1054c93 --- /dev/null +++ b/packages/trueforge/scripts/generate-dev.mjs @@ -0,0 +1,14 @@ +/** + * Watch-mode codegen: local sandbox scripts + shipped catalogs. + */ +import { spawnSync } from 'node:child_process'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const dir = dirname(fileURLToPath(import.meta.url)); +for (const script of ['generate-local-sandbox-scripts.mjs', 'generate-catalog.mjs']) { + const result = spawnSync(process.execPath, [join(dir, script)], { stdio: 'inherit' }); + if (result.status !== 0) { + process.exit(result.status ?? 1); + } +} diff --git a/packages/trueforge/scripts/generate-local-sandbox-scripts.mjs b/packages/trueforge/scripts/generate-local-sandbox-scripts.mjs new file mode 100644 index 000000000..9d39b1aee --- /dev/null +++ b/packages/trueforge/scripts/generate-local-sandbox-scripts.mjs @@ -0,0 +1,22 @@ +/** + * Inlines the local Code Mode MCP client into a generated TS module so + * install paths can self-serve the script with no loose fixture files. + * A plain generated `.ts` file works unchanged across tsc and @swc/jest. + * + * Runs via `build:gen` before build/typecheck/test. The generated output + * (src/sandbox/local/sandboxScripts.gen.ts) is gitignored. + */ +import { readFileSync, writeFileSync } from 'node:fs'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const root = join(dirname(fileURLToPath(import.meta.url)), '..'); +const scriptsDir = join(root, 'src/sandbox/local/scripts'); +const read = f => readFileSync(join(scriptsDir, f), 'utf-8'); + +const out = `// AUTO-GENERATED by scripts/generate-local-sandbox-scripts.mjs — do not edit. +export const sandboxScripts = { + mcpClientLocal: ${JSON.stringify(read('mcp_client_local.py'))}, +} as const; +`; +writeFileSync(join(root, 'src/sandbox/local/sandboxScripts.gen.ts'), out); diff --git a/packages/trueforge/scripts/local-sandbox/probe-loopback.ts b/packages/trueforge/scripts/local-sandbox/probe-loopback.ts new file mode 100644 index 000000000..6251d9c5d --- /dev/null +++ b/packages/trueforge/scripts/local-sandbox/probe-loopback.ts @@ -0,0 +1,232 @@ +/** + * Probe: can an SRT-sandboxed command reach a host-owned 127.0.0.1 listener? + * allowLocalBinding is session-scoped (initialize), not per-exec. + */ +import { getDefaultWritePaths, SandboxManager } from '@anthropic-ai/sandbox-runtime'; +import { spawn } from 'node:child_process'; +import { randomUUID } from 'node:crypto'; +import { mkdir } from 'node:fs/promises'; +import { createServer } from 'node:http'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const ROOT = join(fileURLToPath(import.meta.url), '..', '..'); +const sandboxRootPath = join(ROOT, 'sandboxes', `probe-loopback-${randomUUID()}`); +const SRT_VENDOR = join( + dirname(fileURLToPath(import.meta.resolve('@anthropic-ai/sandbox-runtime/package.json'))), + 'vendor', +); + +function denySharedDefaultWritePaths(): string[] { + return getDefaultWritePaths().filter(path => !path.startsWith('/dev/')); +} + +function platformAllowRead(): string[] { + const common = [sandboxRootPath, '/usr/bin', '/bin', '/usr/sbin', '/sbin', '/usr/lib', '/dev', SRT_VENDOR]; + if (process.platform === 'darwin') { + return [ + ...common, + '/System/Library', + '/Library', + '/opt/homebrew', + '/opt/homebrew/bin', + '/private/var/db/dyld', + '/private/var/select', + ]; + } + return [...common, '/lib', '/lib64', '/usr/lib64', '/usr/local', '/etc', '/proc', '/sys']; +} + +async function listenHost(): Promise<{ port: number; close: () => Promise }> { + const server = createServer((_req, res) => { + res.writeHead(200, { 'content-type': 'text/plain' }); + res.end('host-loopback-ok\n'); + }); + await new Promise((resolve, reject) => { + server.once('error', reject); + server.listen(0, '127.0.0.1', () => { + resolve(); + }); + }); + const addr = server.address(); + if (addr === null || typeof addr === 'string') { + throw new Error('expected TCP address'); + } + return { + port: addr.port, + close: () => + new Promise((resolve, reject) => { + server.close(err => { + if (err) { + reject(err); + } else { + resolve(); + } + }); + }), + }; +} + +async function runSandboxed(params: { label: string; command: string; allowedDomains: string[] }): Promise { + const wrap = await SandboxManager.wrapWithSandboxArgv( + params.command, + '/bin/bash', + { + filesystem: { + allowWrite: [sandboxRootPath], + denyWrite: denySharedDefaultWritePaths(), + denyRead: ['/'], + allowRead: platformAllowRead(), + }, + network: { + allowedDomains: params.allowedDomains, + deniedDomains: [], + }, + }, + undefined, + sandboxRootPath, + { commandId: randomUUID(), commandText: params.command }, + ); + const [argv0, ...argvRest] = wrap.argv; + if (argv0 === undefined) { + throw new Error('empty argv'); + } + + const result = await new Promise<{ code: number | null; out: string }>((resolve, reject) => { + const child = spawn(argv0, argvRest, { + cwd: sandboxRootPath, + env: { + HOME: join(sandboxRootPath, '.home'), + TMPDIR: join(sandboxRootPath, '.tmp'), + PATH: process.platform === 'darwin' ? '/opt/homebrew/bin:/usr/bin:/bin' : '/usr/bin:/bin', + ...wrap.env, + }, + stdio: ['ignore', 'pipe', 'pipe'], + }); + let out = ''; + child.stdout.on('data', (c: Buffer) => { + out += c.toString('utf8'); + }); + child.stderr.on('data', (c: Buffer) => { + out += c.toString('utf8'); + }); + child.on('error', reject); + child.on('close', code => { + resolve({ code, out }); + }); + }); + + const preview = result.out.replace(/\s+/g, ' ').trim().slice(0, 280); + console.log(`[${process.platform}] ${params.label}: exit=${String(result.code)} out=${JSON.stringify(preview)}`); +} + +async function runSuite(params: { allowLocalBinding: boolean; hostPort: number }): Promise { + await SandboxManager.reset().catch(() => undefined); + await SandboxManager.initialize({ + network: { + allowedDomains: [], + deniedDomains: [], + allowLocalBinding: params.allowLocalBinding, + }, + filesystem: { + allowWrite: [], + denyWrite: denySharedDefaultWritePaths(), + denyRead: ['/'], + allowRead: platformAllowRead(), + }, + }); + + console.log(`\n=== ${process.platform} session allowLocalBinding=${String(params.allowLocalBinding)} ===`); + + const connectCmd = [ + "python3 - <<'PY'", + 'import socket,sys', + `port=${String(params.hostPort)}`, + 'try:', + ' s=socket.create_connection(("127.0.0.1", port), timeout=2)', + ' s.sendall(b"GET / HTTP/1.0\\r\\nHost: 127.0.0.1\\r\\n\\r\\n")', + ' data=s.recv(200).decode("utf-8","replace")', + ' s.close()', + ' print("CONNECT_OK", "host-loopback-ok" in data, repr(data[:80]))', + ' sys.exit(0 if "host-loopback-ok" in data else 1)', + 'except OSError as e:', + ' print("CONNECT_FAIL", type(e).__name__, e)', + ' sys.exit(2)', + 'PY', + ].join('\n'); + + const bindCmd = [ + "python3 - <<'PY'", + 'import socket,sys', + 'try:', + ' s=socket.socket(socket.AF_INET, socket.SOCK_STREAM)', + ' s.bind(("127.0.0.1", 0))', + ' print("BIND_OK", s.getsockname())', + ' s.close()', + ' sys.exit(0)', + 'except OSError as e:', + ' print("BIND_FAIL", type(e).__name__, e)', + ' sys.exit(2)', + 'PY', + ].join('\n'); + + const ifacesCmd = [ + "python3 - <<'PY'", + 'import socket,sys', + 'print("hostname", socket.gethostname())', + 'try:', + ' print("primary", socket.gethostbyname(socket.gethostname()))', + 'except OSError as e:', + ' print("primary_fail", e)', + 'try:', + ' s=socket.socket(socket.AF_INET, socket.SOCK_DGRAM)', + ' s.connect(("8.8.8.8", 80))', + ' print("udp_route_ip", s.getsockname()[0])', + ' s.close()', + 'except OSError as e:', + ' print("udp_route_fail", e)', + 'PY', + ].join('\n'); + + await runSandboxed({ + label: 'ifaces/route probe', + command: ifacesCmd, + allowedDomains: [], + }); + await runSandboxed({ + label: 'connect host port (allowedDomains=[])', + command: connectCmd, + allowedDomains: [], + }); + await runSandboxed({ + label: `connect host port (allowedDomains=127.0.0.1:${String(params.hostPort)})`, + command: connectCmd, + allowedDomains: [`127.0.0.1:${String(params.hostPort)}`], + }); + await runSandboxed({ + label: 'bind sandbox 127.0.0.1:0', + command: bindCmd, + allowedDomains: [], + }); +} + +async function main(): Promise { + await mkdir(join(sandboxRootPath, '.tmp'), { recursive: true, mode: 0o700 }); + await mkdir(join(sandboxRootPath, '.home'), { recursive: true, mode: 0o700 }); + + const host = await listenHost(); + console.log(`[${process.platform}] host listener 127.0.0.1:${String(host.port)}`); + + try { + await runSuite({ allowLocalBinding: false, hostPort: host.port }); + await runSuite({ allowLocalBinding: true, hostPort: host.port }); + } finally { + await host.close(); + await SandboxManager.reset().catch(() => undefined); + } +} + +main().catch((error: unknown) => { + console.error(error); + process.exit(1); +}); diff --git a/packages/trueforge/scripts/local-sandbox/smoke-lima.sh b/packages/trueforge/scripts/local-sandbox/smoke-lima.sh new file mode 100755 index 000000000..ef6c99828 --- /dev/null +++ b/packages/trueforge/scripts/local-sandbox/smoke-lima.sh @@ -0,0 +1,36 @@ +#!/usr/bin/env bash +# Create/start a minimal Lima VM and run `pnpm smoke:local` for Linux SRT coverage. +set -euo pipefail + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../../.." && pwd)" +INSTANCE="${LIMA_INSTANCE:-local-sandbox-poc}" +YAML_TEMPLATE="${ROOT}/packages/trueforge/lima/local-sandbox.yaml" + +if ! command -v limactl >/dev/null 2>&1; then + echo "limactl not found; install Lima first (e.g. brew install lima)" >&2 + exit 1 +fi + +if ! limactl list -f '{{.Name}}' 2>/dev/null | grep -qx "${INSTANCE}"; then + echo "creating Lima instance ${INSTANCE} (minimal: 1 CPU / 2GiB)..." + YAML="$(mktemp -t local-sandbox-lima.XXXXXX.yaml)" + trap 'rm -f "${YAML}"' EXIT + # Lima requires absolute mount locations. + sed "s|__LOCAL_SANDBOX_ROOT__|${ROOT}|g" "${YAML_TEMPLATE}" >"${YAML}" + limactl create --name="${INSTANCE}" --yes "${YAML}" +fi + +status="$(limactl list -f '{{.Name}} {{.Status}}' | awk -v n="${INSTANCE}" '$1==n { print $2; exit }')" +if [[ "${status}" != "Running" ]]; then + echo "starting Lima instance ${INSTANCE}..." + limactl start "${INSTANCE}" +fi + +echo "running Linux smoke inside ${INSTANCE}..." +# Mount mirrors the host absolute path. Guest deps/sysctl come from lima provision. +limactl shell "${INSTANCE}" -- bash -lc " + set -euo pipefail + cd $(printf '%q' "${ROOT}") + CI=true pnpm install --no-frozen-lockfile + pnpm --filter @truefoundry/trueforge smoke:local +" diff --git a/packages/trueforge/src/apis/capabilities.ts b/packages/trueforge/src/apis/capabilities.ts index 6f4f65b58..1149a3024 100644 --- a/packages/trueforge/src/apis/capabilities.ts +++ b/packages/trueforge/src/apis/capabilities.ts @@ -5,6 +5,7 @@ import { isAdmin, resolveUserContext } from '../auth/identity'; import type { ISandboxProviderStore } from '../db/sandboxProviderStore'; import type { WithTransaction } from '../db/transaction'; import { getCapabilitiesRoute } from '../routes/capabilityRoutes'; +import { isLocalSandboxFallbackEnabled } from '../sandbox/localRuntime'; import { checkSnapshotStatus } from '../sandbox/providerUtils'; import type { SandboxBuildStatus } from '../schemas/sandboxProvider'; import { TENANT_ID } from './sessions'; @@ -40,7 +41,7 @@ export function createCapabilitiesRouter(deps: { } catch (error) { deps.logger.warn('Sandbox image status check failed; reporting sandbox disabled', extractErrorLogFields(error)); } - const sandboxEnabled = status === 'ready'; + const sandboxEnabled = status === 'ready' || (status === undefined && isLocalSandboxFallbackEnabled()); const settingsEnabled = isAdmin(resolveUserContext(c)); return c.json( { diff --git a/packages/trueforge/src/apis/turns.ts b/packages/trueforge/src/apis/turns.ts index 5837e43b5..71bafed94 100644 --- a/packages/trueforge/src/apis/turns.ts +++ b/packages/trueforge/src/apis/turns.ts @@ -14,12 +14,13 @@ import { } from '@truefoundry/trueforge-core/agent-session'; import { AgentHarnessError, + existingSandboxIdForProvider, extractErrorLogFields, isAgentInputUserMessage, isFileContentPart, McpConnectionError, + rawSandboxId, SandboxError, - validateSandboxOwnedByTenant, VercelAILLM, } from '@truefoundry/trueforge-core/core'; import type { Context } from 'hono'; @@ -185,10 +186,14 @@ function createTurnResolver(deps: { message: 'no sandbox provider configured — PUT /settings/sandbox-providers', }); } - // A fresh sandbox is cloned from the release snapshot, so the build must be ready first. - // Restoring an existing sandbox goes through daytona.get and never touches the snapshot, - // so reuse skips this gate entirely. - if (existingSandboxId === undefined) { + const carriedSandboxId = existingSandboxIdForProvider({ + existingSandboxId, + currentProviderType: provider.type, + }); + // A fresh Daytona sandbox is cloned from the release snapshot, so the build must be ready first. + // Restoring an existing sandbox goes through daytona.get and never touches the snapshot. + // Local fallback has no image build. + if (carriedSandboxId === undefined && provider.type !== 'local') { const status = await checkSnapshotStatus({ store: sandboxProviderStore, tenant_id: TENANT_ID, logger }); if (status?.status !== 'ready') { throw new HTTPException(422, { @@ -209,7 +214,7 @@ function createTurnResolver(deps: { logger, gitSkills, fileDownloadEnabled: spec.config.sandbox.file_downloads, - existingSandboxId, + existingSandboxId: carriedSandboxId, tracing, tenantName: TENANT_ID, }); @@ -431,9 +436,8 @@ export function createTurnsRouter(deps: TurnsRouterDeps) { return c.json({ error: { message: 'No sandbox provider configured' } }, 412); } - validateSandboxOwnedByTenant(sandboxId, TENANT_ID); // TODO: stream the body instead of buffering the whole file in memory. - const content = await provider.downloadFile({ sandboxId, path }); + const content = await provider.downloadFile({ sandboxId: rawSandboxId(sandboxId), path }); return c.body(toArrayBuffer(content), 200, { 'Content-Type': 'application/octet-stream', 'Content-Length': String(content.byteLength), diff --git a/packages/trueforge/src/config.ts b/packages/trueforge/src/config.ts index 772074dac..65805963b 100644 --- a/packages/trueforge/src/config.ts +++ b/packages/trueforge/src/config.ts @@ -12,6 +12,7 @@ * `redis://localhost:6379`). */ import { existsSync } from 'node:fs'; +import os from 'node:os'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; @@ -167,6 +168,16 @@ function resolveSqlitePath(): string { return path.join(paths.data, 'db', 'db.sqlite'); } +/** Parent for local sandbox roots. Same env-paths data dir as SQLite (`{suffix:''}`). */ +function resolveLocalSandboxRootParent(): string { + return path.join(envPaths(ENV_PATHS_APP_NAME, { suffix: '' }).data, 'sandboxes'); +} + +/** Short tmp parent for Code Mode UDS socks (≤60 bytes after realpath). */ +function resolveCodeModeSocketParent(): string { + return path.join(os.tmpdir(), 'tf_cms'); +} + /** Redis peering URL for distributed mode. Env: `REDIS_URL`. */ function resolveRedisUrl(): string { const raw = getEnv('REDIS_URL', { defaultValue: DEFAULT_REDIS_URL }) ?? DEFAULT_REDIS_URL; @@ -399,6 +410,16 @@ export type StandaloneServerConfiguration = SharedServerConfiguration & { * Env: `SQLITE_PATH` (optional). Default: env-paths data dir + `db/db.sqlite`. */ SQLITE_PATH: string; + /** + * Parent directory for local sandbox roots (ULID children). + * Derived: `{env-paths data}/sandboxes`. + */ + LOCAL_SANDBOX_ROOT_PARENT: string; + /** + * Parent directory for Code Mode UDS sockets (`tf_cms` under os.tmpdir()). + * Caller prepares/removes this directory; must stay ≤60 bytes after realpath. + */ + CODE_MODE_SOCKET_PARENT: string; }; export type DistributedServerConfiguration = SharedServerConfiguration & { @@ -542,6 +563,8 @@ const configuration: ServerConfiguration = standalone ...shared, STANDALONE: true, SQLITE_PATH: resolveSqlitePath(), + LOCAL_SANDBOX_ROOT_PARENT: resolveLocalSandboxRootParent(), + CODE_MODE_SOCKET_PARENT: resolveCodeModeSocketParent(), } : { ...shared, diff --git a/packages/trueforge/src/main.ts b/packages/trueforge/src/main.ts index f0a634d63..981eefc86 100644 --- a/packages/trueforge/src/main.ts +++ b/packages/trueforge/src/main.ts @@ -12,6 +12,12 @@ import { extractErrorLogFields } from '@truefoundry/trueforge-core/core'; import { mkdir } from 'node:fs/promises'; import path from 'node:path'; +import { + ensureLocalSandboxRootParent, + prepareCodeModeSocketParent, + removeCodeModeSocketParent, +} from './sandbox/localLifecycle'; +import { setCachedLocalSandboxSupport } from './sandbox/localRuntime'; let configuration: typeof import('./config').default; let isOidcConfigured: typeof import('./config').isOidcConfigured; @@ -248,6 +254,14 @@ try { if (configuration.STANDALONE) { printStandaloneStartupBanner({ version: PACKAGE_VERSION, color: shouldColorize() }); + await prepareCodeModeSocketParent({ path: configuration.CODE_MODE_SOCKET_PARENT, logger }); + await ensureLocalSandboxRootParent(configuration.LOCAL_SANDBOX_ROOT_PARENT); + const { LocalSandboxProvider } = await import('./sandbox/local/provider/LocalSandboxProvider'); + const support = await LocalSandboxProvider.isSupported(); + setCachedLocalSandboxSupport(support); + if (!support.supported) { + logger.warn('Local sandbox fallback is unavailable', { reason: support.reason }); + } } else { logger.info('TrueForge starting', { mode: 'distributed' }); } @@ -342,6 +356,11 @@ try { await redis?.close().catch((error: unknown) => { logger.warn('[Redis] Error closing client during shutdown', extractErrorLogFields(error)); }); + if (configuration.STANDALONE) { + await removeCodeModeSocketParent(configuration.CODE_MODE_SOCKET_PARENT).catch((error: unknown) => { + logger.warn('Error removing Code Mode socket parent during shutdown', extractErrorLogFields(error)); + }); + } await destroyDb(); process.exit(0); }; diff --git a/packages/trueforge/src/routes/turnRoutes.ts b/packages/trueforge/src/routes/turnRoutes.ts index 92eb41bd5..6fc68b245 100644 --- a/packages/trueforge/src/routes/turnRoutes.ts +++ b/packages/trueforge/src/routes/turnRoutes.ts @@ -110,7 +110,7 @@ export const downloadSandboxFileRoute = createRoute({ }, 403: { content: { 'application/json': { schema: RequestErrorResponseSchema } }, - description: 'Caller is not the session creator, or sandbox belongs to another tenant.', + description: 'Caller is not the session creator.', }, 404: { content: { 'application/json': { schema: RequestErrorResponseSchema } }, diff --git a/packages/trueforge/src/runtime/sessionResources.ts b/packages/trueforge/src/runtime/sessionResources.ts index ec0b4dd47..7c04ac6ba 100644 --- a/packages/trueforge/src/runtime/sessionResources.ts +++ b/packages/trueforge/src/runtime/sessionResources.ts @@ -21,6 +21,8 @@ import type { ISandboxProviderStore } from '../db/sandboxProviderStore'; import type { ISkillStore } from '../db/skillStore'; import { isMcpAuthRequired, resolveMcpAuth } from '../mcp/auth/mcpDcr'; import type { IOAuthTokenStore } from '../mcp/auth/types'; +import { LocalSandboxProvider } from '../sandbox/local/provider/LocalSandboxProvider'; +import { getCachedLocalSandboxSupport, isLocalSandboxFallbackEnabled } from '../sandbox/localRuntime'; import { toDaytonaSandboxProvider } from '../sandbox/providerUtils'; import { resolveConfiguredMcpRequestHeaders } from '../schemas/mcpServer'; @@ -205,8 +207,9 @@ export async function resolveGitSkills({ } /** - * Build a runtime SandboxProvider from the configured store row, or undefined - * when no provider is configured. Builds a fresh Daytona client per call (no network I/O). + * Build a runtime SandboxProvider from the configured store row, or the + * in-memory local fallback when standalone + the cached probe is supported. + * Builds a fresh Daytona client per call (no network I/O). */ export async function resolveSandboxProvider({ tenant_id, @@ -218,22 +221,34 @@ export async function resolveSandboxProvider({ logger: Logger; }): Promise { const record = await store.getSandboxProvider(tenant_id); - if (record === undefined) { + if (record !== undefined) { + // Clone from the snapshot that was actually built (persisted build_ref), not a name + // derived from the current image — otherwise an image bump breaks creation until rebuild. + return toDaytonaSandboxProvider({ + manifest: record.manifest, + tenant_id, + logger, + build_metadata: record.build_metadata, + }); + } + if (!configuration.STANDALONE) { return undefined; } - // Clone from the snapshot that was actually built (persisted build_ref), not a name - // derived from the current image — otherwise an image bump breaks creation until rebuild. - return toDaytonaSandboxProvider({ - manifest: record.manifest, - tenant_id, - logger, - build_metadata: record.build_metadata, + const support = getCachedLocalSandboxSupport(); + if (support?.supported !== true) { + return undefined; + } + return new LocalSandboxProvider({ + sandboxRootPathParent: configuration.LOCAL_SANDBOX_ROOT_PARENT, + codeModeSocketParentPath: configuration.CODE_MODE_SOCKET_PARENT, + support, + fileMaxBytesForDownload: configuration.SANDBOX_FILE_MAX_BYTES_FOR_DOWNLOAD, }); } /** * Builds a Sandbox for one turn from a resolved provider and git mounts. - * `tenantName` must match the name given to DaytonaSandboxProvider (ownership check). + * `tenantName` is forwarded as `TFY_TENANT_NAME` for Daytona/agent env only. */ export function buildTurnSandbox(input: { provider: SandboxProvider; @@ -252,8 +267,6 @@ export function buildTurnSandbox(input: { blockDestructiveToolsInCodeMode: true, mcpRequestTimeoutMs: configuration.MCP_REQUEST_TIMEOUT_MS, mcpConnectTimeoutMs: configuration.MCP_CONNECT_TIMEOUT_MS, - // Sandbox reads its tenant from TFY_TENANT_NAME for the ownership check - // against provider-created sandbox ids (`.`). execExtraEnv: { TFY_TENANT_NAME: input.tenantName }, ...(skillMounter ? { skillMounter } : {}), tracing: input.tracing, @@ -341,7 +354,7 @@ export async function validateAgentSpec({ const hasSkills = requestedSkills.length > 0; if (wantsSandbox || hasSkills) { const record = await sandboxProviderStore.getSandboxProvider(tenant_id); - if (record === undefined) { + if (record === undefined && !isLocalSandboxFallbackEnabled()) { throw new HTTPException(422, { message: hasSkills ? 'skills require a sandbox provider — configure via PUT /settings/sandbox-providers' diff --git a/packages/trueforge/src/sandbox/local/core/CodeModeUdsTransport.ts b/packages/trueforge/src/sandbox/local/core/CodeModeUdsTransport.ts new file mode 100644 index 000000000..beadaafe5 --- /dev/null +++ b/packages/trueforge/src/sandbox/local/core/CodeModeUdsTransport.ts @@ -0,0 +1,271 @@ +/** + * Handle-scoped Code Mode UDS transport. Listen/accept live for the Sandbox handle lifetime; + * one UTF-8 JSON request/reply per connection (peer write-close); no request_id. + * + * Sockets live under {@link CodeModeUdsTransportOptions.codeModeSocketParentPath} as ULID names. + * Parent must be mode 0700 (enforced) so other accounts cannot replace the sock inode before + * connect; after listen the sock is chmod 0600. Same-UID isolation is still SRT path policy. + * The caller owns that parent directory's lifetime; this transport unlinks the sock it creates. + */ +import type { + CodeModeClientInstall, + CodeModeDispatcher, + CodeModeReply, + CodeModeRequest, + CodeModeTransport, +} from '@truefoundry/trueforge-core/core'; +import { CodeModeRequestSchema, validateNoPathTraversal } from '@truefoundry/trueforge-core/core'; +import { chmodSync, existsSync, realpathSync, statSync } from 'node:fs'; +import { chmod, mkdir, rm, symlink, unlink, writeFile } from 'node:fs/promises'; +import { createServer, type Server, type Socket } from 'node:net'; +import { dirname, isAbsolute, join, resolve } from 'node:path'; +import { ulid } from 'ulid'; +import { sandboxScripts } from '../sandboxScripts.gen.js'; +import { encodeJsonMessage, JsonMessageReader, MAX_MESSAGE_BYTES } from './frame.js'; +import { registerCodeModeSocketPath, unregisterCodeModeSocketPath } from './hostRun.js'; + +const MAX_CODE_MODE_SOCKET_PARENT_BYTES = 60; +const CODE_MODE_SOCKET_PARENT_MODE = 0o700; +const CODE_MODE_SOCKET_MODE = 0o600; + +/** Install layout for local Code Mode MCP client (sandboxId = absolute sandbox root). */ +export function localMcpClientRemotePath(sandboxId: string): string { + return join(sandboxId, 'mcp-client', 'mcp_client.py'); +} + +/** Install the local Code Mode MCP client into the sandbox (same layout as Sandbox.init). */ +export async function installMcpFixture(sandboxRootPath: string): Promise<{ remotePath: string }> { + const remotePath = localMcpClientRemotePath(sandboxRootPath); + const binLink = join(dirname(remotePath), 'bin', 'mcp-client'); + await mkdir(dirname(binLink), { recursive: true, mode: 0o700 }); + await writeFile(remotePath, sandboxScripts.mcpClientLocal, { encoding: 'utf8', mode: 0o555 }); + await rm(binLink, { force: true }); + await symlink(remotePath, binLink); + return { remotePath }; +} + +export interface CodeModeUdsTransportOptions { + /** + * Absolute existing directory for Code Mode UDS files (≤60 bytes, mode 0700). + * Socket path is `join(parent, ulid)`; sock is chmod 0600 after listen. + */ + codeModeSocketParentPath: string; + maxMessageBytes?: number | undefined; + /** Optional: observe inbound protocol failures (oversized / malformed). */ + onProtocolError?: ((message: string) => void) | undefined; +} + +/** Validate and normalize parent dir for Code Mode socks; enforce mode 0700. */ +export function assertCodeModeSocketParentPath(path: string): string { + if (!isAbsolute(path)) { + throw new Error('codeModeSocketParentPath must be an absolute path'); + } + validateNoPathTraversal(path); + const resolved = resolve(path); + if (!existsSync(resolved) || !statSync(resolved).isDirectory()) { + throw new Error('codeModeSocketParentPath must be an existing directory'); + } + // Seatbelt / allowUnixSockets match real paths (/private/var/... on macOS). + const real = realpathSync(resolved); + const bytes = Buffer.byteLength(real); + if (bytes > MAX_CODE_MODE_SOCKET_PARENT_BYTES) { + throw new Error( + `codeModeSocketParentPath must be at most ${String(MAX_CODE_MODE_SOCKET_PARENT_BYTES)} bytes (got ${String(bytes)})`, + ); + } + // Owner-only parent: other accounts cannot rename/replace socks under this dir. + chmodSync(real, CODE_MODE_SOCKET_PARENT_MODE); + const mode = statSync(real).mode & 0o777; + if (mode !== CODE_MODE_SOCKET_PARENT_MODE) { + throw new Error(`codeModeSocketParentPath must be mode 0700 after chmod (got 0o${mode.toString(8)})`); + } + return real; +} + +export class CodeModeUdsTransport implements CodeModeTransport { + private readonly codeModeSocketParentPath: string; + private readonly maxMessageBytes: number; + private readonly onProtocolError: ((message: string) => void) | undefined; + + private sessionPromise: Promise<{ env: Record }> | undefined; + private server: Server | undefined; + private sockPath: string | undefined; + private dispatcher: CodeModeDispatcher | undefined; + private cachedEnv: Record | undefined; + + constructor(options: CodeModeUdsTransportOptions) { + this.codeModeSocketParentPath = assertCodeModeSocketParentPath(options.codeModeSocketParentPath); + this.maxMessageBytes = options.maxMessageBytes ?? MAX_MESSAGE_BYTES; + this.onProtocolError = options.onProtocolError; + } + + getClientInstall(params: { sandboxId: string }): CodeModeClientInstall { + return { + content: sandboxScripts.mcpClientLocal, + remotePath: localMcpClientRemotePath(params.sandboxId), + }; + } + + start(params: { + codeModeDispatcher: CodeModeDispatcher; + sandboxId: string; + requestTimeoutSeconds: number; + }): Promise<{ env: Record }> { + this.dispatcher = params.codeModeDispatcher; + this.sessionPromise ??= this.listenSession(params).catch((e: unknown) => { + this.sessionPromise = undefined; + this.cachedEnv = undefined; + throw e; + }); + return this.sessionPromise; + } + + async stop(): Promise { + const pending = this.sessionPromise; + this.sessionPromise = undefined; + this.cachedEnv = undefined; + if (pending !== undefined) { + try { + await pending; + } catch { + // Listen failed; nothing to close. + } + } + const server = this.server; + const sockPath = this.sockPath; + this.server = undefined; + this.sockPath = undefined; + if (server !== undefined) { + await new Promise(resolveClose => { + server.close(() => { + resolveClose(); + }); + }); + } + if (sockPath !== undefined) { + unregisterCodeModeSocketPath(sockPath); + await unlink(sockPath).catch(() => undefined); + } + } + + private async listenSession(params: { requestTimeoutSeconds: number }): Promise<{ env: Record }> { + if (this.server !== undefined && this.cachedEnv !== undefined) { + return { env: this.cachedEnv }; + } + + // Re-check: caller owns the parent dir and may have removed it after construct. + assertCodeModeSocketParentPath(this.codeModeSocketParentPath); + + const sockPath = join(this.codeModeSocketParentPath, ulid().toLowerCase()); + await unlink(sockPath).catch(() => undefined); + registerCodeModeSocketPath(sockPath); + const server = createServer({ allowHalfOpen: true }); + try { + await new Promise((resolveListen, reject) => { + server.once('error', reject); + server.listen(sockPath, () => { + server.off('error', reject); + resolveListen(); + }); + }); + // Narrow the listen→chmod window; 0700 parent already blocks other accounts from replace. + await chmod(sockPath, CODE_MODE_SOCKET_MODE); + } catch (error) { + unregisterCodeModeSocketPath(sockPath); + server.close(); + await unlink(sockPath).catch(() => undefined); + throw error; + } + + this.server = server; + this.sockPath = sockPath; + server.on('connection', socket => { + this.handleConnection(socket); + }); + + const env = { + TFY_MCP_SOCK: sockPath, + TFY_CM_REQUEST_TIMEOUT_SECONDS: String(params.requestTimeoutSeconds), + }; + this.cachedEnv = env; + return { env }; + } + + private handleConnection(socket: Socket): void { + const reader = new JsonMessageReader({ maxBytes: this.maxMessageBytes }); + let settled = false; + + socket.on('error', () => undefined); + + // Oversized / malformed frames only tear down this connection (and notify + // onProtocolError). Transport is handle-scoped and does not kill the process group. + const fail = (message: string): void => { + if (settled) { + return; + } + settled = true; + this.onProtocolError?.(message); + socket.destroy(); + }; + + socket.on('data', (chunk: Buffer) => { + try { + reader.push(chunk); + } catch (error) { + fail(error instanceof Error ? error.message : String(error)); + } + }); + + socket.on('end', () => { + if (settled) { + return; + } + settled = true; + void this.dispatchConnection(socket, reader).catch((error: unknown) => { + this.onProtocolError?.(error instanceof Error ? error.message : String(error)); + socket.destroy(); + }); + }); + } + + private async dispatchConnection(socket: Socket, reader: JsonMessageReader): Promise { + let request: CodeModeRequest; + try { + const parsed = CodeModeRequestSchema.safeParse(reader.finish()); + if (!parsed.success) { + const reply: CodeModeReply = { + ok: false, + error: 'Malformed Code Mode request', + source: 'caller', + }; + socket.write(encodeJsonMessage(reply)); + socket.end(); + return; + } + request = parsed.data; + } catch (error) { + this.onProtocolError?.(error instanceof Error ? error.message : String(error)); + socket.destroy(); + return; + } + + const dispatcher = this.dispatcher; + if (dispatcher === undefined) { + const reply: CodeModeReply = { + ok: false, + error: 'Code Mode dispatcher is not configured', + source: 'internal', + }; + socket.write(encodeJsonMessage(reply)); + socket.end(); + return; + } + + const reply = await dispatcher.dispatch({ request, traceCarrier: {} }); + try { + socket.write(encodeJsonMessage(reply)); + } finally { + socket.end(); + } + } +} diff --git a/packages/trueforge/src/sandbox/local/core/frame.ts b/packages/trueforge/src/sandbox/local/core/frame.ts new file mode 100644 index 000000000..e56e8b89f --- /dev/null +++ b/packages/trueforge/src/sandbox/local/core/frame.ts @@ -0,0 +1,36 @@ +/** + * Code Mode UDS payload: one UTF-8 JSON value per connection. + * Peer write-close (EOF) delimits the message; no length prefix. + */ +import { JsonMessageValueSchema } from '../schemas/jsonMessage.js'; + +export const MAX_MESSAGE_BYTES = 64 * 1024 * 1024; + +export function encodeJsonMessage(value: unknown): Buffer { + return Buffer.from(JSON.stringify(value), 'utf8'); +} + +/** Accumulates inbound socket bytes until EOF, then parses JSON. */ +export class JsonMessageReader { + #buffer = Buffer.alloc(0); + readonly #maxBytes: number; + + constructor(options: { maxBytes?: number } = {}) { + this.#maxBytes = options.maxBytes ?? MAX_MESSAGE_BYTES; + } + + push(chunk: Buffer): void { + if (this.#buffer.length + chunk.length > this.#maxBytes) { + throw new Error(`message exceeds max ${String(this.#maxBytes)} bytes`); + } + this.#buffer = Buffer.concat([this.#buffer, chunk]); + } + + finish(): unknown { + try { + return JsonMessageValueSchema.parse(JSON.parse(this.#buffer.toString('utf8'))); + } catch (error) { + throw new Error('invalid JSON message', { cause: error }); + } + } +} diff --git a/packages/trueforge/src/sandbox/local/core/hostRun.ts b/packages/trueforge/src/sandbox/local/core/hostRun.ts new file mode 100644 index 000000000..ce9fab816 --- /dev/null +++ b/packages/trueforge/src/sandbox/local/core/hostRun.ts @@ -0,0 +1,504 @@ +/** + * Host-side sandboxed exec (in-process supervisor). + * Only the untrusted command argv is SRT-wrapped. Code Mode UDS is owned by + * {@link CodeModeUdsTransport} (handle-scoped); pass TFY_MCP_SOCK via `env` when needed. + * + * Platform policy (allowRead / AF_UNIX / PATH) uses {@link LocalSandboxPlatform} from + * {@link initSrt} — the same platform captured by LocalSandboxProvider.isSupported. + */ +import { getDefaultWritePaths, SandboxManager } from '@anthropic-ai/sandbox-runtime'; +import { execFile, spawn, type ChildProcess } from 'node:child_process'; +import { randomUUID } from 'node:crypto'; +import { realpathSync } from 'node:fs'; +import { mkdir, rm } from 'node:fs/promises'; +import { createRequire } from 'node:module'; +import { dirname, isAbsolute, join } from 'node:path'; +import { promisify } from 'node:util'; + +const execFileAsync = promisify(execFile); + +/** SRT ships Linux helpers (e.g. apply-seccomp) under vendor/; the wrapped command must read them. */ +// Package-root resolve (not app-module loading): Jest's CJS transform breaks import.meta.resolve. +const SRT_VENDOR = join( + dirname(createRequire(import.meta.url).resolve('@anthropic-ai/sandbox-runtime/package.json')), + 'vendor', +); + +/** + * Cap for buffered stdout+stderr per exec. + * Sized for base64 of a max-sized download (10 MiB → ~13.3 MiB) plus headroom. + */ +export const MAX_OUTPUT_BYTES = 14 * 1024 * 1024; + +/** Platforms LocalSandboxProvider / hostRun can run on. */ +export type LocalSandboxPlatform = 'darwin' | 'linux'; + +/** Cached from {@link initSrt}; cleared by {@link resetSrt}. Used by session policy helpers after init. */ +let activePlatform: LocalSandboxPlatform | undefined; + +function requireActivePlatform(): LocalSandboxPlatform { + if (activePlatform === undefined) { + throw new Error('SRT platform is not set; call initSrt({ platform }) first'); + } + return activePlatform; +} + +/** PATH for sandboxed commands — must stay aligned with allowRead exec roots. */ +const COMMAND_PATH_BY_PLATFORM = { + // On macOS, prefer Homebrew ahead of `/usr/bin` shims (those need Xcode select + // paths that we intentionally do not allow-read). + darwin: '/opt/homebrew/bin:/usr/bin:/bin:/usr/sbin:/sbin', + linux: '/usr/bin:/bin:/usr/sbin:/sbin', +} as const satisfies Record; + +export function commandPath(platform: LocalSandboxPlatform): string { + return COMMAND_PATH_BY_PLATFORM[platform]; +} + +/** + * Resolve a command name to an absolute path on the host using the sandbox PATH. + * Uses `/bin/sh` only as a host bootstrap for `command -v` (not the sandboxed wrap shell). + */ +export async function resolveCommandOnHost(params: { + platform: LocalSandboxPlatform; + name: string; +}): Promise { + if (!/^[A-Za-z0-9._+-]+$/.test(params.name)) { + throw new Error(`invalid command name for resolveCommandOnHost: ${params.name}`); + } + const pathEnv = commandPath(params.platform); + try { + const { stdout } = await execFileAsync('/bin/sh', ['-c', `command -v -- ${params.name}`], { + env: { PATH: pathEnv }, + encoding: 'utf8', + }); + const resolved = stdout.trim().split(/\r?\n/).filter(Boolean).at(-1); + if (resolved === undefined || resolved.length === 0 || !isAbsolute(resolved)) { + return undefined; + } + return resolved; + } catch { + return undefined; + } +} + +export interface SessionResult { + stdoutText: string; + stderrText: string; + exitCode: number; + protocolError: string | undefined; + timedOut: boolean; + /** Process-group leader pid of the sandboxed command (Unix). */ + childPid: number | undefined; +} + +/** + * SRT always unions getDefaultWritePaths() into allowWrite. There is no config + * flag to disable that. Deny the shared/host defaults (not /dev/*) so they are + * not usable as cross-sandbox writable storage. denyWrite wins over allowWrite. + */ +function denySharedDefaultWritePaths(): string[] { + return getDefaultWritePaths().filter(path => !path.startsWith('/dev/')); +} + +const ALLOW_READ_BY_PLATFORM = { + darwin: [ + '/opt/homebrew/bin', + '/usr/bin', + '/bin', + '/usr/sbin', + '/sbin', + '/usr/lib', + '/System/Library', + '/Library', + '/private/var/db/dyld', + '/private/var/select', + '/opt/homebrew', + '/dev', + ], + linux: [ + '/usr/bin', + '/bin', + '/usr/sbin', + '/sbin', + '/lib', + '/lib64', + '/usr/lib', + '/usr/lib64', + '/usr/local', + '/etc', + '/dev', + '/proc', + '/sys', + '/tmp', + SRT_VENDOR, + ], +} as const satisfies Record; + +export function platformAllowRead(platform: LocalSandboxPlatform): string[] { + return [...ALLOW_READ_BY_PLATFORM[platform]]; +} + +/** + * Policy for the untrusted command only (deny-by-default reads). + * The host (in-process supervisor) is never placed under this policy. + */ +function filesystemPolicy(params: { sandboxRootPath: string; platform: LocalSandboxPlatform }): { + allowWrite: string[]; + denyWrite: string[]; + denyRead: string[]; + allowRead: string[]; +} { + return { + allowWrite: [params.sandboxRootPath], + denyWrite: denySharedDefaultWritePaths(), + denyRead: ['/'], + allowRead: [params.sandboxRootPath, ...codeModeSocketPaths, ...platformAllowRead(params.platform)], + }; +} + +/** Curated env for the sandboxed command — never the full host process.env. */ +function commandEnv(params: { + sandboxRootPath: string; + platform: LocalSandboxPlatform; + extra?: Record; +}): Record { + const tmp = join(params.sandboxRootPath, '.tmp'); + const home = join(params.sandboxRootPath, '.home'); + const locked = { + HOME: home, + TMPDIR: tmp, + TMP: tmp, + TEMP: tmp, + // Local MCP client CLI lives at /mcp-client/bin (see CodeModeUdsTransport install layout). + PATH: (() => { + const base = commandPath(params.platform); + const ours = `${join(params.sandboxRootPath, 'mcp-client', 'bin')}:${base}`; + const theirs = params.extra?.['PATH']; + return theirs !== undefined && theirs.length > 0 ? `${ours}:${theirs}` : ours; + })(), + }; + return { + ...params.extra, + ...locked, + }; +} + +/** Session filesystem floor (per-exec customConfig still tightens allowWrite/allowRead). */ +function sessionFilesystem(platform: LocalSandboxPlatform): { + allowWrite: string[]; + denyWrite: string[]; + denyRead: string[]; + allowRead: string[]; +} { + const allowWrite: string[] = []; + return { + allowWrite, + denyWrite: denySharedDefaultWritePaths(), + denyRead: ['/'], + allowRead: platformAllowRead(platform), + }; +} + +/** + * AF_UNIX policy is session-scoped only (wrap customConfig cannot set it). + * - Linux: allowAllUnixSockets; pathname connect still needs FS allowRead (bwrap). + * - macOS: allowAllUnixSockets does NOT consult allowRead for connect — use + * allowUnixSockets subpath, synced at sandbox create/remove. + */ +function sessionNetwork(params: { platform: LocalSandboxPlatform; unixSockets?: string[] }): + | { + allowedDomains: string[]; + deniedDomains: string[]; + allowAllUnixSockets: true; + } + | { + allowedDomains: string[]; + deniedDomains: string[]; + allowAllUnixSockets: false; + allowUnixSockets: string[]; + } { + const allowedDomains: string[] = []; + const deniedDomains: string[] = []; + if (params.platform === 'linux') { + return { + allowedDomains, + deniedDomains, + allowAllUnixSockets: true, + }; + } + return { + allowedDomains, + deniedDomains, + allowAllUnixSockets: false, + allowUnixSockets: params.unixSockets ?? [], + }; +} + +/** Active sandbox roots allowed for macOS pathname UDS (seatbelt subpath). */ +const darwinUnixSocketSandboxRoots = new Set(); +/** Exact Code Mode UDS paths — macOS allowUnixSockets + Linux allowRead. */ +const codeModeSocketPaths = new Set(); + +function darwinUnixSocketPaths(): string[] { + return [...darwinUnixSocketSandboxRoots, ...codeModeSocketPaths]; +} + +function syncDarwinUnixSockets(): void { + // No-op until initSrt: register/unregister may run from transport-only tests. + if (SandboxManager.getConfig() === undefined) { + return; + } + const platform = requireActivePlatform(); + if (platform !== 'darwin') { + return; + } + SandboxManager.updateConfig(buildSessionConfig(platform)); +} + +/** Single source for process-scoped SRT session config (init + sock register/unregister). */ +function buildSessionConfig(platform: LocalSandboxPlatform): { + network: ReturnType; + filesystem: ReturnType; +} { + return { + network: sessionNetwork({ platform, unixSockets: darwinUnixSocketPaths() }), + filesystem: sessionFilesystem(platform), + }; +} + +/** Allow sandboxed clients to connect to this exact Code Mode sock path. */ +export function registerCodeModeSocketPath(sockPath: string): void { + codeModeSocketPaths.add(sockPath); + syncDarwinUnixSockets(); +} + +export function unregisterCodeModeSocketPath(sockPath: string): void { + codeModeSocketPaths.delete(sockPath); + syncDarwinUnixSockets(); +} + +/** Create a sandbox directory at `sandboxRootPath` (also the sandbox id). */ +export async function createSandbox(sandboxRootPath: string): Promise { + await mkdir(sandboxRootPath, { recursive: true, mode: 0o700 }); + await mkdir(join(sandboxRootPath, '.tmp'), { recursive: true, mode: 0o700 }); + await mkdir(join(sandboxRootPath, '.home'), { recursive: true, mode: 0o700 }); + // Seatbelt allowWrite matches real paths (/private/var/... on macOS). + const realRoot = realpathSync(sandboxRootPath); + darwinUnixSocketSandboxRoots.add(realRoot); + syncDarwinUnixSockets(); + return realRoot; +} + +export async function removeSandbox(sandboxRootPath: string): Promise { + darwinUnixSocketSandboxRoots.delete(sandboxRootPath); + syncDarwinUnixSockets(); + await rm(sandboxRootPath, { recursive: true, force: true }); +} + +/** + * Process-scoped SRT init. Per-exec filesystem policy is applied in + * {@link runSupervisorSession} via wrapWithSandboxArgv customConfig. + */ +export async function initSrt(params: { platform: LocalSandboxPlatform }): Promise { + activePlatform = params.platform; + await SandboxManager.initialize(buildSessionConfig(params.platform)); +} + +export async function resetSrt(): Promise { + codeModeSocketPaths.clear(); + activePlatform = undefined; + await SandboxManager.reset(); +} + +/** Whether process-scoped SRT session config is already initialized. */ +export function isSrtInitialized(): boolean { + return activePlatform !== undefined && SandboxManager.getConfig() !== undefined; +} + +/** + * Tear down the sandboxed exec and every process in its group. + * Child is spawned as a process-group leader (`detached: true` on Unix). + */ +export function killExecTree(child: ChildProcess | undefined): void { + if (!child) { + return; + } + const pid = child.pid; + if (pid !== undefined && process.platform !== 'win32') { + try { + process.kill(-pid, 'SIGKILL'); + return; + } catch { + // ESRCH if the group is already gone — fall through. + } + } + if (!child.killed) { + child.kill('SIGKILL'); + } +} + +/** + * Run one SRT-wrapped command. Code Mode UDS (if any) is supplied via `env.TFY_MCP_SOCK` + * from {@link CodeModeUdsTransport.start}. + */ +export async function runSupervisorSession(params: { + sandboxRootPath: string; + command: string; + /** Absolute shell path used to wrap the command string (from isSupported). */ + shell: string; + /** Platform policy for allowRead / PATH (from isSupported). */ + platform: LocalSandboxPlatform; + cwd?: string; + env?: Record; + /** Optional stdin bytes for the sandboxed command (e.g. upload payload). */ + stdin?: Buffer; + /** Host-visible pid of the sandboxed process-group leader (after spawn). */ + onChildSpawn?: (pid: number) => void; + /** Hard wall-clock limit for the sandboxed command; caller must choose deliberately. */ + timeoutMs: number; +}): Promise { + const { + sandboxRootPath, + command, + shell, + platform, + cwd = sandboxRootPath, + env, + stdin, + onChildSpawn, + timeoutMs, + } = params; + + const wrap = await SandboxManager.wrapWithSandboxArgv( + command, + shell, + { + filesystem: filesystemPolicy({ sandboxRootPath, platform }), + network: { + allowedDomains: [], + deniedDomains: [], + }, + }, + undefined, + sandboxRootPath, + { commandId: randomUUID(), commandText: command }, + ); + + const [argv0, ...argvRest] = wrap.argv; + if (argv0 === undefined) { + throw new Error('wrapWithSandboxArgv returned empty argv'); + } + + // Curated env only — do not spread wrap.env (it can carry ambient host secrets). + // Code Mode sock path (TFY_MCP_SOCK) is expected in `env` when the caller starts a transport. + const childEnv: NodeJS.ProcessEnv = { + ...commandEnv({ sandboxRootPath, platform, ...(env === undefined ? {} : { extra: env }) }), + }; + + const child = spawn(argv0, argvRest, { + cwd, + env: childEnv, + shell: false, + // Detached process groups break stdin forwarding for upload (`cat` via pipe) under Jest. + detached: stdin === undefined && process.platform !== 'win32', + stdio: [stdin === undefined ? 'ignore' : 'pipe', 'pipe', 'pipe'], + }); + if (child.pid !== undefined) { + onChildSpawn?.(child.pid); + } + if (stdin !== undefined) { + const stdinStream = child.stdin; + if (stdinStream === null) { + killExecTree(child); + SandboxManager.cleanupAfterCommand(); + throw new Error('stdin unavailable for sandboxed command'); + } + stdinStream.on('error', () => undefined); + await new Promise((resolve, reject) => { + stdinStream.end(stdin, (error?: Error | null) => { + if (error) { + reject(error); + } else { + resolve(); + } + }); + }); + } + + let stdoutText = ''; + let stderrText = ''; + let bufferedOutput = 0; + let protocolError: string | undefined; + let timedOut = false; + let closed = false; + + const ignoreStreamError = ( + stream: + | { + on: (event: 'error', cb: (err: Error) => void) => void; + } + | null + | undefined, + ): void => { + stream?.on('error', () => undefined); + }; + + const appendOutput = (stream: 'stdout' | 'stderr', chunk: Buffer): void => { + bufferedOutput += chunk.length; + if (bufferedOutput > MAX_OUTPUT_BYTES) { + protocolError = `buffered output exceeded ${String(MAX_OUTPUT_BYTES)} bytes`; + killExecTree(child); + return; + } + const text = chunk.toString('utf8'); + if (stream === 'stdout') { + stdoutText += text; + } else { + stderrText += text; + } + }; + + ignoreStreamError(child.stdout); + ignoreStreamError(child.stderr); + child.stdout?.on('data', (chunk: Buffer) => { + appendOutput('stdout', chunk); + }); + child.stderr?.on('data', (chunk: Buffer) => { + appendOutput('stderr', chunk); + }); + + return await new Promise((resolve, reject) => { + const timer = setTimeout(() => { + timedOut = true; + killExecTree(child); + }, timeoutMs); + + child.on('error', error => { + if (closed) { + return; + } + closed = true; + clearTimeout(timer); + SandboxManager.cleanupAfterCommand(); + reject(error); + }); + + child.on('close', code => { + if (closed) { + return; + } + closed = true; + clearTimeout(timer); + SandboxManager.cleanupAfterCommand(); + resolve({ + stdoutText, + stderrText, + exitCode: typeof code === 'number' ? code : timedOut ? 1 : 0, + protocolError, + timedOut, + childPid: child.pid, + }); + }); + }); +} diff --git a/packages/trueforge/src/sandbox/local/index.ts b/packages/trueforge/src/sandbox/local/index.ts new file mode 100644 index 000000000..145849cc5 --- /dev/null +++ b/packages/trueforge/src/sandbox/local/index.ts @@ -0,0 +1,5 @@ +export { CodeModeUdsTransport, installMcpFixture, localMcpClientRemotePath } from './core/CodeModeUdsTransport.js'; +export type { CodeModeUdsTransportOptions } from './core/CodeModeUdsTransport.js'; +export type { LocalSandboxPlatform } from './core/hostRun.js'; +export { LocalSandboxProvider } from './provider/LocalSandboxProvider.js'; +export type { LocalSandboxProviderOptions, LocalSandboxSupportResult } from './provider/LocalSandboxProvider.js'; diff --git a/packages/trueforge/src/sandbox/local/provider/LocalSandboxProvider.ts b/packages/trueforge/src/sandbox/local/provider/LocalSandboxProvider.ts new file mode 100644 index 000000000..7063ce06c --- /dev/null +++ b/packages/trueforge/src/sandbox/local/provider/LocalSandboxProvider.ts @@ -0,0 +1,409 @@ +/** + * Local SRT SandboxProvider. Code Mode UDS is handle-scoped via {@link CodeModeUdsTransport}. + */ +import type { + CodeModeTransport, + ExecResult, + SandboxBuild, + SandboxExecParams, + SandboxProvider, +} from '@truefoundry/trueforge-core/core'; +import { + SandboxFileNotFoundError, + SandboxFileTooLargeError, + SandboxNotAvailableError, + SandboxPathIsDirectoryError, + shellEscape, + validateNoPathTraversal, +} from '@truefoundry/trueforge-core/core'; +import { existsSync, statSync } from 'node:fs'; +import { mkdir, mkdtemp } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { isAbsolute, join, relative, resolve, sep } from 'node:path'; +import { ulid } from 'ulid'; +import { CodeModeUdsTransport, assertCodeModeSocketParentPath } from '../core/CodeModeUdsTransport.js'; +import { + createSandbox, + initSrt, + isSrtInitialized, + removeSandbox, + resetSrt, + resolveCommandOnHost, + runSupervisorSession, + type LocalSandboxPlatform, +} from '../core/hostRun.js'; +import { XferFileInfoSchema, type XferFileInfo } from '../schemas/xferFileInfo.js'; + +const DEFAULT_EXEC_TIMEOUT_SECONDS = 60; +const DEFAULT_FILE_MAX_BYTES = 10 * 1024 * 1024; +/** Cap for isSupported shell/Python probes (not general exec). */ +const SUPPORT_PROBE_TIMEOUT_MS = 5_000; + +/** Command names resolved via `command -v` (PATH from sandbox policy). */ +const SHELL_CANDIDATES = ['bash', 'sh'] as const; +const PYTHON_CANDIDATES = ['python3', 'python'] as const; + +export type { LocalSandboxPlatform }; + +export type LocalSandboxSupportResult = + | { supported: true; platform: LocalSandboxPlatform; shell: string; python: string } + | { supported: false; reason: string }; + +type LocalSandboxSupported = Extract; + +export interface LocalSandboxProviderOptions { + /** Absolute parent directory under which each createSandbox makes a ULID child root. */ + sandboxRootPathParent: string; + /** + * Absolute existing directory for Code Mode UDS (≤60 bytes, mode 0700). Caller owns its lifetime. + * Transport chmod's the parent to 0700 and each sock to 0600 after listen. + */ + codeModeSocketParentPath: string; + /** Result of {@link LocalSandboxProvider.isSupported}; must be `{ supported: true }`. */ + support: LocalSandboxSupportResult; + fileMaxBytesForDownload?: number | undefined; + defaultExecTimeoutSeconds?: number | undefined; +} + +/** Sandbox-relative path for sandboxed commands (avoids /var vs /private/var seatbelt mismatches). */ +function toSandboxRelativePath(params: { sandboxRootPath: string; absolutePath: string }): string { + const rel = relative(params.sandboxRootPath, params.absolutePath); + return rel === '' ? '.' : rel; +} + +export class LocalSandboxProvider implements SandboxProvider { + readonly type = 'local'; + private readonly sandboxRootPathParent: string; + private readonly codeModeSocketParentPath: string; + private readonly support: LocalSandboxSupported; + private readonly fileMaxBytesForDownload: number; + private readonly defaultExecTimeoutSeconds: number; + private srtInitialized = false; + + /** Local SRT has no image build step — always ready. */ + private static readonly readyBuild: SandboxBuild = { + status: 'ready', + reason: null, + metadata: null, + }; + + /** + * Probe whether this host can run LocalSandboxProvider (OS + in-sandbox shell + Python 3). + * On success, returns platform/shell/python to pass into the constructor as `support`. + */ + static async isSupported(): Promise { + if (process.platform !== 'darwin' && process.platform !== 'linux') { + return { + supported: false, + reason: `LocalSandboxProvider supports macOS and Linux only (got ${process.platform})`, + }; + } + const platform: LocalSandboxPlatform = process.platform; + + const alreadyInitialized = isSrtInitialized(); + let probeRoot: string | undefined; + + try { + if (!alreadyInitialized) { + await initSrt({ platform }); + } + + probeRoot = await createSandbox(await mkdtemp(join(tmpdir(), 'tfy-local-sandbox-support-'))); + + let shell: string | undefined; + for (const name of SHELL_CANDIDATES) { + const resolved = await resolveCommandOnHost({ platform, name }); + if (resolved === undefined) continue; + const probe = await runSupervisorSession({ + sandboxRootPath: probeRoot, + platform, + shell: resolved, + command: 'echo shell-ok', + timeoutMs: SUPPORT_PROBE_TIMEOUT_MS, + }); + if (probe.protocolError === undefined && probe.exitCode === 0 && probe.stdoutText.includes('shell-ok')) { + shell = resolved; + break; + } + } + if (shell === undefined) { + return { + supported: false, + reason: 'No usable shell in sandbox (bash or sh via command -v)', + }; + } + + let python: string | undefined; + for (const name of PYTHON_CANDIDATES) { + const resolved = await resolveCommandOnHost({ platform, name }); + if (resolved === undefined) continue; + const probe = await runSupervisorSession({ + sandboxRootPath: probeRoot, + platform, + shell, + command: `${shellEscape(resolved)} -c ${shellEscape( + 'import sys; raise SystemExit(0 if sys.version_info[0] == 3 else 1)', + )}`, + timeoutMs: SUPPORT_PROBE_TIMEOUT_MS, + }); + if (probe.protocolError === undefined && probe.exitCode === 0) { + python = resolved; + break; + } + } + if (python === undefined) { + return { + supported: false, + reason: 'No usable Python 3 interpreter in sandbox (python3 or python via command -v)', + }; + } + + return { supported: true, platform, shell, python }; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + return { supported: false, reason: message }; + } finally { + if (probeRoot !== undefined) { + await removeSandbox(probeRoot); + } + if (!alreadyInitialized) { + await resetSrt(); + } + } + } + + constructor(options: LocalSandboxProviderOptions) { + if (!options.support.supported) { + throw new Error(`LocalSandboxProvider is not supported: ${options.support.reason}`); + } + if (!isAbsolute(options.sandboxRootPathParent)) { + throw new Error('sandboxRootPathParent must be an absolute path'); + } + validateNoPathTraversal(options.sandboxRootPathParent); + this.sandboxRootPathParent = resolve(options.sandboxRootPathParent); + // Same validation as CodeModeUdsTransport (absolute, exists, ≤60 bytes, realpath). + this.codeModeSocketParentPath = assertCodeModeSocketParentPath(options.codeModeSocketParentPath); + this.support = options.support; + this.fileMaxBytesForDownload = options.fileMaxBytesForDownload ?? DEFAULT_FILE_MAX_BYTES; + this.defaultExecTimeoutSeconds = options.defaultExecTimeoutSeconds ?? DEFAULT_EXEC_TIMEOUT_SECONDS; + } + + private pythonC(code: string, relPath: string): string { + return `${this.support.python} -c ${shellEscape(code)} ${shellEscape(relPath)}`; + } + + private statCommand(relPath: string): string { + const code = [ + 'import json, os, sys', + 'p = sys.argv[1]', + 'st = os.stat(p)', + 'print(json.dumps({"size": st.st_size, "isDir": os.path.isdir(p)}))', + ].join('\n'); + return this.pythonC(code, relPath); + } + + private base64EncodeCommand(relPath: string): string { + const code = [ + 'import base64, sys', + 'p = sys.argv[1]', + 'sys.stdout.write(base64.b64encode(open(p, "rb").read()).decode("ascii"))', + ].join('\n'); + return this.pythonC(code, relPath); + } + + buildImage(): Promise { + return Promise.resolve(LocalSandboxProvider.readyBuild); + } + + getImageBuildStatus(): Promise { + return Promise.resolve(LocalSandboxProvider.readyBuild); + } + + private async ensureSrt(): Promise { + if (this.srtInitialized) return; + await initSrt({ platform: this.support.platform }); + this.srtInitialized = true; + } + + /** Missing or non-directory root → recreate path in Sandbox. */ + private ensureSandboxRoot(sandboxRootPath: string): void { + if (!isAbsolute(sandboxRootPath) || !existsSync(sandboxRootPath) || !statSync(sandboxRootPath).isDirectory()) { + throw new SandboxNotAvailableError(sandboxRootPath); + } + } + + private resolveInSandboxRoot(sandboxRootPath: string, userPath: string): string { + validateNoPathTraversal(userPath); + const resolved = userPath.startsWith('/') ? resolve(userPath) : resolve(sandboxRootPath, userPath); + const root = resolve(sandboxRootPath); + if (resolved !== root && !resolved.startsWith(root + sep)) { + throw new SandboxFileNotFoundError(userPath); + } + return resolved; + } + + private async runSandboxCommand(params: { + sandboxRootPath: string; + command: string; + stdin?: Buffer; + }): Promise<{ exitCode: number; stdoutText: string; stderrText: string }> { + const session = await runSupervisorSession({ + sandboxRootPath: params.sandboxRootPath, + platform: this.support.platform, + shell: this.support.shell, + command: params.command, + ...(params.stdin === undefined ? {} : { stdin: params.stdin }), + timeoutMs: this.defaultExecTimeoutSeconds * 1000, + }); + if (session.protocolError !== undefined) { + throw new Error(session.protocolError); + } + return { + exitCode: session.exitCode, + stdoutText: session.stdoutText, + stderrText: session.stderrText, + }; + } + + private async getFileInfo(params: { + sandboxRootPath: string; + relPath: string; + userPath: string; + }): Promise { + const result = await this.runSandboxCommand({ + sandboxRootPath: params.sandboxRootPath, + command: this.statCommand(params.relPath), + }); + if (result.exitCode !== 0) { + throw new SandboxFileNotFoundError(params.userPath); + } + return XferFileInfoSchema.parse(JSON.parse(result.stdoutText.trim())); + } + + async createSandbox(): Promise<{ sandboxId: string }> { + await this.ensureSrt(); + const sandboxId = await createSandbox(join(this.sandboxRootPathParent, ulid().toLowerCase())); + await mkdir(join(sandboxId, 'tool-results'), { recursive: true, mode: 0o700 }); + await mkdir(join(sandboxId, 'uploads'), { recursive: true, mode: 0o700 }); + return { sandboxId }; + } + + async exec(params: SandboxExecParams): Promise { + this.ensureSandboxRoot(params.sandboxId); + try { + await this.ensureSrt(); + const sandboxRootPath = params.sandboxId; + const cwd = + params.cwd === undefined || params.cwd === '' + ? sandboxRootPath + : this.resolveInSandboxRoot(sandboxRootPath, params.cwd); + const timeoutSeconds = params.timeoutSeconds ?? this.defaultExecTimeoutSeconds; + const session = await runSupervisorSession({ + sandboxRootPath, + platform: this.support.platform, + shell: this.support.shell, + command: params.command, + cwd, + ...(params.env === undefined ? {} : { env: params.env }), + timeoutMs: timeoutSeconds * 1000, + }); + if (session.protocolError !== undefined) { + return { success: false, error: session.protocolError }; + } + const result = session.stdoutText + (session.stderrText ? session.stderrText : ''); + return { + success: true, + response: { exitCode: session.exitCode, result }, + }; + } catch (error) { + if (error instanceof SandboxNotAvailableError) { + throw error; + } + const message = error instanceof Error ? error.message : String(error); + return { success: false, error: message }; + } + } + + getAdditionalInstructions(): string { + return [ + 'SANDBOX RULES:', + `- Platform: ${this.support.platform}.`, + `- Commands run under the sandbox shell: ${this.support.shell}.`, + `- Python 3 is available as: ${this.support.python}. Prefer this binary for Python scripts.`, + "- The Agent's first sandbox command should be `pwd` to discover the working directory.", + '- ALL file creation and writes MUST stay within that working directory.', + '- The Agent must NOT write outside the working directory (including host home and /tmp).', + ].join('\n'); + } + + getToolResultDumpDir(sandboxId: string): string { + return join(sandboxId, 'tool-results'); + } + + getGitCredentialsPath(sandboxId: string): string { + return join(sandboxId, '.git-credentials'); + } + + async downloadFile(params: { sandboxId: string; path: string }): Promise { + this.ensureSandboxRoot(params.sandboxId); + await this.ensureSrt(); + const sandboxRootPath = params.sandboxId; + const absolutePath = this.resolveInSandboxRoot(sandboxRootPath, params.path); + const relPath = toSandboxRelativePath({ sandboxRootPath, absolutePath }); + const info = await this.getFileInfo({ sandboxRootPath, relPath, userPath: params.path }); + if (info.isDir) { + throw new SandboxPathIsDirectoryError(params.path); + } + if (info.size > this.fileMaxBytesForDownload) { + throw new SandboxFileTooLargeError(params.path, info.size, this.fileMaxBytesForDownload); + } + const result = await this.runSandboxCommand({ + sandboxRootPath, + command: this.base64EncodeCommand(relPath), + }); + if (result.exitCode !== 0) { + throw new SandboxFileNotFoundError(params.path); + } + const buf = Buffer.from(result.stdoutText.trim(), 'base64'); + if (buf.length > this.fileMaxBytesForDownload) { + throw new SandboxFileTooLargeError(params.path, buf.length, this.fileMaxBytesForDownload); + } + return buf; + } + + /** Payload on stdin so large uploads stay off argv. Parent dirs must already exist. */ + async uploadFile(params: { sandboxId: string; remotePath: string; content: Buffer }): Promise { + this.ensureSandboxRoot(params.sandboxId); + await this.ensureSrt(); + if (params.content.length > this.fileMaxBytesForDownload) { + throw new SandboxFileTooLargeError(params.remotePath, params.content.length, this.fileMaxBytesForDownload); + } + const sandboxRootPath = params.sandboxId; + // Resolve for traversal checks, but pass sandbox-relative paths to the shell. + // Absolute /var/folders/... paths lose quoting under SRT and become mkdir /var. + const absolutePath = this.resolveInSandboxRoot(sandboxRootPath, params.remotePath); + const remotePath = toSandboxRelativePath({ sandboxRootPath, absolutePath }); + const result = await this.runSandboxCommand({ + sandboxRootPath, + command: `cat > ${shellEscape(remotePath)}`, + stdin: params.content, + }); + if (result.exitCode !== 0) { + throw new SandboxFileNotFoundError(params.remotePath); + } + } + + createCodeModeTransport(): CodeModeTransport { + return new CodeModeUdsTransport({ + codeModeSocketParentPath: this.codeModeSocketParentPath, + }); + } + + /** Reset process-scoped SRT for this provider. */ + async dispose(): Promise { + if (this.srtInitialized) { + await resetSrt(); + this.srtInitialized = false; + } + } +} diff --git a/packages/trueforge/src/sandbox/local/schemas/jsonMessage.ts b/packages/trueforge/src/sandbox/local/schemas/jsonMessage.ts new file mode 100644 index 000000000..5d95b9e6d --- /dev/null +++ b/packages/trueforge/src/sandbox/local/schemas/jsonMessage.ts @@ -0,0 +1,4 @@ +/** Code Mode UDS JSON payload after JSON.parse (any JSON value). */ +import { z } from 'zod'; + +export const JsonMessageValueSchema = z.json(); diff --git a/packages/trueforge/src/sandbox/local/schemas/xferFileInfo.ts b/packages/trueforge/src/sandbox/local/schemas/xferFileInfo.ts new file mode 100644 index 000000000..18e50d463 --- /dev/null +++ b/packages/trueforge/src/sandbox/local/schemas/xferFileInfo.ts @@ -0,0 +1,8 @@ +/** `stat` / xfer probe output from sandboxed python. */ +import { z } from 'zod'; + +export const XferFileInfoSchema = z.object({ + size: z.number(), + isDir: z.boolean(), +}); +export type XferFileInfo = z.infer; diff --git a/packages/trueforge/src/sandbox/local/scripts/mcp_client_local.py b/packages/trueforge/src/sandbox/local/scripts/mcp_client_local.py new file mode 100644 index 000000000..0a1eb1af4 --- /dev/null +++ b/packages/trueforge/src/sandbox/local/scripts/mcp_client_local.py @@ -0,0 +1,277 @@ +#!/usr/bin/env python3 +"""Code Mode UDS client — same public surface as product mcp_client.py, stdlib only. + +Authoritative reference (do not diverge on API/CLI/policy semantics): + packages/trueforge-core/src/core/sandbox/scripts/mcp_client.py + +Inlined into TypeScript via scripts/generate-local-sandbox-scripts.mjs +(src/sandbox/local/sandboxScripts.gen.ts), same pattern as core sandboxScripts. +""" + +from __future__ import annotations + +import argparse +import asyncio +import base64 +import json +import logging +import os +import socket +import sys +import time +from pathlib import Path +from typing import Any, TypedDict + +logger = logging.getLogger(__name__) + +MAX_MESSAGE_BYTES = 64 * 1024 * 1024 +_TOOLS_CACHE_TTL_SECONDS = 600 + + +class _ServerConfig(TypedDict): + allowed_tools: list[str] + + +_inflight_list_tools: dict[str, asyncio.Task[list[dict[str, Any]]]] = {} + +_raw_servers = os.environ.get("TFY_MCP_SERVERS") +_servers_map: dict[str, _ServerConfig] = ( + json.loads(base64.b64decode(_raw_servers).decode()) if _raw_servers else {} +) +_enable_agent_approvals = os.environ.get("TFY_ENABLE_AGENT_APPROVALS", "true").lower() == "true" + + +def _sock_path() -> str: + path = os.environ.get("TFY_MCP_SOCK") + if not path: + raise RuntimeError("TFY_MCP_SOCK is not set") + return path + + +def _request_timeout() -> float: + return float(os.environ.get("TFY_CM_REQUEST_TIMEOUT_SECONDS", "60")) + + +def _check_tool_allowed(server: str, tool_name: str) -> None: + server_config = _servers_map.get(server) + if server_config is None: + raise RuntimeError(f"Access denied: MCP server '{server}' is not available for this agent") from None + server_tools = server_config.get("allowed_tools") or [] + if len(server_tools) > 0 and tool_name not in server_tools: + raise RuntimeError(f"Access denied: tool '{tool_name}' is not enabled on server '{server}'") from None + + +def _cache_path(server: str) -> Path: + return Path(__file__).parent / f"{server}.tools.json" + + +def _read_tools_cache(server: str) -> list[dict[str, Any]] | None: + p = _cache_path(server) + if not p.exists(): + return None + try: + raw = json.loads(p.read_text(encoding="utf-8")) + if not isinstance(raw, dict): + raise ValueError("cache root must be object") + fetched_at = raw.get("fetched_at") + tools = raw.get("tools") + if not isinstance(fetched_at, (int, float)) or not isinstance(tools, list): + raise ValueError("cache shape invalid") + if time.time() - float(fetched_at) > _TOOLS_CACHE_TTL_SECONDS: + p.unlink(missing_ok=True) + return None + return [t for t in tools if isinstance(t, dict)] + except Exception: + logger.exception("_read_tools_cache") + p.unlink(missing_ok=True) + return None + + +def _write_tools_cache(server: str, tools: list[dict[str, Any]]) -> None: + try: + payload = {"fetched_at": time.time(), "tools": tools} + _cache_path(server).write_text(json.dumps(payload), encoding="utf-8") + except Exception: + logger.exception("_write_tools_cache") + + +def _read_message(sock: socket.socket) -> Any: + body = b"" + while True: + chunk = sock.recv(65536) + if not chunk: + break + body += chunk + if len(body) > MAX_MESSAGE_BYTES: + raise RuntimeError(f"message exceeds max {MAX_MESSAGE_BYTES} bytes") + return json.loads(body.decode("utf-8")) + + +def _write_message(sock: socket.socket, value: Any) -> None: + sock.sendall(json.dumps(value).encode("utf-8")) + + +def _uds_request_sync(payload: dict[str, Any]) -> Any: + """Connect → JSON request → write-close → JSON reply (no request_id).""" + sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) + try: + sock.settimeout(_request_timeout()) + sock.connect(_sock_path()) + _write_message(sock, payload) + sock.shutdown(socket.SHUT_WR) + reply = _read_message(sock) + finally: + sock.close() + if not isinstance(reply, dict): + raise RuntimeError(f"Code Mode reply is not an object: {reply!r}") + ok = reply.get("ok") + if ok is not True: + source = reply.get("source", "internal") + error = reply.get("error", "unknown error") + if source == "caller": + raise RuntimeError(f"Invalid MCP request: {error}") + if source == "transport": + raise RuntimeError(f"Code Mode transport error: {error}") + raise RuntimeError(f"Internal MCP error: {error}") + return reply.get("result") + + +async def _uds_request(payload: dict[str, Any]) -> Any: + return await asyncio.to_thread(_uds_request_sync, payload) + + +async def _fetch_tools(server: str) -> list[dict[str, Any]]: + result = await _uds_request({"op": "list_tools", "server": server}) + if not isinstance(result, dict): + raise RuntimeError(f"list_tools '{server}' returned unexpected shape: {result!r}") from None + tools = result.get("tools", []) + if not isinstance(tools, list): + raise RuntimeError(f"list_tools '{server}' tools is not a list: {tools!r}") from None + return [t for t in tools if isinstance(t, dict)] + + +async def _fetch_and_cache_tools(server: str) -> list[dict[str, Any]]: + tools = await _fetch_tools(server) + _write_tools_cache(server, tools) + return tools + + +async def _get_tools(server: str) -> list[dict[str, Any]]: + cached = _read_tools_cache(server) + if cached is not None: + return cached + task = _inflight_list_tools.get(server) + if task is None: + task = asyncio.create_task(_fetch_and_cache_tools(server)) + _inflight_list_tools[server] = task + task.add_done_callback(lambda _t: _inflight_list_tools.pop(server, None)) + return await task + + +async def _get_tool(server: str, tool_name: str) -> dict[str, Any] | None: + for t in await _get_tools(server): + if t.get("name") == tool_name: + return t + return None + + +def _is_destructive(tool: dict[str, Any]) -> bool: + annotations = tool.get("annotations") + if annotations is None: + return False + if not isinstance(annotations, dict): + return False + destructive = annotations.get("destructiveHint") + read_only = annotations.get("readOnlyHint") + return bool(destructive) or (not read_only and read_only is not None) + + +async def _ensure_non_destructive(server: str, tool_name: str) -> None: + tool = await _get_tool(server, tool_name) + if tool is None: + raise RuntimeError(f"Tool '{tool_name}' not found on MCP server '{server}'") from None + if _is_destructive(tool): + raise RuntimeError( + f"Tool '{tool_name}' on MCP server '{server}' is destructive and cannot be called in Code Mode; " + f"call it directly so it can go through the user approval flow" + ) from None + + +def _project_call_tool_result(server: str, tool: str, result: Any) -> Any: + """Project an MCP-wire CallToolResult-shaped object into the user-facing Python value.""" + if not isinstance(result, dict): + raise RuntimeError(f"call_tool reply for '{server}/{tool}' is malformed: expected object") from None + + if result.get("isError"): + content = result.get("content") + text_parts: list[str] = [] + if isinstance(content, list): + for c in content: + if isinstance(c, dict) and c.get("type") == "text": + text = c.get("text") + if isinstance(text, str) and text: + text_parts.append(text) + msg = "; ".join(text_parts) if text_parts else "tool returned an error" + raise RuntimeError(f"MCP tool error (server={server}, tool={tool}): {msg}") from None + + if result.get("structuredContent") is not None: + return result["structuredContent"] + + content = result.get("content") + if isinstance(content, list) and len(content) == 1: + first = content[0] + if isinstance(first, dict) and first.get("type") == "text": + text = first.get("text") + if isinstance(text, str) and text: + try: + return json.loads(text) + except Exception: + pass + if isinstance(content, list) and content: + return content + return None + + +async def call_tool(server: str, tool: str, body: dict[str, Any]) -> Any: + _check_tool_allowed(server, tool) + if _enable_agent_approvals: + await _ensure_non_destructive(server, tool) + + raw = await _uds_request( + { + "op": "call_tool", + "server": server, + "tool": tool, + "arguments": body, + }, + ) + return _project_call_tool_result(server, tool, raw) + + +_USAGE = "mcp_client_local.py call-tool " + + +def _build_arg_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser(prog="mcp_client_local.py", usage=_USAGE) + sub = parser.add_subparsers(dest="cmd", required=True) + + call_tool_p = sub.add_parser("call-tool", help="Invoke an MCP tool") + call_tool_p.add_argument("server") + call_tool_p.add_argument("tool") + call_tool_p.add_argument("args_json", metavar="args-json", type=json.loads) + + return parser + + +async def _main() -> None: + args = _build_arg_parser().parse_args() + try: + if args.cmd == "call-tool": + result = await call_tool(args.server, args.tool, args.args_json) + print(json.dumps(result, default=str)) + except RuntimeError as e: + sys.exit(str(e)) + + +if __name__ == "__main__": + asyncio.run(_main()) diff --git a/packages/trueforge/src/sandbox/localLifecycle.ts b/packages/trueforge/src/sandbox/localLifecycle.ts new file mode 100644 index 000000000..89618ae3f --- /dev/null +++ b/packages/trueforge/src/sandbox/localLifecycle.ts @@ -0,0 +1,22 @@ +import { existsSync } from 'node:fs'; +import { mkdir, rm } from 'node:fs/promises'; +import type { Logger } from 'winston'; + +const SOCKET_PARENT_MODE = 0o700; + +/** Exists → warn, then rm + mkdir 0700. Reliability path for leftover UDS files (including watch restarts). */ +export async function prepareCodeModeSocketParent(params: { path: string; logger: Logger }): Promise { + if (existsSync(params.path)) { + params.logger.warn('Removing leftover Code Mode socket parent', { path: params.path }); + await rm(params.path, { recursive: true, force: true }); + } + await mkdir(params.path, { recursive: true, mode: SOCKET_PARENT_MODE }); +} + +export async function removeCodeModeSocketParent(socketParentPath: string): Promise { + await rm(socketParentPath, { recursive: true, force: true }); +} + +export async function ensureLocalSandboxRootParent(sandboxRootPathParent: string): Promise { + await mkdir(sandboxRootPathParent, { recursive: true, mode: SOCKET_PARENT_MODE }); +} diff --git a/packages/trueforge/src/sandbox/localRuntime.ts b/packages/trueforge/src/sandbox/localRuntime.ts new file mode 100644 index 000000000..1b63eb84e --- /dev/null +++ b/packages/trueforge/src/sandbox/localRuntime.ts @@ -0,0 +1,21 @@ +/** + * Process-scoped local-sandbox probe cache. `isSupported()` inits SRT and + * creates a temp sandbox — run once at standalone boot, never per request. + */ +import configuration from '../config'; +import type { LocalSandboxSupportResult } from './local/provider/LocalSandboxProvider'; + +let cachedSupport: LocalSandboxSupportResult | undefined; + +export function setCachedLocalSandboxSupport(support: LocalSandboxSupportResult | undefined): void { + cachedSupport = support; +} + +export function getCachedLocalSandboxSupport(): LocalSandboxSupportResult | undefined { + return cachedSupport; +} + +/** Standalone + cached probe succeeded. No DB row required. */ +export function isLocalSandboxFallbackEnabled(): boolean { + return configuration.STANDALONE && cachedSupport?.supported === true; +} diff --git a/packages/trueforge/tests/sandbox/local/smoke.test.ts b/packages/trueforge/tests/sandbox/local/smoke.test.ts new file mode 100644 index 000000000..5869bd5f1 --- /dev/null +++ b/packages/trueforge/tests/sandbox/local/smoke.test.ts @@ -0,0 +1,1975 @@ +/** + * LocalSandboxProvider smoke (macOS host or Linux via Lima) + * plus Code Mode UDS and security probes. Run via `pnpm smoke`. + */ +import { getDefaultWritePaths, SandboxManager } from '@anthropic-ai/sandbox-runtime'; +import { CodeModeDispatcher, type IToolSet } from '@truefoundry/trueforge-core/core'; +import assert from 'node:assert/strict'; +import { spawn } from 'node:child_process'; +import { randomUUID } from 'node:crypto'; +import { access, mkdir, mkdtemp, readFile, realpath, rm, stat, unlink, writeFile } from 'node:fs/promises'; +import { createRequire } from 'node:module'; +import { createServer } from 'node:net'; +import { tmpdir } from 'node:os'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { ulid } from 'ulid'; +import { CodeModeUdsTransport, installMcpFixture } from '../../../src/sandbox/local/core/CodeModeUdsTransport.js'; +import { + commandPath, + createSandbox, + MAX_OUTPUT_BYTES, + platformAllowRead, + registerCodeModeSocketPath, + removeSandbox, + runSupervisorSession, + unregisterCodeModeSocketPath, +} from '../../../src/sandbox/local/core/hostRun.js'; +import { LocalSandboxProvider } from '../../../src/sandbox/local/provider/LocalSandboxProvider.js'; + +const ROOT = join(dirname(fileURLToPath(import.meta.url)), '../../..'); +const SANDBOXES = join(ROOT, 'sandboxes'); +const DENY_READ_SECRET = join(SANDBOXES, '.poc-deny-read-secret'); +const DEFAULT_TMP_CLAUDE = '/tmp/claude'; +const DELETE_TARGET = join(DEFAULT_TMP_CLAUDE, 'poc-delete-target.txt'); +const SECRET_CONTENTS = 'host-secret-should-not-leak\n'; +const HOST_HOME = process.env['HOME']; +const ENV_LEAK_MARKER = 'TFY_SMOKE_HOST_SECRET'; +const ENV_LEAK_VALUE = 'host-env-must-not-reach-sandbox'; +const ENV_INHERIT_MARKER = 'TFY_SMOKE_INHERIT'; +const ENV_INHERIT_VALUE = `inherit-${randomUUID()}`; +const ENV_PEER_MARKER = 'TFY_SMOKE_PEER_ENV'; +const ENV_PEER_VALUE = `peer-secret-${randomUUID()}`; +/** Product-shaped allowlist for smoke Code Mode client (demo/ping only). */ +const TFY_MCP_SERVERS_DEMO = Buffer.from(JSON.stringify({ demo: { allowed_tools: ['ping'] } }), 'utf8').toString( + 'base64', +); +// Package-root resolve: Jest's CJS transform breaks import.meta.resolve. +const SRT_VENDOR = join( + dirname(createRequire(import.meta.url).resolve('@anthropic-ai/sandbox-runtime/package.json')), + 'vendor', +); +async function prepareHostProbeFiles(): Promise { + await mkdir(DEFAULT_TMP_CLAUDE, { recursive: true, mode: 0o700 }); + await mkdir(SANDBOXES, { recursive: true, mode: 0o700 }); + await writeFile(DELETE_TARGET, 'delete-me\n', { mode: 0o600 }); + await writeFile(DENY_READ_SECRET, SECRET_CONTENTS, { mode: 0o600 }); +} + +async function cleanupHostProbeFiles(): Promise { + await rm(DELETE_TARGET, { force: true }); + await rm(DENY_READ_SECRET, { force: true }); +} + +function sleep(ms: number): Promise { + return new Promise(resolve => { + setTimeout(resolve, ms); + }); +} + +/** + * Direct check: after initSrt, registerCodeModeSocketPath → updateConfig must + * allow a sandboxed client to connect to that exact sock (and unregister revoke it). + */ +async function smokeLiveSrtUnixSocketAllowlistUpdate(params: { + sandboxRootPath: string; + codeModeSocketParentPath: string; + shell: string; + platform: 'darwin' | 'linux'; +}): Promise { + const parent = await realpath(params.codeModeSocketParentPath); + const sockPath = join(parent, ulid().toLowerCase()); + await unlink(sockPath).catch(() => undefined); + + const server = createServer(); + await new Promise((resolve, reject) => { + server.once('error', reject); + server.listen(sockPath, () => { + server.off('error', reject); + resolve(); + }); + }); + server.on('connection', socket => { + socket.end(); + }); + + const connectCmd = [ + "python3 - <<'PY'", + 'import socket, sys', + `path = ${JSON.stringify(sockPath)}`, + 's = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)', + 'try:', + ' s.connect(path)', + ' print("connected")', + 'except OSError as e:', + ' print(type(e).__name__, e, file=sys.stderr)', + ' sys.exit(1)', + 'finally:', + ' s.close()', + 'PY', + ].join('\n'); + + try { + const before = await runSupervisorSession({ + sandboxRootPath: params.sandboxRootPath, + shell: params.shell, + platform: params.platform, + command: connectCmd, + timeoutMs: 10_000, + }); + assert.notEqual(before.exitCode, 0, 'connect must fail before register/updateConfig'); + assert.ok(!before.stdoutText.includes('connected')); + + registerCodeModeSocketPath(sockPath); + + const afterRegister = await runSupervisorSession({ + sandboxRootPath: params.sandboxRootPath, + shell: params.shell, + platform: params.platform, + command: connectCmd, + timeoutMs: 10_000, + }); + assert.equal(afterRegister.exitCode, 0, afterRegister.stderrText); + assert.match(afterRegister.stdoutText, /connected/); + + unregisterCodeModeSocketPath(sockPath); + + const afterUnregister = await runSupervisorSession({ + sandboxRootPath: params.sandboxRootPath, + shell: params.shell, + platform: params.platform, + command: connectCmd, + timeoutMs: 10_000, + }); + assert.notEqual(afterUnregister.exitCode, 0, 'connect must fail after unregister/updateConfig'); + assert.ok(!afterUnregister.stdoutText.includes('connected')); + + console.log('ok: live SRT updateConfig allowlists exact Code Mode sock (register/unregister)'); + } finally { + unregisterCodeModeSocketPath(sockPath); + await new Promise(resolve => { + server.close(() => { + resolve(); + }); + }); + await unlink(sockPath).catch(() => undefined); + } +} + +function pidAlive(pid: number): boolean { + try { + process.kill(pid, 0); + return true; + } catch { + return false; + } +} + +function makeSilentCodeModeLogger() { + const logger = { + error: () => undefined, + child: () => logger, + }; + return logger; +} + +function makeDemoToolSet(params: { onRequest?: () => void }): IToolSet { + return { + name: 'demo', + id: 'demo', + preload: true, + hasPreloadedTools: true, + listTools: () => { + params.onRequest?.(); + return Promise.resolve({ + result: { + tools: [ + { + name: 'ping', + description: 'ping', + inputSchema: { type: 'object' as const, properties: {} }, + preload: true, + }, + ], + }, + wasInitialized: undefined, + }); + }, + callTool: async request => { + params.onRequest?.(); + const args = request.arguments ?? {}; + const delayRaw = args['delay_ms']; + const delayMs = typeof delayRaw === 'number' && Number.isFinite(delayRaw) ? delayRaw : 0; + if (delayMs > 0) await sleep(delayMs); + return { + result: { + content: [{ type: 'text' as const, text: JSON.stringify({ echo: args }) }], + isError: false, + }, + wasInitialized: undefined, + }; + }, + toolCallInfo: () => undefined, + }; +} + +async function withCodeModeTransport(params: { + codeModeSocketParentPath: string; + sandboxRootPath: string; + maxMessageBytes?: number; + onProtocolError?: (message: string) => void; + onRequest?: () => void; + run: (env: Record) => Promise; +}): Promise { + const transport = new CodeModeUdsTransport({ + codeModeSocketParentPath: params.codeModeSocketParentPath, + ...(params.maxMessageBytes === undefined ? {} : { maxMessageBytes: params.maxMessageBytes }), + ...(params.onProtocolError === undefined ? {} : { onProtocolError: params.onProtocolError }), + }); + const dispatcher = new CodeModeDispatcher({ + toolSets: [makeDemoToolSet({ onRequest: params.onRequest })], + logger: makeSilentCodeModeLogger(), + }); + const install = transport.getClientInstall({ sandboxId: params.sandboxRootPath }); + try { + const { env } = await transport.start({ + codeModeDispatcher: dispatcher, + sandboxId: params.sandboxRootPath, + requestTimeoutSeconds: 60, + }); + await params.run({ + ...env, + PYTHONPATH: dirname(install.remotePath), + TFY_MCP_SERVERS: TFY_MCP_SERVERS_DEMO, + TFY_ENABLE_AGENT_APPROVALS: 'true', + }); + } finally { + dispatcher.close(); + await transport.stop(); + } +} + +async function smokeCodeMode(params: { + sandboxRootPath: string; + codeModeSocketParentPath: string; + shell: string; + platform: 'darwin' | 'linux'; +}): Promise { + await installMcpFixture(params.sandboxRootPath); + let toolRequests = 0; + + await withCodeModeTransport({ + codeModeSocketParentPath: params.codeModeSocketParentPath, + sandboxRootPath: params.sandboxRootPath, + onRequest: () => { + toolRequests += 1; + }, + run: async env => { + const sockPath = env['TFY_MCP_SOCK']; + assert.ok(sockPath !== undefined && sockPath.length > 0); + const sockStat = await stat(sockPath); + assert.equal(sockStat.mode & 0o777, 0o600, `Code Mode sock must be 0600: ${sockPath}`); + const parentStat = await stat(params.codeModeSocketParentPath); + assert.equal( + parentStat.mode & 0o777, + 0o700, + `Code Mode sock parent must be 0700: ${params.codeModeSocketParentPath}`, + ); + console.log('ok: Code Mode UDS parent 0700 + sock 0600'); + + const call = await runSupervisorSession({ + sandboxRootPath: params.sandboxRootPath, + shell: params.shell, + platform: params.platform, + command: `mcp-client call-tool demo ping '${JSON.stringify({ message: 'poc' })}'`, + env, + timeoutMs: 15_000, + }); + assert.equal(call.protocolError, undefined, call.protocolError); + assert.equal(call.exitCode, 0, call.stderrText); + const callJson: unknown = JSON.parse(call.stdoutText.trim().split(/\r?\n/).filter(Boolean).at(-1) ?? ''); + assert.ok(callJson !== null && typeof callJson === 'object'); + assert.ok('echo' in callJson); + console.log('ok: Code Mode call-tool (UDS)'); + }, + }); + + const oversizeCap = 1024; + let oversizeError: string | undefined; + await withCodeModeTransport({ + codeModeSocketParentPath: params.codeModeSocketParentPath, + sandboxRootPath: params.sandboxRootPath, + maxMessageBytes: oversizeCap, + onProtocolError: message => { + oversizeError = message; + }, + run: async env => { + const oversize = await runSupervisorSession({ + sandboxRootPath: params.sandboxRootPath, + shell: params.shell, + platform: params.platform, + command: [ + "python3 - <<'PY'", + 'import os, socket, time', + 'path = os.environ["TFY_MCP_SOCK"]', + 's = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)', + 's.connect(path)', + `s.sendall(b"x" * ${String(oversizeCap + 1)})`, + 's.shutdown(socket.SHUT_WR)', + 'time.sleep(1)', + 'PY', + ].join('\n'), + env, + timeoutMs: 10_000, + }); + assert.equal(oversize.exitCode, 0, oversize.stderrText); + assert.match(String(oversizeError), /exceeds max/); + console.log('ok: Code Mode oversized message is terminal'); + }, + }); + + let badJsonError: string | undefined; + await withCodeModeTransport({ + codeModeSocketParentPath: params.codeModeSocketParentPath, + sandboxRootPath: params.sandboxRootPath, + onProtocolError: message => { + badJsonError = message; + }, + run: async env => { + const badJson = await runSupervisorSession({ + sandboxRootPath: params.sandboxRootPath, + shell: params.shell, + platform: params.platform, + command: [ + "python3 - <<'PY'", + 'import os, socket, time', + 'path = os.environ["TFY_MCP_SOCK"]', + 's = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)', + 's.connect(path)', + 's.sendall(b"not")', + 's.shutdown(socket.SHUT_WR)', + 'time.sleep(1)', + 'PY', + ].join('\n'), + env, + timeoutMs: 10_000, + }); + assert.equal(badJson.exitCode, 0, badJson.stderrText); + assert.match(String(badJsonError), /invalid JSON message/); + console.log('ok: Code Mode malformed JSON message is terminal'); + }, + }); + + await withCodeModeTransport({ + codeModeSocketParentPath: params.codeModeSocketParentPath, + sandboxRootPath: params.sandboxRootPath, + onRequest: () => { + toolRequests += 1; + }, + run: async env => { + const multiplex = await runSupervisorSession({ + sandboxRootPath: params.sandboxRootPath, + shell: params.shell, + platform: params.platform, + command: [ + "python3 - <<'PY'", + 'import asyncio, json, time', + 'from mcp_client import call_tool', + 'async def main():', + ' started = time.monotonic()', + ' results = await asyncio.gather(', + ' call_tool("demo", "ping", {"message": "m0", "delay_ms": 150}),', + ' call_tool("demo", "ping", {"message": "m1", "delay_ms": 150}),', + ' )', + ' print("multiplex-ok", int((time.monotonic() - started) * 1000), json.dumps(results))', + 'asyncio.run(main())', + 'PY', + ].join('\n'), + env, + timeoutMs: 15_000, + }); + assert.equal(multiplex.protocolError, undefined, multiplex.protocolError); + assert.equal(multiplex.exitCode, 0, multiplex.stderrText); + const multiplexMatch = /multiplex-ok (\d+)/.exec(multiplex.stdoutText); + assert.ok(multiplexMatch, multiplex.stdoutText); + const multiplexMs = Number(multiplexMatch[1]); + assert.ok(multiplexMs < 280, `multiplex looked serial: gather ${String(multiplexMs)}ms (expected < 280ms)`); + console.log('ok: Code Mode concurrent UDS multiplex', `${String(multiplexMs)}ms`); + }, + }); + + const beforeMissing = toolRequests; + await withCodeModeTransport({ + codeModeSocketParentPath: params.codeModeSocketParentPath, + sandboxRootPath: params.sandboxRootPath, + onRequest: () => { + toolRequests += 1; + }, + run: async env => { + const missingSock = await runSupervisorSession({ + sandboxRootPath: params.sandboxRootPath, + shell: params.shell, + platform: params.platform, + command: [ + 'set -euo pipefail', + 'unset TFY_MCP_SOCK', + `if mcp-client call-tool demo ping '${JSON.stringify({ message: 'x' })}'; then`, + ' echo "expected missing-sock failure" >&2', + ' exit 1', + 'fi', + 'echo ok-missing-sock', + ].join('\n'), + env, + timeoutMs: 10_000, + }); + assert.equal(missingSock.exitCode, 0, missingSock.stderrText); + assert.match(missingSock.stdoutText, /ok-missing-sock/); + assert.equal(toolRequests, beforeMissing, 'missing sock must not deliver tool requests'); + console.log('ok: Code Mode requires TFY_MCP_SOCK'); + }, + }); + + let hostInjected = 0; + let holdPid: number | undefined; + await withCodeModeTransport({ + codeModeSocketParentPath: params.codeModeSocketParentPath, + sandboxRootPath: params.sandboxRootPath, + onRequest: () => { + hostInjected += 1; + }, + run: async env => { + const holdSession = runSupervisorSession({ + sandboxRootPath: params.sandboxRootPath, + shell: params.shell, + platform: params.platform, + command: [ + 'set -euo pipefail', + "python3 - <<'PY'", + 'import os, time', + 'open(".uds-ready", "w").write(os.environ["TFY_MCP_SOCK"] + "\\n")', + 'time.sleep(60)', + 'PY', + ].join('\n'), + env, + onChildSpawn: pid => { + holdPid = pid; + }, + timeoutMs: 15_000, + }); + let sockFromSandbox = ''; + for (let i = 0; i < 80 && sockFromSandbox === ''; i++) { + try { + sockFromSandbox = (await readFile(join(params.sandboxRootPath, '.uds-ready'), 'utf8')).trim(); + } catch { + await sleep(50); + } + } + assert.match(sockFromSandbox, /^\//, 'sandbox never published absolute TFY_MCP_SOCK'); + const hostSockPath = sockFromSandbox.startsWith('/') + ? sockFromSandbox + : join(params.sandboxRootPath, sockFromSandbox); + const hostConnect = await new Promise<{ code: number | null; err: string }>((resolve, reject) => { + const child = spawn( + 'python3', + [ + '-c', + [ + 'import os, socket, sys, json', + 'path = sys.argv[1]', + 'req = {"op":"list_tools","server":"demo"}', + 'body = json.dumps(req).encode()', + 's = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)', + 'if len(path.encode()) >= 104:', + ' os.chdir(os.path.dirname(path))', + ' path = os.path.basename(path)', + 's.connect(path)', + 's.sendall(body)', + 's.shutdown(socket.SHUT_WR)', + 'chunks = []', + 'while True:', + ' c = s.recv(65536)', + ' if not c: break', + ' chunks.append(c)', + 'print(b"".join(chunks).decode())', + ].join('\n'), + hostSockPath, + ], + { stdio: ['ignore', 'pipe', 'pipe'] }, + ); + let err = ''; + child.stderr?.on('data', (c: Buffer) => { + err += c.toString('utf8'); + }); + child.on('error', reject); + child.on('close', code => resolve({ code, err })); + }); + assert.equal(hostConnect.code, 0, hostConnect.err); + for (let i = 0; i < 50 && hostInjected === 0; i++) { + await sleep(50); + } + assert.equal(hostInjected, 1, 'same-UID host connect to Code Mode UDS must work'); + console.log('ok: same-UID host can connect to Code Mode UDS (expected for path UDS)'); + if (holdPid !== undefined) { + try { + process.kill(-holdPid, 'SIGKILL'); + } catch { + try { + process.kill(holdPid, 'SIGKILL'); + } catch { + // already gone + } + } + } + await holdSession; + }, + }); +} + +/** + * Prove Unix env inheritance with no explicit env= copying: + * 1) curated exec env → python child → python grandchild + * 2) bash `cmd1 & cmd2` — both jobs are shell children and must see the marker + */ +async function smokeEnvInheritance(provider: LocalSandboxProvider, sandboxId: string): Promise { + const pyResult = await provider.exec({ + sandboxId, + env: { [ENV_INHERIT_MARKER]: ENV_INHERIT_VALUE }, + command: [ + "python3 - <<'PY'", + 'import os, subprocess, sys', + `marker = ${JSON.stringify(ENV_INHERIT_MARKER)}`, + `expected = ${JSON.stringify(ENV_INHERIT_VALUE)}`, + 'child_val = os.environ.get(marker)', + 'if child_val != expected:', + ' print(f"child-missing:{child_val!r}", file=sys.stderr)', + ' raise SystemExit(1)', + '# Grandchild: subprocess with default env inheritance (no env= override).', + 'grand = subprocess.run(', + ' [sys.executable, "-c", f"import os; print(os.environ[{marker!r}])"],', + ' check=True,', + ' capture_output=True,', + ' text=True,', + ')', + 'got = grand.stdout.strip()', + 'if got != expected:', + ' print(f"grandchild-missing:{got!r}", file=sys.stderr)', + ' raise SystemExit(1)', + 'print("env-inherit-ok", expected)', + 'PY', + ].join('\n'), + }); + assert.equal(pyResult.success, true, JSON.stringify(pyResult)); + if (!pyResult.success) throw new Error('unreachable'); + assert.equal(pyResult.response.exitCode, 0, pyResult.response.result); + assert.match(pyResult.response.result, new RegExp(`env-inherit-ok ${ENV_INHERIT_VALUE}`)); + console.log('ok: env auto-inherits parent → child → grandchild (no extra code)'); + + // Background job + foreground job are both subprocesses of the exec shell. + const marker = ENV_INHERIT_MARKER; + const expected = ENV_INHERIT_VALUE; + const bashResult = await provider.exec({ + sandboxId, + env: { [marker]: expected }, + command: [ + // sandbox-local file (mktemp may target a denied host TMPDIR) + 'bg_out="./.tfy-smoke-env-bg"', + // command 1: background — writes marker value then exits + `( printenv ${marker} > "$bg_out" ) &`, + 'bg_pid=$!', + // command 2: foreground — must see the same env + `fg_val="$(printenv ${marker})"`, + 'wait "$bg_pid"', + 'bg_val="$(cat "$bg_out")"', + 'rm -f "$bg_out"', + `test "$fg_val" = ${JSON.stringify(expected)} || { echo "fg-missing:$fg_val" >&2; exit 1; }`, + `test "$bg_val" = ${JSON.stringify(expected)} || { echo "bg-missing:$bg_val" >&2; exit 1; }`, + `echo "env-bg-ok ${expected}"`, + ].join('\n'), + }); + assert.equal(bashResult.success, true, JSON.stringify(bashResult)); + if (!bashResult.success) throw new Error('unreachable'); + assert.equal(bashResult.response.exitCode, 0, bashResult.response.result); + assert.match(bashResult.response.result, new RegExp(`env-bg-ok ${expected}`)); + console.log('ok: env auto-inherits to bash background + foreground jobs (cmd1 & cmd2)'); +} + +function runCapture(command: string, args: string[]): Promise<{ code: number | null; out: string }> { + return new Promise((resolve, reject) => { + const child = spawn(command, args, { stdio: ['ignore', 'pipe', 'pipe'] }); + let out = ''; + child.stdout?.on('data', (c: Buffer) => { + out += c.toString('utf8'); + }); + child.stderr?.on('data', (c: Buffer) => { + out += c.toString('utf8'); + }); + child.on('error', reject); + child.on('close', code => resolve({ code, out })); + }); +} + +function assertPeerSecretAbsent(label: string, sample: string): void { + assert.ok( + !sample.includes(ENV_PEER_VALUE), + `${label} unexpectedly exposed peer env secret:\n${sample.slice(0, 2000)}`, + ); +} + +function assertPeerSecretPresent(label: string, sample: string): void { + assert.ok( + sample.includes(ENV_PEER_VALUE), + `${label} did not expose peer env secret (expected same-UID visibility):\n${sample.slice(0, 2000)}`, + ); +} + +/** + * Prove kernel UDS peer credentials on accept: + * - Linux: SO_PEERCRED → peer pid/uid/gid + * - macOS: LOCAL_PEERPID + getpeereid → peer pid/uid/gid + * Identity comes from the kernel, not from client-supplied fields. + */ +async function smokeUdsPeerCredentials(): Promise { + const sockPath = join(tmpdir(), `cm-pc-${ulid().toLowerCase().slice(0, 10)}`); + await unlink(sockPath).catch(() => undefined); + + const script = [ + 'import ctypes, json, os, platform, socket, struct', + `path = ${JSON.stringify(sockPath)}`, + 'srv = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)', + 'try:', + ' try: os.unlink(path)', + ' except FileNotFoundError: pass', + ' srv.bind(path)', + ' srv.listen(1)', + ' child = os.fork()', + ' if child == 0:', + ' c = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)', + ' c.connect(path)', + ' c.sendall(b"hi")', + ' try: c.recv(1)', + ' except OSError: pass', + ' c.close()', + ' os._exit(0)', + ' conn, _ = srv.accept()', + ' _ = conn.recv(16)', + ' if platform.system() == "Linux":', + ' SO_PEERCRED = 17', + " raw = conn.getsockopt(socket.SOL_SOCKET, SO_PEERCRED, struct.calcsize('iii'))", + " peer_pid, peer_uid, peer_gid = struct.unpack('iii', raw)", + ' method = "SO_PEERCRED"', + ' else:', + ' SOL_LOCAL, LOCAL_PEERPID = 0, 2', + ' raw = conn.getsockopt(SOL_LOCAL, LOCAL_PEERPID, 4)', + " peer_pid = struct.unpack('I', raw)[0]", + ' libc = ctypes.CDLL(None)', + ' uid = ctypes.c_uint()', + ' gid = ctypes.c_uint()', + ' rc = libc.getpeereid(ctypes.c_int(conn.fileno()), ctypes.byref(uid), ctypes.byref(gid))', + ' if rc != 0:', + ' raise SystemExit(f"getpeereid failed rc={rc} errno={ctypes.get_errno()}")', + ' peer_uid, peer_gid = uid.value, gid.value', + ' method = "LOCAL_PEERPID+getpeereid"', + ' conn.close()', + ' os.waitpid(child, 0)', + ' print(json.dumps({', + ' "method": method,', + ' "peer_pid": peer_pid,', + ' "peer_uid": peer_uid,', + ' "peer_gid": peer_gid,', + ' "child_pid": child,', + ' "self_uid": os.getuid(),', + ' "self_gid": os.getgid(),', + ' }))', + 'finally:', + ' srv.close()', + ' try: os.unlink(path)', + ' except FileNotFoundError: pass', + ].join('\n'); + + try { + const probe = await runCapture('python3', ['-c', script]); + assert.equal(probe.code, 0, probe.out); + const line = probe.out.trim().split(/\r?\n/).filter(Boolean).at(-1); + assert.ok(line !== undefined && line.length > 0, `empty peercred probe output: ${probe.out}`); + const parsed: unknown = JSON.parse(line); + assert.ok(parsed !== null && typeof parsed === 'object'); + assert.ok('method' in parsed && typeof parsed.method === 'string'); + assert.ok('peer_pid' in parsed && typeof parsed.peer_pid === 'number'); + assert.ok('peer_uid' in parsed && typeof parsed.peer_uid === 'number'); + assert.ok('peer_gid' in parsed && typeof parsed.peer_gid === 'number'); + assert.ok('child_pid' in parsed && typeof parsed.child_pid === 'number'); + assert.ok('self_uid' in parsed && typeof parsed.self_uid === 'number'); + assert.ok('self_gid' in parsed && typeof parsed.self_gid === 'number'); + assert.equal(parsed.peer_pid, parsed.child_pid, 'peer pid must match connecting child'); + assert.equal(parsed.peer_uid, parsed.self_uid, 'peer uid must match listener uid (same-UID connect)'); + assert.equal(parsed.peer_gid, parsed.self_gid, 'peer gid must match listener gid (same-UID connect)'); + if (process.platform === 'linux') { + assert.equal(parsed.method, 'SO_PEERCRED'); + } else { + assert.equal(parsed.method, 'LOCAL_PEERPID+getpeereid'); + } + console.log( + `ok: proved UDS peer credentials via ${parsed.method} (pid=${String(parsed.peer_pid)} uid=${String(parsed.peer_uid)} gid=${String(parsed.peer_gid)})`, + ); + } finally { + await unlink(sockPath).catch(() => undefined); + } +} + +/** + * Same-UID peer env visibility (host processes, not sandbox policy). + * Proves the easy leak path on each OS — not that env is protected. + * - Linux: `/proc//environ` contains the peer secret + * - macOS: `ps -E -p ` contains the peer secret; plain `ps -o command=` does not + */ +async function smokeSameUidEnvironRead(): Promise { + const holder = spawn( + 'python3', + ['-c', ['import os, time', 'print(os.getpid(), flush=True)', 'time.sleep(30)'].join('\n')], + { + env: { + PATH: process.env['PATH'] ?? '/usr/bin:/bin', + [ENV_PEER_MARKER]: ENV_PEER_VALUE, + }, + stdio: ['ignore', 'pipe', 'pipe'], + }, + ); + + let pidLine = ''; + holder.stdout?.on('data', (chunk: Buffer) => { + pidLine += chunk.toString('utf8'); + }); + + try { + for (let i = 0; i < 50 && !/^\d+/m.test(pidLine); i++) { + await sleep(50); + } + const peerPid = Number(pidLine.trim().split(/\s+/)[0]); + assert.ok(Number.isInteger(peerPid) && peerPid > 0, `holder pid missing: ${pidLine}`); + + if (process.platform === 'linux') { + const environ = await readFile(`/proc/${String(peerPid)}/environ`); + const decoded = environ.toString('utf8').replaceAll('\0', '\n'); + assertPeerSecretPresent('/proc//environ', decoded); + console.log('ok: proved Linux same-UID env leak via /proc//environ'); + return; + } + + // Prove macOS same-UID env is easy to read via the common `ps -E` path. + const psPlain = await runCapture('ps', ['-p', String(peerPid), '-ww', '-o', 'command=']); + assert.equal(psPlain.code, 0, `ps -o command= failed: ${psPlain.out}`); + assertPeerSecretAbsent('ps -o command=', psPlain.out); + + const psE = await runCapture('ps', ['-E', '-p', String(peerPid), '-ww']); + assert.equal(psE.code, 0, `ps -E failed: ${psE.out}`); + assertPeerSecretPresent('ps -E -p', psE.out); + assert.match(psE.out, new RegExp(`${ENV_PEER_MARKER}=${ENV_PEER_VALUE}`)); + console.log('ok: proved macOS same-UID env leak via ps -E (plain ps hid it)'); + } finally { + holder.kill('SIGKILL'); + await new Promise(resolve => { + holder.on('close', () => resolve()); + setTimeout(resolve, 1000); + }); + } +} + +/** + * Same-UID access to another process's pipe / socketpair ends (host, not SRT). + * - Linux pipe: `open(/proc//fd/N)` duplicates the fd + * - Linux socketpair: `open(/proc/...)` fails (ENXIO); `pidfd_getfd` steals it + * - macOS: no `/proc//fd` (ENOENT) + */ +async function smokeSameUidInheritedFdAccess(): Promise { + const PIPE_SECRET = 'pipe-secret-marker'; + const SOCK_SECRET = 'sock-secret-marker'; + const script = [ + 'import ctypes, json, os, platform, socket, subprocess, sys', + `PIPE_SECRET = ${JSON.stringify(PIPE_SECRET)}.encode()`, + `SOCK_SECRET = ${JSON.stringify(SOCK_SECRET)}.encode()`, + 'holder = subprocess.Popen(', + ' [sys.executable, "-c",', + ' "import os, socket, time\\n"', + ' "r, w = os.pipe()\\n"', + ' "os.write(w, " + repr(PIPE_SECRET) + ")\\n"', + ' "a, b = socket.socketpair()\\n"', + ' "b.sendall(" + repr(SOCK_SECRET) + ")\\n"', + ' "print(os.getpid(), r, a.fileno(), flush=True)\\n"', + ' "time.sleep(60)\\n"],', + ' stdout=subprocess.PIPE, text=True,', + ')', + 'try:', + ' line = holder.stdout.readline().strip()', + ' parts = line.split()', + ' if len(parts) != 3:', + ' raise SystemExit(f"bad holder line: {line!r}")', + ' pid, pipe_fd, sock_fd = map(int, parts)', + ' if platform.system() != "Linux":', + ' path = f"/proc/{pid}/fd/{pipe_fd}"', + ' try:', + ' open(path, "rb").close()', + ' raise SystemExit(f"unexpected open ok: {path}")', + ' except FileNotFoundError:', + ' print(json.dumps({"platform": "darwin", "proc_fd": "ENOENT"}))', + ' raise SystemExit(0)', + ' pipe_path = f"/proc/{pid}/fd/{pipe_fd}"', + ' with open(pipe_path, "rb", buffering=0) as f:', + ' pipe_data = f.read(64)', + ' if pipe_data != PIPE_SECRET:', + ' raise SystemExit(f"pipe steal mismatch: {pipe_data!r}")', + ' sock_path = f"/proc/{pid}/fd/{sock_fd}"', + ' sock_open_err = None', + ' try:', + ' open(sock_path, "rb", buffering=0).close()', + ' raise SystemExit("socketpair open(/proc) unexpectedly succeeded")', + ' except OSError as e:', + ' sock_open_err = e.errno', + ' libc = ctypes.CDLL(None, use_errno=True)', + ' libc.pidfd_open.argtypes = [ctypes.c_int, ctypes.c_uint]', + ' libc.pidfd_open.restype = ctypes.c_int', + ' libc.pidfd_getfd.argtypes = [ctypes.c_int, ctypes.c_int, ctypes.c_uint]', + ' libc.pidfd_getfd.restype = ctypes.c_int', + ' pidfd = libc.pidfd_open(pid, 0)', + ' if pidfd < 0:', + ' raise SystemExit(f"pidfd_open failed errno={ctypes.get_errno()}")', + ' stolen = libc.pidfd_getfd(pidfd, sock_fd, 0)', + ' if stolen < 0:', + ' raise SystemExit(f"pidfd_getfd failed errno={ctypes.get_errno()}")', + ' sock_data = os.read(stolen, 64)', + ' os.close(stolen)', + ' os.close(pidfd)', + ' if sock_data != SOCK_SECRET:', + ' raise SystemExit(f"socketpair steal mismatch: {sock_data!r}")', + ' print(json.dumps({', + ' "platform": "linux",', + ' "pipe_via": "open(/proc/pid/fd)",', + ' "socketpair_open_errno": sock_open_err,', + ' "socketpair_via": "pidfd_getfd",', + ' "holder_pid": pid,', + ' }))', + 'finally:', + ' holder.kill()', + ' try: holder.wait(timeout=2)', + ' except Exception: pass', + ].join('\n'); + + const probe = await runCapture('python3', ['-c', script]); + assert.equal(probe.code, 0, probe.out); + const line = probe.out.trim().split(/\r?\n/).filter(Boolean).at(-1); + assert.ok(line !== undefined && line.length > 0, `empty inherited-fd probe output: ${probe.out}`); + const parsed: unknown = JSON.parse(line); + assert.ok(parsed !== null && typeof parsed === 'object'); + assert.ok('platform' in parsed && typeof parsed.platform === 'string'); + if (process.platform === 'linux') { + assert.equal(parsed.platform, 'linux'); + assert.ok('pipe_via' in parsed && parsed.pipe_via === 'open(/proc/pid/fd)'); + assert.ok('socketpair_via' in parsed && parsed.socketpair_via === 'pidfd_getfd'); + console.log( + `ok: proved Linux same-UID fd steal (pipe via /proc/pid/fd; socketpair via pidfd_getfd) pid=${String( + 'holder_pid' in parsed ? parsed.holder_pid : '?', + )}`, + ); + return; + } + assert.equal(parsed.platform, 'darwin'); + assert.ok('proc_fd' in parsed && parsed.proc_fd === 'ENOENT'); + console.log('ok: macOS has no /proc//fd same-UID steal path (ENOENT)'); +} + +/** + * Exec timeout must SIGKILL the process group — not only the direct child — + * so a forked `while True` grandchild dies too. + */ +async function smokeProcessGroupTimeout(params: { + sandboxRootPath: string; + shell: string; + platform: 'darwin' | 'linux'; +}): Promise { + const session = await runSupervisorSession({ + sandboxRootPath: params.sandboxRootPath, + shell: params.shell, + platform: params.platform, + command: [ + "python3 - <<'PY'", + 'import os, time', + 'open("leader.pid","w").write(str(os.getpid()))', + 'child = os.fork()', + 'if child == 0:', + ' while True:', + ' time.sleep(1)', + 'open("grandchild.pid","w").write(str(child))', + 'time.sleep(3600)', + 'PY', + ].join('\n'), + timeoutMs: 1500, + }); + assert.equal(session.timedOut, true, 'session should time out'); + const leaderPid = Number((await readFile(join(params.sandboxRootPath, 'leader.pid'), 'utf8')).trim()); + const grandchildPid = Number((await readFile(join(params.sandboxRootPath, 'grandchild.pid'), 'utf8')).trim()); + assert.ok(leaderPid > 0 && grandchildPid > 0); + // Give the kernel a moment after SIGKILL. + await sleep(200); + assert.equal(pidAlive(leaderPid), false, `leader ${String(leaderPid)} still alive`); + assert.equal(pidAlive(grandchildPid), false, `grandchild ${String(grandchildPid)} still alive`); + console.log('ok: exec timeout kills process group (leader + while-True grandchild)'); +} + +async function assertExecFails( + provider: LocalSandboxProvider, + sandboxId: string, + command: string, + label: string, + options?: { + timeoutSeconds?: number; + /** Reject these exits as "wrong reason" (e.g. 127 = command missing). */ + forbidExitCodes?: number[]; + /** Require output evidence of policy/IO denial, not just any failure. */ + outputMustMatch?: RegExp; + }, +): Promise<{ exitCode: number; result: string }> { + const result = await provider.exec({ + sandboxId, + command, + ...(options?.timeoutSeconds === undefined ? {} : { timeoutSeconds: options.timeoutSeconds }), + }); + assert.equal(result.success, true, `${label}: provider error ${JSON.stringify(result)}`); + if (!result.success) throw new Error('unreachable'); + assert.notEqual(result.response.exitCode, 0, `${label}: expected non-zero exit\n${result.response.result}`); + if (options?.forbidExitCodes?.includes(result.response.exitCode)) { + assert.fail(`${label}: exit ${String(result.response.exitCode)} is not a policy denial\n${result.response.result}`); + } + if (options?.outputMustMatch !== undefined) { + assert.match( + result.response.result, + options.outputMustMatch, + `${label}: output lacked denial evidence\n${result.response.result}`, + ); + } + console.log(`ok: ${label}`); + return { exitCode: result.response.exitCode, result: result.response.result }; +} + +/** + * Host TCP listeners on loopback must be unreachable from the sandbox + * (macOS Seatbelt deny, or Linux netns isolation). + */ +async function smokeLoopbackDenied(provider: LocalSandboxProvider, sandboxId: string): Promise { + const listen = async (host: string): Promise<{ port: number; close: () => Promise }> => { + const server = createServer(socket => { + socket.end('loopback-open\n'); + }); + await new Promise((resolve, reject) => { + server.once('error', reject); + server.listen(0, host, () => resolve()); + }); + const addr = server.address(); + if (addr === null || typeof addr === 'string') { + throw new Error(`expected TCP address for ${host}`); + } + return { + port: addr.port, + close: () => + new Promise((resolve, reject) => { + server.close(err => (err ? reject(err) : resolve())); + }), + }; + }; + + const v4 = await listen('127.0.0.1'); + let v6: { port: number; close: () => Promise } | undefined; + try { + v6 = await listen('::1'); + } catch { + v6 = undefined; + } + + try { + await assertExecFails( + provider, + sandboxId, + [ + "python3 - <<'PY'", + 'import socket, sys', + `port = ${String(v4.port)}`, + 'try:', + ' s = socket.create_connection(("127.0.0.1", port), timeout=2)', + ' data = s.recv(64)', + ' s.close()', + ' print("loopback-v4-open", data)', + ' raise SystemExit(0)', + 'except OSError as e:', + ' print("loopback-v4-blocked", type(e).__name__, e)', + ' raise SystemExit(2)', + 'PY', + ].join('\n'), + 'host 127.0.0.1 listener unreachable from sandbox', + { outputMustMatch: /loopback-v4-blocked/ }, + ); + + if (v6 !== undefined) { + const v6Port = v6.port; + await assertExecFails( + provider, + sandboxId, + [ + "python3 - <<'PY'", + 'import socket, sys', + `port = ${String(v6Port)}`, + 'try:', + ' s = socket.create_connection(("::1", port), timeout=2)', + ' data = s.recv(64)', + ' s.close()', + ' print("loopback-v6-open", data)', + ' raise SystemExit(0)', + 'except OSError as e:', + ' print("loopback-v6-blocked", type(e).__name__, e)', + ' raise SystemExit(2)', + 'PY', + ].join('\n'), + 'host ::1 listener unreachable from sandbox', + { outputMustMatch: /loopback-v6-blocked/ }, + ); + } else { + console.log('ok: skip ::1 listener (host cannot bind)'); + } + + // Private / link-local: no controlled listener; still must not connect. + await assertExecFails( + provider, + sandboxId, + [ + "python3 - <<'PY'", + 'import socket, sys', + 'targets = [("10.255.255.1", 9), ("169.254.169.254", 80), ("192.168.255.1", 9)]', + 'opened = []', + 'for host, port in targets:', + ' try:', + ' s = socket.create_connection((host, port), timeout=1)', + ' s.close()', + ' opened.append(f"{host}:{port}")', + ' except OSError as e:', + ' print(f"private-blocked {host}:{port} {type(e).__name__}")', + 'if opened:', + ' print("private-open", opened)', + ' raise SystemExit(0)', + 'raise SystemExit(2)', + 'PY', + ].join('\n'), + 'private/link-local TCP connect denied', + { outputMustMatch: /private-blocked/ }, + ); + } finally { + await v4.close().catch(() => undefined); + if (v6 !== undefined) { + await v6.close().catch(() => undefined); + } + } +} + +/** + * setsid/double-fork vs kill(-pgid): + * - macOS: no PID ns — escape leaves the process group and survives killpg + * (known limitation; host must reap via the written host pid). + * - Linux SRT: PID ns + die-with-parent — escape dies with the sandbox; in-ns + * pids are not host-visible, so we watch a heartbeat file instead of kill(pid). + */ +async function smokeSetsidEscapeSurvivesKillpg(params: { + sandboxRootPath: string; + shell: string; + platform: 'darwin' | 'linux'; +}): Promise { + const heartbeatPath = join(params.sandboxRootPath, 'escaped.heartbeat'); + // Escaped child drops stdio so host pipes can close after killpg. + // Cap wait: SRT wrapper teardown can still lag; do not hang the suite. + const sessionPromise = runSupervisorSession({ + sandboxRootPath: params.sandboxRootPath, + shell: params.shell, + platform: params.platform, + command: [ + "python3 - <<'PY'", + 'import os, time', + 'open("leader.pid", "w", encoding="utf-8").write(str(os.getpid()))', + 'child = os.fork()', + 'if child == 0:', + ' os.setsid()', + ' grand = os.fork()', + ' if grand > 0:', + ' os._exit(0)', + ' dn = os.open("/dev/null", os.O_RDWR)', + ' os.dup2(dn, 0); os.dup2(dn, 1); os.dup2(dn, 2)', + ' if dn > 2: os.close(dn)', + ' # Close Code Mode fds if present so host is not held open.', + ' for fd in (3, 4):', + ' try: os.close(fd)', + ' except OSError: pass', + ' open("escaped.pid", "w", encoding="utf-8").write(str(os.getpid()))', + ' n = 0', + ' while True:', + ' n += 1', + ' open("escaped.heartbeat", "w", encoding="utf-8").write(str(n))', + ' time.sleep(0.2)', + 'os.waitpid(child, 0)', + 'time.sleep(3600)', + 'PY', + ].join('\n'), + timeoutMs: 1500, + }); + + let escapedRaw = ''; + for (let i = 0; i < 60 && !/^\d+$/.test(escapedRaw); i++) { + try { + escapedRaw = (await readFile(join(params.sandboxRootPath, 'escaped.pid'), 'utf8')).trim(); + } catch { + // not yet + } + await sleep(50); + } + assert.match(escapedRaw, /^\d+$/, 'escaped.pid missing — setsid child never started'); + const escapedPid = Number(escapedRaw); + + let heartbeatBeforeSession = ''; + for (let i = 0; i < 40 && heartbeatBeforeSession === ''; i++) { + try { + heartbeatBeforeSession = (await readFile(heartbeatPath, 'utf8')).trim(); + } catch { + // not yet + } + await sleep(50); + } + assert.match(heartbeatBeforeSession, /^\d+$/, 'escaped.heartbeat missing — escape never ran'); + + const sessionOrTimeout = await Promise.race([ + sessionPromise.then(session => ({ kind: 'session' as const, session })), + sleep(5000).then(() => ({ kind: 'hung' as const })), + ]); + if (sessionOrTimeout.kind === 'hung') { + // Last resort: session did not settle after killpg (SRT wrapper leak). + if (process.platform === 'darwin') { + try { + process.kill(escapedPid, 'SIGKILL'); + } catch { + // ignore + } + } + assert.fail('runSupervisorSession hung after timeout — killpg did not finish teardown'); + } + const { session } = sessionOrTimeout; + assert.equal(session.timedOut, true, 'session should time out'); + await sleep(500); + + const hb1 = (await readFile(heartbeatPath, 'utf8')).trim(); + await sleep(600); + const hb2 = (await readFile(heartbeatPath, 'utf8')).trim(); + + if (process.platform === 'linux') { + // In-ns pid is not the host pid; survival is judged by heartbeat freeze. + assert.equal(hb2, hb1, 'Linux: setsid escape should die with PID ns / die-with-parent (heartbeat still advancing)'); + console.log('ok: setsid escape dies with Linux PID ns / die-with-parent'); + return; + } + + // macOS: host-visible pid; kill(-pgid) misses the new session. + assert.notEqual(hb2, hb1, 'macOS: expected setsid escape to keep writing heartbeat after kill(-pgid)'); + assert.equal(pidAlive(escapedPid), true, `expected setsid escape pid ${String(escapedPid)} to survive kill(-pgid)`); + try { + process.kill(escapedPid, 'SIGKILL'); + } catch { + // already gone + } + console.log('ok: setsid/double-fork escape survives killpg on macOS (known limitation)'); +} + +/** + * Match hostRun AF_UNIX policy (Linux allowAllUnixSockets; macOS allowUnixSockets=[sandboxRoot]), + * then prove pathname connect is still gated by allowRead (FS) / seatbelt, not by the Unix-socket toggle alone. + * On Linux, also prove /proc/net/unix is the sandbox netns table (host abstract absent). + */ +async function smokeUnixSocketFsGate(): Promise { + // Keep paths short: macOS sun_path is ~104 bytes (long sandbox path UUIDs → EINVAL). + const id = randomUUID().replaceAll('-', '').slice(0, 8); + const sandboxRootPath = join(SANDBOXES, `u${id}`); + const insideSock = join(sandboxRootPath, 'c.sock'); + const outsideSock = join(SANDBOXES, `o${id}.sock`); + // Host-owned fake Docker socket (path shape only) — must not be in allowRead / allowUnixSockets. + const emulatedDockerRoot = join(SANDBOXES, `v${id}`); + const emulatedDockerSock = join(emulatedDockerRoot, 'run', 'docker.sock'); + const hostAbstractName = `\0tfy-abs-${id}`; + const hostAbstractProcMarker = `@tfy-abs-${id}`; + + const platform = process.platform === 'darwin' ? 'darwin' : 'linux'; + const allowRead = [sandboxRootPath, ...platformAllowRead(platform)]; + + const denyWrite = getDefaultWritePaths().filter(path => !path.startsWith('/dev/')); + + const listenUds = async (path: string): Promise<{ close: () => Promise }> => { + await unlink(path).catch(() => undefined); + const server = createServer(socket => { + socket.end('uds-ok\n'); + }); + await new Promise((resolve, reject) => { + server.once('error', reject); + server.listen(path, () => resolve()); + }); + return { + close: async () => { + await new Promise(resolve => { + server.close(() => resolve()); + }); + await unlink(path).catch(() => undefined); + }, + }; + }; + + const runSandboxed = async (command: string): Promise<{ code: number | null; out: string }> => { + const wrap = await SandboxManager.wrapWithSandboxArgv( + command, + '/bin/bash', + { + filesystem: { + allowWrite: [sandboxRootPath], + denyWrite, + denyRead: ['/'], + allowRead, + }, + network: { allowedDomains: [], deniedDomains: [] }, + }, + undefined, + sandboxRootPath, + { commandId: randomUUID(), commandText: command }, + ); + const [argv0, ...argvRest] = wrap.argv; + if (argv0 === undefined) throw new Error('empty argv'); + return await new Promise((resolve, reject) => { + const child = spawn(argv0, argvRest, { + cwd: sandboxRootPath, + env: { + HOME: join(sandboxRootPath, '.home'), + TMPDIR: join(sandboxRootPath, '.tmp'), + PATH: commandPath(platform), + ...wrap.env, + }, + stdio: ['ignore', 'pipe', 'pipe'], + }); + let out = ''; + child.stdout?.on('data', (c: Buffer) => { + out += c.toString('utf8'); + }); + child.stderr?.on('data', (c: Buffer) => { + out += c.toString('utf8'); + }); + child.on('error', reject); + child.on('close', code => resolve({ code, out })); + }); + }; + + const connectScript = (sockPath: string): string => + [ + "python3 - <<'PY'", + 'import socket, sys', + `path = ${JSON.stringify(sockPath)}`, + 'try:', + ' s = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)', + ' s.settimeout(2)', + ' s.connect(path)', + ' data = s.recv(64)', + ' s.close()', + ' print("CONNECT_OK", data)', + ' sys.exit(0)', + 'except OSError as e:', + ' print("CONNECT_FAIL", type(e).__name__, e.errno, e)', + ' sys.exit(2)', + 'PY', + ].join('\n'); + + /** Prove path is not discoverable/readable and connect also fails (no discover-then-connect shortcut). */ + const discoverAndConnectDeniedScript = (sockPath: string): string => + [ + "python3 - <<'PY'", + 'import os, socket, stat, sys', + `path = ${JSON.stringify(sockPath)}`, + 'parent = os.path.dirname(path)', + 'base = os.path.basename(path)', + 'discover_ok = False', + 'try:', + ' st = os.stat(path)', + ' print("STAT_OK", int(st.st_mode))', + ' if stat.S_ISSOCK(st.st_mode):', + ' discover_ok = True', + ' print("DISCOVER_STAT_SOCK")', + 'except OSError as e:', + ' print("STAT_FAIL", type(e).__name__, getattr(e, "errno", None))', + 'try:', + ' if os.path.exists(path):', + ' discover_ok = True', + ' print("DISCOVER_EXISTS")', + ' else:', + ' print("EXISTS_FALSE")', + 'except OSError as e:', + ' print("EXISTS_FAIL", type(e).__name__, getattr(e, "errno", None))', + 'try:', + ' names = os.listdir(parent)', + ' print("LISTDIR_OK", names)', + ' if base in names:', + ' discover_ok = True', + ' print("DISCOVER_LISTDIR")', + 'except OSError as e:', + ' print("LISTDIR_FAIL", type(e).__name__, getattr(e, "errno", None))', + 'if discover_ok:', + ' print("DISCOVER_REACHABLE")', + ' sys.exit(1)', + 'print("DISCOVER_DENIED")', + 'try:', + ' s = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)', + ' s.settimeout(2)', + ' s.connect(path)', + ' s.close()', + ' print("CONNECT_OK")', + ' sys.exit(2)', + 'except OSError as e:', + ' print("CONNECT_FAIL", type(e).__name__, getattr(e, "errno", None))', + ' sys.exit(0)', + 'PY', + ].join('\n'); + + await mkdir(join(sandboxRootPath, '.tmp'), { recursive: true, mode: 0o700 }); + await mkdir(join(sandboxRootPath, '.home'), { recursive: true, mode: 0o700 }); + await mkdir(dirname(emulatedDockerSock), { recursive: true, mode: 0o700 }); + const inside = await listenUds(insideSock); + const outside = await listenUds(outsideSock); + const emulatedDocker = await listenUds(emulatedDockerSock); + + // Match hostRun: Linux allowAll + FS gate; macOS allowUnixSockets=[sandboxRootPath] (FS does not gate UDS). + const network = + process.platform === 'darwin' + ? { + allowedDomains: [] as string[], + deniedDomains: [] as string[], + allowAllUnixSockets: false, + allowUnixSockets: [sandboxRootPath], + } + : { + allowedDomains: [] as string[], + deniedDomains: [] as string[], + allowAllUnixSockets: true, + }; + + await SandboxManager.initialize({ + network, + filesystem: { + allowWrite: [], + denyWrite, + denyRead: ['/'], + allowRead, + }, + }); + + let hostAbstract: { close: () => Promise } | undefined; + try { + const ok = await runSandboxed(connectScript(insideSock)); + assert.equal(ok.code, 0, `inside sock should connect:\n${ok.out}`); + assert.match(ok.out, /CONNECT_OK/); + console.log('ok: sandbox can connect to sandbox UDS (allowRead)'); + + await access(outsideSock); + const denied = await runSandboxed(connectScript(outsideSock)); + assert.notEqual(denied.code, 0, `outside sock must not connect:\n${denied.out}`); + assert.match(denied.out, /CONNECT_FAIL/); + console.log( + process.platform === 'linux' + ? 'ok: FS-denied path UDS connect fails under allowAllUnixSockets' + : 'ok: path outside allowUnixSockets=[sandboxRootPath] connect fails (macOS seatbelt)', + ); + + // Prove the emulated docker listener is live on the host, then denied from the sandbox. + const hostDockerConnect = await new Promise<{ code: number | null; out: string }>((resolve, reject) => { + const child = spawn( + 'python3', + [ + '-c', + [ + 'import socket, sys', + 'path = sys.argv[1]', + 's = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)', + 's.settimeout(2)', + 's.connect(path)', + 'print(s.recv(64))', + 's.close()', + ].join('\n'), + emulatedDockerSock, + ], + { stdio: ['ignore', 'pipe', 'pipe'] }, + ); + let out = ''; + child.stdout?.on('data', (c: Buffer) => { + out += c.toString('utf8'); + }); + child.stderr?.on('data', (c: Buffer) => { + out += c.toString('utf8'); + }); + child.on('error', reject); + child.on('close', code => resolve({ code, out })); + }); + assert.equal(hostDockerConnect.code, 0, `host must reach emulated docker.sock:\n${hostDockerConnect.out}`); + assert.match(hostDockerConnect.out, /uds-ok/); + console.log('ok: host can connect to emulated docker.sock'); + + const dockerDenied = await runSandboxed(connectScript(emulatedDockerSock)); + assert.notEqual(dockerDenied.code, 0, `sandbox must not connect to emulated docker.sock:\n${dockerDenied.out}`); + assert.match(dockerDenied.out, /CONNECT_FAIL/); + console.log('ok: sandbox cannot connect to host-created emulated docker.sock'); + + const inventory = await runSandboxed( + [ + "python3 - <<'PY'", + 'import os, socket, stat, sys', + 'roots = ["/dev", "/etc", "/usr"]', + 'found = []', + 'for root in roots:', + ' if not os.path.isdir(root):', + ' continue', + ' for dirpath, dirnames, filenames in os.walk(root):', + ' if dirpath.count(os.sep) - root.count(os.sep) > 3:', + ' dirnames[:] = []', + ' continue', + ' for name in filenames:', + ' p = os.path.join(dirpath, name)', + ' try:', + ' st = os.stat(p)', + ' except OSError:', + ' continue', + ' if stat.S_ISSOCK(st.st_mode):', + ' found.append(p)', + 'print("FOUND", len(found))', + 'for p in found[:20]:', + ' print("SOCK", p)', + 'bad = 0', + 'for p in found:', + ' try:', + ' s = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)', + ' s.settimeout(0.3)', + ' s.connect(p)', + ' s.close()', + ' print("CONNECTED", p)', + ' bad += 1', + ' except OSError as e:', + ' print("BLOCKED_OR_USELESS", p, type(e).__name__)', + 'sys.exit(1 if bad else 0)', + 'PY', + ].join('\n'), + ); + const invLines = inventory.out.trim().split('\n').slice(0, 40); + console.log(invLines.join('\n')); + assert.equal(inventory.code, 0, `sandbox connected to a socket under /dev|/etc|/usr:\n${inventory.out}`); + console.log('ok: no successful connect to sockets under /dev|/etc|/usr (if any visible)'); + + if (process.platform === 'linux') { + // Host abstract listener (Linux-only). Sandbox has --unshare-net → own /proc/net/unix. + const absServer = createServer(socket => { + socket.end('abs-ok\n'); + }); + await new Promise((resolve, reject) => { + absServer.once('error', reject); + absServer.listen(hostAbstractName, () => resolve()); + }); + hostAbstract = { + close: async () => { + await new Promise(resolve => { + absServer.close(() => resolve()); + }); + }, + }; + + const hostProc = await readFile('/proc/net/unix', 'utf8'); + assert.match( + hostProc, + new RegExp(hostAbstractProcMarker.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')), + 'host /proc/net/unix must list the abstract listener', + ); + + const procUnix = await runSandboxed( + [ + "python3 - <<'PY'", + 'import sys', + `marker = ${JSON.stringify(hostAbstractProcMarker)}`, + 'path = "/proc/net/unix"', + 'try:', + ' text = open(path, "r", encoding="utf-8", errors="replace").read()', + 'except OSError as e:', + ' print("PROC_NET_UNIX_UNREADABLE", getattr(e, "errno", None), e)', + ' sys.exit(2)', + 'print("PROC_NET_UNIX_READABLE", "bytes", len(text), "lines", len(text.splitlines()))', + 'if marker in text:', + ' print("HOST_ABSTRACT_VISIBLE", marker)', + ' sys.exit(3)', + 'print("HOST_ABSTRACT_ABSENT", marker)', + // Also prove connect to host abstract fails (different netns). + 'import socket', + `abs_name = ${JSON.stringify(hostAbstractName)}`, + 'try:', + ' s = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)', + ' s.settimeout(1)', + ' s.connect(abs_name)', + ' s.close()', + ' print("HOST_ABSTRACT_CONNECT_OK")', + ' sys.exit(4)', + 'except OSError as e:', + ' print("HOST_ABSTRACT_CONNECT_FAIL", type(e).__name__, getattr(e, "errno", None))', + 'sys.exit(0)', + 'PY', + ].join('\n'), + ); + assert.equal(procUnix.code, 0, procUnix.out); + assert.match(procUnix.out, /PROC_NET_UNIX_READABLE/); + assert.match(procUnix.out, /HOST_ABSTRACT_ABSENT/); + assert.match(procUnix.out, /HOST_ABSTRACT_CONNECT_FAIL/); + console.log('ok: /proc/net/unix is sandbox netns (host abstract not listed / not connectable)'); + console.log(procUnix.out.trim().split('\n').filter(Boolean).join(' | ')); + } else { + console.log('ok: skip Linux abstract /proc/net/unix netns probe (not Linux)'); + } + + console.log( + process.platform === 'linux' + ? 'ok: Linux allowAllUnixSockets + FS allowRead gates pathname UDS' + : 'ok: macOS allowUnixSockets=[sandboxRootPath] gates pathname UDS (not allowRead)', + ); + } finally { + await hostAbstract?.close().catch(() => undefined); + await emulatedDocker.close().catch(() => undefined); + await inside.close().catch(() => undefined); + await outside.close().catch(() => undefined); + await SandboxManager.reset().catch(() => undefined); + await removeSandbox(sandboxRootPath).catch(() => undefined); + await rm(emulatedDockerRoot, { recursive: true, force: true }).catch(() => undefined); + } +} + +async function smokeHostPackageManagerDenied(provider: LocalSandboxProvider, sandboxId: string): Promise { + if (process.platform === 'darwin') { + await assertExecFails( + provider, + sandboxId, + "printf 'poc\\n' > /opt/homebrew/Cellar/.tfy-poc-write || exit 2", + 'host Homebrew Cellar write denied', + { outputMustMatch: /Permission|Read-only|Operation not permitted|denied|No such|cannot/i }, + ); + // Prefer reinstall so an already-installed keg cannot no-op to exit 0. + // Brew also needs host API/cache reads + network; both are denied under SRT. + await assertExecFails( + provider, + sandboxId, + [ + 'set +e', + 'command -v brew >/dev/null 2>&1 || { echo "brew-missing" >&2; exit 127; }', + 'export HOMEBREW_NO_AUTO_UPDATE=1 HOMEBREW_NO_ANALYTICS=1 HOMEBREW_NO_ENV_HINTS=1', + 'brew install lima', + 'install_rc=$?', + 'if [ "$install_rc" -eq 0 ]; then', + ' brew reinstall lima', + ' install_rc=$?', + 'fi', + 'exit "$install_rc"', + ].join('\n'), + 'brew install/reinstall lima denied', + { + // Brew may stall on network/API; fail-closed quickly under SRT. + timeoutSeconds: 20, + forbidExitCodes: [127], + outputMustMatch: /not writable|Permission|Operation not permitted|Read-only|denied|Failed to download|Error:/i, + }, + ); + return; + } + + await assertExecFails( + provider, + sandboxId, + "printf 'poc\\n' > /usr/bin/.tfy-poc-write || exit 2", + 'host /usr/bin write denied', + { outputMustMatch: /Permission|Read-only|Operation not permitted|denied|No such file|cannot/i }, + ); + await assertExecFails( + provider, + sandboxId, + [ + 'set +e', + 'command -v apt-get >/dev/null 2>&1 || { echo "apt-get-missing" >&2; exit 127; }', + 'export DEBIAN_FRONTEND=noninteractive', + 'apt-get install -y cowsay', + 'exit $?', + ].join('\n'), + 'apt-get install denied', + { + timeoutSeconds: 20, + forbidExitCodes: [127], + outputMustMatch: /Permission|Read-only|Operation not permitted|denied|not open|Could not|E:/i, + }, + ); +} + +async function main(): Promise { + if (process.platform !== 'darwin' && process.platform !== 'linux') { + console.error('smoke: skipping (darwin/linux only)'); + process.exit(0); + } + + process.env[ENV_LEAK_MARKER] = ENV_LEAK_VALUE; + + const support = await LocalSandboxProvider.isSupported(); + assert.equal(support.supported, true, support.supported ? '' : support.reason); + console.log('ok: LocalSandboxProvider.isSupported'); + + const sandboxRootPathParent = await mkdtemp(join(tmpdir(), 'tfy-local-sandbox-smoke-')); + const codeModeSocketParentPath = join(tmpdir(), 'cm'); + await mkdir(codeModeSocketParentPath, { recursive: true, mode: 0o700 }); + if (!support.supported) { + throw new Error(support.reason); + } + const provider = new LocalSandboxProvider({ sandboxRootPathParent, codeModeSocketParentPath, support }); + const instructions = provider.getAdditionalInstructions(); + assert.match(instructions, /sandbox shell: \S+/); + assert.match(instructions, /Python 3 is available as: \S+/); + console.log('ok: getAdditionalInstructions names shell and python'); + await prepareHostProbeFiles(); + let codeModeSandboxRootPath: string | undefined; + try { + const { sandboxId } = await provider.createSandbox(); + console.log('sandboxId', sandboxId); + + const printf = await provider.exec({ + sandboxId, + command: "printf 'poc-ok\\n'", + }); + assert.equal(printf.success, true); + if (!printf.success) throw new Error('unreachable'); + assert.equal(printf.response.exitCode, 0); + assert.equal(printf.response.result, 'poc-ok\n'); + console.log('ok: provider exec printf'); + + await smokeLiveSrtUnixSocketAllowlistUpdate({ + sandboxRootPath: sandboxId, + codeModeSocketParentPath, + shell: support.shell, + platform: support.platform, + }); + + const write = await provider.exec({ + sandboxId, + command: "printf 'sandbox-ok\\n' > note.txt && cat note.txt", + }); + assert.equal(write.success, true); + if (!write.success) throw new Error('unreachable'); + assert.equal(write.response.exitCode, 0); + assert.equal(write.response.result, 'sandbox-ok\n'); + console.log('ok: sandbox-local write/read'); + + await provider.uploadFile({ + sandboxId, + remotePath: 'uploads/hello.txt', + content: Buffer.from('upload-ok\n'), + }); + const downloaded = await provider.downloadFile({ + sandboxId, + path: 'uploads/hello.txt', + }); + assert.equal(downloaded.toString('utf8'), 'upload-ok\n'); + const catUpload = await provider.exec({ + sandboxId, + command: 'cat uploads/hello.txt', + }); + assert.equal(catUpload.success, true); + if (!catUpload.success) throw new Error('unreachable'); + assert.equal(catUpload.response.result, 'upload-ok\n'); + console.log('ok: upload/download'); + + await assertExecFails( + provider, + sandboxId, + "printf 'leak\\n' > /tmp/claude/poc-should-fail.txt || exit 2", + 'SRT default /tmp/claude write denied', + { outputMustMatch: /Permission|Read-only|Operation not permitted|denied|No such|cannot/i }, + ); + + const before = await readFile(DELETE_TARGET, 'utf8'); + assert.equal(before, 'delete-me\n'); + await assertExecFails( + provider, + sandboxId, + `python3 -c 'import os; os.unlink(${JSON.stringify(DELETE_TARGET)})'`, + 'SRT default /tmp/claude delete denied', + ); + await access(DELETE_TARGET); + + const denyRead = await provider.exec({ + sandboxId, + command: `cat ${JSON.stringify(DENY_READ_SECRET)}`, + }); + assert.equal(denyRead.success, true); + if (!denyRead.success) throw new Error('unreachable'); + assert.notEqual(denyRead.response.exitCode, 0); + assert.ok(!denyRead.response.result.includes('host-secret-should-not-leak')); + console.log('ok: host secret outside sandbox blocked'); + + assert.ok(HOST_HOME && HOST_HOME.length > 0); + await assertExecFails(provider, sandboxId, `ls ${JSON.stringify(HOST_HOME)}`, 'host home listing denied'); + + await smokeHostPackageManagerDenied(provider, sandboxId); + + // System pip install (no --user/--target): must fail closed without network. + // Local trivial package so the failure is install/prefix write, not PyPI fetch. + await assertExecFails( + provider, + sandboxId, + [ + 'set -euo pipefail', + // ensurepip covers guests that only have python3 (no python3-pip package). + 'python3 -m pip --version >/dev/null 2>&1 || python3 -m ensurepip --upgrade >/dev/null 2>&1 || true', + 'python3 -m pip --version >/dev/null || { echo "pip-missing" >&2; exit 127; }', + 'mkdir -p tfy_poc_pip_pkg/tfy_poc_pip', + "cat > tfy_poc_pip_pkg/setup.py <<'EOF'", + 'from setuptools import setup', + 'setup(name="tfy-poc-pip", version="0.0.1", packages=["tfy_poc_pip"])', + 'EOF', + 'touch tfy_poc_pip_pkg/tfy_poc_pip/__init__.py', + 'python3 -m pip install --no-input --no-deps --no-build-isolation ./tfy_poc_pip_pkg', + ].join('\n'), + 'system pip install denied', + { + forbidExitCodes: [127], + outputMustMatch: /Permission|Read-only|Operation not permitted|denied|ERROR:|Could not|No module|error/i, + }, + ); + + await smokeLoopbackDenied(provider, sandboxId); + + await assertExecFails( + provider, + sandboxId, + 'python3 -c \'import socket,sys\ntry:\n socket.create_connection(("1.1.1.1",443),timeout=2)\n print("network-open"); sys.exit(0)\nexcept OSError as e:\n print("network-blocked:%s"%e); sys.exit(2)\'', + 'egress to 1.1.1.1:443 denied', + { outputMustMatch: /network-blocked:/ }, + ); + await assertExecFails( + provider, + sandboxId, + 'python3 -c \'import socket,sys\ntry:\n socket.getaddrinfo("example.com",443)\n print("dns-open"); sys.exit(0)\nexcept OSError as e:\n print("dns-blocked:%s"%e); sys.exit(2)\'', + 'DNS for example.com denied', + { outputMustMatch: /dns-blocked:/ }, + ); + + const envLeak = await provider.exec({ + sandboxId, + command: `printenv ${ENV_LEAK_MARKER} || true`, + }); + assert.equal(envLeak.success, true); + if (!envLeak.success) throw new Error('unreachable'); + assert.ok(!envLeak.response.result.includes(ENV_LEAK_VALUE)); + console.log('ok: host env secret not visible in sandbox'); + + await smokeEnvInheritance(provider, sandboxId); + await smokeUdsPeerCredentials(); + await smokeSameUidEnvironRead(); + await smokeSameUidInheritedFdAccess(); + + await assertExecFails( + provider, + sandboxId, + `cat ${JSON.stringify('../.poc-deny-read-secret')}`, + 'path escape via .. denied', + ); + + await assertExecFails( + provider, + sandboxId, + ['set -e', `ln -sf ${JSON.stringify(DENY_READ_SECRET)} escape-link`, 'cat escape-link'].join('\n'), + 'symlink escape read denied', + ); + + // Plain sandboxed open() following a sandbox→host symlink must not leak the host file. + await assertExecFails( + provider, + sandboxId, + [ + `ln -sf ${JSON.stringify(DENY_READ_SECRET)} escape-open`, + "python3 - <<'PY'", + 'import sys', + 'try:', + ' data = open("escape-open", "rb").read()', + ' sys.stdout.write(data.decode("utf-8", "replace"))', + ' raise SystemExit(0)', + 'except OSError as e:', + ' print(f"open-blocked {type(e).__name__}", file=sys.stderr)', + ' raise SystemExit(2)', + 'PY', + ].join('\n'), + 'sandbox open() symlink follow read denied', + { + outputMustMatch: /open-blocked|Permission|Operation not permitted|denied|No such file/i, + }, + ); + assert.equal(await readFile(DENY_READ_SECRET, 'utf8'), SECRET_CONTENTS); + + // Symlink follow write: host target must stay intact regardless of sandbox exit code. + const writeFollow = await provider.exec({ + sandboxId, + command: [ + `ln -sf ${JSON.stringify(DENY_READ_SECRET)} escape-open-w`, + "python3 - <<'PY'", + 'import sys', + 'try:', + ' open("escape-open-w", "wb").write(b"pwned-exec\\n")', + ' print("open-write-ok")', + ' raise SystemExit(0)', + 'except OSError as e:', + ' print(f"open-write-blocked {type(e).__name__}", file=sys.stderr)', + ' raise SystemExit(2)', + 'PY', + ].join('\n'), + }); + assert.equal(writeFollow.success, true, JSON.stringify(writeFollow)); + if (!writeFollow.success) throw new Error('unreachable'); + assert.equal( + await readFile(DENY_READ_SECRET, 'utf8'), + SECRET_CONTENTS, + 'sandbox symlink follow write must not mutate host secret', + ); + if (writeFollow.response.exitCode !== 0) { + assert.match(writeFollow.response.result, /open-write-blocked|Permission|Operation not permitted|denied/i); + } + console.log('ok: sandbox open() symlink follow write left host secret intact'); + + // Provider upload/download: SRT must stop symlink follow from leaking or mutating the host. + const mkDlLink = await provider.exec({ + sandboxId, + command: `ln -sf ${JSON.stringify(DENY_READ_SECRET)} api-escape-dl && test -L api-escape-dl`, + }); + assert.equal(mkDlLink.success, true); + if (!mkDlLink.success) throw new Error('unreachable'); + assert.equal(mkDlLink.response.exitCode, 0, mkDlLink.response.result); + let downloadLeaked = false; + try { + const leaked = await provider.downloadFile({ sandboxId, path: 'api-escape-dl' }); + downloadLeaked = leaked.toString('utf8').includes('host-secret-should-not-leak'); + } catch { + // deny / throw is fine; host check below still runs + } + assert.equal(downloadLeaked, false, 'downloadFile must not return host secret via symlink'); + assert.equal(await readFile(DENY_READ_SECRET, 'utf8'), SECRET_CONTENTS); + console.log('ok: downloadFile does not leak host via symlink (SRT)'); + + const mkUlLink = await provider.exec({ + sandboxId, + command: `ln -sf ${JSON.stringify(DENY_READ_SECRET)} api-escape-ul && test -L api-escape-ul`, + }); + assert.equal(mkUlLink.success, true); + if (!mkUlLink.success) throw new Error('unreachable'); + assert.equal(mkUlLink.response.exitCode, 0, mkUlLink.response.result); + try { + await provider.uploadFile({ + sandboxId, + remotePath: 'api-escape-ul', + content: Buffer.from('pwned-via-host-api\n'), + }); + } catch { + // deny / throw is fine; host check below is the gate + } + assert.equal( + await readFile(DENY_READ_SECRET, 'utf8'), + SECRET_CONTENTS, + 'uploadFile must not mutate host via symlink', + ); + console.log('ok: uploadFile does not mutate host via symlink (SRT)'); + + const { sandboxId: otherId } = await provider.createSandbox(); + await provider.uploadFile({ + sandboxId, + remotePath: 'cross-secret.txt', + content: Buffer.from('cross-sandbox-secret\n'), + }); + const otherRead = await provider.exec({ + sandboxId: otherId, + command: `cat ${JSON.stringify(join(sandboxId, 'cross-secret.txt'))}`, + }); + assert.equal(otherRead.success, true); + if (!otherRead.success) throw new Error('unreachable'); + assert.notEqual(otherRead.response.exitCode, 0); + assert.ok(!otherRead.response.result.includes('cross-sandbox-secret')); + console.log('ok: cross-sandbox absolute path read denied'); + + const otherWrite = await provider.exec({ + sandboxId: otherId, + command: `printf 'cross-write\\n' > ${JSON.stringify(join(sandboxId, 'cross-write.txt'))}`, + }); + assert.equal(otherWrite.success, true); + if (!otherWrite.success) throw new Error('unreachable'); + assert.notEqual(otherWrite.response.exitCode, 0); + await assert.rejects(async () => readFile(join(sandboxId, 'cross-write.txt'))); + console.log('ok: cross-sandbox absolute path write denied'); + + const persist1 = await provider.exec({ + sandboxId, + command: "printf 'persist-ok\\n' > persist.txt", + }); + assert.equal(persist1.success, true); + if (!persist1.success) throw new Error('unreachable'); + assert.equal(persist1.response.exitCode, 0); + const persist2 = await provider.exec({ + sandboxId, + command: 'cat persist.txt', + }); + assert.equal(persist2.success, true); + if (!persist2.success) throw new Error('unreachable'); + assert.equal(persist2.response.result, 'persist-ok\n'); + console.log('ok: sandbox persists across execs'); + + const flood = await provider.exec({ + sandboxId, + command: `python3 -c 'import sys; sys.stdout.write("x" * ${String(MAX_OUTPUT_BYTES + 1)})'`, + timeoutSeconds: 30, + }); + assert.equal(flood.success, false); + if (flood.success) throw new Error('unreachable'); + assert.match(flood.error, /buffered output exceeded/); + console.log(`ok: oversized stdout is terminal (${String(MAX_OUTPUT_BYTES)} byte cap)`); + + assert.match(provider.getToolResultDumpDir(sandboxId), /tool-results$/); + assert.match(provider.getGitCredentialsPath(sandboxId), /\.git-credentials$/); + console.log('ok: dump/git credential paths'); + + codeModeSandboxRootPath = await createSandbox(join(sandboxRootPathParent, `codemode-${Date.now()}`)); + await smokeProcessGroupTimeout({ + sandboxRootPath: codeModeSandboxRootPath, + shell: support.shell, + platform: support.platform, + }); + await smokeSetsidEscapeSurvivesKillpg({ + sandboxRootPath: codeModeSandboxRootPath, + shell: support.shell, + platform: support.platform, + }); + await smokeCodeMode({ + sandboxRootPath: codeModeSandboxRootPath, + codeModeSocketParentPath, + shell: support.shell, + platform: support.platform, + }); + + console.log('all LocalSandboxProvider + Code Mode smokes passed'); + } finally { + delete process.env[ENV_LEAK_MARKER]; + await provider.dispose(); + if (codeModeSandboxRootPath !== undefined) { + await removeSandbox(codeModeSandboxRootPath).catch(() => undefined); + } + await rm(sandboxRootPathParent, { recursive: true, force: true }).catch(() => undefined); + await cleanupHostProbeFiles().catch(() => undefined); + } + + // Own SRT session: AF_UNIX enabled so FS / allowUnixSockets gating is what we measure. + await smokeUnixSocketFsGate(); + console.log('all smokes passed'); +} + +test('local-sandbox smoke', async () => { + await main(); +}, 600_000); diff --git a/packages/trueforge/tests/unit/apis/capabilities.test.ts b/packages/trueforge/tests/unit/apis/capabilities.test.ts index 6bdee7d53..78856454f 100644 --- a/packages/trueforge/tests/unit/apis/capabilities.test.ts +++ b/packages/trueforge/tests/unit/apis/capabilities.test.ts @@ -12,6 +12,7 @@ import type { OIDCConfig } from '../../../src/config'; import { migrateSqliteToLatest } from '../../../src/db/migrateSqlite'; import { createSqliteDb } from '../../../src/db/sqlite/client'; import { SqliteSandboxProviderStore } from '../../../src/db/sqlite/sandbox-provider-store/SqliteSandboxProviderStore'; +import { setCachedLocalSandboxSupport } from '../../../src/sandbox/localRuntime'; import { checkSnapshotStatus } from '../../../src/sandbox/providerUtils'; import type { SandboxBuildStatus, SandboxStatus } from '../../../src/schemas/sandboxProvider'; @@ -55,6 +56,11 @@ describe('capabilities routers', () => { beforeEach(() => { mockStatus.mockReset(); mockStatus.mockResolvedValue(undefined); + setCachedLocalSandboxSupport(undefined); + }); + + afterEach(() => { + setCachedLocalSandboxSupport(undefined); }); function makeRouter(): OpenAPIHono { @@ -87,6 +93,28 @@ describe('capabilities routers', () => { }); }); + it('reports sandbox + skill enabled when local fallback is cached and no image status exists', async () => { + disableOidcAuth(); + mockStatus.mockResolvedValue(undefined); + setCachedLocalSandboxSupport({ + supported: true, + platform: 'darwin', + shell: '/bin/bash', + python: '/usr/bin/python3', + }); + const router = makeRouter(); + + const response = await router.request('/'); + expect(response.status).toBe(200); + expect(await response.json()).toEqual({ + data: { + sandbox: { enabled: true }, + skill: { enabled: true }, + settings: { enabled: true }, + }, + }); + }); + it('reports sandbox + skill enabled only when the image is ready', async () => { disableOidcAuth(); mockStatus.mockResolvedValue(buildWithStatus('ready')); diff --git a/packages/trueforge/tests/unit/runtime/sessionResources.test.ts b/packages/trueforge/tests/unit/runtime/sessionResources.test.ts index 2101b8eaf..93dff9c8c 100644 --- a/packages/trueforge/tests/unit/runtime/sessionResources.test.ts +++ b/packages/trueforge/tests/unit/runtime/sessionResources.test.ts @@ -8,9 +8,14 @@ import { SqliteModelProviderStore } from '../../../src/db/sqlite/model-provider- import { SqliteSandboxProviderStore } from '../../../src/db/sqlite/sandbox-provider-store/SqliteSandboxProviderStore'; import { SqliteSkillStore } from '../../../src/db/sqlite/skill-store/SqliteSkillStore'; import { getModelDetails, validateAgentSpec } from '../../../src/runtime/sessionResources'; +import { setCachedLocalSandboxSupport } from '../../../src/sandbox/localRuntime'; import type { ReasoningEffort } from '../../../src/schemas/modelProvider'; describe('validateAgentSpec', () => { + afterEach(() => { + setCachedLocalSandboxSupport(undefined); + }); + async function setup(options?: { reasoningEfforts?: ReasoningEffort[] | undefined }) { const db = createSqliteDb(':memory:'); await migrateSqliteToLatest(db); @@ -245,4 +250,26 @@ describe('validateAgentSpec', () => { }), ).resolves.toBeUndefined(); }); + + it('admits sandbox.enabled when local fallback is cached and the store is empty', async () => { + const stores = await setup(); + setCachedLocalSandboxSupport({ + supported: true, + platform: 'darwin', + shell: '/bin/bash', + python: '/usr/bin/python3', + }); + await expect( + validateAgentSpec({ + spec: AgentSpecSchema.parse({ + model: { name: 'test-provider/test-model' }, + instructions: 'test', + config: { sandbox: { enabled: true } }, + }), + tenant_id: TENANT_ID, + ...stores, + }), + ).resolves.toBeUndefined(); + expect(await stores.sandboxProviderStore.getSandboxProvider(TENANT_ID)).toBeUndefined(); + }); }); diff --git a/packages/trueforge/tests/unit/sandbox/local/codeModeUdsTransport.contract.test.ts b/packages/trueforge/tests/unit/sandbox/local/codeModeUdsTransport.contract.test.ts new file mode 100644 index 000000000..11c5999c0 --- /dev/null +++ b/packages/trueforge/tests/unit/sandbox/local/codeModeUdsTransport.contract.test.ts @@ -0,0 +1,148 @@ +/** + * Node UDS binder for the Code Mode transport contract suite. + */ +import { + CodeModeDispatcher, + CodeModeReplySchema, + type CodeModeReply, + type CodeModeRequest, + type IToolSet, +} from '@truefoundry/trueforge-core/core'; +import { mkdir } from 'node:fs/promises'; +import { createConnection } from 'node:net'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { + runCodeModeTransportContractSuite, + type CodeModeTransportContractFixture, +} from '../../../../../trueforge-core/tests/core/sandbox/codeMode/codeModeTransportContractSuite'; +import { CodeModeUdsTransport } from '../../../../src/sandbox/local/core/CodeModeUdsTransport.js'; +import { encodeJsonMessage, JsonMessageReader, MAX_MESSAGE_BYTES } from '../../../../src/sandbox/local/core/frame.js'; + +function makeSilentLogger() { + const logger = { + error: () => undefined, + child: () => logger, + }; + return logger; +} + +function makeDemoToolSet(): IToolSet { + return { + name: 'demo', + id: 'demo', + preload: true, + hasPreloadedTools: true, + listTools: () => + Promise.resolve({ + result: { + tools: [ + { + name: 'ping', + description: 'ping', + inputSchema: { type: 'object' as const, properties: {} }, + preload: true, + }, + ], + }, + wasInitialized: undefined, + }), + callTool: () => + Promise.resolve({ + result: { content: [{ type: 'text' as const, text: 'ok' }], isError: false }, + wasInitialized: undefined, + }), + toolCallInfo: () => undefined, + }; +} + +function resolveSockPath(env: Record): string { + const sock = env['TFY_MCP_SOCK']; + if (sock === undefined || sock === '') { + throw new Error('TFY_MCP_SOCK missing from transport env'); + } + return sock; +} + +function sendUdsRequest(params: { env: Record; request: CodeModeRequest }): Promise { + const path = resolveSockPath(params.env); + const timeoutSeconds = Number(params.env['TFY_CM_REQUEST_TIMEOUT_SECONDS'] ?? '30'); + const timeoutMs = Number.isFinite(timeoutSeconds) ? timeoutSeconds * 1000 : 30_000; + + return new Promise((resolve, reject) => { + const socket = createConnection({ path, allowHalfOpen: true }); + const reader = new JsonMessageReader({ maxBytes: MAX_MESSAGE_BYTES }); + let settled = false; + + const finish = (error: Error | undefined, reply?: CodeModeReply): void => { + if (settled) return; + settled = true; + clearTimeout(timer); + socket.destroy(); + if (error) reject(error); + else if (reply !== undefined) resolve(reply); + else reject(new Error('missing reply')); + }; + + const timer = setTimeout(() => { + finish(new Error(`UDS request timed out after ${String(timeoutMs)}ms`)); + }, timeoutMs); + + socket.on('error', error => { + finish(error); + }); + socket.on('data', (chunk: Buffer) => { + try { + reader.push(chunk); + } catch (error) { + finish(error instanceof Error ? error : new Error(String(error))); + } + }); + socket.on('end', () => { + try { + const parsed = CodeModeReplySchema.safeParse(reader.finish()); + if (!parsed.success) { + finish(new Error('malformed Code Mode reply')); + return; + } + finish(undefined, parsed.data); + } catch (error) { + finish(error instanceof Error ? error : new Error(String(error))); + } + }); + socket.on('connect', () => { + const body = encodeJsonMessage(params.request); + socket.write(body, writeErr => { + if (writeErr) { + finish(writeErr); + return; + } + socket.end(); + }); + }); + }); +} + +runCodeModeTransportContractSuite(async (): Promise => { + const codeModeSocketParentPath = join(tmpdir(), 'cm'); + await mkdir(codeModeSocketParentPath, { recursive: true, mode: 0o700 }); + const transport = new CodeModeUdsTransport({ + codeModeSocketParentPath, + }); + const dispatcher = new CodeModeDispatcher({ + toolSets: [makeDemoToolSet()], + logger: makeSilentLogger(), + }); + + return { + transport, + dispatcher, + sandboxId: 'contract-sandbox', + requestTimeoutSeconds: 30, + sendRequest: ({ env, request }) => sendUdsRequest({ env, request }), + dispose: async () => { + dispatcher.close(); + await transport.stop(); + }, + }; +}); diff --git a/packages/trueforge/tests/unit/sandbox/local/provider/contract.test.ts b/packages/trueforge/tests/unit/sandbox/local/provider/contract.test.ts new file mode 100644 index 000000000..c3bd8c324 --- /dev/null +++ b/packages/trueforge/tests/unit/sandbox/local/provider/contract.test.ts @@ -0,0 +1,33 @@ +import { mkdir, mkdtemp, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { runSandboxProviderContractSuite } from '../../../../../../trueforge-core/tests/core/sandbox/provider/sandboxProviderContractSuite'; +import { LocalSandboxProvider } from '../../../../../src/sandbox/local/provider/LocalSandboxProvider'; + +describe('LocalSandboxProvider (SandboxProvider contract)', () => { + runSandboxProviderContractSuite(async () => { + const support = await LocalSandboxProvider.isSupported(); + if (!support.supported) { + pending(`Local sandbox not supported: ${support.reason}`); + } + if (!support.supported) { + throw new Error(support.reason); + } + const sandboxRootPathParent = await mkdtemp(join(tmpdir(), 'tfy-local-sandbox-contract-')); + // Short path: macOS tmpdir ~48 bytes; keep parent ≤60 for Code Mode UDS. + const codeModeSocketParentPath = join(tmpdir(), 'cm'); + await mkdir(codeModeSocketParentPath, { recursive: true, mode: 0o700 }); + const provider = new LocalSandboxProvider({ + sandboxRootPathParent, + codeModeSocketParentPath, + support, + }); + return { + provider, + dispose: async () => { + await provider.dispose(); + await rm(sandboxRootPathParent, { recursive: true, force: true }); + }, + }; + }); +}); diff --git a/packages/trueforge/tests/unit/sandbox/local/provider/missingRoot.test.ts b/packages/trueforge/tests/unit/sandbox/local/provider/missingRoot.test.ts new file mode 100644 index 000000000..793afe7b0 --- /dev/null +++ b/packages/trueforge/tests/unit/sandbox/local/provider/missingRoot.test.ts @@ -0,0 +1,25 @@ +import { SandboxNotAvailableError } from '@truefoundry/trueforge-core/core'; +import { mkdir, mkdtemp, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { LocalSandboxProvider } from '../../../../../src/sandbox/local/provider/LocalSandboxProvider'; + +describe('LocalSandboxProvider missing root', () => { + it('throws SandboxNotAvailableError when the sandbox root does not exist', async () => { + const sandboxRootPathParent = await mkdtemp(join(tmpdir(), 'tfy-local-missing-')); + const codeModeSocketParentPath = join(tmpdir(), 'cm'); + await mkdir(codeModeSocketParentPath, { recursive: true, mode: 0o700 }); + const provider = new LocalSandboxProvider({ + sandboxRootPathParent, + codeModeSocketParentPath, + support: { supported: true, platform: 'darwin', shell: '/bin/bash', python: '/usr/bin/python3' }, + }); + try { + await expect( + provider.exec({ sandboxId: join(sandboxRootPathParent, 'does-not-exist'), command: 'true' }), + ).rejects.toBeInstanceOf(SandboxNotAvailableError); + } finally { + await rm(sandboxRootPathParent, { recursive: true, force: true }); + } + }); +}); diff --git a/packages/trueforge/tests/unit/tsconfig.json b/packages/trueforge/tests/unit/tsconfig.json index e8486b199..5383d43d2 100644 --- a/packages/trueforge/tests/unit/tsconfig.json +++ b/packages/trueforge/tests/unit/tsconfig.json @@ -3,5 +3,6 @@ // AgentPassthroughEventSchemaMap, which widens SessionEventItem and breaks the // route handler types of any server source pulled in alongside it. "extends": "../../tsconfig.json", - "include": ["**/*"] + "include": ["**/*"], + "exclude": ["**/*.contract.test.ts"] } diff --git a/packages/trueforge/tsup.config.ts b/packages/trueforge/tsup.config.ts index 9ea6ba650..6156c2f75 100644 --- a/packages/trueforge/tsup.config.ts +++ b/packages/trueforge/tsup.config.ts @@ -41,6 +41,8 @@ export default defineConfig([ ...migrationEntries('postgres'), ...migrationEntries('sqlite'), }, + // SRT vendor helpers must resolve from node_modules at runtime (createRequire). + external: ['@anthropic-ai/sandbox-runtime'], }, { ...shared, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 30f653c47..0e4541a50 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -146,6 +146,9 @@ importers: packages/trueforge: dependencies: + '@anthropic-ai/sandbox-runtime': + specifier: 0.0.71 + version: 0.0.71 '@daytona/sdk': specifier: ^0.204.1 version: 0.204.1(debug@4.4.3(supports-color@8.1.1))(supports-color@8.1.1) From ca2f38047581e9594d2f8d9c79f7b884f9addf1e Mon Sep 17 00:00:00 2001 From: "trueforge-dev-bot[bot]" Date: Mon, 17 Aug 2026 12:03:06 +0000 Subject: [PATCH 05/10] Regenerate OpenAPI document and TypeScript SDK --- .github/fern/openapi/openapi.json | 2 +- docs/openapi.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/fern/openapi/openapi.json b/.github/fern/openapi/openapi.json index 531098b55..f3ff5fe41 100644 --- a/.github/fern/openapi/openapi.json +++ b/.github/fern/openapi/openapi.json @@ -5996,7 +5996,7 @@ } } }, - "description": "Caller is not the session creator, or sandbox belongs to another tenant." + "description": "Caller is not the session creator." }, "404": { "content": { diff --git a/docs/openapi.json b/docs/openapi.json index 531098b55..f3ff5fe41 100644 --- a/docs/openapi.json +++ b/docs/openapi.json @@ -5996,7 +5996,7 @@ } } }, - "description": "Caller is not the session creator, or sandbox belongs to another tenant." + "description": "Caller is not the session creator." }, "404": { "content": { From 91bd90ef03132525c6668f429ee0f8de2adfc7b9 Mon Sep 17 00:00:00 2001 From: Chirag Jain Date: Mon, 17 Aug 2026 21:47:44 +0530 Subject: [PATCH 06/10] Cleanup local-sandbox and fix python detection ossues --- .cursor/BUGBOT.md | 2 +- .gitignore | 1 - AGENTS.md | 2 +- package.json | 2 +- packages/local-sandbox/.gitignore | 5 - packages/local-sandbox/jest.config.cjs | 38 - .../local-sandbox/lima/local-sandbox.yaml | 50 - packages/local-sandbox/package.json | 50 - .../scripts/generate-sandbox-scripts.mjs | 22 - .../local-sandbox/scripts/probe-loopback.ts | 232 -- packages/local-sandbox/scripts/smoke-lima.sh | 36 - .../src/core/CodeModeUdsTransport.ts | 271 --- packages/local-sandbox/src/core/frame.ts | 36 - packages/local-sandbox/src/core/hostRun.ts | 504 ----- packages/local-sandbox/src/index.ts | 5 - .../src/provider/LocalSandboxProvider.ts | 400 ---- .../local-sandbox/src/schemas/jsonMessage.ts | 4 - .../local-sandbox/src/schemas/xferFileInfo.ts | 8 - .../src/scripts/mcp_client_local.py | 277 --- .../codeModeUdsTransport.contract.test.ts | 149 -- .../test/provider/contract.test.ts | 30 - packages/local-sandbox/test/smoke.test.ts | 1975 ----------------- packages/local-sandbox/tsconfig.build.json | 19 - packages/local-sandbox/tsconfig.json | 10 - packages/local-sandbox/tsconfig.smoke.json | 21 - .../src/core/sandbox/Sandbox.ts | 37 +- .../codeMode/nats/CodeModeNatsTransport.ts | 2 +- .../src/core/sandbox/constants.ts | 3 - .../core/sandbox/provider/DaytonaProvider.ts | 19 +- .../src/core/sandbox/provider/Provider.ts | 8 +- .../sandbox/provider/TFYSandboxProvider.ts | 19 +- .../src/core/sandbox/skills/ISkillMounter.ts | 4 +- .../src/core/sandbox/skills/SkillMounter.ts | 13 +- .../src/core/sandbox/skills/constants.ts | 7 +- .../src/core/sandbox/skills/index.ts | 2 +- .../trueforge-core/tests/core/harnessMocks.ts | 3 + .../tests/core/sandbox/Sandbox.ids.test.ts | 3 + .../tests/core/sandbox/Sandbox.paths.test.ts | 110 + .../core/sandbox/sandboxBridgeTimeout.test.ts | 3 + .../core/sandbox/skills/skillMounter.test.ts | 24 +- .../trueforge/jest.local-contract.config.cjs | 40 +- .../trueforge/jest.local-smoke.config.cjs | 40 +- packages/trueforge/src/apis/turns.ts | 4 + packages/trueforge/src/main.ts | 14 +- .../trueforge/src/runtime/sessionResources.ts | 2 + .../local/core/CodeModeUdsTransport.ts | 4 +- .../src/sandbox/local/core/hostRun.ts | 29 +- packages/trueforge/src/sandbox/local/index.ts | 8 +- .../local/provider/LocalSandboxProvider.ts | 192 +- .../resolvePythonExecutableOnHost.test.ts | 45 + .../sandbox/local/provider/layout.test.ts | 49 + .../local/provider/supportReason.test.ts | 34 + pnpm-lock.yaml | 34 - 53 files changed, 635 insertions(+), 4266 deletions(-) delete mode 100644 packages/local-sandbox/.gitignore delete mode 100644 packages/local-sandbox/jest.config.cjs delete mode 100644 packages/local-sandbox/lima/local-sandbox.yaml delete mode 100644 packages/local-sandbox/package.json delete mode 100644 packages/local-sandbox/scripts/generate-sandbox-scripts.mjs delete mode 100644 packages/local-sandbox/scripts/probe-loopback.ts delete mode 100755 packages/local-sandbox/scripts/smoke-lima.sh delete mode 100644 packages/local-sandbox/src/core/CodeModeUdsTransport.ts delete mode 100644 packages/local-sandbox/src/core/frame.ts delete mode 100644 packages/local-sandbox/src/core/hostRun.ts delete mode 100644 packages/local-sandbox/src/index.ts delete mode 100644 packages/local-sandbox/src/provider/LocalSandboxProvider.ts delete mode 100644 packages/local-sandbox/src/schemas/jsonMessage.ts delete mode 100644 packages/local-sandbox/src/schemas/xferFileInfo.ts delete mode 100644 packages/local-sandbox/src/scripts/mcp_client_local.py delete mode 100644 packages/local-sandbox/test/codeModeUdsTransport.contract.test.ts delete mode 100644 packages/local-sandbox/test/provider/contract.test.ts delete mode 100644 packages/local-sandbox/test/smoke.test.ts delete mode 100644 packages/local-sandbox/tsconfig.build.json delete mode 100644 packages/local-sandbox/tsconfig.json delete mode 100644 packages/local-sandbox/tsconfig.smoke.json create mode 100644 packages/trueforge-core/tests/core/sandbox/Sandbox.paths.test.ts create mode 100644 packages/trueforge/tests/unit/sandbox/local/core/resolvePythonExecutableOnHost.test.ts create mode 100644 packages/trueforge/tests/unit/sandbox/local/provider/layout.test.ts create mode 100644 packages/trueforge/tests/unit/sandbox/local/provider/supportReason.test.ts diff --git a/.cursor/BUGBOT.md b/.cursor/BUGBOT.md index aaca56cc1..5d0aa58f2 100644 --- a/.cursor/BUGBOT.md +++ b/.cursor/BUGBOT.md @@ -15,7 +15,7 @@ If the diff changes code that ships in a published package and does not add a ne Skip when: - The PR already adds `.changeset/*.md` -- Changes are docs, `AGENTS.md`, CI/workflows, charts, docker-compose, or `packages/local-sandbox` only +- Changes are docs, `AGENTS.md`, CI/workflows, charts, or docker-compose only - The PR is a Version Packages / `changeset-release/*` release PR ## CI and release wiring diff --git a/.gitignore b/.gitignore index 09aa30d98..6ba4f9250 100644 --- a/.gitignore +++ b/.gitignore @@ -15,7 +15,6 @@ packages/trueforge/src/catalog/mcpCatalog.gen.ts packages/trueforge/src/catalog/skillCatalog.gen.ts packages/trueforge/src/catalog/sandboxCatalog.gen.ts packages/trueforge/src/sandbox/local/sandboxScripts.gen.ts -packages/local-sandbox/src/sandboxScripts.gen.ts data/ # Helm subchart deps are fetched via `helm dependency build` (pinned by the # committed Chart.lock); the downloaded .tgz/dirs are not committed. diff --git a/AGENTS.md b/AGENTS.md index bda083a68..313e093e1 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,7 +1,7 @@ - Workspace tasks MUST use `package.json` scripts; add a script when a missing workflow is repeatable like the existing commands, not ad hoc commands. - CI package path filters, matrix package ids, and root scripts `test:*` in `.github/workflows/ci.yml` and root `package.json` MUST stay synchronized when a workspace package is added, renamed, or moved; the `store` filter sync rule lives in `packages/trueforge-core/src/agent-session/store/AGENTS.md`. - Release wiring MUST keep dist-free host development, `pnpm smoke`, and packed CJS/ESM consumers of `@truefoundry/trueforge-core` working without changes. -- PRs that change published-package code (`packages/trueforge-core`, `packages/trueforge`, `packages/trueforge-ui`, `packages/trueforge-sdk`) or `packages/frontend` (ships inside `@truefoundry/trueforge`) MUST include a new `.changeset/*.md` file (`pnpm changeset`). Docs, CI/workflows, charts, docker-compose, and `packages/local-sandbox`-only changes do not. SDK regeneration already adds `@truefoundry/trueforge-sdk` via `pnpm changeset:sdk-regen`. +- PRs that change published-package code (`packages/trueforge-core`, `packages/trueforge`, `packages/trueforge-ui`, `packages/trueforge-sdk`) or `packages/frontend` (ships inside `@truefoundry/trueforge`) MUST include a new `.changeset/*.md` file (`pnpm changeset`). Docs, CI/workflows, charts, and docker-compose changes do not. SDK regeneration already adds `@truefoundry/trueforge-sdk` via `pnpm changeset:sdk-regen`. - Shared Postgres/Redis settings in `docker-compose.yml` and `docker-compose.dev.yml` (image versions, health checks, `env_file`) MUST stay synchronized; intentional differences (app services, data paths, project `name`, host ports, in-network `POSTGRES_HOST` / `REDIS_URL`) MUST stay explicit. `packages/trueforge/.env` is the host-dev + secrets source; `docker-compose.yml` may read it but MUST override container connectivity so host-dev localhost values are not used inside the smoke-test stack. - Changes to types or schemas MUST keep `packages/trueforge-core`, `packages/frontend`, `packages/trueforge`, and `patches` synchronized; they MUST NOT update only one affected layer. - TypeScript code MUST NOT use assertion escapes such as `as T`, `as unknown as T`, non-null `!`, or `as never` to silence type errors; implementations MUST use sound contracts, guards, or corrected types. diff --git a/package.json b/package.json index 66cb49e74..893c895d1 100644 --- a/package.json +++ b/package.json @@ -46,7 +46,7 @@ "test:store:sqlite": "pnpm --filter @truefoundry/trueforge test:store:sqlite", "test:trueforge-ui": "pnpm --filter @truefoundry/trueforge-ui test", "test": "pnpm test:trueforge-core && pnpm test:trueforge-ui && pnpm test:trueforge && pnpm test:frontend", - "typecheck": "pnpm --filter @truefoundry/trueforge-core typecheck && pnpm --filter @truefoundry/trueforge-ui typecheck && pnpm --filter @truefoundry/trueforge typecheck && pnpm --filter frontend typecheck && pnpm --filter @truefoundry/local-sandbox typecheck", + "typecheck": "pnpm --filter @truefoundry/trueforge-core typecheck && pnpm --filter @truefoundry/trueforge-ui typecheck && pnpm --filter @truefoundry/trueforge typecheck && pnpm --filter frontend typecheck", "version": "node scripts/version.mjs" }, "devDependencies": { diff --git a/packages/local-sandbox/.gitignore b/packages/local-sandbox/.gitignore deleted file mode 100644 index 16fc3238e..000000000 --- a/packages/local-sandbox/.gitignore +++ /dev/null @@ -1,5 +0,0 @@ -node_modules/ -dist/ -sandboxes/ -*.log -coverage/ diff --git a/packages/local-sandbox/jest.config.cjs b/packages/local-sandbox/jest.config.cjs deleted file mode 100644 index 88fe39627..000000000 --- a/packages/local-sandbox/jest.config.cjs +++ /dev/null @@ -1,38 +0,0 @@ -/** @type {import('jest').Config} */ -module.exports = { - testEnvironment: 'node', - transform: { - '^.+\\.tsx?$': [ - '@swc/jest', - { - jsc: { - parser: { syntax: 'typescript', decorators: true }, - target: 'es2022', - }, - module: { type: 'commonjs' }, - }, - ], - // Dependencies that ship as ESM — compile them to CJS for Jest (same as trueforge-core). - '^.+\\.js$': [ - '@swc/jest', - { - jsc: { - parser: { syntax: 'ecmascript' }, - target: 'es2022', - }, - module: { type: 'commonjs' }, - }, - ], - }, - transformIgnorePatterns: [], - moduleNameMapper: { - '^(\\.{1,2}/.*)\\.js$': '$1', - '^@truefoundry/trueforge-core/core/(.*)$': '/../trueforge-core/src/core/$1', - '^@truefoundry/trueforge-core/core$': '/../trueforge-core/src/core/index.ts', - '^@truefoundry/trueforge-core$': '/../trueforge-core/src/index.ts', - }, - testTimeout: 120_000, - maxWorkers: '50%', - roots: ['/test'], - testMatch: ['**/test/**/*.test.ts'], -}; diff --git a/packages/local-sandbox/lima/local-sandbox.yaml b/packages/local-sandbox/lima/local-sandbox.yaml deleted file mode 100644 index 109f495b4..000000000 --- a/packages/local-sandbox/lima/local-sandbox.yaml +++ /dev/null @@ -1,50 +0,0 @@ -# Minimal Lima guest for local-sandbox Linux SRT smoke. -# Mounts the package root at the same absolute host path. -cpus: 1 -memory: '2GiB' -disk: '20GiB' - -images: - - location: 'https://cloud-images.ubuntu.com/releases/24.04/release/ubuntu-24.04-server-cloudimg-arm64.img' - arch: 'aarch64' - - location: 'https://cloud-images.ubuntu.com/releases/24.04/release/ubuntu-24.04-server-cloudimg-amd64.img' - arch: 'x86_64' - -# `location` is rewritten to an absolute host path by scripts/smoke-lima.sh -mounts: - - location: '__LOCAL_SANDBOX_ROOT__' - writable: true - -containerd: - system: false - user: false - -provision: - - mode: system - script: | - #!/bin/bash - set -euxo pipefail - export DEBIAN_FRONTEND=noninteractive - # Ubuntu 24.04 blocks capability-bearing user namespaces by default; SRT/bwrap needs them. - if [[ -e /proc/sys/kernel/apparmor_restrict_unprivileged_userns ]]; then - sysctl -w kernel.apparmor_restrict_unprivileged_userns=0 - printf '%s\n' 'kernel.apparmor_restrict_unprivileged_userns=0' \ - >/etc/sysctl.d/99-local-sandbox-userns.conf - fi - apt-get update -y - apt-get install -y --no-install-recommends \ - bubblewrap \ - socat \ - ripgrep \ - python3 \ - python3-pip \ - python3-setuptools \ - ca-certificates \ - curl \ - gnupg - if ! command -v node >/dev/null 2>&1; then - curl -fsSL https://deb.nodesource.com/setup_22.x | bash - - apt-get install -y --no-install-recommends nodejs - fi - corepack enable - corepack prepare pnpm@9.15.9 --activate diff --git a/packages/local-sandbox/package.json b/packages/local-sandbox/package.json deleted file mode 100644 index 2115a489d..000000000 --- a/packages/local-sandbox/package.json +++ /dev/null @@ -1,50 +0,0 @@ -{ - "name": "@truefoundry/local-sandbox", - "version": "0.0.0", - "description": "Local SRT SandboxProvider. Private package — not published to npm.", - "author": "TrueFoundry", - "license": "MIT", - "private": true, - "type": "module", - "main": "./dist/src/index.js", - "types": "./dist/src/index.d.ts", - "exports": { - ".": { - "types": "./dist/src/index.d.ts", - "default": "./dist/src/index.js" - }, - "./package.json": "./package.json" - }, - "files": [ - "dist" - ], - "engines": { - "node": ">=22" - }, - "scripts": { - "build:gen": "node scripts/generate-sandbox-scripts.mjs", - "build:gen:watch": "node --watch-path=src/scripts scripts/generate-sandbox-scripts.mjs", - "build": "pnpm run build:gen && tsc -p tsconfig.build.json", - "build:smoke": "pnpm run build:gen && tsc -p tsconfig.smoke.json", - "typecheck": "pnpm run build:gen && tsc -p tsconfig.json --noEmit", - "lint": "eslint src scripts --max-warnings 0 --config ../../eslint.config.mjs", - "test": "pnpm run build:gen && NODE_OPTIONS='--conditions=trueforge-dev' jest --config jest.config.cjs --testPathIgnorePatterns=smoke\\.test\\.ts$", - "smoke": "pnpm run build:gen && NODE_OPTIONS='--conditions=trueforge-dev' jest --config jest.config.cjs --runInBand --forceExit test/smoke.test.ts", - "smoke:lima": "bash scripts/smoke-lima.sh", - "probe:loopback": "pnpm build:smoke && node dist/scripts/probe-loopback.js" - }, - "dependencies": { - "@anthropic-ai/sandbox-runtime": "0.0.71", - "@truefoundry/trueforge-core": "workspace:*", - "ulid": "^3.0.2", - "zod": "^4.4.3" - }, - "devDependencies": { - "@swc/core": "^1.11.0", - "@swc/jest": "^0.2.37", - "@types/jest": "^29.5.14", - "@types/node": "^24.12.0", - "jest": "^29.7.0", - "typescript": "^7.0.2" - } -} diff --git a/packages/local-sandbox/scripts/generate-sandbox-scripts.mjs b/packages/local-sandbox/scripts/generate-sandbox-scripts.mjs deleted file mode 100644 index 24f1e0906..000000000 --- a/packages/local-sandbox/scripts/generate-sandbox-scripts.mjs +++ /dev/null @@ -1,22 +0,0 @@ -/** - * Inlines the local Code Mode MCP client into a generated TS module so - * install paths can self-serve the script with no loose fixture files. - * A plain generated `.ts` file works unchanged across tsc and @swc/jest. - * - * Runs via the `build:gen` script before build/typecheck/test/smoke. The - * generated output (src/sandboxScripts.gen.ts) is gitignored. - */ -import { readFileSync, writeFileSync } from 'node:fs'; -import { dirname, join } from 'node:path'; -import { fileURLToPath } from 'node:url'; - -const root = join(dirname(fileURLToPath(import.meta.url)), '..'); -const scriptsDir = join(root, 'src/scripts'); -const read = f => readFileSync(join(scriptsDir, f), 'utf-8'); - -const out = `// AUTO-GENERATED by scripts/generate-sandbox-scripts.mjs — do not edit. -export const sandboxScripts = { - mcpClientLocal: ${JSON.stringify(read('mcp_client_local.py'))}, -} as const; -`; -writeFileSync(join(root, 'src/sandboxScripts.gen.ts'), out); diff --git a/packages/local-sandbox/scripts/probe-loopback.ts b/packages/local-sandbox/scripts/probe-loopback.ts deleted file mode 100644 index 6251d9c5d..000000000 --- a/packages/local-sandbox/scripts/probe-loopback.ts +++ /dev/null @@ -1,232 +0,0 @@ -/** - * Probe: can an SRT-sandboxed command reach a host-owned 127.0.0.1 listener? - * allowLocalBinding is session-scoped (initialize), not per-exec. - */ -import { getDefaultWritePaths, SandboxManager } from '@anthropic-ai/sandbox-runtime'; -import { spawn } from 'node:child_process'; -import { randomUUID } from 'node:crypto'; -import { mkdir } from 'node:fs/promises'; -import { createServer } from 'node:http'; -import { dirname, join } from 'node:path'; -import { fileURLToPath } from 'node:url'; - -const ROOT = join(fileURLToPath(import.meta.url), '..', '..'); -const sandboxRootPath = join(ROOT, 'sandboxes', `probe-loopback-${randomUUID()}`); -const SRT_VENDOR = join( - dirname(fileURLToPath(import.meta.resolve('@anthropic-ai/sandbox-runtime/package.json'))), - 'vendor', -); - -function denySharedDefaultWritePaths(): string[] { - return getDefaultWritePaths().filter(path => !path.startsWith('/dev/')); -} - -function platformAllowRead(): string[] { - const common = [sandboxRootPath, '/usr/bin', '/bin', '/usr/sbin', '/sbin', '/usr/lib', '/dev', SRT_VENDOR]; - if (process.platform === 'darwin') { - return [ - ...common, - '/System/Library', - '/Library', - '/opt/homebrew', - '/opt/homebrew/bin', - '/private/var/db/dyld', - '/private/var/select', - ]; - } - return [...common, '/lib', '/lib64', '/usr/lib64', '/usr/local', '/etc', '/proc', '/sys']; -} - -async function listenHost(): Promise<{ port: number; close: () => Promise }> { - const server = createServer((_req, res) => { - res.writeHead(200, { 'content-type': 'text/plain' }); - res.end('host-loopback-ok\n'); - }); - await new Promise((resolve, reject) => { - server.once('error', reject); - server.listen(0, '127.0.0.1', () => { - resolve(); - }); - }); - const addr = server.address(); - if (addr === null || typeof addr === 'string') { - throw new Error('expected TCP address'); - } - return { - port: addr.port, - close: () => - new Promise((resolve, reject) => { - server.close(err => { - if (err) { - reject(err); - } else { - resolve(); - } - }); - }), - }; -} - -async function runSandboxed(params: { label: string; command: string; allowedDomains: string[] }): Promise { - const wrap = await SandboxManager.wrapWithSandboxArgv( - params.command, - '/bin/bash', - { - filesystem: { - allowWrite: [sandboxRootPath], - denyWrite: denySharedDefaultWritePaths(), - denyRead: ['/'], - allowRead: platformAllowRead(), - }, - network: { - allowedDomains: params.allowedDomains, - deniedDomains: [], - }, - }, - undefined, - sandboxRootPath, - { commandId: randomUUID(), commandText: params.command }, - ); - const [argv0, ...argvRest] = wrap.argv; - if (argv0 === undefined) { - throw new Error('empty argv'); - } - - const result = await new Promise<{ code: number | null; out: string }>((resolve, reject) => { - const child = spawn(argv0, argvRest, { - cwd: sandboxRootPath, - env: { - HOME: join(sandboxRootPath, '.home'), - TMPDIR: join(sandboxRootPath, '.tmp'), - PATH: process.platform === 'darwin' ? '/opt/homebrew/bin:/usr/bin:/bin' : '/usr/bin:/bin', - ...wrap.env, - }, - stdio: ['ignore', 'pipe', 'pipe'], - }); - let out = ''; - child.stdout.on('data', (c: Buffer) => { - out += c.toString('utf8'); - }); - child.stderr.on('data', (c: Buffer) => { - out += c.toString('utf8'); - }); - child.on('error', reject); - child.on('close', code => { - resolve({ code, out }); - }); - }); - - const preview = result.out.replace(/\s+/g, ' ').trim().slice(0, 280); - console.log(`[${process.platform}] ${params.label}: exit=${String(result.code)} out=${JSON.stringify(preview)}`); -} - -async function runSuite(params: { allowLocalBinding: boolean; hostPort: number }): Promise { - await SandboxManager.reset().catch(() => undefined); - await SandboxManager.initialize({ - network: { - allowedDomains: [], - deniedDomains: [], - allowLocalBinding: params.allowLocalBinding, - }, - filesystem: { - allowWrite: [], - denyWrite: denySharedDefaultWritePaths(), - denyRead: ['/'], - allowRead: platformAllowRead(), - }, - }); - - console.log(`\n=== ${process.platform} session allowLocalBinding=${String(params.allowLocalBinding)} ===`); - - const connectCmd = [ - "python3 - <<'PY'", - 'import socket,sys', - `port=${String(params.hostPort)}`, - 'try:', - ' s=socket.create_connection(("127.0.0.1", port), timeout=2)', - ' s.sendall(b"GET / HTTP/1.0\\r\\nHost: 127.0.0.1\\r\\n\\r\\n")', - ' data=s.recv(200).decode("utf-8","replace")', - ' s.close()', - ' print("CONNECT_OK", "host-loopback-ok" in data, repr(data[:80]))', - ' sys.exit(0 if "host-loopback-ok" in data else 1)', - 'except OSError as e:', - ' print("CONNECT_FAIL", type(e).__name__, e)', - ' sys.exit(2)', - 'PY', - ].join('\n'); - - const bindCmd = [ - "python3 - <<'PY'", - 'import socket,sys', - 'try:', - ' s=socket.socket(socket.AF_INET, socket.SOCK_STREAM)', - ' s.bind(("127.0.0.1", 0))', - ' print("BIND_OK", s.getsockname())', - ' s.close()', - ' sys.exit(0)', - 'except OSError as e:', - ' print("BIND_FAIL", type(e).__name__, e)', - ' sys.exit(2)', - 'PY', - ].join('\n'); - - const ifacesCmd = [ - "python3 - <<'PY'", - 'import socket,sys', - 'print("hostname", socket.gethostname())', - 'try:', - ' print("primary", socket.gethostbyname(socket.gethostname()))', - 'except OSError as e:', - ' print("primary_fail", e)', - 'try:', - ' s=socket.socket(socket.AF_INET, socket.SOCK_DGRAM)', - ' s.connect(("8.8.8.8", 80))', - ' print("udp_route_ip", s.getsockname()[0])', - ' s.close()', - 'except OSError as e:', - ' print("udp_route_fail", e)', - 'PY', - ].join('\n'); - - await runSandboxed({ - label: 'ifaces/route probe', - command: ifacesCmd, - allowedDomains: [], - }); - await runSandboxed({ - label: 'connect host port (allowedDomains=[])', - command: connectCmd, - allowedDomains: [], - }); - await runSandboxed({ - label: `connect host port (allowedDomains=127.0.0.1:${String(params.hostPort)})`, - command: connectCmd, - allowedDomains: [`127.0.0.1:${String(params.hostPort)}`], - }); - await runSandboxed({ - label: 'bind sandbox 127.0.0.1:0', - command: bindCmd, - allowedDomains: [], - }); -} - -async function main(): Promise { - await mkdir(join(sandboxRootPath, '.tmp'), { recursive: true, mode: 0o700 }); - await mkdir(join(sandboxRootPath, '.home'), { recursive: true, mode: 0o700 }); - - const host = await listenHost(); - console.log(`[${process.platform}] host listener 127.0.0.1:${String(host.port)}`); - - try { - await runSuite({ allowLocalBinding: false, hostPort: host.port }); - await runSuite({ allowLocalBinding: true, hostPort: host.port }); - } finally { - await host.close(); - await SandboxManager.reset().catch(() => undefined); - } -} - -main().catch((error: unknown) => { - console.error(error); - process.exit(1); -}); diff --git a/packages/local-sandbox/scripts/smoke-lima.sh b/packages/local-sandbox/scripts/smoke-lima.sh deleted file mode 100755 index a0a57b96d..000000000 --- a/packages/local-sandbox/scripts/smoke-lima.sh +++ /dev/null @@ -1,36 +0,0 @@ -#!/usr/bin/env bash -# Create/start a minimal Lima VM and run `pnpm smoke` for Linux SRT coverage. -set -euo pipefail - -ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" -INSTANCE="${LIMA_INSTANCE:-local-sandbox-poc}" -YAML_TEMPLATE="${ROOT}/lima/local-sandbox.yaml" - -if ! command -v limactl >/dev/null 2>&1; then - echo "limactl not found; install Lima first (e.g. brew install lima)" >&2 - exit 1 -fi - -if ! limactl list -f '{{.Name}}' 2>/dev/null | grep -qx "${INSTANCE}"; then - echo "creating Lima instance ${INSTANCE} (minimal: 1 CPU / 2GiB)..." - YAML="$(mktemp -t local-sandbox-lima.XXXXXX.yaml)" - trap 'rm -f "${YAML}"' EXIT - # Lima requires absolute mount locations. - sed "s|__LOCAL_SANDBOX_ROOT__|${ROOT}|g" "${YAML_TEMPLATE}" >"${YAML}" - limactl create --name="${INSTANCE}" --yes "${YAML}" -fi - -status="$(limactl list -f '{{.Name}} {{.Status}}' | awk -v n="${INSTANCE}" '$1==n { print $2; exit }')" -if [[ "${status}" != "Running" ]]; then - echo "starting Lima instance ${INSTANCE}..." - limactl start "${INSTANCE}" -fi - -echo "running Linux smoke inside ${INSTANCE}..." -# Mount mirrors the host absolute path. Guest deps/sysctl come from lima provision. -limactl shell "${INSTANCE}" -- bash -lc " - set -euo pipefail - cd $(printf '%q' "${ROOT}") - CI=true pnpm install --ignore-workspace --no-frozen-lockfile - pnpm smoke -" diff --git a/packages/local-sandbox/src/core/CodeModeUdsTransport.ts b/packages/local-sandbox/src/core/CodeModeUdsTransport.ts deleted file mode 100644 index beadaafe5..000000000 --- a/packages/local-sandbox/src/core/CodeModeUdsTransport.ts +++ /dev/null @@ -1,271 +0,0 @@ -/** - * Handle-scoped Code Mode UDS transport. Listen/accept live for the Sandbox handle lifetime; - * one UTF-8 JSON request/reply per connection (peer write-close); no request_id. - * - * Sockets live under {@link CodeModeUdsTransportOptions.codeModeSocketParentPath} as ULID names. - * Parent must be mode 0700 (enforced) so other accounts cannot replace the sock inode before - * connect; after listen the sock is chmod 0600. Same-UID isolation is still SRT path policy. - * The caller owns that parent directory's lifetime; this transport unlinks the sock it creates. - */ -import type { - CodeModeClientInstall, - CodeModeDispatcher, - CodeModeReply, - CodeModeRequest, - CodeModeTransport, -} from '@truefoundry/trueforge-core/core'; -import { CodeModeRequestSchema, validateNoPathTraversal } from '@truefoundry/trueforge-core/core'; -import { chmodSync, existsSync, realpathSync, statSync } from 'node:fs'; -import { chmod, mkdir, rm, symlink, unlink, writeFile } from 'node:fs/promises'; -import { createServer, type Server, type Socket } from 'node:net'; -import { dirname, isAbsolute, join, resolve } from 'node:path'; -import { ulid } from 'ulid'; -import { sandboxScripts } from '../sandboxScripts.gen.js'; -import { encodeJsonMessage, JsonMessageReader, MAX_MESSAGE_BYTES } from './frame.js'; -import { registerCodeModeSocketPath, unregisterCodeModeSocketPath } from './hostRun.js'; - -const MAX_CODE_MODE_SOCKET_PARENT_BYTES = 60; -const CODE_MODE_SOCKET_PARENT_MODE = 0o700; -const CODE_MODE_SOCKET_MODE = 0o600; - -/** Install layout for local Code Mode MCP client (sandboxId = absolute sandbox root). */ -export function localMcpClientRemotePath(sandboxId: string): string { - return join(sandboxId, 'mcp-client', 'mcp_client.py'); -} - -/** Install the local Code Mode MCP client into the sandbox (same layout as Sandbox.init). */ -export async function installMcpFixture(sandboxRootPath: string): Promise<{ remotePath: string }> { - const remotePath = localMcpClientRemotePath(sandboxRootPath); - const binLink = join(dirname(remotePath), 'bin', 'mcp-client'); - await mkdir(dirname(binLink), { recursive: true, mode: 0o700 }); - await writeFile(remotePath, sandboxScripts.mcpClientLocal, { encoding: 'utf8', mode: 0o555 }); - await rm(binLink, { force: true }); - await symlink(remotePath, binLink); - return { remotePath }; -} - -export interface CodeModeUdsTransportOptions { - /** - * Absolute existing directory for Code Mode UDS files (≤60 bytes, mode 0700). - * Socket path is `join(parent, ulid)`; sock is chmod 0600 after listen. - */ - codeModeSocketParentPath: string; - maxMessageBytes?: number | undefined; - /** Optional: observe inbound protocol failures (oversized / malformed). */ - onProtocolError?: ((message: string) => void) | undefined; -} - -/** Validate and normalize parent dir for Code Mode socks; enforce mode 0700. */ -export function assertCodeModeSocketParentPath(path: string): string { - if (!isAbsolute(path)) { - throw new Error('codeModeSocketParentPath must be an absolute path'); - } - validateNoPathTraversal(path); - const resolved = resolve(path); - if (!existsSync(resolved) || !statSync(resolved).isDirectory()) { - throw new Error('codeModeSocketParentPath must be an existing directory'); - } - // Seatbelt / allowUnixSockets match real paths (/private/var/... on macOS). - const real = realpathSync(resolved); - const bytes = Buffer.byteLength(real); - if (bytes > MAX_CODE_MODE_SOCKET_PARENT_BYTES) { - throw new Error( - `codeModeSocketParentPath must be at most ${String(MAX_CODE_MODE_SOCKET_PARENT_BYTES)} bytes (got ${String(bytes)})`, - ); - } - // Owner-only parent: other accounts cannot rename/replace socks under this dir. - chmodSync(real, CODE_MODE_SOCKET_PARENT_MODE); - const mode = statSync(real).mode & 0o777; - if (mode !== CODE_MODE_SOCKET_PARENT_MODE) { - throw new Error(`codeModeSocketParentPath must be mode 0700 after chmod (got 0o${mode.toString(8)})`); - } - return real; -} - -export class CodeModeUdsTransport implements CodeModeTransport { - private readonly codeModeSocketParentPath: string; - private readonly maxMessageBytes: number; - private readonly onProtocolError: ((message: string) => void) | undefined; - - private sessionPromise: Promise<{ env: Record }> | undefined; - private server: Server | undefined; - private sockPath: string | undefined; - private dispatcher: CodeModeDispatcher | undefined; - private cachedEnv: Record | undefined; - - constructor(options: CodeModeUdsTransportOptions) { - this.codeModeSocketParentPath = assertCodeModeSocketParentPath(options.codeModeSocketParentPath); - this.maxMessageBytes = options.maxMessageBytes ?? MAX_MESSAGE_BYTES; - this.onProtocolError = options.onProtocolError; - } - - getClientInstall(params: { sandboxId: string }): CodeModeClientInstall { - return { - content: sandboxScripts.mcpClientLocal, - remotePath: localMcpClientRemotePath(params.sandboxId), - }; - } - - start(params: { - codeModeDispatcher: CodeModeDispatcher; - sandboxId: string; - requestTimeoutSeconds: number; - }): Promise<{ env: Record }> { - this.dispatcher = params.codeModeDispatcher; - this.sessionPromise ??= this.listenSession(params).catch((e: unknown) => { - this.sessionPromise = undefined; - this.cachedEnv = undefined; - throw e; - }); - return this.sessionPromise; - } - - async stop(): Promise { - const pending = this.sessionPromise; - this.sessionPromise = undefined; - this.cachedEnv = undefined; - if (pending !== undefined) { - try { - await pending; - } catch { - // Listen failed; nothing to close. - } - } - const server = this.server; - const sockPath = this.sockPath; - this.server = undefined; - this.sockPath = undefined; - if (server !== undefined) { - await new Promise(resolveClose => { - server.close(() => { - resolveClose(); - }); - }); - } - if (sockPath !== undefined) { - unregisterCodeModeSocketPath(sockPath); - await unlink(sockPath).catch(() => undefined); - } - } - - private async listenSession(params: { requestTimeoutSeconds: number }): Promise<{ env: Record }> { - if (this.server !== undefined && this.cachedEnv !== undefined) { - return { env: this.cachedEnv }; - } - - // Re-check: caller owns the parent dir and may have removed it after construct. - assertCodeModeSocketParentPath(this.codeModeSocketParentPath); - - const sockPath = join(this.codeModeSocketParentPath, ulid().toLowerCase()); - await unlink(sockPath).catch(() => undefined); - registerCodeModeSocketPath(sockPath); - const server = createServer({ allowHalfOpen: true }); - try { - await new Promise((resolveListen, reject) => { - server.once('error', reject); - server.listen(sockPath, () => { - server.off('error', reject); - resolveListen(); - }); - }); - // Narrow the listen→chmod window; 0700 parent already blocks other accounts from replace. - await chmod(sockPath, CODE_MODE_SOCKET_MODE); - } catch (error) { - unregisterCodeModeSocketPath(sockPath); - server.close(); - await unlink(sockPath).catch(() => undefined); - throw error; - } - - this.server = server; - this.sockPath = sockPath; - server.on('connection', socket => { - this.handleConnection(socket); - }); - - const env = { - TFY_MCP_SOCK: sockPath, - TFY_CM_REQUEST_TIMEOUT_SECONDS: String(params.requestTimeoutSeconds), - }; - this.cachedEnv = env; - return { env }; - } - - private handleConnection(socket: Socket): void { - const reader = new JsonMessageReader({ maxBytes: this.maxMessageBytes }); - let settled = false; - - socket.on('error', () => undefined); - - // Oversized / malformed frames only tear down this connection (and notify - // onProtocolError). Transport is handle-scoped and does not kill the process group. - const fail = (message: string): void => { - if (settled) { - return; - } - settled = true; - this.onProtocolError?.(message); - socket.destroy(); - }; - - socket.on('data', (chunk: Buffer) => { - try { - reader.push(chunk); - } catch (error) { - fail(error instanceof Error ? error.message : String(error)); - } - }); - - socket.on('end', () => { - if (settled) { - return; - } - settled = true; - void this.dispatchConnection(socket, reader).catch((error: unknown) => { - this.onProtocolError?.(error instanceof Error ? error.message : String(error)); - socket.destroy(); - }); - }); - } - - private async dispatchConnection(socket: Socket, reader: JsonMessageReader): Promise { - let request: CodeModeRequest; - try { - const parsed = CodeModeRequestSchema.safeParse(reader.finish()); - if (!parsed.success) { - const reply: CodeModeReply = { - ok: false, - error: 'Malformed Code Mode request', - source: 'caller', - }; - socket.write(encodeJsonMessage(reply)); - socket.end(); - return; - } - request = parsed.data; - } catch (error) { - this.onProtocolError?.(error instanceof Error ? error.message : String(error)); - socket.destroy(); - return; - } - - const dispatcher = this.dispatcher; - if (dispatcher === undefined) { - const reply: CodeModeReply = { - ok: false, - error: 'Code Mode dispatcher is not configured', - source: 'internal', - }; - socket.write(encodeJsonMessage(reply)); - socket.end(); - return; - } - - const reply = await dispatcher.dispatch({ request, traceCarrier: {} }); - try { - socket.write(encodeJsonMessage(reply)); - } finally { - socket.end(); - } - } -} diff --git a/packages/local-sandbox/src/core/frame.ts b/packages/local-sandbox/src/core/frame.ts deleted file mode 100644 index e56e8b89f..000000000 --- a/packages/local-sandbox/src/core/frame.ts +++ /dev/null @@ -1,36 +0,0 @@ -/** - * Code Mode UDS payload: one UTF-8 JSON value per connection. - * Peer write-close (EOF) delimits the message; no length prefix. - */ -import { JsonMessageValueSchema } from '../schemas/jsonMessage.js'; - -export const MAX_MESSAGE_BYTES = 64 * 1024 * 1024; - -export function encodeJsonMessage(value: unknown): Buffer { - return Buffer.from(JSON.stringify(value), 'utf8'); -} - -/** Accumulates inbound socket bytes until EOF, then parses JSON. */ -export class JsonMessageReader { - #buffer = Buffer.alloc(0); - readonly #maxBytes: number; - - constructor(options: { maxBytes?: number } = {}) { - this.#maxBytes = options.maxBytes ?? MAX_MESSAGE_BYTES; - } - - push(chunk: Buffer): void { - if (this.#buffer.length + chunk.length > this.#maxBytes) { - throw new Error(`message exceeds max ${String(this.#maxBytes)} bytes`); - } - this.#buffer = Buffer.concat([this.#buffer, chunk]); - } - - finish(): unknown { - try { - return JsonMessageValueSchema.parse(JSON.parse(this.#buffer.toString('utf8'))); - } catch (error) { - throw new Error('invalid JSON message', { cause: error }); - } - } -} diff --git a/packages/local-sandbox/src/core/hostRun.ts b/packages/local-sandbox/src/core/hostRun.ts deleted file mode 100644 index ce9fab816..000000000 --- a/packages/local-sandbox/src/core/hostRun.ts +++ /dev/null @@ -1,504 +0,0 @@ -/** - * Host-side sandboxed exec (in-process supervisor). - * Only the untrusted command argv is SRT-wrapped. Code Mode UDS is owned by - * {@link CodeModeUdsTransport} (handle-scoped); pass TFY_MCP_SOCK via `env` when needed. - * - * Platform policy (allowRead / AF_UNIX / PATH) uses {@link LocalSandboxPlatform} from - * {@link initSrt} — the same platform captured by LocalSandboxProvider.isSupported. - */ -import { getDefaultWritePaths, SandboxManager } from '@anthropic-ai/sandbox-runtime'; -import { execFile, spawn, type ChildProcess } from 'node:child_process'; -import { randomUUID } from 'node:crypto'; -import { realpathSync } from 'node:fs'; -import { mkdir, rm } from 'node:fs/promises'; -import { createRequire } from 'node:module'; -import { dirname, isAbsolute, join } from 'node:path'; -import { promisify } from 'node:util'; - -const execFileAsync = promisify(execFile); - -/** SRT ships Linux helpers (e.g. apply-seccomp) under vendor/; the wrapped command must read them. */ -// Package-root resolve (not app-module loading): Jest's CJS transform breaks import.meta.resolve. -const SRT_VENDOR = join( - dirname(createRequire(import.meta.url).resolve('@anthropic-ai/sandbox-runtime/package.json')), - 'vendor', -); - -/** - * Cap for buffered stdout+stderr per exec. - * Sized for base64 of a max-sized download (10 MiB → ~13.3 MiB) plus headroom. - */ -export const MAX_OUTPUT_BYTES = 14 * 1024 * 1024; - -/** Platforms LocalSandboxProvider / hostRun can run on. */ -export type LocalSandboxPlatform = 'darwin' | 'linux'; - -/** Cached from {@link initSrt}; cleared by {@link resetSrt}. Used by session policy helpers after init. */ -let activePlatform: LocalSandboxPlatform | undefined; - -function requireActivePlatform(): LocalSandboxPlatform { - if (activePlatform === undefined) { - throw new Error('SRT platform is not set; call initSrt({ platform }) first'); - } - return activePlatform; -} - -/** PATH for sandboxed commands — must stay aligned with allowRead exec roots. */ -const COMMAND_PATH_BY_PLATFORM = { - // On macOS, prefer Homebrew ahead of `/usr/bin` shims (those need Xcode select - // paths that we intentionally do not allow-read). - darwin: '/opt/homebrew/bin:/usr/bin:/bin:/usr/sbin:/sbin', - linux: '/usr/bin:/bin:/usr/sbin:/sbin', -} as const satisfies Record; - -export function commandPath(platform: LocalSandboxPlatform): string { - return COMMAND_PATH_BY_PLATFORM[platform]; -} - -/** - * Resolve a command name to an absolute path on the host using the sandbox PATH. - * Uses `/bin/sh` only as a host bootstrap for `command -v` (not the sandboxed wrap shell). - */ -export async function resolveCommandOnHost(params: { - platform: LocalSandboxPlatform; - name: string; -}): Promise { - if (!/^[A-Za-z0-9._+-]+$/.test(params.name)) { - throw new Error(`invalid command name for resolveCommandOnHost: ${params.name}`); - } - const pathEnv = commandPath(params.platform); - try { - const { stdout } = await execFileAsync('/bin/sh', ['-c', `command -v -- ${params.name}`], { - env: { PATH: pathEnv }, - encoding: 'utf8', - }); - const resolved = stdout.trim().split(/\r?\n/).filter(Boolean).at(-1); - if (resolved === undefined || resolved.length === 0 || !isAbsolute(resolved)) { - return undefined; - } - return resolved; - } catch { - return undefined; - } -} - -export interface SessionResult { - stdoutText: string; - stderrText: string; - exitCode: number; - protocolError: string | undefined; - timedOut: boolean; - /** Process-group leader pid of the sandboxed command (Unix). */ - childPid: number | undefined; -} - -/** - * SRT always unions getDefaultWritePaths() into allowWrite. There is no config - * flag to disable that. Deny the shared/host defaults (not /dev/*) so they are - * not usable as cross-sandbox writable storage. denyWrite wins over allowWrite. - */ -function denySharedDefaultWritePaths(): string[] { - return getDefaultWritePaths().filter(path => !path.startsWith('/dev/')); -} - -const ALLOW_READ_BY_PLATFORM = { - darwin: [ - '/opt/homebrew/bin', - '/usr/bin', - '/bin', - '/usr/sbin', - '/sbin', - '/usr/lib', - '/System/Library', - '/Library', - '/private/var/db/dyld', - '/private/var/select', - '/opt/homebrew', - '/dev', - ], - linux: [ - '/usr/bin', - '/bin', - '/usr/sbin', - '/sbin', - '/lib', - '/lib64', - '/usr/lib', - '/usr/lib64', - '/usr/local', - '/etc', - '/dev', - '/proc', - '/sys', - '/tmp', - SRT_VENDOR, - ], -} as const satisfies Record; - -export function platformAllowRead(platform: LocalSandboxPlatform): string[] { - return [...ALLOW_READ_BY_PLATFORM[platform]]; -} - -/** - * Policy for the untrusted command only (deny-by-default reads). - * The host (in-process supervisor) is never placed under this policy. - */ -function filesystemPolicy(params: { sandboxRootPath: string; platform: LocalSandboxPlatform }): { - allowWrite: string[]; - denyWrite: string[]; - denyRead: string[]; - allowRead: string[]; -} { - return { - allowWrite: [params.sandboxRootPath], - denyWrite: denySharedDefaultWritePaths(), - denyRead: ['/'], - allowRead: [params.sandboxRootPath, ...codeModeSocketPaths, ...platformAllowRead(params.platform)], - }; -} - -/** Curated env for the sandboxed command — never the full host process.env. */ -function commandEnv(params: { - sandboxRootPath: string; - platform: LocalSandboxPlatform; - extra?: Record; -}): Record { - const tmp = join(params.sandboxRootPath, '.tmp'); - const home = join(params.sandboxRootPath, '.home'); - const locked = { - HOME: home, - TMPDIR: tmp, - TMP: tmp, - TEMP: tmp, - // Local MCP client CLI lives at /mcp-client/bin (see CodeModeUdsTransport install layout). - PATH: (() => { - const base = commandPath(params.platform); - const ours = `${join(params.sandboxRootPath, 'mcp-client', 'bin')}:${base}`; - const theirs = params.extra?.['PATH']; - return theirs !== undefined && theirs.length > 0 ? `${ours}:${theirs}` : ours; - })(), - }; - return { - ...params.extra, - ...locked, - }; -} - -/** Session filesystem floor (per-exec customConfig still tightens allowWrite/allowRead). */ -function sessionFilesystem(platform: LocalSandboxPlatform): { - allowWrite: string[]; - denyWrite: string[]; - denyRead: string[]; - allowRead: string[]; -} { - const allowWrite: string[] = []; - return { - allowWrite, - denyWrite: denySharedDefaultWritePaths(), - denyRead: ['/'], - allowRead: platformAllowRead(platform), - }; -} - -/** - * AF_UNIX policy is session-scoped only (wrap customConfig cannot set it). - * - Linux: allowAllUnixSockets; pathname connect still needs FS allowRead (bwrap). - * - macOS: allowAllUnixSockets does NOT consult allowRead for connect — use - * allowUnixSockets subpath, synced at sandbox create/remove. - */ -function sessionNetwork(params: { platform: LocalSandboxPlatform; unixSockets?: string[] }): - | { - allowedDomains: string[]; - deniedDomains: string[]; - allowAllUnixSockets: true; - } - | { - allowedDomains: string[]; - deniedDomains: string[]; - allowAllUnixSockets: false; - allowUnixSockets: string[]; - } { - const allowedDomains: string[] = []; - const deniedDomains: string[] = []; - if (params.platform === 'linux') { - return { - allowedDomains, - deniedDomains, - allowAllUnixSockets: true, - }; - } - return { - allowedDomains, - deniedDomains, - allowAllUnixSockets: false, - allowUnixSockets: params.unixSockets ?? [], - }; -} - -/** Active sandbox roots allowed for macOS pathname UDS (seatbelt subpath). */ -const darwinUnixSocketSandboxRoots = new Set(); -/** Exact Code Mode UDS paths — macOS allowUnixSockets + Linux allowRead. */ -const codeModeSocketPaths = new Set(); - -function darwinUnixSocketPaths(): string[] { - return [...darwinUnixSocketSandboxRoots, ...codeModeSocketPaths]; -} - -function syncDarwinUnixSockets(): void { - // No-op until initSrt: register/unregister may run from transport-only tests. - if (SandboxManager.getConfig() === undefined) { - return; - } - const platform = requireActivePlatform(); - if (platform !== 'darwin') { - return; - } - SandboxManager.updateConfig(buildSessionConfig(platform)); -} - -/** Single source for process-scoped SRT session config (init + sock register/unregister). */ -function buildSessionConfig(platform: LocalSandboxPlatform): { - network: ReturnType; - filesystem: ReturnType; -} { - return { - network: sessionNetwork({ platform, unixSockets: darwinUnixSocketPaths() }), - filesystem: sessionFilesystem(platform), - }; -} - -/** Allow sandboxed clients to connect to this exact Code Mode sock path. */ -export function registerCodeModeSocketPath(sockPath: string): void { - codeModeSocketPaths.add(sockPath); - syncDarwinUnixSockets(); -} - -export function unregisterCodeModeSocketPath(sockPath: string): void { - codeModeSocketPaths.delete(sockPath); - syncDarwinUnixSockets(); -} - -/** Create a sandbox directory at `sandboxRootPath` (also the sandbox id). */ -export async function createSandbox(sandboxRootPath: string): Promise { - await mkdir(sandboxRootPath, { recursive: true, mode: 0o700 }); - await mkdir(join(sandboxRootPath, '.tmp'), { recursive: true, mode: 0o700 }); - await mkdir(join(sandboxRootPath, '.home'), { recursive: true, mode: 0o700 }); - // Seatbelt allowWrite matches real paths (/private/var/... on macOS). - const realRoot = realpathSync(sandboxRootPath); - darwinUnixSocketSandboxRoots.add(realRoot); - syncDarwinUnixSockets(); - return realRoot; -} - -export async function removeSandbox(sandboxRootPath: string): Promise { - darwinUnixSocketSandboxRoots.delete(sandboxRootPath); - syncDarwinUnixSockets(); - await rm(sandboxRootPath, { recursive: true, force: true }); -} - -/** - * Process-scoped SRT init. Per-exec filesystem policy is applied in - * {@link runSupervisorSession} via wrapWithSandboxArgv customConfig. - */ -export async function initSrt(params: { platform: LocalSandboxPlatform }): Promise { - activePlatform = params.platform; - await SandboxManager.initialize(buildSessionConfig(params.platform)); -} - -export async function resetSrt(): Promise { - codeModeSocketPaths.clear(); - activePlatform = undefined; - await SandboxManager.reset(); -} - -/** Whether process-scoped SRT session config is already initialized. */ -export function isSrtInitialized(): boolean { - return activePlatform !== undefined && SandboxManager.getConfig() !== undefined; -} - -/** - * Tear down the sandboxed exec and every process in its group. - * Child is spawned as a process-group leader (`detached: true` on Unix). - */ -export function killExecTree(child: ChildProcess | undefined): void { - if (!child) { - return; - } - const pid = child.pid; - if (pid !== undefined && process.platform !== 'win32') { - try { - process.kill(-pid, 'SIGKILL'); - return; - } catch { - // ESRCH if the group is already gone — fall through. - } - } - if (!child.killed) { - child.kill('SIGKILL'); - } -} - -/** - * Run one SRT-wrapped command. Code Mode UDS (if any) is supplied via `env.TFY_MCP_SOCK` - * from {@link CodeModeUdsTransport.start}. - */ -export async function runSupervisorSession(params: { - sandboxRootPath: string; - command: string; - /** Absolute shell path used to wrap the command string (from isSupported). */ - shell: string; - /** Platform policy for allowRead / PATH (from isSupported). */ - platform: LocalSandboxPlatform; - cwd?: string; - env?: Record; - /** Optional stdin bytes for the sandboxed command (e.g. upload payload). */ - stdin?: Buffer; - /** Host-visible pid of the sandboxed process-group leader (after spawn). */ - onChildSpawn?: (pid: number) => void; - /** Hard wall-clock limit for the sandboxed command; caller must choose deliberately. */ - timeoutMs: number; -}): Promise { - const { - sandboxRootPath, - command, - shell, - platform, - cwd = sandboxRootPath, - env, - stdin, - onChildSpawn, - timeoutMs, - } = params; - - const wrap = await SandboxManager.wrapWithSandboxArgv( - command, - shell, - { - filesystem: filesystemPolicy({ sandboxRootPath, platform }), - network: { - allowedDomains: [], - deniedDomains: [], - }, - }, - undefined, - sandboxRootPath, - { commandId: randomUUID(), commandText: command }, - ); - - const [argv0, ...argvRest] = wrap.argv; - if (argv0 === undefined) { - throw new Error('wrapWithSandboxArgv returned empty argv'); - } - - // Curated env only — do not spread wrap.env (it can carry ambient host secrets). - // Code Mode sock path (TFY_MCP_SOCK) is expected in `env` when the caller starts a transport. - const childEnv: NodeJS.ProcessEnv = { - ...commandEnv({ sandboxRootPath, platform, ...(env === undefined ? {} : { extra: env }) }), - }; - - const child = spawn(argv0, argvRest, { - cwd, - env: childEnv, - shell: false, - // Detached process groups break stdin forwarding for upload (`cat` via pipe) under Jest. - detached: stdin === undefined && process.platform !== 'win32', - stdio: [stdin === undefined ? 'ignore' : 'pipe', 'pipe', 'pipe'], - }); - if (child.pid !== undefined) { - onChildSpawn?.(child.pid); - } - if (stdin !== undefined) { - const stdinStream = child.stdin; - if (stdinStream === null) { - killExecTree(child); - SandboxManager.cleanupAfterCommand(); - throw new Error('stdin unavailable for sandboxed command'); - } - stdinStream.on('error', () => undefined); - await new Promise((resolve, reject) => { - stdinStream.end(stdin, (error?: Error | null) => { - if (error) { - reject(error); - } else { - resolve(); - } - }); - }); - } - - let stdoutText = ''; - let stderrText = ''; - let bufferedOutput = 0; - let protocolError: string | undefined; - let timedOut = false; - let closed = false; - - const ignoreStreamError = ( - stream: - | { - on: (event: 'error', cb: (err: Error) => void) => void; - } - | null - | undefined, - ): void => { - stream?.on('error', () => undefined); - }; - - const appendOutput = (stream: 'stdout' | 'stderr', chunk: Buffer): void => { - bufferedOutput += chunk.length; - if (bufferedOutput > MAX_OUTPUT_BYTES) { - protocolError = `buffered output exceeded ${String(MAX_OUTPUT_BYTES)} bytes`; - killExecTree(child); - return; - } - const text = chunk.toString('utf8'); - if (stream === 'stdout') { - stdoutText += text; - } else { - stderrText += text; - } - }; - - ignoreStreamError(child.stdout); - ignoreStreamError(child.stderr); - child.stdout?.on('data', (chunk: Buffer) => { - appendOutput('stdout', chunk); - }); - child.stderr?.on('data', (chunk: Buffer) => { - appendOutput('stderr', chunk); - }); - - return await new Promise((resolve, reject) => { - const timer = setTimeout(() => { - timedOut = true; - killExecTree(child); - }, timeoutMs); - - child.on('error', error => { - if (closed) { - return; - } - closed = true; - clearTimeout(timer); - SandboxManager.cleanupAfterCommand(); - reject(error); - }); - - child.on('close', code => { - if (closed) { - return; - } - closed = true; - clearTimeout(timer); - SandboxManager.cleanupAfterCommand(); - resolve({ - stdoutText, - stderrText, - exitCode: typeof code === 'number' ? code : timedOut ? 1 : 0, - protocolError, - timedOut, - childPid: child.pid, - }); - }); - }); -} diff --git a/packages/local-sandbox/src/index.ts b/packages/local-sandbox/src/index.ts deleted file mode 100644 index 145849cc5..000000000 --- a/packages/local-sandbox/src/index.ts +++ /dev/null @@ -1,5 +0,0 @@ -export { CodeModeUdsTransport, installMcpFixture, localMcpClientRemotePath } from './core/CodeModeUdsTransport.js'; -export type { CodeModeUdsTransportOptions } from './core/CodeModeUdsTransport.js'; -export type { LocalSandboxPlatform } from './core/hostRun.js'; -export { LocalSandboxProvider } from './provider/LocalSandboxProvider.js'; -export type { LocalSandboxProviderOptions, LocalSandboxSupportResult } from './provider/LocalSandboxProvider.js'; diff --git a/packages/local-sandbox/src/provider/LocalSandboxProvider.ts b/packages/local-sandbox/src/provider/LocalSandboxProvider.ts deleted file mode 100644 index 92db0445f..000000000 --- a/packages/local-sandbox/src/provider/LocalSandboxProvider.ts +++ /dev/null @@ -1,400 +0,0 @@ -/** - * Local SRT SandboxProvider. Code Mode UDS is handle-scoped via {@link CodeModeUdsTransport}. - */ -import type { - CodeModeTransport, - ExecResult, - SandboxBuild, - SandboxExecParams, - SandboxProvider, -} from '@truefoundry/trueforge-core/core'; -import { - SandboxFileNotFoundError, - SandboxFileTooLargeError, - SandboxPathIsDirectoryError, - shellEscape, - validateNoPathTraversal, -} from '@truefoundry/trueforge-core/core'; -import { mkdir, mkdtemp } from 'node:fs/promises'; -import { tmpdir } from 'node:os'; -import { isAbsolute, join, relative, resolve, sep } from 'node:path'; -import { ulid } from 'ulid'; -import { CodeModeUdsTransport, assertCodeModeSocketParentPath } from '../core/CodeModeUdsTransport.js'; -import { - createSandbox, - initSrt, - isSrtInitialized, - removeSandbox, - resetSrt, - resolveCommandOnHost, - runSupervisorSession, - type LocalSandboxPlatform, -} from '../core/hostRun.js'; -import { XferFileInfoSchema, type XferFileInfo } from '../schemas/xferFileInfo.js'; - -const DEFAULT_EXEC_TIMEOUT_SECONDS = 60; -const DEFAULT_FILE_MAX_BYTES = 10 * 1024 * 1024; -/** Cap for isSupported shell/Python probes (not general exec). */ -const SUPPORT_PROBE_TIMEOUT_MS = 5_000; - -/** Command names resolved via `command -v` (PATH from sandbox policy). */ -const SHELL_CANDIDATES = ['bash', 'sh'] as const; -const PYTHON_CANDIDATES = ['python3', 'python'] as const; - -export type { LocalSandboxPlatform }; - -export type LocalSandboxSupportResult = - | { supported: true; platform: LocalSandboxPlatform; shell: string; python: string } - | { supported: false; reason: string }; - -type LocalSandboxSupported = Extract; - -export interface LocalSandboxProviderOptions { - /** Absolute parent directory under which each createSandbox makes a ULID child root. */ - sandboxRootPathParent: string; - /** - * Absolute existing directory for Code Mode UDS (≤60 bytes, mode 0700). Caller owns its lifetime. - * Transport chmod's the parent to 0700 and each sock to 0600 after listen. - */ - codeModeSocketParentPath: string; - /** Result of {@link LocalSandboxProvider.isSupported}; must be `{ supported: true }`. */ - support: LocalSandboxSupportResult; - fileMaxBytesForDownload?: number | undefined; - defaultExecTimeoutSeconds?: number | undefined; -} - -/** Sandbox-relative path for sandboxed commands (avoids /var vs /private/var seatbelt mismatches). */ -function toSandboxRelativePath(params: { sandboxRootPath: string; absolutePath: string }): string { - const rel = relative(params.sandboxRootPath, params.absolutePath); - return rel === '' ? '.' : rel; -} - -export class LocalSandboxProvider implements SandboxProvider { - readonly type = 'local'; - private readonly sandboxRootPathParent: string; - private readonly codeModeSocketParentPath: string; - private readonly support: LocalSandboxSupported; - private readonly fileMaxBytesForDownload: number; - private readonly defaultExecTimeoutSeconds: number; - private srtInitialized = false; - - /** Local SRT has no image build step — always ready. */ - private static readonly readyBuild: SandboxBuild = { - status: 'ready', - reason: null, - metadata: null, - }; - - /** - * Probe whether this host can run LocalSandboxProvider (OS + in-sandbox shell + Python 3). - * On success, returns platform/shell/python to pass into the constructor as `support`. - */ - static async isSupported(): Promise { - if (process.platform !== 'darwin' && process.platform !== 'linux') { - return { - supported: false, - reason: `LocalSandboxProvider supports macOS and Linux only (got ${process.platform})`, - }; - } - const platform: LocalSandboxPlatform = process.platform; - - const alreadyInitialized = isSrtInitialized(); - let probeRoot: string | undefined; - - try { - if (!alreadyInitialized) { - await initSrt({ platform }); - } - - probeRoot = await createSandbox(await mkdtemp(join(tmpdir(), 'tfy-local-sandbox-support-'))); - - let shell: string | undefined; - for (const name of SHELL_CANDIDATES) { - const resolved = await resolveCommandOnHost({ platform, name }); - if (resolved === undefined) { - continue; - } - const probe = await runSupervisorSession({ - sandboxRootPath: probeRoot, - platform, - shell: resolved, - command: 'echo shell-ok', - timeoutMs: SUPPORT_PROBE_TIMEOUT_MS, - }); - if (probe.protocolError === undefined && probe.exitCode === 0 && probe.stdoutText.includes('shell-ok')) { - shell = resolved; - break; - } - } - if (shell === undefined) { - return { - supported: false, - reason: 'No usable shell in sandbox (bash or sh via command -v)', - }; - } - - let python: string | undefined; - for (const name of PYTHON_CANDIDATES) { - const resolved = await resolveCommandOnHost({ platform, name }); - if (resolved === undefined) { - continue; - } - const probe = await runSupervisorSession({ - sandboxRootPath: probeRoot, - platform, - shell, - command: `${shellEscape(resolved)} -c ${shellEscape( - 'import sys; raise SystemExit(0 if sys.version_info[0] == 3 else 1)', - )}`, - timeoutMs: SUPPORT_PROBE_TIMEOUT_MS, - }); - if (probe.protocolError === undefined && probe.exitCode === 0) { - python = resolved; - break; - } - } - if (python === undefined) { - return { - supported: false, - reason: 'No usable Python 3 interpreter in sandbox (python3 or python via command -v)', - }; - } - - return { supported: true, platform, shell, python }; - } catch (error) { - const message = error instanceof Error ? error.message : String(error); - return { supported: false, reason: message }; - } finally { - if (probeRoot !== undefined) { - await removeSandbox(probeRoot); - } - if (!alreadyInitialized) { - await resetSrt(); - } - } - } - - constructor(options: LocalSandboxProviderOptions) { - if (!options.support.supported) { - throw new Error(`LocalSandboxProvider is not supported: ${options.support.reason}`); - } - if (!isAbsolute(options.sandboxRootPathParent)) { - throw new Error('sandboxRootPathParent must be an absolute path'); - } - validateNoPathTraversal(options.sandboxRootPathParent); - this.sandboxRootPathParent = resolve(options.sandboxRootPathParent); - // Same validation as CodeModeUdsTransport (absolute, exists, ≤60 bytes, realpath). - this.codeModeSocketParentPath = assertCodeModeSocketParentPath(options.codeModeSocketParentPath); - this.support = options.support; - this.fileMaxBytesForDownload = options.fileMaxBytesForDownload ?? DEFAULT_FILE_MAX_BYTES; - this.defaultExecTimeoutSeconds = options.defaultExecTimeoutSeconds ?? DEFAULT_EXEC_TIMEOUT_SECONDS; - } - - private pythonC(code: string, relPath: string): string { - return `${this.support.python} -c ${shellEscape(code)} ${shellEscape(relPath)}`; - } - - private statCommand(relPath: string): string { - const code = [ - 'import json, os, sys', - 'p = sys.argv[1]', - 'st = os.stat(p)', - 'print(json.dumps({"size": st.st_size, "isDir": os.path.isdir(p)}))', - ].join('\n'); - return this.pythonC(code, relPath); - } - - private base64EncodeCommand(relPath: string): string { - const code = [ - 'import base64, sys', - 'p = sys.argv[1]', - 'sys.stdout.write(base64.b64encode(open(p, "rb").read()).decode("ascii"))', - ].join('\n'); - return this.pythonC(code, relPath); - } - - buildImage(): Promise { - return Promise.resolve(LocalSandboxProvider.readyBuild); - } - - getImageBuildStatus(): Promise { - return Promise.resolve(LocalSandboxProvider.readyBuild); - } - - private async ensureSrt(): Promise { - if (this.srtInitialized) { - return; - } - await initSrt({ platform: this.support.platform }); - this.srtInitialized = true; - } - - private resolveInSandboxRoot(sandboxRootPath: string, userPath: string): string { - validateNoPathTraversal(userPath); - const resolved = userPath.startsWith('/') ? resolve(userPath) : resolve(sandboxRootPath, userPath); - const root = resolve(sandboxRootPath); - if (resolved !== root && !resolved.startsWith(root + sep)) { - throw new SandboxFileNotFoundError(userPath); - } - return resolved; - } - - private async runSandboxCommand(params: { - sandboxRootPath: string; - command: string; - stdin?: Buffer; - }): Promise<{ exitCode: number; stdoutText: string; stderrText: string }> { - const session = await runSupervisorSession({ - sandboxRootPath: params.sandboxRootPath, - platform: this.support.platform, - shell: this.support.shell, - command: params.command, - ...(params.stdin === undefined ? {} : { stdin: params.stdin }), - timeoutMs: this.defaultExecTimeoutSeconds * 1000, - }); - if (session.protocolError !== undefined) { - throw new Error(session.protocolError); - } - return { - exitCode: session.exitCode, - stdoutText: session.stdoutText, - stderrText: session.stderrText, - }; - } - - private async getFileInfo(params: { - sandboxRootPath: string; - relPath: string; - userPath: string; - }): Promise { - const result = await this.runSandboxCommand({ - sandboxRootPath: params.sandboxRootPath, - command: this.statCommand(params.relPath), - }); - if (result.exitCode !== 0) { - throw new SandboxFileNotFoundError(params.userPath); - } - return XferFileInfoSchema.parse(JSON.parse(result.stdoutText.trim())); - } - - async createSandbox(): Promise<{ sandboxId: string }> { - await this.ensureSrt(); - const sandboxId = await createSandbox(join(this.sandboxRootPathParent, ulid().toLowerCase())); - await mkdir(join(sandboxId, 'tool-results'), { recursive: true, mode: 0o700 }); - await mkdir(join(sandboxId, 'uploads'), { recursive: true, mode: 0o700 }); - return { sandboxId }; - } - - async exec(params: SandboxExecParams): Promise { - try { - await this.ensureSrt(); - const sandboxRootPath = params.sandboxId; - const cwd = - params.cwd === undefined || params.cwd === '' - ? sandboxRootPath - : this.resolveInSandboxRoot(sandboxRootPath, params.cwd); - const timeoutSeconds = params.timeoutSeconds ?? this.defaultExecTimeoutSeconds; - const session = await runSupervisorSession({ - sandboxRootPath, - platform: this.support.platform, - shell: this.support.shell, - command: params.command, - cwd, - ...(params.env === undefined ? {} : { env: params.env }), - timeoutMs: timeoutSeconds * 1000, - }); - if (session.protocolError !== undefined) { - return { success: false, error: session.protocolError }; - } - const result = session.stdoutText + (session.stderrText ? session.stderrText : ''); - return { - success: true, - response: { exitCode: session.exitCode, result }, - }; - } catch (error) { - const message = error instanceof Error ? error.message : String(error); - return { success: false, error: message }; - } - } - - getAdditionalInstructions(): string { - return [ - 'SANDBOX RULES:', - `- Platform: ${this.support.platform}.`, - `- Commands run under the sandbox shell: ${this.support.shell}.`, - `- Python 3 is available as: ${this.support.python}. Prefer this binary for Python scripts.`, - "- The Agent's first sandbox command should be `pwd` to discover the working directory.", - '- ALL file creation and writes MUST stay within that working directory.', - '- The Agent must NOT write outside the working directory (including host home and /tmp).', - ].join('\n'); - } - - getToolResultDumpDir(sandboxId: string): string { - return join(sandboxId, 'tool-results'); - } - - getGitCredentialsPath(sandboxId: string): string { - return join(sandboxId, '.git-credentials'); - } - - async downloadFile(params: { sandboxId: string; path: string }): Promise { - await this.ensureSrt(); - const sandboxRootPath = params.sandboxId; - const absolutePath = this.resolveInSandboxRoot(sandboxRootPath, params.path); - const relPath = toSandboxRelativePath({ sandboxRootPath, absolutePath }); - const info = await this.getFileInfo({ sandboxRootPath, relPath, userPath: params.path }); - if (info.isDir) { - throw new SandboxPathIsDirectoryError(params.path); - } - if (info.size > this.fileMaxBytesForDownload) { - throw new SandboxFileTooLargeError(params.path, info.size, this.fileMaxBytesForDownload); - } - const result = await this.runSandboxCommand({ - sandboxRootPath, - command: this.base64EncodeCommand(relPath), - }); - if (result.exitCode !== 0) { - throw new SandboxFileNotFoundError(params.path); - } - const buf = Buffer.from(result.stdoutText.trim(), 'base64'); - if (buf.length > this.fileMaxBytesForDownload) { - throw new SandboxFileTooLargeError(params.path, buf.length, this.fileMaxBytesForDownload); - } - return buf; - } - - /** Payload on stdin so large uploads stay off argv. Parent dirs must already exist. */ - async uploadFile(params: { sandboxId: string; remotePath: string; content: Buffer }): Promise { - await this.ensureSrt(); - if (params.content.length > this.fileMaxBytesForDownload) { - throw new SandboxFileTooLargeError(params.remotePath, params.content.length, this.fileMaxBytesForDownload); - } - const sandboxRootPath = params.sandboxId; - // Resolve for traversal checks, but pass sandbox-relative paths to the shell. - // Absolute /var/folders/... paths lose quoting under SRT and become mkdir /var. - const absolutePath = this.resolveInSandboxRoot(sandboxRootPath, params.remotePath); - const remotePath = toSandboxRelativePath({ sandboxRootPath, absolutePath }); - const result = await this.runSandboxCommand({ - sandboxRootPath, - command: `cat > ${shellEscape(remotePath)}`, - stdin: params.content, - }); - if (result.exitCode !== 0) { - throw new SandboxFileNotFoundError(params.remotePath); - } - } - - createCodeModeTransport(): CodeModeTransport { - return new CodeModeUdsTransport({ - codeModeSocketParentPath: this.codeModeSocketParentPath, - }); - } - - /** Reset process-scoped SRT for this provider. */ - async dispose(): Promise { - if (this.srtInitialized) { - await resetSrt(); - this.srtInitialized = false; - } - } -} diff --git a/packages/local-sandbox/src/schemas/jsonMessage.ts b/packages/local-sandbox/src/schemas/jsonMessage.ts deleted file mode 100644 index 5d95b9e6d..000000000 --- a/packages/local-sandbox/src/schemas/jsonMessage.ts +++ /dev/null @@ -1,4 +0,0 @@ -/** Code Mode UDS JSON payload after JSON.parse (any JSON value). */ -import { z } from 'zod'; - -export const JsonMessageValueSchema = z.json(); diff --git a/packages/local-sandbox/src/schemas/xferFileInfo.ts b/packages/local-sandbox/src/schemas/xferFileInfo.ts deleted file mode 100644 index 18e50d463..000000000 --- a/packages/local-sandbox/src/schemas/xferFileInfo.ts +++ /dev/null @@ -1,8 +0,0 @@ -/** `stat` / xfer probe output from sandboxed python. */ -import { z } from 'zod'; - -export const XferFileInfoSchema = z.object({ - size: z.number(), - isDir: z.boolean(), -}); -export type XferFileInfo = z.infer; diff --git a/packages/local-sandbox/src/scripts/mcp_client_local.py b/packages/local-sandbox/src/scripts/mcp_client_local.py deleted file mode 100644 index 8c163b368..000000000 --- a/packages/local-sandbox/src/scripts/mcp_client_local.py +++ /dev/null @@ -1,277 +0,0 @@ -#!/usr/bin/env python3 -"""Code Mode UDS client — same public surface as product mcp_client.py, stdlib only. - -Authoritative reference (do not diverge on API/CLI/policy semantics): - packages/harness/src/core/sandbox/scripts/mcp_client.py - -Inlined into TypeScript via scripts/generate-sandbox-scripts.mjs (sandboxScripts.gen.ts), -same pattern as harness sandboxScripts. -""" - -from __future__ import annotations - -import argparse -import asyncio -import base64 -import json -import logging -import os -import socket -import sys -import time -from pathlib import Path -from typing import Any, TypedDict - -logger = logging.getLogger(__name__) - -MAX_MESSAGE_BYTES = 64 * 1024 * 1024 -_TOOLS_CACHE_TTL_SECONDS = 600 - - -class _ServerConfig(TypedDict): - allowed_tools: list[str] - - -_inflight_list_tools: dict[str, asyncio.Task[list[dict[str, Any]]]] = {} - -_raw_servers = os.environ.get("TFY_MCP_SERVERS") -_servers_map: dict[str, _ServerConfig] = ( - json.loads(base64.b64decode(_raw_servers).decode()) if _raw_servers else {} -) -_enable_agent_approvals = os.environ.get("TFY_ENABLE_AGENT_APPROVALS", "true").lower() == "true" - - -def _sock_path() -> str: - path = os.environ.get("TFY_MCP_SOCK") - if not path: - raise RuntimeError("TFY_MCP_SOCK is not set") - return path - - -def _request_timeout() -> float: - return float(os.environ.get("TFY_CM_REQUEST_TIMEOUT_SECONDS", "60")) - - -def _check_tool_allowed(server: str, tool_name: str) -> None: - server_config = _servers_map.get(server) - if server_config is None: - raise RuntimeError(f"Access denied: MCP server '{server}' is not available for this agent") from None - server_tools = server_config.get("allowed_tools") or [] - if len(server_tools) > 0 and tool_name not in server_tools: - raise RuntimeError(f"Access denied: tool '{tool_name}' is not enabled on server '{server}'") from None - - -def _cache_path(server: str) -> Path: - return Path(__file__).parent / f"{server}.tools.json" - - -def _read_tools_cache(server: str) -> list[dict[str, Any]] | None: - p = _cache_path(server) - if not p.exists(): - return None - try: - raw = json.loads(p.read_text(encoding="utf-8")) - if not isinstance(raw, dict): - raise ValueError("cache root must be object") - fetched_at = raw.get("fetched_at") - tools = raw.get("tools") - if not isinstance(fetched_at, (int, float)) or not isinstance(tools, list): - raise ValueError("cache shape invalid") - if time.time() - float(fetched_at) > _TOOLS_CACHE_TTL_SECONDS: - p.unlink(missing_ok=True) - return None - return [t for t in tools if isinstance(t, dict)] - except Exception: - logger.exception("_read_tools_cache") - p.unlink(missing_ok=True) - return None - - -def _write_tools_cache(server: str, tools: list[dict[str, Any]]) -> None: - try: - payload = {"fetched_at": time.time(), "tools": tools} - _cache_path(server).write_text(json.dumps(payload), encoding="utf-8") - except Exception: - logger.exception("_write_tools_cache") - - -def _read_message(sock: socket.socket) -> Any: - body = b"" - while True: - chunk = sock.recv(65536) - if not chunk: - break - body += chunk - if len(body) > MAX_MESSAGE_BYTES: - raise RuntimeError(f"message exceeds max {MAX_MESSAGE_BYTES} bytes") - return json.loads(body.decode("utf-8")) - - -def _write_message(sock: socket.socket, value: Any) -> None: - sock.sendall(json.dumps(value).encode("utf-8")) - - -def _uds_request_sync(payload: dict[str, Any]) -> Any: - """Connect → JSON request → write-close → JSON reply (no request_id).""" - sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) - try: - sock.settimeout(_request_timeout()) - sock.connect(_sock_path()) - _write_message(sock, payload) - sock.shutdown(socket.SHUT_WR) - reply = _read_message(sock) - finally: - sock.close() - if not isinstance(reply, dict): - raise RuntimeError(f"Code Mode reply is not an object: {reply!r}") - ok = reply.get("ok") - if ok is not True: - source = reply.get("source", "internal") - error = reply.get("error", "unknown error") - if source == "caller": - raise RuntimeError(f"Invalid MCP request: {error}") - if source == "transport": - raise RuntimeError(f"Code Mode transport error: {error}") - raise RuntimeError(f"Internal MCP error: {error}") - return reply.get("result") - - -async def _uds_request(payload: dict[str, Any]) -> Any: - return await asyncio.to_thread(_uds_request_sync, payload) - - -async def _fetch_tools(server: str) -> list[dict[str, Any]]: - result = await _uds_request({"op": "list_tools", "server": server}) - if not isinstance(result, dict): - raise RuntimeError(f"list_tools '{server}' returned unexpected shape: {result!r}") from None - tools = result.get("tools", []) - if not isinstance(tools, list): - raise RuntimeError(f"list_tools '{server}' tools is not a list: {tools!r}") from None - return [t for t in tools if isinstance(t, dict)] - - -async def _fetch_and_cache_tools(server: str) -> list[dict[str, Any]]: - tools = await _fetch_tools(server) - _write_tools_cache(server, tools) - return tools - - -async def _get_tools(server: str) -> list[dict[str, Any]]: - cached = _read_tools_cache(server) - if cached is not None: - return cached - task = _inflight_list_tools.get(server) - if task is None: - task = asyncio.create_task(_fetch_and_cache_tools(server)) - _inflight_list_tools[server] = task - task.add_done_callback(lambda _t: _inflight_list_tools.pop(server, None)) - return await task - - -async def _get_tool(server: str, tool_name: str) -> dict[str, Any] | None: - for t in await _get_tools(server): - if t.get("name") == tool_name: - return t - return None - - -def _is_destructive(tool: dict[str, Any]) -> bool: - annotations = tool.get("annotations") - if annotations is None: - return False - if not isinstance(annotations, dict): - return False - destructive = annotations.get("destructiveHint") - read_only = annotations.get("readOnlyHint") - return bool(destructive) or (not read_only and read_only is not None) - - -async def _ensure_non_destructive(server: str, tool_name: str) -> None: - tool = await _get_tool(server, tool_name) - if tool is None: - raise RuntimeError(f"Tool '{tool_name}' not found on MCP server '{server}'") from None - if _is_destructive(tool): - raise RuntimeError( - f"Tool '{tool_name}' on MCP server '{server}' is destructive and cannot be called in Code Mode; " - f"call it directly so it can go through the user approval flow" - ) from None - - -def _project_call_tool_result(server: str, tool: str, result: Any) -> Any: - """Project an MCP-wire CallToolResult-shaped object into the user-facing Python value.""" - if not isinstance(result, dict): - raise RuntimeError(f"call_tool reply for '{server}/{tool}' is malformed: expected object") from None - - if result.get("isError"): - content = result.get("content") - text_parts: list[str] = [] - if isinstance(content, list): - for c in content: - if isinstance(c, dict) and c.get("type") == "text": - text = c.get("text") - if isinstance(text, str) and text: - text_parts.append(text) - msg = "; ".join(text_parts) if text_parts else "tool returned an error" - raise RuntimeError(f"MCP tool error (server={server}, tool={tool}): {msg}") from None - - if result.get("structuredContent") is not None: - return result["structuredContent"] - - content = result.get("content") - if isinstance(content, list) and len(content) == 1: - first = content[0] - if isinstance(first, dict) and first.get("type") == "text": - text = first.get("text") - if isinstance(text, str) and text: - try: - return json.loads(text) - except Exception: - pass - if isinstance(content, list) and content: - return content - return None - - -async def call_tool(server: str, tool: str, body: dict[str, Any]) -> Any: - _check_tool_allowed(server, tool) - if _enable_agent_approvals: - await _ensure_non_destructive(server, tool) - - raw = await _uds_request( - { - "op": "call_tool", - "server": server, - "tool": tool, - "arguments": body, - }, - ) - return _project_call_tool_result(server, tool, raw) - - -_USAGE = "mcp_client_local.py call-tool " - - -def _build_arg_parser() -> argparse.ArgumentParser: - parser = argparse.ArgumentParser(prog="mcp_client_local.py", usage=_USAGE) - sub = parser.add_subparsers(dest="cmd", required=True) - - call_tool_p = sub.add_parser("call-tool", help="Invoke an MCP tool") - call_tool_p.add_argument("server") - call_tool_p.add_argument("tool") - call_tool_p.add_argument("args_json", metavar="args-json", type=json.loads) - - return parser - - -async def _main() -> None: - args = _build_arg_parser().parse_args() - try: - if args.cmd == "call-tool": - result = await call_tool(args.server, args.tool, args.args_json) - print(json.dumps(result, default=str)) - except RuntimeError as e: - sys.exit(str(e)) - - -if __name__ == "__main__": - asyncio.run(_main()) diff --git a/packages/local-sandbox/test/codeModeUdsTransport.contract.test.ts b/packages/local-sandbox/test/codeModeUdsTransport.contract.test.ts deleted file mode 100644 index 1d269c924..000000000 --- a/packages/local-sandbox/test/codeModeUdsTransport.contract.test.ts +++ /dev/null @@ -1,149 +0,0 @@ -/** - * Node UDS binder for the harness Code Mode transport contract suite. - * Lives in local-sandbox only — no product/server import of this package. - */ -import { - CodeModeDispatcher, - CodeModeReplySchema, - type CodeModeReply, - type CodeModeRequest, - type IToolSet, -} from '@truefoundry/trueforge-core/core'; -import { mkdir } from 'node:fs/promises'; -import { createConnection } from 'node:net'; -import { tmpdir } from 'node:os'; -import { join } from 'node:path'; -import { - runCodeModeTransportContractSuite, - type CodeModeTransportContractFixture, -} from '../../trueforge-core/tests/core/sandbox/codeMode/codeModeTransportContractSuite'; -import { CodeModeUdsTransport } from '../src/core/CodeModeUdsTransport.js'; -import { encodeJsonMessage, JsonMessageReader, MAX_MESSAGE_BYTES } from '../src/core/frame.js'; - -function makeSilentLogger() { - const logger = { - error: () => undefined, - child: () => logger, - }; - return logger; -} - -function makeDemoToolSet(): IToolSet { - return { - name: 'demo', - id: 'demo', - preload: true, - hasPreloadedTools: true, - listTools: () => - Promise.resolve({ - result: { - tools: [ - { - name: 'ping', - description: 'ping', - inputSchema: { type: 'object' as const, properties: {} }, - preload: true, - }, - ], - }, - wasInitialized: undefined, - }), - callTool: () => - Promise.resolve({ - result: { content: [{ type: 'text' as const, text: 'ok' }], isError: false }, - wasInitialized: undefined, - }), - toolCallInfo: () => undefined, - }; -} - -function resolveSockPath(env: Record): string { - const sock = env['TFY_MCP_SOCK']; - if (sock === undefined || sock === '') { - throw new Error('TFY_MCP_SOCK missing from transport env'); - } - return sock; -} - -function sendUdsRequest(params: { env: Record; request: CodeModeRequest }): Promise { - const path = resolveSockPath(params.env); - const timeoutSeconds = Number(params.env['TFY_CM_REQUEST_TIMEOUT_SECONDS'] ?? '30'); - const timeoutMs = Number.isFinite(timeoutSeconds) ? timeoutSeconds * 1000 : 30_000; - - return new Promise((resolve, reject) => { - const socket = createConnection({ path, allowHalfOpen: true }); - const reader = new JsonMessageReader({ maxBytes: MAX_MESSAGE_BYTES }); - let settled = false; - - const finish = (error: Error | undefined, reply?: CodeModeReply): void => { - if (settled) return; - settled = true; - clearTimeout(timer); - socket.destroy(); - if (error) reject(error); - else if (reply !== undefined) resolve(reply); - else reject(new Error('missing reply')); - }; - - const timer = setTimeout(() => { - finish(new Error(`UDS request timed out after ${String(timeoutMs)}ms`)); - }, timeoutMs); - - socket.on('error', error => { - finish(error); - }); - socket.on('data', (chunk: Buffer) => { - try { - reader.push(chunk); - } catch (error) { - finish(error instanceof Error ? error : new Error(String(error))); - } - }); - socket.on('end', () => { - try { - const parsed = CodeModeReplySchema.safeParse(reader.finish()); - if (!parsed.success) { - finish(new Error('malformed Code Mode reply')); - return; - } - finish(undefined, parsed.data); - } catch (error) { - finish(error instanceof Error ? error : new Error(String(error))); - } - }); - socket.on('connect', () => { - const body = encodeJsonMessage(params.request); - socket.write(body, writeErr => { - if (writeErr) { - finish(writeErr); - return; - } - socket.end(); - }); - }); - }); -} - -runCodeModeTransportContractSuite(async (): Promise => { - const codeModeSocketParentPath = join(tmpdir(), 'cm'); - await mkdir(codeModeSocketParentPath, { recursive: true, mode: 0o700 }); - const transport = new CodeModeUdsTransport({ - codeModeSocketParentPath, - }); - const dispatcher = new CodeModeDispatcher({ - toolSets: [makeDemoToolSet()], - logger: makeSilentLogger(), - }); - - return { - transport, - dispatcher, - sandboxId: 'contract-sandbox', - requestTimeoutSeconds: 30, - sendRequest: ({ env, request }) => sendUdsRequest({ env, request }), - dispose: async () => { - dispatcher.close(); - await transport.stop(); - }, - }; -}); diff --git a/packages/local-sandbox/test/provider/contract.test.ts b/packages/local-sandbox/test/provider/contract.test.ts deleted file mode 100644 index 4b0f55944..000000000 --- a/packages/local-sandbox/test/provider/contract.test.ts +++ /dev/null @@ -1,30 +0,0 @@ -import { mkdir, mkdtemp, rm } from 'node:fs/promises'; -import { tmpdir } from 'node:os'; -import { join } from 'node:path'; -import { runSandboxProviderContractSuite } from '../../../trueforge-core/tests/core/sandbox/provider/sandboxProviderContractSuite'; -import { LocalSandboxProvider } from '../../src/provider/LocalSandboxProvider'; - -describe('LocalSandboxProvider (SandboxProvider contract)', () => { - runSandboxProviderContractSuite(async () => { - const support = await LocalSandboxProvider.isSupported(); - if (!support.supported) { - throw new Error(support.reason); - } - const sandboxRootPathParent = await mkdtemp(join(tmpdir(), 'tfy-local-sandbox-contract-')); - // Short path: macOS tmpdir ~48 bytes; keep parent ≤60 for Code Mode UDS. - const codeModeSocketParentPath = join(tmpdir(), 'cm'); - await mkdir(codeModeSocketParentPath, { recursive: true, mode: 0o700 }); - const provider = new LocalSandboxProvider({ - sandboxRootPathParent, - codeModeSocketParentPath, - support, - }); - return { - provider, - dispose: async () => { - await provider.dispose(); - await rm(sandboxRootPathParent, { recursive: true, force: true }); - }, - }; - }); -}); diff --git a/packages/local-sandbox/test/smoke.test.ts b/packages/local-sandbox/test/smoke.test.ts deleted file mode 100644 index e32276246..000000000 --- a/packages/local-sandbox/test/smoke.test.ts +++ /dev/null @@ -1,1975 +0,0 @@ -/** - * LocalSandboxProvider smoke (macOS host or Linux via Lima) - * plus Code Mode UDS and security probes. Run via `pnpm smoke`. - */ -import { getDefaultWritePaths, SandboxManager } from '@anthropic-ai/sandbox-runtime'; -import { CodeModeDispatcher, type IToolSet } from '@truefoundry/trueforge-core/core'; -import assert from 'node:assert/strict'; -import { spawn } from 'node:child_process'; -import { randomUUID } from 'node:crypto'; -import { access, mkdir, mkdtemp, readFile, realpath, rm, stat, unlink, writeFile } from 'node:fs/promises'; -import { createRequire } from 'node:module'; -import { createServer } from 'node:net'; -import { tmpdir } from 'node:os'; -import { dirname, join } from 'node:path'; -import { fileURLToPath } from 'node:url'; -import { ulid } from 'ulid'; -import { CodeModeUdsTransport, installMcpFixture } from '../src/core/CodeModeUdsTransport.js'; -import { - commandPath, - createSandbox, - MAX_OUTPUT_BYTES, - platformAllowRead, - registerCodeModeSocketPath, - removeSandbox, - runSupervisorSession, - unregisterCodeModeSocketPath, -} from '../src/core/hostRun.js'; -import { LocalSandboxProvider } from '../src/provider/LocalSandboxProvider.js'; - -const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..'); -const SANDBOXES = join(ROOT, 'sandboxes'); -const DENY_READ_SECRET = join(SANDBOXES, '.poc-deny-read-secret'); -const DEFAULT_TMP_CLAUDE = '/tmp/claude'; -const DELETE_TARGET = join(DEFAULT_TMP_CLAUDE, 'poc-delete-target.txt'); -const SECRET_CONTENTS = 'host-secret-should-not-leak\n'; -const HOST_HOME = process.env['HOME']; -const ENV_LEAK_MARKER = 'TFY_SMOKE_HOST_SECRET'; -const ENV_LEAK_VALUE = 'host-env-must-not-reach-sandbox'; -const ENV_INHERIT_MARKER = 'TFY_SMOKE_INHERIT'; -const ENV_INHERIT_VALUE = `inherit-${randomUUID()}`; -const ENV_PEER_MARKER = 'TFY_SMOKE_PEER_ENV'; -const ENV_PEER_VALUE = `peer-secret-${randomUUID()}`; -/** Product-shaped allowlist for smoke Code Mode client (demo/ping only). */ -const TFY_MCP_SERVERS_DEMO = Buffer.from(JSON.stringify({ demo: { allowed_tools: ['ping'] } }), 'utf8').toString( - 'base64', -); -// Package-root resolve: Jest's CJS transform breaks import.meta.resolve. -const SRT_VENDOR = join( - dirname(createRequire(import.meta.url).resolve('@anthropic-ai/sandbox-runtime/package.json')), - 'vendor', -); -async function prepareHostProbeFiles(): Promise { - await mkdir(DEFAULT_TMP_CLAUDE, { recursive: true, mode: 0o700 }); - await mkdir(SANDBOXES, { recursive: true, mode: 0o700 }); - await writeFile(DELETE_TARGET, 'delete-me\n', { mode: 0o600 }); - await writeFile(DENY_READ_SECRET, SECRET_CONTENTS, { mode: 0o600 }); -} - -async function cleanupHostProbeFiles(): Promise { - await rm(DELETE_TARGET, { force: true }); - await rm(DENY_READ_SECRET, { force: true }); -} - -function sleep(ms: number): Promise { - return new Promise(resolve => { - setTimeout(resolve, ms); - }); -} - -/** - * Direct check: after initSrt, registerCodeModeSocketPath → updateConfig must - * allow a sandboxed client to connect to that exact sock (and unregister revoke it). - */ -async function smokeLiveSrtUnixSocketAllowlistUpdate(params: { - sandboxRootPath: string; - codeModeSocketParentPath: string; - shell: string; - platform: 'darwin' | 'linux'; -}): Promise { - const parent = await realpath(params.codeModeSocketParentPath); - const sockPath = join(parent, ulid().toLowerCase()); - await unlink(sockPath).catch(() => undefined); - - const server = createServer(); - await new Promise((resolve, reject) => { - server.once('error', reject); - server.listen(sockPath, () => { - server.off('error', reject); - resolve(); - }); - }); - server.on('connection', socket => { - socket.end(); - }); - - const connectCmd = [ - "python3 - <<'PY'", - 'import socket, sys', - `path = ${JSON.stringify(sockPath)}`, - 's = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)', - 'try:', - ' s.connect(path)', - ' print("connected")', - 'except OSError as e:', - ' print(type(e).__name__, e, file=sys.stderr)', - ' sys.exit(1)', - 'finally:', - ' s.close()', - 'PY', - ].join('\n'); - - try { - const before = await runSupervisorSession({ - sandboxRootPath: params.sandboxRootPath, - shell: params.shell, - platform: params.platform, - command: connectCmd, - timeoutMs: 10_000, - }); - assert.notEqual(before.exitCode, 0, 'connect must fail before register/updateConfig'); - assert.ok(!before.stdoutText.includes('connected')); - - registerCodeModeSocketPath(sockPath); - - const afterRegister = await runSupervisorSession({ - sandboxRootPath: params.sandboxRootPath, - shell: params.shell, - platform: params.platform, - command: connectCmd, - timeoutMs: 10_000, - }); - assert.equal(afterRegister.exitCode, 0, afterRegister.stderrText); - assert.match(afterRegister.stdoutText, /connected/); - - unregisterCodeModeSocketPath(sockPath); - - const afterUnregister = await runSupervisorSession({ - sandboxRootPath: params.sandboxRootPath, - shell: params.shell, - platform: params.platform, - command: connectCmd, - timeoutMs: 10_000, - }); - assert.notEqual(afterUnregister.exitCode, 0, 'connect must fail after unregister/updateConfig'); - assert.ok(!afterUnregister.stdoutText.includes('connected')); - - console.log('ok: live SRT updateConfig allowlists exact Code Mode sock (register/unregister)'); - } finally { - unregisterCodeModeSocketPath(sockPath); - await new Promise(resolve => { - server.close(() => { - resolve(); - }); - }); - await unlink(sockPath).catch(() => undefined); - } -} - -function pidAlive(pid: number): boolean { - try { - process.kill(pid, 0); - return true; - } catch { - return false; - } -} - -function makeSilentCodeModeLogger() { - const logger = { - error: () => undefined, - child: () => logger, - }; - return logger; -} - -function makeDemoToolSet(params: { onRequest?: () => void }): IToolSet { - return { - name: 'demo', - id: 'demo', - preload: true, - hasPreloadedTools: true, - listTools: () => { - params.onRequest?.(); - return Promise.resolve({ - result: { - tools: [ - { - name: 'ping', - description: 'ping', - inputSchema: { type: 'object' as const, properties: {} }, - preload: true, - }, - ], - }, - wasInitialized: undefined, - }); - }, - callTool: async request => { - params.onRequest?.(); - const args = request.arguments ?? {}; - const delayRaw = args['delay_ms']; - const delayMs = typeof delayRaw === 'number' && Number.isFinite(delayRaw) ? delayRaw : 0; - if (delayMs > 0) await sleep(delayMs); - return { - result: { - content: [{ type: 'text' as const, text: JSON.stringify({ echo: args }) }], - isError: false, - }, - wasInitialized: undefined, - }; - }, - toolCallInfo: () => undefined, - }; -} - -async function withCodeModeTransport(params: { - codeModeSocketParentPath: string; - sandboxRootPath: string; - maxMessageBytes?: number; - onProtocolError?: (message: string) => void; - onRequest?: () => void; - run: (env: Record) => Promise; -}): Promise { - const transport = new CodeModeUdsTransport({ - codeModeSocketParentPath: params.codeModeSocketParentPath, - ...(params.maxMessageBytes === undefined ? {} : { maxMessageBytes: params.maxMessageBytes }), - ...(params.onProtocolError === undefined ? {} : { onProtocolError: params.onProtocolError }), - }); - const dispatcher = new CodeModeDispatcher({ - toolSets: [makeDemoToolSet({ onRequest: params.onRequest })], - logger: makeSilentCodeModeLogger(), - }); - const install = transport.getClientInstall({ sandboxId: params.sandboxRootPath }); - try { - const { env } = await transport.start({ - codeModeDispatcher: dispatcher, - sandboxId: params.sandboxRootPath, - requestTimeoutSeconds: 60, - }); - await params.run({ - ...env, - PYTHONPATH: dirname(install.remotePath), - TFY_MCP_SERVERS: TFY_MCP_SERVERS_DEMO, - TFY_ENABLE_AGENT_APPROVALS: 'true', - }); - } finally { - dispatcher.close(); - await transport.stop(); - } -} - -async function smokeCodeMode(params: { - sandboxRootPath: string; - codeModeSocketParentPath: string; - shell: string; - platform: 'darwin' | 'linux'; -}): Promise { - await installMcpFixture(params.sandboxRootPath); - let toolRequests = 0; - - await withCodeModeTransport({ - codeModeSocketParentPath: params.codeModeSocketParentPath, - sandboxRootPath: params.sandboxRootPath, - onRequest: () => { - toolRequests += 1; - }, - run: async env => { - const sockPath = env['TFY_MCP_SOCK']; - assert.ok(sockPath !== undefined && sockPath.length > 0); - const sockStat = await stat(sockPath); - assert.equal(sockStat.mode & 0o777, 0o600, `Code Mode sock must be 0600: ${sockPath}`); - const parentStat = await stat(params.codeModeSocketParentPath); - assert.equal( - parentStat.mode & 0o777, - 0o700, - `Code Mode sock parent must be 0700: ${params.codeModeSocketParentPath}`, - ); - console.log('ok: Code Mode UDS parent 0700 + sock 0600'); - - const call = await runSupervisorSession({ - sandboxRootPath: params.sandboxRootPath, - shell: params.shell, - platform: params.platform, - command: `mcp-client call-tool demo ping '${JSON.stringify({ message: 'poc' })}'`, - env, - timeoutMs: 15_000, - }); - assert.equal(call.protocolError, undefined, call.protocolError); - assert.equal(call.exitCode, 0, call.stderrText); - const callJson: unknown = JSON.parse(call.stdoutText.trim().split(/\r?\n/).filter(Boolean).at(-1) ?? ''); - assert.ok(callJson !== null && typeof callJson === 'object'); - assert.ok('echo' in callJson); - console.log('ok: Code Mode call-tool (UDS)'); - }, - }); - - const oversizeCap = 1024; - let oversizeError: string | undefined; - await withCodeModeTransport({ - codeModeSocketParentPath: params.codeModeSocketParentPath, - sandboxRootPath: params.sandboxRootPath, - maxMessageBytes: oversizeCap, - onProtocolError: message => { - oversizeError = message; - }, - run: async env => { - const oversize = await runSupervisorSession({ - sandboxRootPath: params.sandboxRootPath, - shell: params.shell, - platform: params.platform, - command: [ - "python3 - <<'PY'", - 'import os, socket, time', - 'path = os.environ["TFY_MCP_SOCK"]', - 's = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)', - 's.connect(path)', - `s.sendall(b"x" * ${String(oversizeCap + 1)})`, - 's.shutdown(socket.SHUT_WR)', - 'time.sleep(1)', - 'PY', - ].join('\n'), - env, - timeoutMs: 10_000, - }); - assert.equal(oversize.exitCode, 0, oversize.stderrText); - assert.match(String(oversizeError), /exceeds max/); - console.log('ok: Code Mode oversized message is terminal'); - }, - }); - - let badJsonError: string | undefined; - await withCodeModeTransport({ - codeModeSocketParentPath: params.codeModeSocketParentPath, - sandboxRootPath: params.sandboxRootPath, - onProtocolError: message => { - badJsonError = message; - }, - run: async env => { - const badJson = await runSupervisorSession({ - sandboxRootPath: params.sandboxRootPath, - shell: params.shell, - platform: params.platform, - command: [ - "python3 - <<'PY'", - 'import os, socket, time', - 'path = os.environ["TFY_MCP_SOCK"]', - 's = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)', - 's.connect(path)', - 's.sendall(b"not")', - 's.shutdown(socket.SHUT_WR)', - 'time.sleep(1)', - 'PY', - ].join('\n'), - env, - timeoutMs: 10_000, - }); - assert.equal(badJson.exitCode, 0, badJson.stderrText); - assert.match(String(badJsonError), /invalid JSON message/); - console.log('ok: Code Mode malformed JSON message is terminal'); - }, - }); - - await withCodeModeTransport({ - codeModeSocketParentPath: params.codeModeSocketParentPath, - sandboxRootPath: params.sandboxRootPath, - onRequest: () => { - toolRequests += 1; - }, - run: async env => { - const multiplex = await runSupervisorSession({ - sandboxRootPath: params.sandboxRootPath, - shell: params.shell, - platform: params.platform, - command: [ - "python3 - <<'PY'", - 'import asyncio, json, time', - 'from mcp_client import call_tool', - 'async def main():', - ' started = time.monotonic()', - ' results = await asyncio.gather(', - ' call_tool("demo", "ping", {"message": "m0", "delay_ms": 150}),', - ' call_tool("demo", "ping", {"message": "m1", "delay_ms": 150}),', - ' )', - ' print("multiplex-ok", int((time.monotonic() - started) * 1000), json.dumps(results))', - 'asyncio.run(main())', - 'PY', - ].join('\n'), - env, - timeoutMs: 15_000, - }); - assert.equal(multiplex.protocolError, undefined, multiplex.protocolError); - assert.equal(multiplex.exitCode, 0, multiplex.stderrText); - const multiplexMatch = /multiplex-ok (\d+)/.exec(multiplex.stdoutText); - assert.ok(multiplexMatch, multiplex.stdoutText); - const multiplexMs = Number(multiplexMatch[1]); - assert.ok(multiplexMs < 280, `multiplex looked serial: gather ${String(multiplexMs)}ms (expected < 280ms)`); - console.log('ok: Code Mode concurrent UDS multiplex', `${String(multiplexMs)}ms`); - }, - }); - - const beforeMissing = toolRequests; - await withCodeModeTransport({ - codeModeSocketParentPath: params.codeModeSocketParentPath, - sandboxRootPath: params.sandboxRootPath, - onRequest: () => { - toolRequests += 1; - }, - run: async env => { - const missingSock = await runSupervisorSession({ - sandboxRootPath: params.sandboxRootPath, - shell: params.shell, - platform: params.platform, - command: [ - 'set -euo pipefail', - 'unset TFY_MCP_SOCK', - `if mcp-client call-tool demo ping '${JSON.stringify({ message: 'x' })}'; then`, - ' echo "expected missing-sock failure" >&2', - ' exit 1', - 'fi', - 'echo ok-missing-sock', - ].join('\n'), - env, - timeoutMs: 10_000, - }); - assert.equal(missingSock.exitCode, 0, missingSock.stderrText); - assert.match(missingSock.stdoutText, /ok-missing-sock/); - assert.equal(toolRequests, beforeMissing, 'missing sock must not deliver tool requests'); - console.log('ok: Code Mode requires TFY_MCP_SOCK'); - }, - }); - - let hostInjected = 0; - let holdPid: number | undefined; - await withCodeModeTransport({ - codeModeSocketParentPath: params.codeModeSocketParentPath, - sandboxRootPath: params.sandboxRootPath, - onRequest: () => { - hostInjected += 1; - }, - run: async env => { - const holdSession = runSupervisorSession({ - sandboxRootPath: params.sandboxRootPath, - shell: params.shell, - platform: params.platform, - command: [ - 'set -euo pipefail', - "python3 - <<'PY'", - 'import os, time', - 'open(".uds-ready", "w").write(os.environ["TFY_MCP_SOCK"] + "\\n")', - 'time.sleep(60)', - 'PY', - ].join('\n'), - env, - onChildSpawn: pid => { - holdPid = pid; - }, - timeoutMs: 15_000, - }); - let sockFromSandbox = ''; - for (let i = 0; i < 80 && sockFromSandbox === ''; i++) { - try { - sockFromSandbox = (await readFile(join(params.sandboxRootPath, '.uds-ready'), 'utf8')).trim(); - } catch { - await sleep(50); - } - } - assert.match(sockFromSandbox, /^\//, 'sandbox never published absolute TFY_MCP_SOCK'); - const hostSockPath = sockFromSandbox.startsWith('/') - ? sockFromSandbox - : join(params.sandboxRootPath, sockFromSandbox); - const hostConnect = await new Promise<{ code: number | null; err: string }>((resolve, reject) => { - const child = spawn( - 'python3', - [ - '-c', - [ - 'import os, socket, sys, json', - 'path = sys.argv[1]', - 'req = {"op":"list_tools","server":"demo"}', - 'body = json.dumps(req).encode()', - 's = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)', - 'if len(path.encode()) >= 104:', - ' os.chdir(os.path.dirname(path))', - ' path = os.path.basename(path)', - 's.connect(path)', - 's.sendall(body)', - 's.shutdown(socket.SHUT_WR)', - 'chunks = []', - 'while True:', - ' c = s.recv(65536)', - ' if not c: break', - ' chunks.append(c)', - 'print(b"".join(chunks).decode())', - ].join('\n'), - hostSockPath, - ], - { stdio: ['ignore', 'pipe', 'pipe'] }, - ); - let err = ''; - child.stderr?.on('data', (c: Buffer) => { - err += c.toString('utf8'); - }); - child.on('error', reject); - child.on('close', code => resolve({ code, err })); - }); - assert.equal(hostConnect.code, 0, hostConnect.err); - for (let i = 0; i < 50 && hostInjected === 0; i++) { - await sleep(50); - } - assert.equal(hostInjected, 1, 'same-UID host connect to Code Mode UDS must work'); - console.log('ok: same-UID host can connect to Code Mode UDS (expected for path UDS)'); - if (holdPid !== undefined) { - try { - process.kill(-holdPid, 'SIGKILL'); - } catch { - try { - process.kill(holdPid, 'SIGKILL'); - } catch { - // already gone - } - } - } - await holdSession; - }, - }); -} - -/** - * Prove Unix env inheritance with no explicit env= copying: - * 1) curated exec env → python child → python grandchild - * 2) bash `cmd1 & cmd2` — both jobs are shell children and must see the marker - */ -async function smokeEnvInheritance(provider: LocalSandboxProvider, sandboxId: string): Promise { - const pyResult = await provider.exec({ - sandboxId, - env: { [ENV_INHERIT_MARKER]: ENV_INHERIT_VALUE }, - command: [ - "python3 - <<'PY'", - 'import os, subprocess, sys', - `marker = ${JSON.stringify(ENV_INHERIT_MARKER)}`, - `expected = ${JSON.stringify(ENV_INHERIT_VALUE)}`, - 'child_val = os.environ.get(marker)', - 'if child_val != expected:', - ' print(f"child-missing:{child_val!r}", file=sys.stderr)', - ' raise SystemExit(1)', - '# Grandchild: subprocess with default env inheritance (no env= override).', - 'grand = subprocess.run(', - ' [sys.executable, "-c", f"import os; print(os.environ[{marker!r}])"],', - ' check=True,', - ' capture_output=True,', - ' text=True,', - ')', - 'got = grand.stdout.strip()', - 'if got != expected:', - ' print(f"grandchild-missing:{got!r}", file=sys.stderr)', - ' raise SystemExit(1)', - 'print("env-inherit-ok", expected)', - 'PY', - ].join('\n'), - }); - assert.equal(pyResult.success, true, JSON.stringify(pyResult)); - if (!pyResult.success) throw new Error('unreachable'); - assert.equal(pyResult.response.exitCode, 0, pyResult.response.result); - assert.match(pyResult.response.result, new RegExp(`env-inherit-ok ${ENV_INHERIT_VALUE}`)); - console.log('ok: env auto-inherits parent → child → grandchild (no extra code)'); - - // Background job + foreground job are both subprocesses of the exec shell. - const marker = ENV_INHERIT_MARKER; - const expected = ENV_INHERIT_VALUE; - const bashResult = await provider.exec({ - sandboxId, - env: { [marker]: expected }, - command: [ - // sandbox-local file (mktemp may target a denied host TMPDIR) - 'bg_out="./.tfy-smoke-env-bg"', - // command 1: background — writes marker value then exits - `( printenv ${marker} > "$bg_out" ) &`, - 'bg_pid=$!', - // command 2: foreground — must see the same env - `fg_val="$(printenv ${marker})"`, - 'wait "$bg_pid"', - 'bg_val="$(cat "$bg_out")"', - 'rm -f "$bg_out"', - `test "$fg_val" = ${JSON.stringify(expected)} || { echo "fg-missing:$fg_val" >&2; exit 1; }`, - `test "$bg_val" = ${JSON.stringify(expected)} || { echo "bg-missing:$bg_val" >&2; exit 1; }`, - `echo "env-bg-ok ${expected}"`, - ].join('\n'), - }); - assert.equal(bashResult.success, true, JSON.stringify(bashResult)); - if (!bashResult.success) throw new Error('unreachable'); - assert.equal(bashResult.response.exitCode, 0, bashResult.response.result); - assert.match(bashResult.response.result, new RegExp(`env-bg-ok ${expected}`)); - console.log('ok: env auto-inherits to bash background + foreground jobs (cmd1 & cmd2)'); -} - -function runCapture(command: string, args: string[]): Promise<{ code: number | null; out: string }> { - return new Promise((resolve, reject) => { - const child = spawn(command, args, { stdio: ['ignore', 'pipe', 'pipe'] }); - let out = ''; - child.stdout?.on('data', (c: Buffer) => { - out += c.toString('utf8'); - }); - child.stderr?.on('data', (c: Buffer) => { - out += c.toString('utf8'); - }); - child.on('error', reject); - child.on('close', code => resolve({ code, out })); - }); -} - -function assertPeerSecretAbsent(label: string, sample: string): void { - assert.ok( - !sample.includes(ENV_PEER_VALUE), - `${label} unexpectedly exposed peer env secret:\n${sample.slice(0, 2000)}`, - ); -} - -function assertPeerSecretPresent(label: string, sample: string): void { - assert.ok( - sample.includes(ENV_PEER_VALUE), - `${label} did not expose peer env secret (expected same-UID visibility):\n${sample.slice(0, 2000)}`, - ); -} - -/** - * Prove kernel UDS peer credentials on accept: - * - Linux: SO_PEERCRED → peer pid/uid/gid - * - macOS: LOCAL_PEERPID + getpeereid → peer pid/uid/gid - * Identity comes from the kernel, not from client-supplied fields. - */ -async function smokeUdsPeerCredentials(): Promise { - const sockPath = join(tmpdir(), `cm-pc-${ulid().toLowerCase().slice(0, 10)}`); - await unlink(sockPath).catch(() => undefined); - - const script = [ - 'import ctypes, json, os, platform, socket, struct', - `path = ${JSON.stringify(sockPath)}`, - 'srv = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)', - 'try:', - ' try: os.unlink(path)', - ' except FileNotFoundError: pass', - ' srv.bind(path)', - ' srv.listen(1)', - ' child = os.fork()', - ' if child == 0:', - ' c = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)', - ' c.connect(path)', - ' c.sendall(b"hi")', - ' try: c.recv(1)', - ' except OSError: pass', - ' c.close()', - ' os._exit(0)', - ' conn, _ = srv.accept()', - ' _ = conn.recv(16)', - ' if platform.system() == "Linux":', - ' SO_PEERCRED = 17', - " raw = conn.getsockopt(socket.SOL_SOCKET, SO_PEERCRED, struct.calcsize('iii'))", - " peer_pid, peer_uid, peer_gid = struct.unpack('iii', raw)", - ' method = "SO_PEERCRED"', - ' else:', - ' SOL_LOCAL, LOCAL_PEERPID = 0, 2', - ' raw = conn.getsockopt(SOL_LOCAL, LOCAL_PEERPID, 4)', - " peer_pid = struct.unpack('I', raw)[0]", - ' libc = ctypes.CDLL(None)', - ' uid = ctypes.c_uint()', - ' gid = ctypes.c_uint()', - ' rc = libc.getpeereid(ctypes.c_int(conn.fileno()), ctypes.byref(uid), ctypes.byref(gid))', - ' if rc != 0:', - ' raise SystemExit(f"getpeereid failed rc={rc} errno={ctypes.get_errno()}")', - ' peer_uid, peer_gid = uid.value, gid.value', - ' method = "LOCAL_PEERPID+getpeereid"', - ' conn.close()', - ' os.waitpid(child, 0)', - ' print(json.dumps({', - ' "method": method,', - ' "peer_pid": peer_pid,', - ' "peer_uid": peer_uid,', - ' "peer_gid": peer_gid,', - ' "child_pid": child,', - ' "self_uid": os.getuid(),', - ' "self_gid": os.getgid(),', - ' }))', - 'finally:', - ' srv.close()', - ' try: os.unlink(path)', - ' except FileNotFoundError: pass', - ].join('\n'); - - try { - const probe = await runCapture('python3', ['-c', script]); - assert.equal(probe.code, 0, probe.out); - const line = probe.out.trim().split(/\r?\n/).filter(Boolean).at(-1); - assert.ok(line !== undefined && line.length > 0, `empty peercred probe output: ${probe.out}`); - const parsed: unknown = JSON.parse(line); - assert.ok(parsed !== null && typeof parsed === 'object'); - assert.ok('method' in parsed && typeof parsed.method === 'string'); - assert.ok('peer_pid' in parsed && typeof parsed.peer_pid === 'number'); - assert.ok('peer_uid' in parsed && typeof parsed.peer_uid === 'number'); - assert.ok('peer_gid' in parsed && typeof parsed.peer_gid === 'number'); - assert.ok('child_pid' in parsed && typeof parsed.child_pid === 'number'); - assert.ok('self_uid' in parsed && typeof parsed.self_uid === 'number'); - assert.ok('self_gid' in parsed && typeof parsed.self_gid === 'number'); - assert.equal(parsed.peer_pid, parsed.child_pid, 'peer pid must match connecting child'); - assert.equal(parsed.peer_uid, parsed.self_uid, 'peer uid must match listener uid (same-UID connect)'); - assert.equal(parsed.peer_gid, parsed.self_gid, 'peer gid must match listener gid (same-UID connect)'); - if (process.platform === 'linux') { - assert.equal(parsed.method, 'SO_PEERCRED'); - } else { - assert.equal(parsed.method, 'LOCAL_PEERPID+getpeereid'); - } - console.log( - `ok: proved UDS peer credentials via ${parsed.method} (pid=${String(parsed.peer_pid)} uid=${String(parsed.peer_uid)} gid=${String(parsed.peer_gid)})`, - ); - } finally { - await unlink(sockPath).catch(() => undefined); - } -} - -/** - * Same-UID peer env visibility (host processes, not sandbox policy). - * Proves the easy leak path on each OS — not that env is protected. - * - Linux: `/proc//environ` contains the peer secret - * - macOS: `ps -E -p ` contains the peer secret; plain `ps -o command=` does not - */ -async function smokeSameUidEnvironRead(): Promise { - const holder = spawn( - 'python3', - ['-c', ['import os, time', 'print(os.getpid(), flush=True)', 'time.sleep(30)'].join('\n')], - { - env: { - PATH: process.env['PATH'] ?? '/usr/bin:/bin', - [ENV_PEER_MARKER]: ENV_PEER_VALUE, - }, - stdio: ['ignore', 'pipe', 'pipe'], - }, - ); - - let pidLine = ''; - holder.stdout?.on('data', (chunk: Buffer) => { - pidLine += chunk.toString('utf8'); - }); - - try { - for (let i = 0; i < 50 && !/^\d+/m.test(pidLine); i++) { - await sleep(50); - } - const peerPid = Number(pidLine.trim().split(/\s+/)[0]); - assert.ok(Number.isInteger(peerPid) && peerPid > 0, `holder pid missing: ${pidLine}`); - - if (process.platform === 'linux') { - const environ = await readFile(`/proc/${String(peerPid)}/environ`); - const decoded = environ.toString('utf8').replaceAll('\0', '\n'); - assertPeerSecretPresent('/proc//environ', decoded); - console.log('ok: proved Linux same-UID env leak via /proc//environ'); - return; - } - - // Prove macOS same-UID env is easy to read via the common `ps -E` path. - const psPlain = await runCapture('ps', ['-p', String(peerPid), '-ww', '-o', 'command=']); - assert.equal(psPlain.code, 0, `ps -o command= failed: ${psPlain.out}`); - assertPeerSecretAbsent('ps -o command=', psPlain.out); - - const psE = await runCapture('ps', ['-E', '-p', String(peerPid), '-ww']); - assert.equal(psE.code, 0, `ps -E failed: ${psE.out}`); - assertPeerSecretPresent('ps -E -p', psE.out); - assert.match(psE.out, new RegExp(`${ENV_PEER_MARKER}=${ENV_PEER_VALUE}`)); - console.log('ok: proved macOS same-UID env leak via ps -E (plain ps hid it)'); - } finally { - holder.kill('SIGKILL'); - await new Promise(resolve => { - holder.on('close', () => resolve()); - setTimeout(resolve, 1000); - }); - } -} - -/** - * Same-UID access to another process's pipe / socketpair ends (host, not SRT). - * - Linux pipe: `open(/proc//fd/N)` duplicates the fd - * - Linux socketpair: `open(/proc/...)` fails (ENXIO); `pidfd_getfd` steals it - * - macOS: no `/proc//fd` (ENOENT) - */ -async function smokeSameUidInheritedFdAccess(): Promise { - const PIPE_SECRET = 'pipe-secret-marker'; - const SOCK_SECRET = 'sock-secret-marker'; - const script = [ - 'import ctypes, json, os, platform, socket, subprocess, sys', - `PIPE_SECRET = ${JSON.stringify(PIPE_SECRET)}.encode()`, - `SOCK_SECRET = ${JSON.stringify(SOCK_SECRET)}.encode()`, - 'holder = subprocess.Popen(', - ' [sys.executable, "-c",', - ' "import os, socket, time\\n"', - ' "r, w = os.pipe()\\n"', - ' "os.write(w, " + repr(PIPE_SECRET) + ")\\n"', - ' "a, b = socket.socketpair()\\n"', - ' "b.sendall(" + repr(SOCK_SECRET) + ")\\n"', - ' "print(os.getpid(), r, a.fileno(), flush=True)\\n"', - ' "time.sleep(60)\\n"],', - ' stdout=subprocess.PIPE, text=True,', - ')', - 'try:', - ' line = holder.stdout.readline().strip()', - ' parts = line.split()', - ' if len(parts) != 3:', - ' raise SystemExit(f"bad holder line: {line!r}")', - ' pid, pipe_fd, sock_fd = map(int, parts)', - ' if platform.system() != "Linux":', - ' path = f"/proc/{pid}/fd/{pipe_fd}"', - ' try:', - ' open(path, "rb").close()', - ' raise SystemExit(f"unexpected open ok: {path}")', - ' except FileNotFoundError:', - ' print(json.dumps({"platform": "darwin", "proc_fd": "ENOENT"}))', - ' raise SystemExit(0)', - ' pipe_path = f"/proc/{pid}/fd/{pipe_fd}"', - ' with open(pipe_path, "rb", buffering=0) as f:', - ' pipe_data = f.read(64)', - ' if pipe_data != PIPE_SECRET:', - ' raise SystemExit(f"pipe steal mismatch: {pipe_data!r}")', - ' sock_path = f"/proc/{pid}/fd/{sock_fd}"', - ' sock_open_err = None', - ' try:', - ' open(sock_path, "rb", buffering=0).close()', - ' raise SystemExit("socketpair open(/proc) unexpectedly succeeded")', - ' except OSError as e:', - ' sock_open_err = e.errno', - ' libc = ctypes.CDLL(None, use_errno=True)', - ' libc.pidfd_open.argtypes = [ctypes.c_int, ctypes.c_uint]', - ' libc.pidfd_open.restype = ctypes.c_int', - ' libc.pidfd_getfd.argtypes = [ctypes.c_int, ctypes.c_int, ctypes.c_uint]', - ' libc.pidfd_getfd.restype = ctypes.c_int', - ' pidfd = libc.pidfd_open(pid, 0)', - ' if pidfd < 0:', - ' raise SystemExit(f"pidfd_open failed errno={ctypes.get_errno()}")', - ' stolen = libc.pidfd_getfd(pidfd, sock_fd, 0)', - ' if stolen < 0:', - ' raise SystemExit(f"pidfd_getfd failed errno={ctypes.get_errno()}")', - ' sock_data = os.read(stolen, 64)', - ' os.close(stolen)', - ' os.close(pidfd)', - ' if sock_data != SOCK_SECRET:', - ' raise SystemExit(f"socketpair steal mismatch: {sock_data!r}")', - ' print(json.dumps({', - ' "platform": "linux",', - ' "pipe_via": "open(/proc/pid/fd)",', - ' "socketpair_open_errno": sock_open_err,', - ' "socketpair_via": "pidfd_getfd",', - ' "holder_pid": pid,', - ' }))', - 'finally:', - ' holder.kill()', - ' try: holder.wait(timeout=2)', - ' except Exception: pass', - ].join('\n'); - - const probe = await runCapture('python3', ['-c', script]); - assert.equal(probe.code, 0, probe.out); - const line = probe.out.trim().split(/\r?\n/).filter(Boolean).at(-1); - assert.ok(line !== undefined && line.length > 0, `empty inherited-fd probe output: ${probe.out}`); - const parsed: unknown = JSON.parse(line); - assert.ok(parsed !== null && typeof parsed === 'object'); - assert.ok('platform' in parsed && typeof parsed.platform === 'string'); - if (process.platform === 'linux') { - assert.equal(parsed.platform, 'linux'); - assert.ok('pipe_via' in parsed && parsed.pipe_via === 'open(/proc/pid/fd)'); - assert.ok('socketpair_via' in parsed && parsed.socketpair_via === 'pidfd_getfd'); - console.log( - `ok: proved Linux same-UID fd steal (pipe via /proc/pid/fd; socketpair via pidfd_getfd) pid=${String( - 'holder_pid' in parsed ? parsed.holder_pid : '?', - )}`, - ); - return; - } - assert.equal(parsed.platform, 'darwin'); - assert.ok('proc_fd' in parsed && parsed.proc_fd === 'ENOENT'); - console.log('ok: macOS has no /proc//fd same-UID steal path (ENOENT)'); -} - -/** - * Exec timeout must SIGKILL the process group — not only the direct child — - * so a forked `while True` grandchild dies too. - */ -async function smokeProcessGroupTimeout(params: { - sandboxRootPath: string; - shell: string; - platform: 'darwin' | 'linux'; -}): Promise { - const session = await runSupervisorSession({ - sandboxRootPath: params.sandboxRootPath, - shell: params.shell, - platform: params.platform, - command: [ - "python3 - <<'PY'", - 'import os, time', - 'open("leader.pid","w").write(str(os.getpid()))', - 'child = os.fork()', - 'if child == 0:', - ' while True:', - ' time.sleep(1)', - 'open("grandchild.pid","w").write(str(child))', - 'time.sleep(3600)', - 'PY', - ].join('\n'), - timeoutMs: 1500, - }); - assert.equal(session.timedOut, true, 'session should time out'); - const leaderPid = Number((await readFile(join(params.sandboxRootPath, 'leader.pid'), 'utf8')).trim()); - const grandchildPid = Number((await readFile(join(params.sandboxRootPath, 'grandchild.pid'), 'utf8')).trim()); - assert.ok(leaderPid > 0 && grandchildPid > 0); - // Give the kernel a moment after SIGKILL. - await sleep(200); - assert.equal(pidAlive(leaderPid), false, `leader ${String(leaderPid)} still alive`); - assert.equal(pidAlive(grandchildPid), false, `grandchild ${String(grandchildPid)} still alive`); - console.log('ok: exec timeout kills process group (leader + while-True grandchild)'); -} - -async function assertExecFails( - provider: LocalSandboxProvider, - sandboxId: string, - command: string, - label: string, - options?: { - timeoutSeconds?: number; - /** Reject these exits as "wrong reason" (e.g. 127 = command missing). */ - forbidExitCodes?: number[]; - /** Require output evidence of policy/IO denial, not just any failure. */ - outputMustMatch?: RegExp; - }, -): Promise<{ exitCode: number; result: string }> { - const result = await provider.exec({ - sandboxId, - command, - ...(options?.timeoutSeconds === undefined ? {} : { timeoutSeconds: options.timeoutSeconds }), - }); - assert.equal(result.success, true, `${label}: provider error ${JSON.stringify(result)}`); - if (!result.success) throw new Error('unreachable'); - assert.notEqual(result.response.exitCode, 0, `${label}: expected non-zero exit\n${result.response.result}`); - if (options?.forbidExitCodes?.includes(result.response.exitCode)) { - assert.fail(`${label}: exit ${String(result.response.exitCode)} is not a policy denial\n${result.response.result}`); - } - if (options?.outputMustMatch !== undefined) { - assert.match( - result.response.result, - options.outputMustMatch, - `${label}: output lacked denial evidence\n${result.response.result}`, - ); - } - console.log(`ok: ${label}`); - return { exitCode: result.response.exitCode, result: result.response.result }; -} - -/** - * Host TCP listeners on loopback must be unreachable from the sandbox - * (macOS Seatbelt deny, or Linux netns isolation). - */ -async function smokeLoopbackDenied(provider: LocalSandboxProvider, sandboxId: string): Promise { - const listen = async (host: string): Promise<{ port: number; close: () => Promise }> => { - const server = createServer(socket => { - socket.end('loopback-open\n'); - }); - await new Promise((resolve, reject) => { - server.once('error', reject); - server.listen(0, host, () => resolve()); - }); - const addr = server.address(); - if (addr === null || typeof addr === 'string') { - throw new Error(`expected TCP address for ${host}`); - } - return { - port: addr.port, - close: () => - new Promise((resolve, reject) => { - server.close(err => (err ? reject(err) : resolve())); - }), - }; - }; - - const v4 = await listen('127.0.0.1'); - let v6: { port: number; close: () => Promise } | undefined; - try { - v6 = await listen('::1'); - } catch { - v6 = undefined; - } - - try { - await assertExecFails( - provider, - sandboxId, - [ - "python3 - <<'PY'", - 'import socket, sys', - `port = ${String(v4.port)}`, - 'try:', - ' s = socket.create_connection(("127.0.0.1", port), timeout=2)', - ' data = s.recv(64)', - ' s.close()', - ' print("loopback-v4-open", data)', - ' raise SystemExit(0)', - 'except OSError as e:', - ' print("loopback-v4-blocked", type(e).__name__, e)', - ' raise SystemExit(2)', - 'PY', - ].join('\n'), - 'host 127.0.0.1 listener unreachable from sandbox', - { outputMustMatch: /loopback-v4-blocked/ }, - ); - - if (v6 !== undefined) { - const v6Port = v6.port; - await assertExecFails( - provider, - sandboxId, - [ - "python3 - <<'PY'", - 'import socket, sys', - `port = ${String(v6Port)}`, - 'try:', - ' s = socket.create_connection(("::1", port), timeout=2)', - ' data = s.recv(64)', - ' s.close()', - ' print("loopback-v6-open", data)', - ' raise SystemExit(0)', - 'except OSError as e:', - ' print("loopback-v6-blocked", type(e).__name__, e)', - ' raise SystemExit(2)', - 'PY', - ].join('\n'), - 'host ::1 listener unreachable from sandbox', - { outputMustMatch: /loopback-v6-blocked/ }, - ); - } else { - console.log('ok: skip ::1 listener (host cannot bind)'); - } - - // Private / link-local: no controlled listener; still must not connect. - await assertExecFails( - provider, - sandboxId, - [ - "python3 - <<'PY'", - 'import socket, sys', - 'targets = [("10.255.255.1", 9), ("169.254.169.254", 80), ("192.168.255.1", 9)]', - 'opened = []', - 'for host, port in targets:', - ' try:', - ' s = socket.create_connection((host, port), timeout=1)', - ' s.close()', - ' opened.append(f"{host}:{port}")', - ' except OSError as e:', - ' print(f"private-blocked {host}:{port} {type(e).__name__}")', - 'if opened:', - ' print("private-open", opened)', - ' raise SystemExit(0)', - 'raise SystemExit(2)', - 'PY', - ].join('\n'), - 'private/link-local TCP connect denied', - { outputMustMatch: /private-blocked/ }, - ); - } finally { - await v4.close().catch(() => undefined); - if (v6 !== undefined) { - await v6.close().catch(() => undefined); - } - } -} - -/** - * setsid/double-fork vs kill(-pgid): - * - macOS: no PID ns — escape leaves the process group and survives killpg - * (known limitation; host must reap via the written host pid). - * - Linux SRT: PID ns + die-with-parent — escape dies with the sandbox; in-ns - * pids are not host-visible, so we watch a heartbeat file instead of kill(pid). - */ -async function smokeSetsidEscapeSurvivesKillpg(params: { - sandboxRootPath: string; - shell: string; - platform: 'darwin' | 'linux'; -}): Promise { - const heartbeatPath = join(params.sandboxRootPath, 'escaped.heartbeat'); - // Escaped child drops stdio so host pipes can close after killpg. - // Cap wait: SRT wrapper teardown can still lag; do not hang the suite. - const sessionPromise = runSupervisorSession({ - sandboxRootPath: params.sandboxRootPath, - shell: params.shell, - platform: params.platform, - command: [ - "python3 - <<'PY'", - 'import os, time', - 'open("leader.pid", "w", encoding="utf-8").write(str(os.getpid()))', - 'child = os.fork()', - 'if child == 0:', - ' os.setsid()', - ' grand = os.fork()', - ' if grand > 0:', - ' os._exit(0)', - ' dn = os.open("/dev/null", os.O_RDWR)', - ' os.dup2(dn, 0); os.dup2(dn, 1); os.dup2(dn, 2)', - ' if dn > 2: os.close(dn)', - ' # Close Code Mode fds if present so host is not held open.', - ' for fd in (3, 4):', - ' try: os.close(fd)', - ' except OSError: pass', - ' open("escaped.pid", "w", encoding="utf-8").write(str(os.getpid()))', - ' n = 0', - ' while True:', - ' n += 1', - ' open("escaped.heartbeat", "w", encoding="utf-8").write(str(n))', - ' time.sleep(0.2)', - 'os.waitpid(child, 0)', - 'time.sleep(3600)', - 'PY', - ].join('\n'), - timeoutMs: 1500, - }); - - let escapedRaw = ''; - for (let i = 0; i < 60 && !/^\d+$/.test(escapedRaw); i++) { - try { - escapedRaw = (await readFile(join(params.sandboxRootPath, 'escaped.pid'), 'utf8')).trim(); - } catch { - // not yet - } - await sleep(50); - } - assert.match(escapedRaw, /^\d+$/, 'escaped.pid missing — setsid child never started'); - const escapedPid = Number(escapedRaw); - - let heartbeatBeforeSession = ''; - for (let i = 0; i < 40 && heartbeatBeforeSession === ''; i++) { - try { - heartbeatBeforeSession = (await readFile(heartbeatPath, 'utf8')).trim(); - } catch { - // not yet - } - await sleep(50); - } - assert.match(heartbeatBeforeSession, /^\d+$/, 'escaped.heartbeat missing — escape never ran'); - - const sessionOrTimeout = await Promise.race([ - sessionPromise.then(session => ({ kind: 'session' as const, session })), - sleep(5000).then(() => ({ kind: 'hung' as const })), - ]); - if (sessionOrTimeout.kind === 'hung') { - // Last resort: session did not settle after killpg (SRT wrapper leak). - if (process.platform === 'darwin') { - try { - process.kill(escapedPid, 'SIGKILL'); - } catch { - // ignore - } - } - assert.fail('runSupervisorSession hung after timeout — killpg did not finish teardown'); - } - const { session } = sessionOrTimeout; - assert.equal(session.timedOut, true, 'session should time out'); - await sleep(500); - - const hb1 = (await readFile(heartbeatPath, 'utf8')).trim(); - await sleep(600); - const hb2 = (await readFile(heartbeatPath, 'utf8')).trim(); - - if (process.platform === 'linux') { - // In-ns pid is not the host pid; survival is judged by heartbeat freeze. - assert.equal(hb2, hb1, 'Linux: setsid escape should die with PID ns / die-with-parent (heartbeat still advancing)'); - console.log('ok: setsid escape dies with Linux PID ns / die-with-parent'); - return; - } - - // macOS: host-visible pid; kill(-pgid) misses the new session. - assert.notEqual(hb2, hb1, 'macOS: expected setsid escape to keep writing heartbeat after kill(-pgid)'); - assert.equal(pidAlive(escapedPid), true, `expected setsid escape pid ${String(escapedPid)} to survive kill(-pgid)`); - try { - process.kill(escapedPid, 'SIGKILL'); - } catch { - // already gone - } - console.log('ok: setsid/double-fork escape survives killpg on macOS (known limitation)'); -} - -/** - * Match hostRun AF_UNIX policy (Linux allowAllUnixSockets; macOS allowUnixSockets=[sandboxRoot]), - * then prove pathname connect is still gated by allowRead (FS) / seatbelt, not by the Unix-socket toggle alone. - * On Linux, also prove /proc/net/unix is the sandbox netns table (host abstract absent). - */ -async function smokeUnixSocketFsGate(): Promise { - // Keep paths short: macOS sun_path is ~104 bytes (long sandbox path UUIDs → EINVAL). - const id = randomUUID().replaceAll('-', '').slice(0, 8); - const sandboxRootPath = join(SANDBOXES, `u${id}`); - const insideSock = join(sandboxRootPath, 'c.sock'); - const outsideSock = join(SANDBOXES, `o${id}.sock`); - // Host-owned fake Docker socket (path shape only) — must not be in allowRead / allowUnixSockets. - const emulatedDockerRoot = join(SANDBOXES, `v${id}`); - const emulatedDockerSock = join(emulatedDockerRoot, 'run', 'docker.sock'); - const hostAbstractName = `\0tfy-abs-${id}`; - const hostAbstractProcMarker = `@tfy-abs-${id}`; - - const platform = process.platform === 'darwin' ? 'darwin' : 'linux'; - const allowRead = [sandboxRootPath, ...platformAllowRead(platform)]; - - const denyWrite = getDefaultWritePaths().filter(path => !path.startsWith('/dev/')); - - const listenUds = async (path: string): Promise<{ close: () => Promise }> => { - await unlink(path).catch(() => undefined); - const server = createServer(socket => { - socket.end('uds-ok\n'); - }); - await new Promise((resolve, reject) => { - server.once('error', reject); - server.listen(path, () => resolve()); - }); - return { - close: async () => { - await new Promise(resolve => { - server.close(() => resolve()); - }); - await unlink(path).catch(() => undefined); - }, - }; - }; - - const runSandboxed = async (command: string): Promise<{ code: number | null; out: string }> => { - const wrap = await SandboxManager.wrapWithSandboxArgv( - command, - '/bin/bash', - { - filesystem: { - allowWrite: [sandboxRootPath], - denyWrite, - denyRead: ['/'], - allowRead, - }, - network: { allowedDomains: [], deniedDomains: [] }, - }, - undefined, - sandboxRootPath, - { commandId: randomUUID(), commandText: command }, - ); - const [argv0, ...argvRest] = wrap.argv; - if (argv0 === undefined) throw new Error('empty argv'); - return await new Promise((resolve, reject) => { - const child = spawn(argv0, argvRest, { - cwd: sandboxRootPath, - env: { - HOME: join(sandboxRootPath, '.home'), - TMPDIR: join(sandboxRootPath, '.tmp'), - PATH: commandPath(platform), - ...wrap.env, - }, - stdio: ['ignore', 'pipe', 'pipe'], - }); - let out = ''; - child.stdout?.on('data', (c: Buffer) => { - out += c.toString('utf8'); - }); - child.stderr?.on('data', (c: Buffer) => { - out += c.toString('utf8'); - }); - child.on('error', reject); - child.on('close', code => resolve({ code, out })); - }); - }; - - const connectScript = (sockPath: string): string => - [ - "python3 - <<'PY'", - 'import socket, sys', - `path = ${JSON.stringify(sockPath)}`, - 'try:', - ' s = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)', - ' s.settimeout(2)', - ' s.connect(path)', - ' data = s.recv(64)', - ' s.close()', - ' print("CONNECT_OK", data)', - ' sys.exit(0)', - 'except OSError as e:', - ' print("CONNECT_FAIL", type(e).__name__, e.errno, e)', - ' sys.exit(2)', - 'PY', - ].join('\n'); - - /** Prove path is not discoverable/readable and connect also fails (no discover-then-connect shortcut). */ - const discoverAndConnectDeniedScript = (sockPath: string): string => - [ - "python3 - <<'PY'", - 'import os, socket, stat, sys', - `path = ${JSON.stringify(sockPath)}`, - 'parent = os.path.dirname(path)', - 'base = os.path.basename(path)', - 'discover_ok = False', - 'try:', - ' st = os.stat(path)', - ' print("STAT_OK", int(st.st_mode))', - ' if stat.S_ISSOCK(st.st_mode):', - ' discover_ok = True', - ' print("DISCOVER_STAT_SOCK")', - 'except OSError as e:', - ' print("STAT_FAIL", type(e).__name__, getattr(e, "errno", None))', - 'try:', - ' if os.path.exists(path):', - ' discover_ok = True', - ' print("DISCOVER_EXISTS")', - ' else:', - ' print("EXISTS_FALSE")', - 'except OSError as e:', - ' print("EXISTS_FAIL", type(e).__name__, getattr(e, "errno", None))', - 'try:', - ' names = os.listdir(parent)', - ' print("LISTDIR_OK", names)', - ' if base in names:', - ' discover_ok = True', - ' print("DISCOVER_LISTDIR")', - 'except OSError as e:', - ' print("LISTDIR_FAIL", type(e).__name__, getattr(e, "errno", None))', - 'if discover_ok:', - ' print("DISCOVER_REACHABLE")', - ' sys.exit(1)', - 'print("DISCOVER_DENIED")', - 'try:', - ' s = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)', - ' s.settimeout(2)', - ' s.connect(path)', - ' s.close()', - ' print("CONNECT_OK")', - ' sys.exit(2)', - 'except OSError as e:', - ' print("CONNECT_FAIL", type(e).__name__, getattr(e, "errno", None))', - ' sys.exit(0)', - 'PY', - ].join('\n'); - - await mkdir(join(sandboxRootPath, '.tmp'), { recursive: true, mode: 0o700 }); - await mkdir(join(sandboxRootPath, '.home'), { recursive: true, mode: 0o700 }); - await mkdir(dirname(emulatedDockerSock), { recursive: true, mode: 0o700 }); - const inside = await listenUds(insideSock); - const outside = await listenUds(outsideSock); - const emulatedDocker = await listenUds(emulatedDockerSock); - - // Match hostRun: Linux allowAll + FS gate; macOS allowUnixSockets=[sandboxRootPath] (FS does not gate UDS). - const network = - process.platform === 'darwin' - ? { - allowedDomains: [] as string[], - deniedDomains: [] as string[], - allowAllUnixSockets: false, - allowUnixSockets: [sandboxRootPath], - } - : { - allowedDomains: [] as string[], - deniedDomains: [] as string[], - allowAllUnixSockets: true, - }; - - await SandboxManager.initialize({ - network, - filesystem: { - allowWrite: [], - denyWrite, - denyRead: ['/'], - allowRead, - }, - }); - - let hostAbstract: { close: () => Promise } | undefined; - try { - const ok = await runSandboxed(connectScript(insideSock)); - assert.equal(ok.code, 0, `inside sock should connect:\n${ok.out}`); - assert.match(ok.out, /CONNECT_OK/); - console.log('ok: sandbox can connect to sandbox UDS (allowRead)'); - - await access(outsideSock); - const denied = await runSandboxed(connectScript(outsideSock)); - assert.notEqual(denied.code, 0, `outside sock must not connect:\n${denied.out}`); - assert.match(denied.out, /CONNECT_FAIL/); - console.log( - process.platform === 'linux' - ? 'ok: FS-denied path UDS connect fails under allowAllUnixSockets' - : 'ok: path outside allowUnixSockets=[sandboxRootPath] connect fails (macOS seatbelt)', - ); - - // Prove the emulated docker listener is live on the host, then denied from the sandbox. - const hostDockerConnect = await new Promise<{ code: number | null; out: string }>((resolve, reject) => { - const child = spawn( - 'python3', - [ - '-c', - [ - 'import socket, sys', - 'path = sys.argv[1]', - 's = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)', - 's.settimeout(2)', - 's.connect(path)', - 'print(s.recv(64))', - 's.close()', - ].join('\n'), - emulatedDockerSock, - ], - { stdio: ['ignore', 'pipe', 'pipe'] }, - ); - let out = ''; - child.stdout?.on('data', (c: Buffer) => { - out += c.toString('utf8'); - }); - child.stderr?.on('data', (c: Buffer) => { - out += c.toString('utf8'); - }); - child.on('error', reject); - child.on('close', code => resolve({ code, out })); - }); - assert.equal(hostDockerConnect.code, 0, `host must reach emulated docker.sock:\n${hostDockerConnect.out}`); - assert.match(hostDockerConnect.out, /uds-ok/); - console.log('ok: host can connect to emulated docker.sock'); - - const dockerDenied = await runSandboxed(connectScript(emulatedDockerSock)); - assert.notEqual(dockerDenied.code, 0, `sandbox must not connect to emulated docker.sock:\n${dockerDenied.out}`); - assert.match(dockerDenied.out, /CONNECT_FAIL/); - console.log('ok: sandbox cannot connect to host-created emulated docker.sock'); - - const inventory = await runSandboxed( - [ - "python3 - <<'PY'", - 'import os, socket, stat, sys', - 'roots = ["/dev", "/etc", "/usr"]', - 'found = []', - 'for root in roots:', - ' if not os.path.isdir(root):', - ' continue', - ' for dirpath, dirnames, filenames in os.walk(root):', - ' if dirpath.count(os.sep) - root.count(os.sep) > 3:', - ' dirnames[:] = []', - ' continue', - ' for name in filenames:', - ' p = os.path.join(dirpath, name)', - ' try:', - ' st = os.stat(p)', - ' except OSError:', - ' continue', - ' if stat.S_ISSOCK(st.st_mode):', - ' found.append(p)', - 'print("FOUND", len(found))', - 'for p in found[:20]:', - ' print("SOCK", p)', - 'bad = 0', - 'for p in found:', - ' try:', - ' s = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)', - ' s.settimeout(0.3)', - ' s.connect(p)', - ' s.close()', - ' print("CONNECTED", p)', - ' bad += 1', - ' except OSError as e:', - ' print("BLOCKED_OR_USELESS", p, type(e).__name__)', - 'sys.exit(1 if bad else 0)', - 'PY', - ].join('\n'), - ); - const invLines = inventory.out.trim().split('\n').slice(0, 40); - console.log(invLines.join('\n')); - assert.equal(inventory.code, 0, `sandbox connected to a socket under /dev|/etc|/usr:\n${inventory.out}`); - console.log('ok: no successful connect to sockets under /dev|/etc|/usr (if any visible)'); - - if (process.platform === 'linux') { - // Host abstract listener (Linux-only). Sandbox has --unshare-net → own /proc/net/unix. - const absServer = createServer(socket => { - socket.end('abs-ok\n'); - }); - await new Promise((resolve, reject) => { - absServer.once('error', reject); - absServer.listen(hostAbstractName, () => resolve()); - }); - hostAbstract = { - close: async () => { - await new Promise(resolve => { - absServer.close(() => resolve()); - }); - }, - }; - - const hostProc = await readFile('/proc/net/unix', 'utf8'); - assert.match( - hostProc, - new RegExp(hostAbstractProcMarker.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')), - 'host /proc/net/unix must list the abstract listener', - ); - - const procUnix = await runSandboxed( - [ - "python3 - <<'PY'", - 'import sys', - `marker = ${JSON.stringify(hostAbstractProcMarker)}`, - 'path = "/proc/net/unix"', - 'try:', - ' text = open(path, "r", encoding="utf-8", errors="replace").read()', - 'except OSError as e:', - ' print("PROC_NET_UNIX_UNREADABLE", getattr(e, "errno", None), e)', - ' sys.exit(2)', - 'print("PROC_NET_UNIX_READABLE", "bytes", len(text), "lines", len(text.splitlines()))', - 'if marker in text:', - ' print("HOST_ABSTRACT_VISIBLE", marker)', - ' sys.exit(3)', - 'print("HOST_ABSTRACT_ABSENT", marker)', - // Also prove connect to host abstract fails (different netns). - 'import socket', - `abs_name = ${JSON.stringify(hostAbstractName)}`, - 'try:', - ' s = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)', - ' s.settimeout(1)', - ' s.connect(abs_name)', - ' s.close()', - ' print("HOST_ABSTRACT_CONNECT_OK")', - ' sys.exit(4)', - 'except OSError as e:', - ' print("HOST_ABSTRACT_CONNECT_FAIL", type(e).__name__, getattr(e, "errno", None))', - 'sys.exit(0)', - 'PY', - ].join('\n'), - ); - assert.equal(procUnix.code, 0, procUnix.out); - assert.match(procUnix.out, /PROC_NET_UNIX_READABLE/); - assert.match(procUnix.out, /HOST_ABSTRACT_ABSENT/); - assert.match(procUnix.out, /HOST_ABSTRACT_CONNECT_FAIL/); - console.log('ok: /proc/net/unix is sandbox netns (host abstract not listed / not connectable)'); - console.log(procUnix.out.trim().split('\n').filter(Boolean).join(' | ')); - } else { - console.log('ok: skip Linux abstract /proc/net/unix netns probe (not Linux)'); - } - - console.log( - process.platform === 'linux' - ? 'ok: Linux allowAllUnixSockets + FS allowRead gates pathname UDS' - : 'ok: macOS allowUnixSockets=[sandboxRootPath] gates pathname UDS (not allowRead)', - ); - } finally { - await hostAbstract?.close().catch(() => undefined); - await emulatedDocker.close().catch(() => undefined); - await inside.close().catch(() => undefined); - await outside.close().catch(() => undefined); - await SandboxManager.reset().catch(() => undefined); - await removeSandbox(sandboxRootPath).catch(() => undefined); - await rm(emulatedDockerRoot, { recursive: true, force: true }).catch(() => undefined); - } -} - -async function smokeHostPackageManagerDenied(provider: LocalSandboxProvider, sandboxId: string): Promise { - if (process.platform === 'darwin') { - await assertExecFails( - provider, - sandboxId, - "printf 'poc\\n' > /opt/homebrew/Cellar/.tfy-poc-write || exit 2", - 'host Homebrew Cellar write denied', - { outputMustMatch: /Permission|Read-only|Operation not permitted|denied|No such|cannot/i }, - ); - // Prefer reinstall so an already-installed keg cannot no-op to exit 0. - // Brew also needs host API/cache reads + network; both are denied under SRT. - await assertExecFails( - provider, - sandboxId, - [ - 'set +e', - 'command -v brew >/dev/null 2>&1 || { echo "brew-missing" >&2; exit 127; }', - 'export HOMEBREW_NO_AUTO_UPDATE=1 HOMEBREW_NO_ANALYTICS=1 HOMEBREW_NO_ENV_HINTS=1', - 'brew install lima', - 'install_rc=$?', - 'if [ "$install_rc" -eq 0 ]; then', - ' brew reinstall lima', - ' install_rc=$?', - 'fi', - 'exit "$install_rc"', - ].join('\n'), - 'brew install/reinstall lima denied', - { - // Brew may stall on network/API; fail-closed quickly under SRT. - timeoutSeconds: 20, - forbidExitCodes: [127], - outputMustMatch: /not writable|Permission|Operation not permitted|Read-only|denied|Failed to download|Error:/i, - }, - ); - return; - } - - await assertExecFails( - provider, - sandboxId, - "printf 'poc\\n' > /usr/bin/.tfy-poc-write || exit 2", - 'host /usr/bin write denied', - { outputMustMatch: /Permission|Read-only|Operation not permitted|denied|No such file|cannot/i }, - ); - await assertExecFails( - provider, - sandboxId, - [ - 'set +e', - 'command -v apt-get >/dev/null 2>&1 || { echo "apt-get-missing" >&2; exit 127; }', - 'export DEBIAN_FRONTEND=noninteractive', - 'apt-get install -y cowsay', - 'exit $?', - ].join('\n'), - 'apt-get install denied', - { - timeoutSeconds: 20, - forbidExitCodes: [127], - outputMustMatch: /Permission|Read-only|Operation not permitted|denied|not open|Could not|E:/i, - }, - ); -} - -async function main(): Promise { - if (process.platform !== 'darwin' && process.platform !== 'linux') { - console.error('smoke: skipping (darwin/linux only)'); - process.exit(0); - } - - process.env[ENV_LEAK_MARKER] = ENV_LEAK_VALUE; - - const support = await LocalSandboxProvider.isSupported(); - assert.equal(support.supported, true, support.supported ? '' : support.reason); - console.log('ok: LocalSandboxProvider.isSupported'); - - const sandboxRootPathParent = await mkdtemp(join(tmpdir(), 'tfy-local-sandbox-smoke-')); - const codeModeSocketParentPath = join(tmpdir(), 'cm'); - await mkdir(codeModeSocketParentPath, { recursive: true, mode: 0o700 }); - if (!support.supported) { - throw new Error(support.reason); - } - const provider = new LocalSandboxProvider({ sandboxRootPathParent, codeModeSocketParentPath, support }); - const instructions = provider.getAdditionalInstructions(); - assert.match(instructions, /sandbox shell: \S+/); - assert.match(instructions, /Python 3 is available as: \S+/); - console.log('ok: getAdditionalInstructions names shell and python'); - await prepareHostProbeFiles(); - let codeModeSandboxRootPath: string | undefined; - try { - const { sandboxId } = await provider.createSandbox(); - console.log('sandboxId', sandboxId); - - const printf = await provider.exec({ - sandboxId, - command: "printf 'poc-ok\\n'", - }); - assert.equal(printf.success, true); - if (!printf.success) throw new Error('unreachable'); - assert.equal(printf.response.exitCode, 0); - assert.equal(printf.response.result, 'poc-ok\n'); - console.log('ok: provider exec printf'); - - await smokeLiveSrtUnixSocketAllowlistUpdate({ - sandboxRootPath: sandboxId, - codeModeSocketParentPath, - shell: support.shell, - platform: support.platform, - }); - - const write = await provider.exec({ - sandboxId, - command: "printf 'sandbox-ok\\n' > note.txt && cat note.txt", - }); - assert.equal(write.success, true); - if (!write.success) throw new Error('unreachable'); - assert.equal(write.response.exitCode, 0); - assert.equal(write.response.result, 'sandbox-ok\n'); - console.log('ok: sandbox-local write/read'); - - await provider.uploadFile({ - sandboxId, - remotePath: 'uploads/hello.txt', - content: Buffer.from('upload-ok\n'), - }); - const downloaded = await provider.downloadFile({ - sandboxId, - path: 'uploads/hello.txt', - }); - assert.equal(downloaded.toString('utf8'), 'upload-ok\n'); - const catUpload = await provider.exec({ - sandboxId, - command: 'cat uploads/hello.txt', - }); - assert.equal(catUpload.success, true); - if (!catUpload.success) throw new Error('unreachable'); - assert.equal(catUpload.response.result, 'upload-ok\n'); - console.log('ok: upload/download'); - - await assertExecFails( - provider, - sandboxId, - "printf 'leak\\n' > /tmp/claude/poc-should-fail.txt || exit 2", - 'SRT default /tmp/claude write denied', - { outputMustMatch: /Permission|Read-only|Operation not permitted|denied|No such|cannot/i }, - ); - - const before = await readFile(DELETE_TARGET, 'utf8'); - assert.equal(before, 'delete-me\n'); - await assertExecFails( - provider, - sandboxId, - `python3 -c 'import os; os.unlink(${JSON.stringify(DELETE_TARGET)})'`, - 'SRT default /tmp/claude delete denied', - ); - await access(DELETE_TARGET); - - const denyRead = await provider.exec({ - sandboxId, - command: `cat ${JSON.stringify(DENY_READ_SECRET)}`, - }); - assert.equal(denyRead.success, true); - if (!denyRead.success) throw new Error('unreachable'); - assert.notEqual(denyRead.response.exitCode, 0); - assert.ok(!denyRead.response.result.includes('host-secret-should-not-leak')); - console.log('ok: host secret outside sandbox blocked'); - - assert.ok(HOST_HOME && HOST_HOME.length > 0); - await assertExecFails(provider, sandboxId, `ls ${JSON.stringify(HOST_HOME)}`, 'host home listing denied'); - - await smokeHostPackageManagerDenied(provider, sandboxId); - - // System pip install (no --user/--target): must fail closed without network. - // Local trivial package so the failure is install/prefix write, not PyPI fetch. - await assertExecFails( - provider, - sandboxId, - [ - 'set -euo pipefail', - // ensurepip covers guests that only have python3 (no python3-pip package). - 'python3 -m pip --version >/dev/null 2>&1 || python3 -m ensurepip --upgrade >/dev/null 2>&1 || true', - 'python3 -m pip --version >/dev/null || { echo "pip-missing" >&2; exit 127; }', - 'mkdir -p tfy_poc_pip_pkg/tfy_poc_pip', - "cat > tfy_poc_pip_pkg/setup.py <<'EOF'", - 'from setuptools import setup', - 'setup(name="tfy-poc-pip", version="0.0.1", packages=["tfy_poc_pip"])', - 'EOF', - 'touch tfy_poc_pip_pkg/tfy_poc_pip/__init__.py', - 'python3 -m pip install --no-input --no-deps --no-build-isolation ./tfy_poc_pip_pkg', - ].join('\n'), - 'system pip install denied', - { - forbidExitCodes: [127], - outputMustMatch: /Permission|Read-only|Operation not permitted|denied|ERROR:|Could not|No module|error/i, - }, - ); - - await smokeLoopbackDenied(provider, sandboxId); - - await assertExecFails( - provider, - sandboxId, - 'python3 -c \'import socket,sys\ntry:\n socket.create_connection(("1.1.1.1",443),timeout=2)\n print("network-open"); sys.exit(0)\nexcept OSError as e:\n print("network-blocked:%s"%e); sys.exit(2)\'', - 'egress to 1.1.1.1:443 denied', - { outputMustMatch: /network-blocked:/ }, - ); - await assertExecFails( - provider, - sandboxId, - 'python3 -c \'import socket,sys\ntry:\n socket.getaddrinfo("example.com",443)\n print("dns-open"); sys.exit(0)\nexcept OSError as e:\n print("dns-blocked:%s"%e); sys.exit(2)\'', - 'DNS for example.com denied', - { outputMustMatch: /dns-blocked:/ }, - ); - - const envLeak = await provider.exec({ - sandboxId, - command: `printenv ${ENV_LEAK_MARKER} || true`, - }); - assert.equal(envLeak.success, true); - if (!envLeak.success) throw new Error('unreachable'); - assert.ok(!envLeak.response.result.includes(ENV_LEAK_VALUE)); - console.log('ok: host env secret not visible in sandbox'); - - await smokeEnvInheritance(provider, sandboxId); - await smokeUdsPeerCredentials(); - await smokeSameUidEnvironRead(); - await smokeSameUidInheritedFdAccess(); - - await assertExecFails( - provider, - sandboxId, - `cat ${JSON.stringify('../.poc-deny-read-secret')}`, - 'path escape via .. denied', - ); - - await assertExecFails( - provider, - sandboxId, - ['set -e', `ln -sf ${JSON.stringify(DENY_READ_SECRET)} escape-link`, 'cat escape-link'].join('\n'), - 'symlink escape read denied', - ); - - // Plain sandboxed open() following a sandbox→host symlink must not leak the host file. - await assertExecFails( - provider, - sandboxId, - [ - `ln -sf ${JSON.stringify(DENY_READ_SECRET)} escape-open`, - "python3 - <<'PY'", - 'import sys', - 'try:', - ' data = open("escape-open", "rb").read()', - ' sys.stdout.write(data.decode("utf-8", "replace"))', - ' raise SystemExit(0)', - 'except OSError as e:', - ' print(f"open-blocked {type(e).__name__}", file=sys.stderr)', - ' raise SystemExit(2)', - 'PY', - ].join('\n'), - 'sandbox open() symlink follow read denied', - { - outputMustMatch: /open-blocked|Permission|Operation not permitted|denied|No such file/i, - }, - ); - assert.equal(await readFile(DENY_READ_SECRET, 'utf8'), SECRET_CONTENTS); - - // Symlink follow write: host target must stay intact regardless of sandbox exit code. - const writeFollow = await provider.exec({ - sandboxId, - command: [ - `ln -sf ${JSON.stringify(DENY_READ_SECRET)} escape-open-w`, - "python3 - <<'PY'", - 'import sys', - 'try:', - ' open("escape-open-w", "wb").write(b"pwned-exec\\n")', - ' print("open-write-ok")', - ' raise SystemExit(0)', - 'except OSError as e:', - ' print(f"open-write-blocked {type(e).__name__}", file=sys.stderr)', - ' raise SystemExit(2)', - 'PY', - ].join('\n'), - }); - assert.equal(writeFollow.success, true, JSON.stringify(writeFollow)); - if (!writeFollow.success) throw new Error('unreachable'); - assert.equal( - await readFile(DENY_READ_SECRET, 'utf8'), - SECRET_CONTENTS, - 'sandbox symlink follow write must not mutate host secret', - ); - if (writeFollow.response.exitCode !== 0) { - assert.match(writeFollow.response.result, /open-write-blocked|Permission|Operation not permitted|denied/i); - } - console.log('ok: sandbox open() symlink follow write left host secret intact'); - - // Provider upload/download: SRT must stop symlink follow from leaking or mutating the host. - const mkDlLink = await provider.exec({ - sandboxId, - command: `ln -sf ${JSON.stringify(DENY_READ_SECRET)} api-escape-dl && test -L api-escape-dl`, - }); - assert.equal(mkDlLink.success, true); - if (!mkDlLink.success) throw new Error('unreachable'); - assert.equal(mkDlLink.response.exitCode, 0, mkDlLink.response.result); - let downloadLeaked = false; - try { - const leaked = await provider.downloadFile({ sandboxId, path: 'api-escape-dl' }); - downloadLeaked = leaked.toString('utf8').includes('host-secret-should-not-leak'); - } catch { - // deny / throw is fine; host check below still runs - } - assert.equal(downloadLeaked, false, 'downloadFile must not return host secret via symlink'); - assert.equal(await readFile(DENY_READ_SECRET, 'utf8'), SECRET_CONTENTS); - console.log('ok: downloadFile does not leak host via symlink (SRT)'); - - const mkUlLink = await provider.exec({ - sandboxId, - command: `ln -sf ${JSON.stringify(DENY_READ_SECRET)} api-escape-ul && test -L api-escape-ul`, - }); - assert.equal(mkUlLink.success, true); - if (!mkUlLink.success) throw new Error('unreachable'); - assert.equal(mkUlLink.response.exitCode, 0, mkUlLink.response.result); - try { - await provider.uploadFile({ - sandboxId, - remotePath: 'api-escape-ul', - content: Buffer.from('pwned-via-host-api\n'), - }); - } catch { - // deny / throw is fine; host check below is the gate - } - assert.equal( - await readFile(DENY_READ_SECRET, 'utf8'), - SECRET_CONTENTS, - 'uploadFile must not mutate host via symlink', - ); - console.log('ok: uploadFile does not mutate host via symlink (SRT)'); - - const { sandboxId: otherId } = await provider.createSandbox(); - await provider.uploadFile({ - sandboxId, - remotePath: 'cross-secret.txt', - content: Buffer.from('cross-sandbox-secret\n'), - }); - const otherRead = await provider.exec({ - sandboxId: otherId, - command: `cat ${JSON.stringify(join(sandboxId, 'cross-secret.txt'))}`, - }); - assert.equal(otherRead.success, true); - if (!otherRead.success) throw new Error('unreachable'); - assert.notEqual(otherRead.response.exitCode, 0); - assert.ok(!otherRead.response.result.includes('cross-sandbox-secret')); - console.log('ok: cross-sandbox absolute path read denied'); - - const otherWrite = await provider.exec({ - sandboxId: otherId, - command: `printf 'cross-write\\n' > ${JSON.stringify(join(sandboxId, 'cross-write.txt'))}`, - }); - assert.equal(otherWrite.success, true); - if (!otherWrite.success) throw new Error('unreachable'); - assert.notEqual(otherWrite.response.exitCode, 0); - await assert.rejects(async () => readFile(join(sandboxId, 'cross-write.txt'))); - console.log('ok: cross-sandbox absolute path write denied'); - - const persist1 = await provider.exec({ - sandboxId, - command: "printf 'persist-ok\\n' > persist.txt", - }); - assert.equal(persist1.success, true); - if (!persist1.success) throw new Error('unreachable'); - assert.equal(persist1.response.exitCode, 0); - const persist2 = await provider.exec({ - sandboxId, - command: 'cat persist.txt', - }); - assert.equal(persist2.success, true); - if (!persist2.success) throw new Error('unreachable'); - assert.equal(persist2.response.result, 'persist-ok\n'); - console.log('ok: sandbox persists across execs'); - - const flood = await provider.exec({ - sandboxId, - command: `python3 -c 'import sys; sys.stdout.write("x" * ${String(MAX_OUTPUT_BYTES + 1)})'`, - timeoutSeconds: 30, - }); - assert.equal(flood.success, false); - if (flood.success) throw new Error('unreachable'); - assert.match(flood.error, /buffered output exceeded/); - console.log(`ok: oversized stdout is terminal (${String(MAX_OUTPUT_BYTES)} byte cap)`); - - assert.match(provider.getToolResultDumpDir(sandboxId), /tool-results$/); - assert.match(provider.getGitCredentialsPath(sandboxId), /\.git-credentials$/); - console.log('ok: dump/git credential paths'); - - codeModeSandboxRootPath = await createSandbox(join(sandboxRootPathParent, `codemode-${Date.now()}`)); - await smokeProcessGroupTimeout({ - sandboxRootPath: codeModeSandboxRootPath, - shell: support.shell, - platform: support.platform, - }); - await smokeSetsidEscapeSurvivesKillpg({ - sandboxRootPath: codeModeSandboxRootPath, - shell: support.shell, - platform: support.platform, - }); - await smokeCodeMode({ - sandboxRootPath: codeModeSandboxRootPath, - codeModeSocketParentPath, - shell: support.shell, - platform: support.platform, - }); - - console.log('all LocalSandboxProvider + Code Mode smokes passed'); - } finally { - delete process.env[ENV_LEAK_MARKER]; - await provider.dispose(); - if (codeModeSandboxRootPath !== undefined) { - await removeSandbox(codeModeSandboxRootPath).catch(() => undefined); - } - await rm(sandboxRootPathParent, { recursive: true, force: true }).catch(() => undefined); - await cleanupHostProbeFiles().catch(() => undefined); - } - - // Own SRT session: AF_UNIX enabled so FS / allowUnixSockets gating is what we measure. - await smokeUnixSocketFsGate(); - console.log('all smokes passed'); -} - -test('local-sandbox smoke', async () => { - await main(); -}, 600_000); diff --git a/packages/local-sandbox/tsconfig.build.json b/packages/local-sandbox/tsconfig.build.json deleted file mode 100644 index 829cd0426..000000000 --- a/packages/local-sandbox/tsconfig.build.json +++ /dev/null @@ -1,19 +0,0 @@ -{ - "extends": "./tsconfig.json", - "compilerOptions": { - "types": ["node"], - "noEmit": false, - "module": "NodeNext", - "moduleResolution": "NodeNext", - "customConditions": [], - "target": "ES2022", - "lib": ["ES2022"], - "outDir": "dist", - "rootDir": ".", - "sourceMap": true, - "declaration": true, - "declarationMap": true - }, - "include": ["src/**/*.ts"], - "exclude": ["dist", "node_modules", "test", "scripts"] -} diff --git a/packages/local-sandbox/tsconfig.json b/packages/local-sandbox/tsconfig.json deleted file mode 100644 index c73f1c2e4..000000000 --- a/packages/local-sandbox/tsconfig.json +++ /dev/null @@ -1,10 +0,0 @@ -{ - "extends": "../../tsconfig.base.json", - "compilerOptions": { - "types": ["node"], - "noEmit": true, - "customConditions": ["trueforge-dev"] - }, - "include": ["src/**/*.ts", "scripts/**/*.ts"], - "exclude": ["dist", "test", "node_modules"] -} diff --git a/packages/local-sandbox/tsconfig.smoke.json b/packages/local-sandbox/tsconfig.smoke.json deleted file mode 100644 index 2f185cb86..000000000 --- a/packages/local-sandbox/tsconfig.smoke.json +++ /dev/null @@ -1,21 +0,0 @@ -{ - "extends": "./tsconfig.json", - "compilerOptions": { - "types": ["node"], - "noEmit": false, - "module": "NodeNext", - "moduleResolution": "NodeNext", - "customConditions": [], - "target": "ES2022", - "lib": ["ES2022"], - "outDir": "dist", - "rootDir": ".", - "sourceMap": true, - "declaration": false, - "declarationMap": false, - "noUnusedLocals": false, - "noUnusedParameters": false - }, - "include": ["src/**/*.ts", "scripts/probe-loopback.ts"], - "exclude": ["dist", "test", "node_modules"] -} diff --git a/packages/trueforge-core/src/core/sandbox/Sandbox.ts b/packages/trueforge-core/src/core/sandbox/Sandbox.ts index a875689ea..a1b1f389b 100644 --- a/packages/trueforge-core/src/core/sandbox/Sandbox.ts +++ b/packages/trueforge-core/src/core/sandbox/Sandbox.ts @@ -17,13 +17,11 @@ import type { AgentTracing } from '../tracing/AgentTracing'; import { extractErrorLogFields } from '../util/errorLogFields'; import { CodeModeDispatcher } from './codeMode/CodeModeDispatcher'; import { type CodeModeClientInstall, type CodeModeTransport } from './codeMode/CodeModeTransport'; -import { SANDBOX_FILE_UPLOADS_DIR } from './constants'; import { ensureExecSuccess, shellEscape, type SandboxProvider } from './provider/Provider'; import { SandboxNotAvailableError, validateNoPathTraversal } from './SandboxErrors'; import { formatSandboxId, rawSandboxId } from './sandboxRef'; // Import submodules, not the ./skills barrel, to avoid a cycle (the mounters import from Sandbox). import { dirname, join } from 'node:path'; -import { SKILLS_DIR } from './skills/constants'; import type { ISkillMounter } from './skills/ISkillMounter'; /** Layout derived from install remotePath (always `…/mcp_client.py`). */ @@ -93,6 +91,8 @@ export interface SandboxStoredFile { export interface SandboxOptions { provider: SandboxProvider; existingSandboxId?: string | undefined; + /** Chat session id; local provider nests the sandbox root under this segment. */ + sessionId?: string | undefined; skillMounter?: ISkillMounter | undefined; fileDownloadEnabled?: boolean | undefined; /** Pre-resolved credential-store file content (null = clear / no git auth). */ @@ -206,10 +206,10 @@ export class Sandbox extends LocalToolMCP { readonly name = SANDBOX_MCP_SERVER_ID; readonly displayName = 'Sandbox'; override readonly description = 'Persistent sandbox environment for code execution'; - static readonly FILE_UPLOADS_DIR = SANDBOX_FILE_UPLOADS_DIR; private readonly provider: SandboxProvider; private readonly existingSandboxId?: string | undefined; + private readonly sessionId?: string | undefined; private existingSandboxInfo: SandboxInfo | undefined; // Cached promise to prevent concurrent sub-agents from creating duplicate sandboxes. private sandboxCreationPromise?: Promise | undefined; @@ -241,6 +241,7 @@ export class Sandbox extends LocalToolMCP { super({ tracing: options.tracing }); this.provider = options.provider; this.existingSandboxId = options.existingSandboxId; + this.sessionId = options.sessionId; this.skillMounter = options.skillMounter; this.fileDownloadEnabled = options.fileDownloadEnabled ?? false; const mcpBoundTimeoutMs = options.mcpRequestTimeoutMs + options.mcpConnectTimeoutMs; @@ -259,6 +260,11 @@ export class Sandbox extends LocalToolMCP { return rawSandboxId(sessionId); } + /** Raw id for prompt layout paths before create (empty when this turn has no existing sandbox). */ + private promptSandboxId(): string { + return this.existingSandboxId === undefined ? '' : rawSandboxId(this.existingSandboxId); + } + private buildMcpServersEnvelope(): Record { const out: Record = {}; for (const server of this.codeExecToolSets) { @@ -292,7 +298,7 @@ export class Sandbox extends LocalToolMCP { private renderSkillsSection(): string { if (this.cachedSkillsSection === undefined) { const skills = new InstructionBuilder('skills'); - this.skillMounter?.instruction(skills); + this.skillMounter?.instruction(skills, { skillsDir: this.provider.getSkillsDir(this.promptSandboxId()) }); this.cachedSkillsSection = skills.build(); } return this.cachedSkillsSection; @@ -322,7 +328,7 @@ export class Sandbox extends LocalToolMCP { private buildFileUploadsSection(builder: InstructionBuilder): void { builder.addSection( SANDBOX_FILE_UPLOADS_TAG, - `User-uploaded files are placed in ${Sandbox.FILE_UPLOADS_DIR}/. The Agent must not modify files in this directory. Always create a copy at a different location and work on the copy.`, + `User-uploaded files are placed in ${this.provider.getFileUploadsDir(this.promptSandboxId())}/. The Agent must not modify files in this directory. Always create a copy at a different location and work on the copy.`, ); } @@ -467,7 +473,7 @@ export class Sandbox extends LocalToolMCP { } // Provider returns a raw id; persist the fancy `v1:type:raw` session id. this.sandboxCreationPromise ??= this.provider - .createSandbox() + .createSandbox(this.sessionId === undefined ? undefined : { sessionId: this.sessionId }) .then(({ sandboxId }) => ({ sandbox_id: formatSandboxId({ providerType: this.provider.type, rawId: sandboxId }), })) @@ -641,14 +647,16 @@ export class Sandbox extends LocalToolMCP { async uploadUserFile(input: { fileName: string; content: Buffer; mime: string }): Promise { validateNoPathTraversal(input.fileName); + const { sandboxCreated, sandboxInfo } = await this.ensureReadySandbox(); const result = await this.uploadFile({ - targetDir: Sandbox.FILE_UPLOADS_DIR, + targetDir: this.provider.getFileUploadsDir(this.providerSandboxId(sandboxInfo.sandbox_id)), fileName: input.fileName, content: input.content, }); + const created = sandboxCreated ?? result.sandboxCreated; return { filePath: result.filePath, - ...(result.sandboxCreated && { sandboxCreated: result.sandboxCreated }), + ...(created && { sandboxCreated: created }), }; } @@ -695,6 +703,8 @@ export class Sandbox extends LocalToolMCP { private async initSandboxEnvironment(): Promise { const sandboxId = this.providerSandboxId(this.requiredSandboxInfo.sandbox_id); + const fileUploadsDir = this.provider.getFileUploadsDir(sandboxId); + const skillsDir = this.provider.getSkillsDir(sandboxId); const toolResultDumpDir = this.provider.getToolResultDumpDir(sandboxId); this.logger.info('Uploading MCP client script and preparing skills directory in sandbox'); @@ -702,7 +712,7 @@ export class Sandbox extends LocalToolMCP { this.mcpClientInstall = this.codeModeTransport?.getClientInstall({ sandboxId }); const install = this.mcpClientInstall; - const dirs = [shellEscape(Sandbox.FILE_UPLOADS_DIR), shellEscape(toolResultDumpDir), shellEscape(SKILLS_DIR)]; + const dirs = [shellEscape(fileUploadsDir), shellEscape(toolResultDumpDir), shellEscape(skillsDir)]; if (install !== undefined) { const { pythonPath, binDir } = mcpClientLayout(install.remotePath); dirs.push(shellEscape(pythonPath), shellEscape(binDir)); @@ -728,7 +738,10 @@ export class Sandbox extends LocalToolMCP { // Fold skill installation into this single init exec. The mounter returns a declarative // command + env + timeout; runs even when empty so its downloader can prune a reused sandbox. - const skillInit = this.skillMounter?.getSandboxInit(); + const skillInit = this.skillMounter?.getSandboxInit({ + skillsDir, + gitDownloaderPath: this.provider.getGitDownloaderPath(sandboxId), + }); if (skillInit) { initSteps.push(skillInit.command); } @@ -746,8 +759,8 @@ export class Sandbox extends LocalToolMCP { this.logger.info( install === undefined - ? `Sandbox initialized: skills dir ${SKILLS_DIR}` - : `Sandbox initialized: MCP client at ${install.remotePath}; skills dir ${SKILLS_DIR}`, + ? `Sandbox initialized: skills dir ${skillsDir}` + : `Sandbox initialized: MCP client at ${install.remotePath}; skills dir ${skillsDir}`, ); await this.writeGitCredentials(); diff --git a/packages/trueforge-core/src/core/sandbox/codeMode/nats/CodeModeNatsTransport.ts b/packages/trueforge-core/src/core/sandbox/codeMode/nats/CodeModeNatsTransport.ts index cf8fb756e..f0b810d7e 100644 --- a/packages/trueforge-core/src/core/sandbox/codeMode/nats/CodeModeNatsTransport.ts +++ b/packages/trueforge-core/src/core/sandbox/codeMode/nats/CodeModeNatsTransport.ts @@ -52,7 +52,7 @@ export class CodeModeNatsTransport implements CodeModeTransport { this.logger = params.logger.child({ module: 'CodeModeNatsTransport' }); } - getClientInstall(_params: { sandboxId: string }): CodeModeClientInstall { + getClientInstall(): CodeModeClientInstall { return { content: sandboxScripts.mcpClient, remotePath: MCP_CLIENT_PATH, diff --git a/packages/trueforge-core/src/core/sandbox/constants.ts b/packages/trueforge-core/src/core/sandbox/constants.ts index 2bc24fc55..76fc1300c 100644 --- a/packages/trueforge-core/src/core/sandbox/constants.ts +++ b/packages/trueforge-core/src/core/sandbox/constants.ts @@ -1,6 +1,3 @@ -/** Stable directory inside the sandbox for user file uploads (mkdir during sandbox init). */ -export const SANDBOX_FILE_UPLOADS_DIR = '/tmp/uploads'; - /** Default port of the pod-local NATS WebSocket broker used by the sandbox→gateway MCP bridge. */ export const DEFAULT_SANDBOX_NATS_WS_PORT = 4444; diff --git a/packages/trueforge-core/src/core/sandbox/provider/DaytonaProvider.ts b/packages/trueforge-core/src/core/sandbox/provider/DaytonaProvider.ts index 2279c9857..b5d742d6d 100644 --- a/packages/trueforge-core/src/core/sandbox/provider/DaytonaProvider.ts +++ b/packages/trueforge-core/src/core/sandbox/provider/DaytonaProvider.ts @@ -2,7 +2,8 @@ import type { Sandbox, Snapshot } from '@daytona/sdk'; import { Daytona, DaytonaError } from '@daytona/sdk'; import { context } from '@opentelemetry/api'; import { suppressTracing } from '@opentelemetry/core'; -import { randomUUID } from 'crypto'; +import { randomUUID } from 'node:crypto'; +import { join } from 'node:path'; import type { Logger } from 'winston'; import { extractErrorLogFields } from '../../util/errorLogFields'; import { @@ -489,11 +490,23 @@ export class DaytonaSandboxProvider implements SandboxProvider { } getToolResultDumpDir(): string { - return '/tmp/tool-results'; + return join('/tmp', 'tool-results'); } getGitCredentialsPath(): string { // Isolated container per sandbox; absolute path so GIT_CONFIG_* needs no $HOME expansion. - return '/tmp/.git-credentials'; + return join('/tmp', '.git-credentials'); + } + + getFileUploadsDir(): string { + return join('/tmp', 'uploads'); + } + + getSkillsDir(): string { + return join('/opt', 'tf', 'skills'); + } + + getGitDownloaderPath(): string { + return join('/opt', 'tf', 'git_downloader.py'); } } diff --git a/packages/trueforge-core/src/core/sandbox/provider/Provider.ts b/packages/trueforge-core/src/core/sandbox/provider/Provider.ts index b5c35e4bb..a0c9a632f 100644 --- a/packages/trueforge-core/src/core/sandbox/provider/Provider.ts +++ b/packages/trueforge-core/src/core/sandbox/provider/Provider.ts @@ -75,7 +75,7 @@ export interface SandboxProvider { buildImage(): Promise; /** Current build status of the release image. Read-only: never kicks off a build. */ getImageBuildStatus(): Promise; - createSandbox(): Promise<{ sandboxId: string }>; + createSandbox(params?: { sessionId?: string }): Promise<{ sandboxId: string }>; exec(params: SandboxExecParams): Promise; /** Provider-specific instructions appended to the agent system prompt. */ getAdditionalInstructions(): string | undefined; @@ -83,6 +83,12 @@ export interface SandboxProvider { getToolResultDumpDir(sandboxId: string): string; /** Absolute path for the git credential-store file (per logical sandbox when sharing a pod). */ getGitCredentialsPath(sandboxId: string): string; + /** Directory for user-uploaded files (absolute, or cwd-relative when the provider has no global FS). */ + getFileUploadsDir(sandboxId: string): string; + /** Directory where git skills are materialized. */ + getSkillsDir(sandboxId: string): string; + /** Path the git skill downloader script is written to before it runs. */ + getGitDownloaderPath(sandboxId: string): string; /** Downloads a file from the sandbox as a Buffer. Throws SandboxFileNotFoundError / SandboxNotAvailableError / SandboxPathIsDirectoryError / SandboxFileTooLargeError. */ downloadFile(params: { sandboxId: string; path: string }): Promise; /** Uploads a file to the sandbox. */ diff --git a/packages/trueforge-core/src/core/sandbox/provider/TFYSandboxProvider.ts b/packages/trueforge-core/src/core/sandbox/provider/TFYSandboxProvider.ts index f176345dc..e80737f1c 100644 --- a/packages/trueforge-core/src/core/sandbox/provider/TFYSandboxProvider.ts +++ b/packages/trueforge-core/src/core/sandbox/provider/TFYSandboxProvider.ts @@ -1,7 +1,8 @@ import { context } from '@opentelemetry/api'; import { suppressTracing } from '@opentelemetry/core'; -import { randomUUID } from 'crypto'; import dedent from 'dedent'; +import { randomUUID } from 'node:crypto'; +import { join } from 'node:path'; import type { Logger } from 'winston'; import { extractErrorLogFields } from '../../util/errorLogFields'; import type { CodeModeTransport } from '../codeMode/CodeModeTransport'; @@ -200,10 +201,22 @@ export class TFYSandboxProvider implements SandboxProvider { } getToolResultDumpDir(sandboxId: string): string { - return `/tmp/${sandboxId}/tool-results`; + return join('/tmp', sandboxId, 'tool-results'); } getGitCredentialsPath(sandboxId: string): string { - return `/tmp/${sandboxId}/.git-credentials`; + return join('/tmp', sandboxId, '.git-credentials'); + } + + getFileUploadsDir(sandboxId: string): string { + return join('/tmp', sandboxId, 'uploads'); + } + + getSkillsDir(sandboxId: string): string { + return join('/opt', 'tf', sandboxId, 'skills'); + } + + getGitDownloaderPath(sandboxId: string): string { + return join('/opt', 'tf', sandboxId, 'git_downloader.py'); } } diff --git a/packages/trueforge-core/src/core/sandbox/skills/ISkillMounter.ts b/packages/trueforge-core/src/core/sandbox/skills/ISkillMounter.ts index afacd62c8..07fbd601e 100644 --- a/packages/trueforge-core/src/core/sandbox/skills/ISkillMounter.ts +++ b/packages/trueforge-core/src/core/sandbox/skills/ISkillMounter.ts @@ -5,7 +5,7 @@ import type { SandboxInit } from '../provider/Provider'; // holds one mounter and never inspects individual skills. export interface ISkillMounter { // Renders the section; adds nothing when empty, so InstructionBuilder drops the section. - instruction(builder: InstructionBuilder): void; + instruction(builder: InstructionBuilder, paths: { skillsDir: string }): void; // The command (+ env, timeout) that installs these skills; Sandbox folds it into its init exec. - getSandboxInit(): SandboxInit; + getSandboxInit(paths: { skillsDir: string; gitDownloaderPath: string }): SandboxInit; } diff --git a/packages/trueforge-core/src/core/sandbox/skills/SkillMounter.ts b/packages/trueforge-core/src/core/sandbox/skills/SkillMounter.ts index 17156b7f0..6037b5be3 100644 --- a/packages/trueforge-core/src/core/sandbox/skills/SkillMounter.ts +++ b/packages/trueforge-core/src/core/sandbox/skills/SkillMounter.ts @@ -5,9 +5,6 @@ import { sandboxScripts } from '../sandboxScripts.gen'; import { SKILLS_PREAMBLE, getSkillPath, renderSkillPromptBody } from './constants'; import type { ISkillMounter } from './ISkillMounter'; -// Absolute path the git skill downloader script is uploaded to before it is run. -const GIT_DOWNLOADER_PATH = '/opt/tfy/git_downloader.py'; - // A git-sourced skill materialized by a sparse clone in the sandbox (git_downloader.py). Git skills // never preload — their SKILL.md is read from disk at runtime (only name/description/path are // advertised in the prompt). Wire fields match the agent_spec git mount (`url`/`path`/`name`/`ref`); @@ -38,7 +35,7 @@ export class SkillMounter implements ISkillMounter { this.skills = skills; } - instruction(builder: InstructionBuilder): void { + instruction(builder: InstructionBuilder, paths: { skillsDir: string }): void { if (this.skills.length === 0) { return; } @@ -47,7 +44,7 @@ export class SkillMounter implements ISkillMounter { builder.addSection( 'skill', renderSkillPromptBody({ - path: getSkillPath(skill.name), + path: getSkillPath({ skillsDir: paths.skillsDir, skillName: skill.name }), name: skill.name, description: skill.description, preloadContent: null, @@ -56,7 +53,7 @@ export class SkillMounter implements ISkillMounter { } } - getSandboxInit(): SandboxInit { + getSandboxInit(paths: { skillsDir: string; gitDownloaderPath: string }): SandboxInit { // An empty desired set is also the source-neutral cleanup path for a reused sandbox. const specs: GitSkillSpec[] = this.skills.map(skill => ({ name: skill.name, @@ -67,11 +64,11 @@ export class SkillMounter implements ISkillMounter { const gitSkillsB64 = Buffer.from(JSON.stringify(specs)).toString('base64'); return { command: buildWriteAndRunScriptCommand({ - scriptPath: GIT_DOWNLOADER_PATH, + scriptPath: paths.gitDownloaderPath, // Bundled at build time (sandboxScripts.gen.ts) so the packaged library has no loose files. scriptContent: sandboxScripts.gitDownloader, }), - env: { AGENT_GIT_SKILLS: gitSkillsB64 }, + env: { AGENT_GIT_SKILLS: gitSkillsB64, TFY_SKILLS_DIR: paths.skillsDir }, timeoutSeconds: SKILL_DOWNLOAD_TIMEOUT_SECONDS, }; } diff --git a/packages/trueforge-core/src/core/sandbox/skills/constants.ts b/packages/trueforge-core/src/core/sandbox/skills/constants.ts index 8ef328f61..e51a9ba21 100644 --- a/packages/trueforge-core/src/core/sandbox/skills/constants.ts +++ b/packages/trueforge-core/src/core/sandbox/skills/constants.ts @@ -1,11 +1,8 @@ // Skill prompt constants and helpers: how skills are described to the agent in the system prompt. -// Directory inside the sandbox where every skill (whatever its origin) is materialized. -export const SKILLS_DIR = '/opt/tfy/skills'; - // On-disk directory a skill is materialized into, keyed by its (unique) name. -export function getSkillPath(skillName: string): string { - return `${SKILLS_DIR}/${skillName}`; +export function getSkillPath(params: { skillsDir: string; skillName: string }): string { + return `${params.skillsDir.replace(/\/+$/, '')}/${params.skillName}`; } export const SKILLS_PREAMBLE = [ diff --git a/packages/trueforge-core/src/core/sandbox/skills/index.ts b/packages/trueforge-core/src/core/sandbox/skills/index.ts index 4940b6927..23636c14b 100644 --- a/packages/trueforge-core/src/core/sandbox/skills/index.ts +++ b/packages/trueforge-core/src/core/sandbox/skills/index.ts @@ -1,4 +1,4 @@ -export { SKILLS_DIR, SKILLS_PREAMBLE, getSkillPath, renderSkillPromptBody } from './constants'; +export { SKILLS_PREAMBLE, getSkillPath, renderSkillPromptBody } from './constants'; export type { ISkillMounter } from './ISkillMounter'; export { SkillMounter } from './SkillMounter'; export type { GitSkill } from './SkillMounter'; diff --git a/packages/trueforge-core/tests/core/harnessMocks.ts b/packages/trueforge-core/tests/core/harnessMocks.ts index 9b6379a72..68966faed 100644 --- a/packages/trueforge-core/tests/core/harnessMocks.ts +++ b/packages/trueforge-core/tests/core/harnessMocks.ts @@ -65,6 +65,9 @@ export function makeStubPublicSandbox(tenantName = 'test-tenant'): Sandbox { getAdditionalInstructions: () => undefined, getToolResultDumpDir: () => '/tmp/tool-results', getGitCredentialsPath: () => '/tmp/.git-credentials', + getFileUploadsDir: () => '/tmp/uploads', + getSkillsDir: () => '/opt/tfy/skills', + getGitDownloaderPath: () => '/opt/tfy/git_downloader.py', downloadFile: jest.fn(), uploadFile: jest.fn(), createCodeModeTransport: jest.fn(), diff --git a/packages/trueforge-core/tests/core/sandbox/Sandbox.ids.test.ts b/packages/trueforge-core/tests/core/sandbox/Sandbox.ids.test.ts index 39eb08ff6..27673b0ae 100644 --- a/packages/trueforge-core/tests/core/sandbox/Sandbox.ids.test.ts +++ b/packages/trueforge-core/tests/core/sandbox/Sandbox.ids.test.ts @@ -19,6 +19,9 @@ function makeProvider( getAdditionalInstructions: () => undefined, getToolResultDumpDir: sandboxId => `${sandboxId}/tool-results`, getGitCredentialsPath: sandboxId => `${sandboxId}/.git-credentials`, + getFileUploadsDir: sandboxId => `${sandboxId}/uploads`, + getSkillsDir: sandboxId => `${sandboxId}/skills`, + getGitDownloaderPath: sandboxId => `${sandboxId}/git_downloader.py`, downloadFile: jest.fn(), uploadFile: jest.fn().mockResolvedValue(undefined), createCodeModeTransport: jest.fn(), diff --git a/packages/trueforge-core/tests/core/sandbox/Sandbox.paths.test.ts b/packages/trueforge-core/tests/core/sandbox/Sandbox.paths.test.ts new file mode 100644 index 000000000..4387d32ac --- /dev/null +++ b/packages/trueforge-core/tests/core/sandbox/Sandbox.paths.test.ts @@ -0,0 +1,110 @@ +import { InstructionBuilder } from '../../../src/core/InstructionBuilder'; +import type { ExecResult, SandboxProvider } from '../../../src/core/sandbox/provider/Provider'; +import { Sandbox } from '../../../src/core/sandbox/Sandbox'; +import { NOOP_AGENT_TRACING } from '../../../src/core/tracing/NoopAgentTracing'; +import { makeSilentLogger } from '../harnessMocks'; + +function readyExec(): Promise { + return Promise.resolve({ success: true, response: { exitCode: 0, result: 'ok' } }); +} + +function makeProvider(overrides: Partial = {}): SandboxProvider { + return { + type: 'test', + buildImage: () => Promise.resolve({ status: 'ready', reason: null, metadata: null }), + getImageBuildStatus: () => Promise.resolve({ status: 'ready', reason: null, metadata: null }), + createSandbox: () => Promise.resolve({ sandboxId: 'raw-1' }), + exec: () => readyExec(), + getAdditionalInstructions: () => undefined, + getToolResultDumpDir: () => '/prov/tool-results', + getGitCredentialsPath: () => '/prov/.git-credentials', + getFileUploadsDir: () => '/prov/uploads', + getSkillsDir: () => '/prov/skills', + getGitDownloaderPath: () => '/prov/git_downloader.py', + downloadFile: jest.fn(), + uploadFile: jest.fn().mockResolvedValue(undefined), + createCodeModeTransport: jest.fn(), + ...overrides, + }; +} + +function makeSandbox( + provider: SandboxProvider, + options: { existingSandboxId?: string; sessionId?: string } = {}, +): Sandbox { + return new Sandbox({ + provider, + existingSandboxId: options.existingSandboxId, + sessionId: options.sessionId, + blockDestructiveToolsInCodeMode: true, + mcpRequestTimeoutMs: 60_000, + mcpConnectTimeoutMs: 5_000, + logger: makeSilentLogger(), + tracing: NOOP_AGENT_TRACING, + }); +} + +describe('Sandbox provider-owned paths', () => { + it('puts the provider uploads dir in the system prompt, not /tmp/uploads', () => { + const sandbox = makeSandbox(makeProvider()); + const builder = new InstructionBuilder('root'); + sandbox.buildInstruction(builder); + const prompt = builder.build(); + expect(prompt).toContain('/prov/uploads/'); + expect(prompt).not.toContain('/tmp/uploads'); + }); + + it('uploads user files to the provider uploads dir', async () => { + const uploadFile = jest.fn().mockResolvedValue(undefined); + const provider = makeProvider({ uploadFile }); + const sandbox = makeSandbox(provider); + const stored = await sandbox.uploadUserFile({ + fileName: 'notes.txt', + content: Buffer.from('hi'), + mime: 'text/plain', + }); + expect(stored.filePath).toBe('/prov/uploads/notes.txt'); + expect(uploadFile).toHaveBeenCalledWith({ + sandboxId: 'raw-1', + remotePath: '/prov/uploads/notes.txt', + content: Buffer.from('hi'), + }); + }); + + it('mkdirs provider uploads and skills dirs during init', async () => { + const exec = jest.fn().mockImplementation(() => readyExec()); + const sandbox = makeSandbox(makeProvider({ exec })); + await sandbox.uploadUserFile({ + fileName: 'a.txt', + content: Buffer.from('x'), + mime: 'text/plain', + }); + const mkdirCall = exec.mock.calls.find((call: unknown[]) => { + const params = call[0]; + return ( + typeof params === 'object' && + params !== null && + 'command' in params && + typeof params.command === 'string' && + params.command.startsWith('mkdir -p') + ); + }); + expect(mkdirCall).toBeDefined(); + const params = mkdirCall?.[0] as { command: string }; + expect(params.command).toContain('/prov/uploads'); + expect(params.command).toContain('/prov/skills'); + expect(params.command).not.toContain('/tmp/uploads'); + expect(params.command).not.toContain('/opt/tfy/skills'); + }); + + it('passes sessionId through to createSandbox', async () => { + const createSandbox = jest.fn().mockResolvedValue({ sandboxId: 'raw-1' }); + const sandbox = makeSandbox(makeProvider({ createSandbox }), { sessionId: 'sess_1' }); + await sandbox.uploadUserFile({ + fileName: 'a.txt', + content: Buffer.from('x'), + mime: 'text/plain', + }); + expect(createSandbox).toHaveBeenCalledWith({ sessionId: 'sess_1' }); + }); +}); diff --git a/packages/trueforge-core/tests/core/sandbox/sandboxBridgeTimeout.test.ts b/packages/trueforge-core/tests/core/sandbox/sandboxBridgeTimeout.test.ts index 670365984..88f9c8181 100644 --- a/packages/trueforge-core/tests/core/sandbox/sandboxBridgeTimeout.test.ts +++ b/packages/trueforge-core/tests/core/sandbox/sandboxBridgeTimeout.test.ts @@ -28,6 +28,9 @@ function makeSandbox(options: { getAdditionalInstructions: () => undefined, getToolResultDumpDir: () => '/tmp/tool-results', getGitCredentialsPath: () => '/tmp/.git-credentials', + getFileUploadsDir: () => '/tmp/uploads', + getSkillsDir: () => '/opt/tfy/skills', + getGitDownloaderPath: () => '/opt/tfy/git_downloader.py', downloadFile: jest.fn(), uploadFile: jest.fn(), createCodeModeTransport: () => { diff --git a/packages/trueforge-core/tests/core/sandbox/skills/skillMounter.test.ts b/packages/trueforge-core/tests/core/sandbox/skills/skillMounter.test.ts index 1a4988364..aa41fc036 100644 --- a/packages/trueforge-core/tests/core/sandbox/skills/skillMounter.test.ts +++ b/packages/trueforge-core/tests/core/sandbox/skills/skillMounter.test.ts @@ -15,37 +15,47 @@ const GIT_SKILL = { ref: 'a'.repeat(40), }; +const PROVIDER_PATHS = { + skillsDir: '/custom/skills', + gitDownloaderPath: '/custom/git_downloader.py', +}; + // Renders a mounter's section the same way Sandbox does (empty section => ''). function renderSkills(mounter: ISkillMounter): string { const builder = new InstructionBuilder('skills'); - mounter.instruction(builder); + mounter.instruction(builder, { skillsDir: PROVIDER_PATHS.skillsDir }); return builder.build(); } describe('SkillMounter (public, git-only)', () => { - it('embeds the git downloader and passes only git skills', () => { - const init = new SkillMounter([GIT_SKILL]).getSandboxInit(); + it('embeds the injected git downloader and TFY_SKILLS_DIR', () => { + const init = new SkillMounter([GIT_SKILL]).getSandboxInit(PROVIDER_PATHS); - expect(init.command).toContain('/opt/tfy/git_downloader.py'); + expect(init.command).toContain('/custom/git_downloader.py'); + expect(init.command).not.toContain('/opt/tfy/git_downloader.py'); expect(init.env?.['AGENT_GIT_SKILLS']).toBeDefined(); + expect(init.env?.['TFY_SKILLS_DIR']).toBe('/custom/skills'); expect(init.env?.['AGENT_SKILL_VERSION_FQNS']).toBeUndefined(); expect(init.env?.['TFY_API_KEY']).toBeUndefined(); expect(init.timeoutSeconds).toBe(180); }); - it('renders a section with a block per skill', () => { + it('renders a section with a block per skill using the injected skills dir', () => { const rendered = renderSkills(new SkillMounter([GIT_SKILL])); expect(rendered).toContain(''); expect(rendered).toContain('git-skill'); + expect(rendered).toContain('/custom/skills/git-skill'); + expect(rendered).not.toContain('/opt/tfy/skills'); }); it('uses an empty desired set for cleanup on a reused sandbox and renders nothing', () => { const mounter = new SkillMounter([]); - const init = mounter.getSandboxInit(); + const init = mounter.getSandboxInit(PROVIDER_PATHS); expect(renderSkills(mounter)).toBe(''); - expect(init.command).toContain('/opt/tfy/git_downloader.py'); + expect(init.command).toContain('/custom/git_downloader.py'); + expect(init.env?.['TFY_SKILLS_DIR']).toBe('/custom/skills'); expect(JSON.parse(Buffer.from(init.env?.['AGENT_GIT_SKILLS'] ?? '', 'base64').toString())).toEqual([]); }); }); diff --git a/packages/trueforge/jest.local-contract.config.cjs b/packages/trueforge/jest.local-contract.config.cjs index 2bab17595..34cc0cc61 100644 --- a/packages/trueforge/jest.local-contract.config.cjs +++ b/packages/trueforge/jest.local-contract.config.cjs @@ -1,10 +1,40 @@ /** @type {import('jest').Config} */ -const unit = require('./jest.unit.config.cjs'); - module.exports = { - ...unit, - testPathIgnorePatterns: [], - testMatch: ['/tests/unit/sandbox/local/**/*.contract.test.ts'], + testEnvironment: 'node', + transform: { + '^.+\\.tsx?$': [ + '@swc/jest', + { + jsc: { + parser: { syntax: 'typescript', decorators: true, dynamicImport: true }, + target: 'es2022', + }, + module: { type: 'commonjs' }, + }, + ], + '^.+\\.m?js$': [ + '@swc/jest', + { + jsc: { + parser: { syntax: 'ecmascript', dynamicImport: true }, + target: 'es2022', + }, + module: { type: 'commonjs' }, + }, + ], + }, + transformIgnorePatterns: [], + moduleNameMapper: { + '^(\\.{1,2}/.*)\\.js$': '$1', + '^@truefoundry/trueforge-core/agent-session$': '/../trueforge-core/src/agent-session/index.ts', + '^@truefoundry/trueforge-core/agent-session/(.*)$': '/../trueforge-core/src/agent-session/$1', + '^@truefoundry/trueforge-core/request-reply$': '/../trueforge-core/src/request-reply/index.ts', + '^@truefoundry/trueforge-core/request-reply/(.*)$': '/../trueforge-core/src/request-reply/$1', + '^@truefoundry/trueforge-core/core$': '/../trueforge-core/src/core/index.ts', + '^@truefoundry/trueforge-core/core/(.*)$': '/../trueforge-core/src/core/$1', + }, testTimeout: 120_000, maxWorkers: 1, + roots: ['/tests/unit'], + testMatch: ['/tests/unit/sandbox/local/**/*.contract.test.ts'], }; diff --git a/packages/trueforge/jest.local-smoke.config.cjs b/packages/trueforge/jest.local-smoke.config.cjs index 155eea5a4..330ee2292 100644 --- a/packages/trueforge/jest.local-smoke.config.cjs +++ b/packages/trueforge/jest.local-smoke.config.cjs @@ -1,10 +1,40 @@ /** @type {import('jest').Config} */ -const unit = require('./jest.unit.config.cjs'); - module.exports = { - ...unit, - roots: ['/tests/sandbox/local'], - testMatch: ['/tests/sandbox/local/smoke.test.ts'], + testEnvironment: 'node', + transform: { + '^.+\\.tsx?$': [ + '@swc/jest', + { + jsc: { + parser: { syntax: 'typescript', decorators: true, dynamicImport: true }, + target: 'es2022', + }, + module: { type: 'commonjs' }, + }, + ], + '^.+\\.m?js$': [ + '@swc/jest', + { + jsc: { + parser: { syntax: 'ecmascript', dynamicImport: true }, + target: 'es2022', + }, + module: { type: 'commonjs' }, + }, + ], + }, + transformIgnorePatterns: [], + moduleNameMapper: { + '^(\\.{1,2}/.*)\\.js$': '$1', + '^@truefoundry/trueforge-core/agent-session$': '/../trueforge-core/src/agent-session/index.ts', + '^@truefoundry/trueforge-core/agent-session/(.*)$': '/../trueforge-core/src/agent-session/$1', + '^@truefoundry/trueforge-core/request-reply$': '/../trueforge-core/src/request-reply/index.ts', + '^@truefoundry/trueforge-core/request-reply/(.*)$': '/../trueforge-core/src/request-reply/$1', + '^@truefoundry/trueforge-core/core$': '/../trueforge-core/src/core/index.ts', + '^@truefoundry/trueforge-core/core/(.*)$': '/../trueforge-core/src/core/$1', + }, testTimeout: 120_000, maxWorkers: 1, + roots: ['/tests/sandbox/local'], + testMatch: ['/tests/sandbox/local/smoke.test.ts'], }; diff --git a/packages/trueforge/src/apis/turns.ts b/packages/trueforge/src/apis/turns.ts index 71bafed94..9565629a2 100644 --- a/packages/trueforge/src/apis/turns.ts +++ b/packages/trueforge/src/apis/turns.ts @@ -129,6 +129,7 @@ function createTurnResolver(deps: { logger: Logger; signal: AbortSignal; userRef: string; + sessionId: string; }): TurnResourceResolver { const { mcpServerStore, @@ -140,6 +141,7 @@ function createTurnResolver(deps: { logger, signal, userRef, + sessionId, } = deps; return new TurnResourceResolver({ llm: async name => { @@ -215,6 +217,7 @@ function createTurnResolver(deps: { gitSkills, fileDownloadEnabled: spec.config.sandbox.file_downloads, existingSandboxId: carriedSandboxId, + sessionId, tracing, tenantName: TENANT_ID, }); @@ -512,6 +515,7 @@ export function createTurnsRouter(deps: TurnsRouterDeps) { logger: deps.logger, signal: abortController.signal, userRef: deps.resolveUserContext(c).userRef, + sessionId, }); // First turn only: derive the title from the first user message. The store diff --git a/packages/trueforge/src/main.ts b/packages/trueforge/src/main.ts index 981eefc86..d132bb674 100644 --- a/packages/trueforge/src/main.ts +++ b/packages/trueforge/src/main.ts @@ -259,8 +259,18 @@ try { const { LocalSandboxProvider } = await import('./sandbox/local/provider/LocalSandboxProvider'); const support = await LocalSandboxProvider.isSupported(); setCachedLocalSandboxSupport(support); - if (!support.supported) { - logger.warn('Local sandbox fallback is unavailable', { reason: support.reason }); + if (support.supported) { + logger.info('Local sandbox fallback is available', { + platform: support.platform, + shell: support.shell, + python: support.python, + }); + } else { + logger.warn('Local sandbox fallback is unavailable', { + reason: support.reason, + ...(support.platform === undefined ? {} : { platform: support.platform }), + ...(support.attempts === undefined ? {} : { attempts: support.attempts }), + }); } } else { logger.info('TrueForge starting', { mode: 'distributed' }); diff --git a/packages/trueforge/src/runtime/sessionResources.ts b/packages/trueforge/src/runtime/sessionResources.ts index 7c04ac6ba..994868b96 100644 --- a/packages/trueforge/src/runtime/sessionResources.ts +++ b/packages/trueforge/src/runtime/sessionResources.ts @@ -256,6 +256,7 @@ export function buildTurnSandbox(input: { gitSkills: readonly GitSkill[]; fileDownloadEnabled: boolean; existingSandboxId?: string | undefined; + sessionId: string; tracing: AgentTracing; tenantName: string; }): Sandbox { @@ -263,6 +264,7 @@ export function buildTurnSandbox(input: { return new Sandbox({ provider: input.provider, existingSandboxId: input.existingSandboxId, + sessionId: input.sessionId, fileDownloadEnabled: input.fileDownloadEnabled, blockDestructiveToolsInCodeMode: true, mcpRequestTimeoutMs: configuration.MCP_REQUEST_TIMEOUT_MS, diff --git a/packages/trueforge/src/sandbox/local/core/CodeModeUdsTransport.ts b/packages/trueforge/src/sandbox/local/core/CodeModeUdsTransport.ts index beadaafe5..f200f53b5 100644 --- a/packages/trueforge/src/sandbox/local/core/CodeModeUdsTransport.ts +++ b/packages/trueforge/src/sandbox/local/core/CodeModeUdsTransport.ts @@ -24,7 +24,7 @@ import { sandboxScripts } from '../sandboxScripts.gen.js'; import { encodeJsonMessage, JsonMessageReader, MAX_MESSAGE_BYTES } from './frame.js'; import { registerCodeModeSocketPath, unregisterCodeModeSocketPath } from './hostRun.js'; -const MAX_CODE_MODE_SOCKET_PARENT_BYTES = 60; +const MAX_CODE_MODE_SOCKET_PARENT_BYTES = 63; const CODE_MODE_SOCKET_PARENT_MODE = 0o700; const CODE_MODE_SOCKET_MODE = 0o600; @@ -70,7 +70,7 @@ export function assertCodeModeSocketParentPath(path: string): string { const bytes = Buffer.byteLength(real); if (bytes > MAX_CODE_MODE_SOCKET_PARENT_BYTES) { throw new Error( - `codeModeSocketParentPath must be at most ${String(MAX_CODE_MODE_SOCKET_PARENT_BYTES)} bytes (got ${String(bytes)})`, + `codeModeSocketParentPath (${real}) must be at most ${String(MAX_CODE_MODE_SOCKET_PARENT_BYTES)} bytes (got ${String(bytes)})`, ); } // Owner-only parent: other accounts cannot rename/replace socks under this dir. diff --git a/packages/trueforge/src/sandbox/local/core/hostRun.ts b/packages/trueforge/src/sandbox/local/core/hostRun.ts index ce9fab816..8d87d3bcf 100644 --- a/packages/trueforge/src/sandbox/local/core/hostRun.ts +++ b/packages/trueforge/src/sandbox/local/core/hostRun.ts @@ -47,8 +47,8 @@ function requireActivePlatform(): LocalSandboxPlatform { const COMMAND_PATH_BY_PLATFORM = { // On macOS, prefer Homebrew ahead of `/usr/bin` shims (those need Xcode select // paths that we intentionally do not allow-read). - darwin: '/opt/homebrew/bin:/usr/bin:/bin:/usr/sbin:/sbin', - linux: '/usr/bin:/bin:/usr/sbin:/sbin', + darwin: '/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin', + linux: '/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin', } as const satisfies Record; export function commandPath(platform: LocalSandboxPlatform): string { @@ -82,6 +82,30 @@ export async function resolveCommandOnHost(params: { } } +/** + * Resolve the real interpreter on the host before running it under seatbelt. + * Apple's `/usr/bin/python3` is an xcode-select stub; python.org / Homebrew + * leave `sys.executable` as a PATH symlink (`/usr/local/bin/python3`). + */ +export async function resolvePythonExecutableOnHost(params: { commandPath: string }): Promise { + if (!isAbsolute(params.commandPath)) { + return undefined; + } + try { + const { stdout } = await execFileAsync(params.commandPath, ['-c', 'import sys; print(sys.executable)'], { + encoding: 'utf8', + timeout: 5_000, + }); + const executable = stdout.trim().split(/\r?\n/).filter(Boolean).at(-1); + if (executable === undefined || executable.length === 0 || !isAbsolute(executable)) { + return undefined; + } + return realpathSync(executable); + } catch { + return undefined; + } +} + export interface SessionResult { stdoutText: string; stderrText: string; @@ -104,6 +128,7 @@ function denySharedDefaultWritePaths(): string[] { const ALLOW_READ_BY_PLATFORM = { darwin: [ '/opt/homebrew/bin', + '/usr/local', '/usr/bin', '/bin', '/usr/sbin', diff --git a/packages/trueforge/src/sandbox/local/index.ts b/packages/trueforge/src/sandbox/local/index.ts index 145849cc5..2a9ca23bb 100644 --- a/packages/trueforge/src/sandbox/local/index.ts +++ b/packages/trueforge/src/sandbox/local/index.ts @@ -1,5 +1,9 @@ export { CodeModeUdsTransport, installMcpFixture, localMcpClientRemotePath } from './core/CodeModeUdsTransport.js'; export type { CodeModeUdsTransportOptions } from './core/CodeModeUdsTransport.js'; export type { LocalSandboxPlatform } from './core/hostRun.js'; -export { LocalSandboxProvider } from './provider/LocalSandboxProvider.js'; -export type { LocalSandboxProviderOptions, LocalSandboxSupportResult } from './provider/LocalSandboxProvider.js'; +export { LocalSandboxProvider, formatLocalSandboxSupportReason } from './provider/LocalSandboxProvider.js'; +export type { + LocalSandboxProviderOptions, + LocalSandboxSupportProbeAttempt, + LocalSandboxSupportResult, +} from './provider/LocalSandboxProvider.js'; diff --git a/packages/trueforge/src/sandbox/local/provider/LocalSandboxProvider.ts b/packages/trueforge/src/sandbox/local/provider/LocalSandboxProvider.ts index 7063ce06c..42f095615 100644 --- a/packages/trueforge/src/sandbox/local/provider/LocalSandboxProvider.ts +++ b/packages/trueforge/src/sandbox/local/provider/LocalSandboxProvider.ts @@ -29,8 +29,10 @@ import { removeSandbox, resetSrt, resolveCommandOnHost, + resolvePythonExecutableOnHost, runSupervisorSession, type LocalSandboxPlatform, + type SessionResult, } from '../core/hostRun.js'; import { XferFileInfoSchema, type XferFileInfo } from '../schemas/xferFileInfo.js'; @@ -45,9 +47,94 @@ const PYTHON_CANDIDATES = ['python3', 'python'] as const; export type { LocalSandboxPlatform }; +/** One shell/python candidate tried by {@link LocalSandboxProvider.isSupported}. */ +export interface LocalSandboxSupportProbeAttempt { + kind: 'shell' | 'python'; + name: string; + resolved: string | undefined; + executable?: string | undefined; + exitCode?: number | undefined; + stdout?: string | undefined; + stderr?: string | undefined; + protocolError?: string | undefined; + timedOut?: boolean | undefined; +} + export type LocalSandboxSupportResult = | { supported: true; platform: LocalSandboxPlatform; shell: string; python: string } - | { supported: false; reason: string }; + | { + supported: false; + reason: string; + platform?: LocalSandboxPlatform | undefined; + attempts?: readonly LocalSandboxSupportProbeAttempt[] | undefined; + }; + +export function formatLocalSandboxSupportReason(params: { + summary: string; + attempts: readonly LocalSandboxSupportProbeAttempt[]; +}): string { + const details = params.attempts.map(formatLocalSandboxSupportAttempt).join('; '); + return details.length === 0 ? params.summary : `${params.summary}: ${details}`; +} + +function formatLocalSandboxSupportAttempt(attempt: LocalSandboxSupportProbeAttempt): string { + if (attempt.resolved === undefined) { + return `${attempt.name}: not on sandbox PATH`; + } + const parts = [`${attempt.name}: resolved=${attempt.resolved}`]; + if (attempt.executable !== undefined && attempt.executable !== attempt.resolved) { + parts.push(`executable=${attempt.executable}`); + } + if (attempt.protocolError !== undefined) { + parts.push(`protocolError=${attempt.protocolError}`); + } + if (attempt.exitCode !== undefined) { + parts.push(`exit=${String(attempt.exitCode)}`); + } + if (attempt.timedOut === true) { + parts.push('timedOut'); + } + if (attempt.stderr !== undefined && attempt.stderr.length > 0) { + parts.push(`stderr=${JSON.stringify(attempt.stderr)}`); + } + if (attempt.stdout !== undefined && attempt.stdout.length > 0) { + parts.push(`stdout=${JSON.stringify(attempt.stdout)}`); + } + return parts.join(' '); +} + +function probeAttemptFromSession(params: { + kind: 'shell' | 'python'; + name: string; + resolved: string; + executable?: string | undefined; + session: SessionResult; +}): LocalSandboxSupportProbeAttempt { + return { + kind: params.kind, + name: params.name, + resolved: params.resolved, + ...(params.executable === undefined ? {} : { executable: params.executable }), + exitCode: params.session.exitCode, + stdout: params.session.stdoutText, + stderr: params.session.stderrText, + ...(params.session.protocolError === undefined ? {} : { protocolError: params.session.protocolError }), + timedOut: params.session.timedOut, + }; +} + +function unsupported(params: { + reason: string; + platform?: LocalSandboxPlatform | undefined; + attempts?: readonly LocalSandboxSupportProbeAttempt[] | undefined; +}): Extract { + return { + supported: false, + reason: params.reason, + ...(params.platform === undefined ? {} : { platform: params.platform }), + ...(params.attempts === undefined ? {} : { attempts: params.attempts }), + }; +} type LocalSandboxSupported = Extract; @@ -71,6 +158,14 @@ function toSandboxRelativePath(params: { sandboxRootPath: string; absolutePath: return rel === '' ? '.' : rel; } +/** Single path segment under the sandboxes parent (`_` when sessionId is missing or unsafe). */ +export function localSandboxSessionSegment(sessionId: string | undefined): string { + if (sessionId === undefined || sessionId.length === 0 || sessionId.includes('/') || sessionId.includes('..')) { + return '_'; + } + return sessionId; +} + export class LocalSandboxProvider implements SandboxProvider { readonly type = 'local'; private readonly sandboxRootPathParent: string; @@ -93,15 +188,16 @@ export class LocalSandboxProvider implements SandboxProvider { */ static async isSupported(): Promise { if (process.platform !== 'darwin' && process.platform !== 'linux') { - return { - supported: false, + return unsupported({ reason: `LocalSandboxProvider supports macOS and Linux only (got ${process.platform})`, - }; + }); } const platform: LocalSandboxPlatform = process.platform; const alreadyInitialized = isSrtInitialized(); let probeRoot: string | undefined; + const attempts: LocalSandboxSupportProbeAttempt[] = []; + const pythonAttempts: LocalSandboxSupportProbeAttempt[] = []; try { if (!alreadyInitialized) { @@ -113,7 +209,10 @@ export class LocalSandboxProvider implements SandboxProvider { let shell: string | undefined; for (const name of SHELL_CANDIDATES) { const resolved = await resolveCommandOnHost({ platform, name }); - if (resolved === undefined) continue; + if (resolved === undefined) { + attempts.push({ kind: 'shell', name, resolved: undefined }); + continue; + } const probe = await runSupervisorSession({ sandboxRootPath: probeRoot, platform, @@ -121,47 +220,79 @@ export class LocalSandboxProvider implements SandboxProvider { command: 'echo shell-ok', timeoutMs: SUPPORT_PROBE_TIMEOUT_MS, }); + const attempt = probeAttemptFromSession({ kind: 'shell', name, resolved, session: probe }); if (probe.protocolError === undefined && probe.exitCode === 0 && probe.stdoutText.includes('shell-ok')) { shell = resolved; break; } + attempts.push(attempt); } if (shell === undefined) { - return { - supported: false, - reason: 'No usable shell in sandbox (bash or sh via command -v)', - }; + return unsupported({ + platform, + attempts, + reason: formatLocalSandboxSupportReason({ + summary: 'No usable shell in sandbox (bash or sh via command -v)', + attempts, + }), + }); } let python: string | undefined; for (const name of PYTHON_CANDIDATES) { const resolved = await resolveCommandOnHost({ platform, name }); - if (resolved === undefined) continue; + if (resolved === undefined) { + pythonAttempts.push({ kind: 'python', name, resolved: undefined }); + continue; + } + // Prefer the host-resolved interpreter so macOS stubs/symlinks are not + // executed under seatbelt (xcode-select, python.org /usr/local/bin). + const executable = (await resolvePythonExecutableOnHost({ commandPath: resolved })) ?? resolved; const probe = await runSupervisorSession({ sandboxRootPath: probeRoot, platform, shell, - command: `${shellEscape(resolved)} -c ${shellEscape( + command: `${shellEscape(executable)} -c ${shellEscape( 'import sys; raise SystemExit(0 if sys.version_info[0] == 3 else 1)', )}`, timeoutMs: SUPPORT_PROBE_TIMEOUT_MS, }); + const attempt = probeAttemptFromSession({ + kind: 'python', + name, + resolved, + executable, + session: probe, + }); if (probe.protocolError === undefined && probe.exitCode === 0) { - python = resolved; + python = executable; break; } + pythonAttempts.push(attempt); } if (python === undefined) { - return { - supported: false, - reason: 'No usable Python 3 interpreter in sandbox (python3 or python via command -v)', - }; + return unsupported({ + platform, + attempts: pythonAttempts, + reason: formatLocalSandboxSupportReason({ + summary: 'No usable Python 3 interpreter in sandbox (python3 or python via command -v)', + attempts: pythonAttempts, + }), + }); } return { supported: true, platform, shell, python }; } catch (error) { const message = error instanceof Error ? error.message : String(error); - return { supported: false, reason: message }; + const seen = [...attempts, ...pythonAttempts]; + return unsupported({ + platform, + attempts: seen.length === 0 ? undefined : seen, + reason: + seen.length === 0 + ? message + : `${message}: ${formatLocalSandboxSupportReason({ summary: 'probe aborted', attempts: seen })}`, + }); } finally { if (probeRoot !== undefined) { await removeSandbox(probeRoot); @@ -220,7 +351,9 @@ export class LocalSandboxProvider implements SandboxProvider { } private async ensureSrt(): Promise { - if (this.srtInitialized) return; + if (this.srtInitialized) { + return; + } await initSrt({ platform: this.support.platform }); this.srtInitialized = true; } @@ -280,11 +413,14 @@ export class LocalSandboxProvider implements SandboxProvider { return XferFileInfoSchema.parse(JSON.parse(result.stdoutText.trim())); } - async createSandbox(): Promise<{ sandboxId: string }> { + async createSandbox(params?: { sessionId?: string }): Promise<{ sandboxId: string }> { await this.ensureSrt(); - const sandboxId = await createSandbox(join(this.sandboxRootPathParent, ulid().toLowerCase())); - await mkdir(join(sandboxId, 'tool-results'), { recursive: true, mode: 0o700 }); - await mkdir(join(sandboxId, 'uploads'), { recursive: true, mode: 0o700 }); + const sandboxId = await createSandbox( + join(this.sandboxRootPathParent, localSandboxSessionSegment(params?.sessionId), ulid().toLowerCase()), + ); + await mkdir(this.getToolResultDumpDir(sandboxId), { recursive: true, mode: 0o700 }); + await mkdir(this.getFileUploadsDir(sandboxId), { recursive: true, mode: 0o700 }); + await mkdir(this.getSkillsDir(sandboxId), { recursive: true, mode: 0o700 }); return { sandboxId }; } @@ -344,6 +480,18 @@ export class LocalSandboxProvider implements SandboxProvider { return join(sandboxId, '.git-credentials'); } + getFileUploadsDir(sandboxId: string): string { + return join(sandboxId, 'uploads'); + } + + getSkillsDir(sandboxId: string): string { + return join(sandboxId, 'skills'); + } + + getGitDownloaderPath(sandboxId: string): string { + return join(sandboxId, 'git_downloader.py'); + } + async downloadFile(params: { sandboxId: string; path: string }): Promise { this.ensureSandboxRoot(params.sandboxId); await this.ensureSrt(); diff --git a/packages/trueforge/tests/unit/sandbox/local/core/resolvePythonExecutableOnHost.test.ts b/packages/trueforge/tests/unit/sandbox/local/core/resolvePythonExecutableOnHost.test.ts new file mode 100644 index 000000000..9aeb9d21d --- /dev/null +++ b/packages/trueforge/tests/unit/sandbox/local/core/resolvePythonExecutableOnHost.test.ts @@ -0,0 +1,45 @@ +import { existsSync, realpathSync } from 'node:fs'; +import { + commandPath, + platformAllowRead, + resolvePythonExecutableOnHost, +} from '../../../../../src/sandbox/local/core/hostRun'; + +function pathCoveredByAllowRead(params: { path: string; allowRead: readonly string[] }): boolean { + return params.allowRead.some(root => params.path === root || params.path.startsWith(`${root}/`)); +} + +describe('resolvePythonExecutableOnHost', () => { + it('returns undefined when the binary does not exist', async () => { + await expect( + resolvePythonExecutableOnHost({ commandPath: '/this/python/does/not/exist' }), + ).resolves.toBeUndefined(); + }); + + it('unwraps the macOS /usr/bin/python3 xcode-select stub', async () => { + if (process.platform !== 'darwin') { + return; + } + const executable = await resolvePythonExecutableOnHost({ commandPath: '/usr/bin/python3' }); + expect(executable).toEqual(expect.stringMatching(/^\/.+/)); + expect(executable).not.toBe('/usr/bin/python3'); + }); + + it('realpath-unwraps python.org /usr/local/bin/python3 when present', async () => { + if (process.platform !== 'darwin' || !existsSync('/usr/local/bin/python3')) { + return; + } + const executable = await resolvePythonExecutableOnHost({ commandPath: '/usr/local/bin/python3' }); + expect(executable).toBe(realpathSync('/usr/local/bin/python3')); + expect(executable).not.toBe('/usr/local/bin/python3'); + }); +}); + +describe('sandbox PATH vs allowRead', () => { + it.each(['darwin', 'linux'] as const)('every %s PATH directory is under an allowRead root', platform => { + const allowRead = platformAllowRead(platform); + for (const dir of commandPath(platform).split(':')) { + expect(pathCoveredByAllowRead({ path: dir, allowRead })).toBe(true); + } + }); +}); diff --git a/packages/trueforge/tests/unit/sandbox/local/provider/layout.test.ts b/packages/trueforge/tests/unit/sandbox/local/provider/layout.test.ts new file mode 100644 index 000000000..6bd0ced99 --- /dev/null +++ b/packages/trueforge/tests/unit/sandbox/local/provider/layout.test.ts @@ -0,0 +1,49 @@ +import { mkdir, mkdtemp, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { + LocalSandboxProvider, + localSandboxSessionSegment, +} from '../../../../../src/sandbox/local/provider/LocalSandboxProvider'; + +describe('LocalSandboxProvider layout', () => { + let sandboxRootPathParent: string; + let codeModeSocketParentPath: string; + + beforeEach(async () => { + sandboxRootPathParent = await mkdtemp(join(tmpdir(), 'tfy-local-layout-')); + // Code Mode parent must stay ≤60 bytes after realpath; keep it short and shared. + codeModeSocketParentPath = '/tmp/tfl'; + await mkdir(codeModeSocketParentPath, { recursive: true, mode: 0o700 }); + }); + + afterEach(async () => { + await rm(sandboxRootPathParent, { recursive: true, force: true }); + }); + + it('uses cwd-relative uploads, skills, and git-downloader paths', () => { + const provider = new LocalSandboxProvider({ + sandboxRootPathParent, + codeModeSocketParentPath, + support: { supported: true, platform: 'darwin', shell: '/bin/bash', python: '/usr/bin/python3' }, + }); + expect(provider.getFileUploadsDir('ignored')).toBe('uploads'); + expect(provider.getSkillsDir('ignored')).toBe('skills'); + expect(provider.getGitDownloaderPath('ignored')).toBe('git_downloader.py'); + }); + + it('nests the sandbox root under a safe session id segment', () => { + expect(localSandboxSessionSegment('sess_1')).toBe('sess_1'); + expect(join('/data/sandboxes', localSandboxSessionSegment('sess_1'), '01ulid')).toBe( + '/data/sandboxes/sess_1/01ulid', + ); + }); + + it('falls back to _ when session id is missing or not a single path segment', () => { + expect(localSandboxSessionSegment(undefined)).toBe('_'); + expect(localSandboxSessionSegment('')).toBe('_'); + expect(localSandboxSessionSegment('a/b')).toBe('_'); + expect(localSandboxSessionSegment('..')).toBe('_'); + expect(localSandboxSessionSegment('foo..bar')).toBe('_'); + }); +}); diff --git a/packages/trueforge/tests/unit/sandbox/local/provider/supportReason.test.ts b/packages/trueforge/tests/unit/sandbox/local/provider/supportReason.test.ts new file mode 100644 index 000000000..869502458 --- /dev/null +++ b/packages/trueforge/tests/unit/sandbox/local/provider/supportReason.test.ts @@ -0,0 +1,34 @@ +import { formatLocalSandboxSupportReason } from '../../../../../src/sandbox/local/provider/LocalSandboxProvider'; + +describe('formatLocalSandboxSupportReason', () => { + it('keeps the summary when no candidates were tried', () => { + expect(formatLocalSandboxSupportReason({ summary: 'No usable Python 3 interpreter', attempts: [] })).toBe( + 'No usable Python 3 interpreter', + ); + }); + + it('records PATH misses and in-sandbox exec failures', () => { + expect( + formatLocalSandboxSupportReason({ + summary: 'No usable Python 3 interpreter in sandbox (python3 or python via command -v)', + attempts: [ + { + kind: 'python', + name: 'python3', + resolved: '/usr/local/bin/python3', + executable: '/Library/Frameworks/Python.framework/Versions/3.14/bin/python3.14', + exitCode: 126, + stderr: '/opt/homebrew/bin/bash: line 1: /usr/local/bin/python3: Operation not permitted\n', + timedOut: false, + }, + { kind: 'python', name: 'python', resolved: undefined }, + ], + }), + ).toBe( + 'No usable Python 3 interpreter in sandbox (python3 or python via command -v): ' + + 'python3: resolved=/usr/local/bin/python3 executable=/Library/Frameworks/Python.framework/Versions/3.14/bin/python3.14 exit=126 ' + + 'stderr="/opt/homebrew/bin/bash: line 1: /usr/local/bin/python3: Operation not permitted\\n"; ' + + 'python: not on sandbox PATH', + ); + }); +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 0e4541a50..bb062c254 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -110,40 +110,6 @@ importers: specifier: ^2.0.2 version: 2.0.2(monaco-editor@0.52.2) - packages/local-sandbox: - dependencies: - '@anthropic-ai/sandbox-runtime': - specifier: 0.0.71 - version: 0.0.71 - '@truefoundry/trueforge-core': - specifier: workspace:* - version: link:../trueforge-core - ulid: - specifier: ^3.0.2 - version: 3.0.2 - zod: - specifier: ^4.4.3 - version: 4.4.3 - devDependencies: - '@swc/core': - specifier: ^1.11.0 - version: 1.15.46 - '@swc/jest': - specifier: ^0.2.37 - version: 0.2.39(@swc/core@1.15.46) - '@types/jest': - specifier: ^29.5.14 - version: 29.5.14 - '@types/node': - specifier: ^24.12.0 - version: 24.13.3 - jest: - specifier: ^29.7.0 - version: 29.7.0(@types/node@24.13.3)(babel-plugin-macros@3.1.0)(supports-color@8.1.1) - typescript: - specifier: ^7.0.2 - version: 7.0.2 - packages/trueforge: dependencies: '@anthropic-ai/sandbox-runtime': From fcb80331abbc9b331527a388411bfb82fa43daae Mon Sep 17 00:00:00 2001 From: Chirag Jain Date: Mon, 17 Aug 2026 23:17:58 +0530 Subject: [PATCH 07/10] Logs and tests --- packages/trueforge/src/runtime/sessionResources.ts | 1 + .../sandbox/local/provider/LocalSandboxProvider.ts | 9 +++++++++ packages/trueforge/tests/sandbox/local/smoke.test.ts | 8 +++++++- .../unit/sandbox/local/provider/contract.test.ts | 2 ++ .../tests/unit/sandbox/local/provider/layout.test.ts | 12 ++++++++---- .../unit/sandbox/local/provider/missingRoot.test.ts | 2 ++ 6 files changed, 29 insertions(+), 5 deletions(-) diff --git a/packages/trueforge/src/runtime/sessionResources.ts b/packages/trueforge/src/runtime/sessionResources.ts index 994868b96..e898dc869 100644 --- a/packages/trueforge/src/runtime/sessionResources.ts +++ b/packages/trueforge/src/runtime/sessionResources.ts @@ -243,6 +243,7 @@ export async function resolveSandboxProvider({ codeModeSocketParentPath: configuration.CODE_MODE_SOCKET_PARENT, support, fileMaxBytesForDownload: configuration.SANDBOX_FILE_MAX_BYTES_FOR_DOWNLOAD, + logger, }); } diff --git a/packages/trueforge/src/sandbox/local/provider/LocalSandboxProvider.ts b/packages/trueforge/src/sandbox/local/provider/LocalSandboxProvider.ts index 42f095615..587515af2 100644 --- a/packages/trueforge/src/sandbox/local/provider/LocalSandboxProvider.ts +++ b/packages/trueforge/src/sandbox/local/provider/LocalSandboxProvider.ts @@ -21,6 +21,7 @@ import { mkdir, mkdtemp } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { isAbsolute, join, relative, resolve, sep } from 'node:path'; import { ulid } from 'ulid'; +import type { Logger } from 'winston'; import { CodeModeUdsTransport, assertCodeModeSocketParentPath } from '../core/CodeModeUdsTransport.js'; import { createSandbox, @@ -150,6 +151,7 @@ export interface LocalSandboxProviderOptions { support: LocalSandboxSupportResult; fileMaxBytesForDownload?: number | undefined; defaultExecTimeoutSeconds?: number | undefined; + logger: Logger; } /** Sandbox-relative path for sandboxed commands (avoids /var vs /private/var seatbelt mismatches). */ @@ -174,6 +176,7 @@ export class LocalSandboxProvider implements SandboxProvider { private readonly fileMaxBytesForDownload: number; private readonly defaultExecTimeoutSeconds: number; private srtInitialized = false; + private readonly logger: Logger; /** Local SRT has no image build step — always ready. */ private static readonly readyBuild: SandboxBuild = { @@ -317,6 +320,7 @@ export class LocalSandboxProvider implements SandboxProvider { this.support = options.support; this.fileMaxBytesForDownload = options.fileMaxBytesForDownload ?? DEFAULT_FILE_MAX_BYTES; this.defaultExecTimeoutSeconds = options.defaultExecTimeoutSeconds ?? DEFAULT_EXEC_TIMEOUT_SECONDS; + this.logger = options.logger.child({ module: 'LocalSandboxProvider' }); } private pythonC(code: string, relPath: string): string { @@ -355,6 +359,11 @@ export class LocalSandboxProvider implements SandboxProvider { return; } await initSrt({ platform: this.support.platform }); + this.logger.info('LocalSandboxProvider initialized SRT', { + rootPath: this.sandboxRootPathParent, + shell: this.support.shell, + python: this.support.python, + }); this.srtInitialized = true; } diff --git a/packages/trueforge/tests/sandbox/local/smoke.test.ts b/packages/trueforge/tests/sandbox/local/smoke.test.ts index 5869bd5f1..babef6c19 100644 --- a/packages/trueforge/tests/sandbox/local/smoke.test.ts +++ b/packages/trueforge/tests/sandbox/local/smoke.test.ts @@ -14,6 +14,7 @@ import { tmpdir } from 'node:os'; import { dirname, join } from 'node:path'; import { fileURLToPath } from 'node:url'; import { ulid } from 'ulid'; +import { createLogger } from 'winston'; import { CodeModeUdsTransport, installMcpFixture } from '../../../src/sandbox/local/core/CodeModeUdsTransport.js'; import { commandPath, @@ -1625,7 +1626,12 @@ async function main(): Promise { if (!support.supported) { throw new Error(support.reason); } - const provider = new LocalSandboxProvider({ sandboxRootPathParent, codeModeSocketParentPath, support }); + const provider = new LocalSandboxProvider({ + sandboxRootPathParent, + codeModeSocketParentPath, + support, + logger: createLogger({ silent: true }), + }); const instructions = provider.getAdditionalInstructions(); assert.match(instructions, /sandbox shell: \S+/); assert.match(instructions, /Python 3 is available as: \S+/); diff --git a/packages/trueforge/tests/unit/sandbox/local/provider/contract.test.ts b/packages/trueforge/tests/unit/sandbox/local/provider/contract.test.ts index c3bd8c324..7a7b6b0be 100644 --- a/packages/trueforge/tests/unit/sandbox/local/provider/contract.test.ts +++ b/packages/trueforge/tests/unit/sandbox/local/provider/contract.test.ts @@ -1,6 +1,7 @@ import { mkdir, mkdtemp, rm } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; +import { createLogger } from 'winston'; import { runSandboxProviderContractSuite } from '../../../../../../trueforge-core/tests/core/sandbox/provider/sandboxProviderContractSuite'; import { LocalSandboxProvider } from '../../../../../src/sandbox/local/provider/LocalSandboxProvider'; @@ -21,6 +22,7 @@ describe('LocalSandboxProvider (SandboxProvider contract)', () => { sandboxRootPathParent, codeModeSocketParentPath, support, + logger: createLogger({ silent: true }), }); return { provider, diff --git a/packages/trueforge/tests/unit/sandbox/local/provider/layout.test.ts b/packages/trueforge/tests/unit/sandbox/local/provider/layout.test.ts index 6bd0ced99..ef676cf3e 100644 --- a/packages/trueforge/tests/unit/sandbox/local/provider/layout.test.ts +++ b/packages/trueforge/tests/unit/sandbox/local/provider/layout.test.ts @@ -1,11 +1,14 @@ import { mkdir, mkdtemp, rm } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; +import { createLogger } from 'winston'; import { LocalSandboxProvider, localSandboxSessionSegment, } from '../../../../../src/sandbox/local/provider/LocalSandboxProvider'; +const logger = createLogger({ silent: true }); + describe('LocalSandboxProvider layout', () => { let sandboxRootPathParent: string; let codeModeSocketParentPath: string; @@ -21,15 +24,16 @@ describe('LocalSandboxProvider layout', () => { await rm(sandboxRootPathParent, { recursive: true, force: true }); }); - it('uses cwd-relative uploads, skills, and git-downloader paths', () => { + it('nests uploads, skills, and git-downloader paths under the sandbox id', () => { const provider = new LocalSandboxProvider({ sandboxRootPathParent, codeModeSocketParentPath, support: { supported: true, platform: 'darwin', shell: '/bin/bash', python: '/usr/bin/python3' }, + logger, }); - expect(provider.getFileUploadsDir('ignored')).toBe('uploads'); - expect(provider.getSkillsDir('ignored')).toBe('skills'); - expect(provider.getGitDownloaderPath('ignored')).toBe('git_downloader.py'); + expect(provider.getFileUploadsDir('ignored')).toBe(join('ignored', 'uploads')); + expect(provider.getSkillsDir('ignored')).toBe(join('ignored', 'skills')); + expect(provider.getGitDownloaderPath('ignored')).toBe(join('ignored', 'git_downloader.py')); }); it('nests the sandbox root under a safe session id segment', () => { diff --git a/packages/trueforge/tests/unit/sandbox/local/provider/missingRoot.test.ts b/packages/trueforge/tests/unit/sandbox/local/provider/missingRoot.test.ts index 793afe7b0..202b74bcc 100644 --- a/packages/trueforge/tests/unit/sandbox/local/provider/missingRoot.test.ts +++ b/packages/trueforge/tests/unit/sandbox/local/provider/missingRoot.test.ts @@ -2,6 +2,7 @@ import { SandboxNotAvailableError } from '@truefoundry/trueforge-core/core'; import { mkdir, mkdtemp, rm } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; +import { createLogger } from 'winston'; import { LocalSandboxProvider } from '../../../../../src/sandbox/local/provider/LocalSandboxProvider'; describe('LocalSandboxProvider missing root', () => { @@ -13,6 +14,7 @@ describe('LocalSandboxProvider missing root', () => { sandboxRootPathParent, codeModeSocketParentPath, support: { supported: true, platform: 'darwin', shell: '/bin/bash', python: '/usr/bin/python3' }, + logger: createLogger({ silent: true }), }); try { await expect( From c6fee98b883fdc471d59664c28812a57ff3139b0 Mon Sep 17 00:00:00 2001 From: Chirag Jain Date: Mon, 17 Aug 2026 23:24:53 +0530 Subject: [PATCH 08/10] Remove superficial tests --- .../tests/core/sandbox/Sandbox.paths.test.ts | 26 ---------- .../core/sandbox/ownershipRemoved.test.ts | 8 ---- packages/trueforge/src/sandbox/local/index.ts | 2 +- .../resolvePythonExecutableOnHost.test.ts | 6 --- .../sandbox/local/provider/layout.test.ts | 47 ++----------------- .../local/provider/supportReason.test.ts | 34 -------------- 6 files changed, 4 insertions(+), 119 deletions(-) delete mode 100644 packages/trueforge-core/tests/core/sandbox/ownershipRemoved.test.ts delete mode 100644 packages/trueforge/tests/unit/sandbox/local/provider/supportReason.test.ts diff --git a/packages/trueforge-core/tests/core/sandbox/Sandbox.paths.test.ts b/packages/trueforge-core/tests/core/sandbox/Sandbox.paths.test.ts index 4387d32ac..a6a474605 100644 --- a/packages/trueforge-core/tests/core/sandbox/Sandbox.paths.test.ts +++ b/packages/trueforge-core/tests/core/sandbox/Sandbox.paths.test.ts @@ -71,32 +71,6 @@ describe('Sandbox provider-owned paths', () => { }); }); - it('mkdirs provider uploads and skills dirs during init', async () => { - const exec = jest.fn().mockImplementation(() => readyExec()); - const sandbox = makeSandbox(makeProvider({ exec })); - await sandbox.uploadUserFile({ - fileName: 'a.txt', - content: Buffer.from('x'), - mime: 'text/plain', - }); - const mkdirCall = exec.mock.calls.find((call: unknown[]) => { - const params = call[0]; - return ( - typeof params === 'object' && - params !== null && - 'command' in params && - typeof params.command === 'string' && - params.command.startsWith('mkdir -p') - ); - }); - expect(mkdirCall).toBeDefined(); - const params = mkdirCall?.[0] as { command: string }; - expect(params.command).toContain('/prov/uploads'); - expect(params.command).toContain('/prov/skills'); - expect(params.command).not.toContain('/tmp/uploads'); - expect(params.command).not.toContain('/opt/tfy/skills'); - }); - it('passes sessionId through to createSandbox', async () => { const createSandbox = jest.fn().mockResolvedValue({ sandboxId: 'raw-1' }); const sandbox = makeSandbox(makeProvider({ createSandbox }), { sessionId: 'sess_1' }); diff --git a/packages/trueforge-core/tests/core/sandbox/ownershipRemoved.test.ts b/packages/trueforge-core/tests/core/sandbox/ownershipRemoved.test.ts deleted file mode 100644 index e27810f9a..000000000 --- a/packages/trueforge-core/tests/core/sandbox/ownershipRemoved.test.ts +++ /dev/null @@ -1,8 +0,0 @@ -import * as core from '../../../src/core/index'; - -describe('sandbox tenant ownership helpers', () => { - it('no longer exports validateSandboxOwnedByTenant or SandboxTenantMismatchError', () => { - expect('validateSandboxOwnedByTenant' in core).toBe(false); - expect('SandboxTenantMismatchError' in core).toBe(false); - }); -}); diff --git a/packages/trueforge/src/sandbox/local/index.ts b/packages/trueforge/src/sandbox/local/index.ts index 2a9ca23bb..829c692fe 100644 --- a/packages/trueforge/src/sandbox/local/index.ts +++ b/packages/trueforge/src/sandbox/local/index.ts @@ -1,7 +1,7 @@ export { CodeModeUdsTransport, installMcpFixture, localMcpClientRemotePath } from './core/CodeModeUdsTransport.js'; export type { CodeModeUdsTransportOptions } from './core/CodeModeUdsTransport.js'; export type { LocalSandboxPlatform } from './core/hostRun.js'; -export { LocalSandboxProvider, formatLocalSandboxSupportReason } from './provider/LocalSandboxProvider.js'; +export { LocalSandboxProvider } from './provider/LocalSandboxProvider.js'; export type { LocalSandboxProviderOptions, LocalSandboxSupportProbeAttempt, diff --git a/packages/trueforge/tests/unit/sandbox/local/core/resolvePythonExecutableOnHost.test.ts b/packages/trueforge/tests/unit/sandbox/local/core/resolvePythonExecutableOnHost.test.ts index 9aeb9d21d..fb2ced2fd 100644 --- a/packages/trueforge/tests/unit/sandbox/local/core/resolvePythonExecutableOnHost.test.ts +++ b/packages/trueforge/tests/unit/sandbox/local/core/resolvePythonExecutableOnHost.test.ts @@ -10,12 +10,6 @@ function pathCoveredByAllowRead(params: { path: string; allowRead: readonly stri } describe('resolvePythonExecutableOnHost', () => { - it('returns undefined when the binary does not exist', async () => { - await expect( - resolvePythonExecutableOnHost({ commandPath: '/this/python/does/not/exist' }), - ).resolves.toBeUndefined(); - }); - it('unwraps the macOS /usr/bin/python3 xcode-select stub', async () => { if (process.platform !== 'darwin') { return; diff --git a/packages/trueforge/tests/unit/sandbox/local/provider/layout.test.ts b/packages/trueforge/tests/unit/sandbox/local/provider/layout.test.ts index ef676cf3e..cb97baff7 100644 --- a/packages/trueforge/tests/unit/sandbox/local/provider/layout.test.ts +++ b/packages/trueforge/tests/unit/sandbox/local/provider/layout.test.ts @@ -1,49 +1,8 @@ -import { mkdir, mkdtemp, rm } from 'node:fs/promises'; -import { tmpdir } from 'node:os'; -import { join } from 'node:path'; -import { createLogger } from 'winston'; -import { - LocalSandboxProvider, - localSandboxSessionSegment, -} from '../../../../../src/sandbox/local/provider/LocalSandboxProvider'; +import { localSandboxSessionSegment } from '../../../../../src/sandbox/local/provider/LocalSandboxProvider'; -const logger = createLogger({ silent: true }); - -describe('LocalSandboxProvider layout', () => { - let sandboxRootPathParent: string; - let codeModeSocketParentPath: string; - - beforeEach(async () => { - sandboxRootPathParent = await mkdtemp(join(tmpdir(), 'tfy-local-layout-')); - // Code Mode parent must stay ≤60 bytes after realpath; keep it short and shared. - codeModeSocketParentPath = '/tmp/tfl'; - await mkdir(codeModeSocketParentPath, { recursive: true, mode: 0o700 }); - }); - - afterEach(async () => { - await rm(sandboxRootPathParent, { recursive: true, force: true }); - }); - - it('nests uploads, skills, and git-downloader paths under the sandbox id', () => { - const provider = new LocalSandboxProvider({ - sandboxRootPathParent, - codeModeSocketParentPath, - support: { supported: true, platform: 'darwin', shell: '/bin/bash', python: '/usr/bin/python3' }, - logger, - }); - expect(provider.getFileUploadsDir('ignored')).toBe(join('ignored', 'uploads')); - expect(provider.getSkillsDir('ignored')).toBe(join('ignored', 'skills')); - expect(provider.getGitDownloaderPath('ignored')).toBe(join('ignored', 'git_downloader.py')); - }); - - it('nests the sandbox root under a safe session id segment', () => { +describe('localSandboxSessionSegment', () => { + it('keeps a single-segment session id and rejects missing or unsafe values', () => { expect(localSandboxSessionSegment('sess_1')).toBe('sess_1'); - expect(join('/data/sandboxes', localSandboxSessionSegment('sess_1'), '01ulid')).toBe( - '/data/sandboxes/sess_1/01ulid', - ); - }); - - it('falls back to _ when session id is missing or not a single path segment', () => { expect(localSandboxSessionSegment(undefined)).toBe('_'); expect(localSandboxSessionSegment('')).toBe('_'); expect(localSandboxSessionSegment('a/b')).toBe('_'); diff --git a/packages/trueforge/tests/unit/sandbox/local/provider/supportReason.test.ts b/packages/trueforge/tests/unit/sandbox/local/provider/supportReason.test.ts deleted file mode 100644 index 869502458..000000000 --- a/packages/trueforge/tests/unit/sandbox/local/provider/supportReason.test.ts +++ /dev/null @@ -1,34 +0,0 @@ -import { formatLocalSandboxSupportReason } from '../../../../../src/sandbox/local/provider/LocalSandboxProvider'; - -describe('formatLocalSandboxSupportReason', () => { - it('keeps the summary when no candidates were tried', () => { - expect(formatLocalSandboxSupportReason({ summary: 'No usable Python 3 interpreter', attempts: [] })).toBe( - 'No usable Python 3 interpreter', - ); - }); - - it('records PATH misses and in-sandbox exec failures', () => { - expect( - formatLocalSandboxSupportReason({ - summary: 'No usable Python 3 interpreter in sandbox (python3 or python via command -v)', - attempts: [ - { - kind: 'python', - name: 'python3', - resolved: '/usr/local/bin/python3', - executable: '/Library/Frameworks/Python.framework/Versions/3.14/bin/python3.14', - exitCode: 126, - stderr: '/opt/homebrew/bin/bash: line 1: /usr/local/bin/python3: Operation not permitted\n', - timedOut: false, - }, - { kind: 'python', name: 'python', resolved: undefined }, - ], - }), - ).toBe( - 'No usable Python 3 interpreter in sandbox (python3 or python via command -v): ' + - 'python3: resolved=/usr/local/bin/python3 executable=/Library/Frameworks/Python.framework/Versions/3.14/bin/python3.14 exit=126 ' + - 'stderr="/opt/homebrew/bin/bash: line 1: /usr/local/bin/python3: Operation not permitted\\n"; ' + - 'python: not on sandbox PATH', - ); - }); -}); From a0c29b47faeb6bec429b56e6eef38ed5f1b47b46 Mon Sep 17 00:00:00 2001 From: Chirag Jain Date: Tue, 18 Aug 2026 01:00:40 +0530 Subject: [PATCH 09/10] Move lima file and rename local sandbox test commands --- package.json | 3 +++ ...s => jest.local-sandbox.contract.config.cjs} | 0 ....cjs => jest.local-sandbox.smoke.config.cjs} | 0 packages/trueforge/jest.unit.config.cjs | 2 +- packages/trueforge/package.json | 6 +++--- .../local-sandbox/lima.yaml} | 4 ++-- .../scripts/local-sandbox/smoke-lima.sh | 10 ++++++---- .../local/provider/LocalSandboxProvider.ts | 17 ++++++++++------- ...ntract.test.ts => provider.contract.test.ts} | 0 9 files changed, 25 insertions(+), 17 deletions(-) rename packages/trueforge/{jest.local-contract.config.cjs => jest.local-sandbox.contract.config.cjs} (100%) rename packages/trueforge/{jest.local-smoke.config.cjs => jest.local-sandbox.smoke.config.cjs} (100%) rename packages/trueforge/{lima/local-sandbox.yaml => scripts/local-sandbox/lima.yaml} (91%) rename packages/trueforge/tests/unit/sandbox/local/provider/{contract.test.ts => provider.contract.test.ts} (100%) diff --git a/package.json b/package.json index 893c895d1..8440ec19f 100644 --- a/package.json +++ b/package.json @@ -41,6 +41,9 @@ "test:frontend": "pnpm --filter frontend test", "test:trueforge-core": "pnpm --filter @truefoundry/trueforge-core test", "test:trueforge": "pnpm --filter @truefoundry/trueforge test", + "test:local-sandbox:contract": "pnpm --filter @truefoundry/trueforge test:local-sandbox:contract", + "smoke:local-sandbox": "pnpm --filter @truefoundry/trueforge smoke:local-sandbox", + "smoke:local-sandbox:lima": "pnpm --filter @truefoundry/trueforge smoke:local-sandbox:lima", "test:store:local": "bash scripts/test-store-local.sh", "test:store:postgres": "pnpm --filter @truefoundry/trueforge test:store:postgres", "test:store:sqlite": "pnpm --filter @truefoundry/trueforge test:store:sqlite", diff --git a/packages/trueforge/jest.local-contract.config.cjs b/packages/trueforge/jest.local-sandbox.contract.config.cjs similarity index 100% rename from packages/trueforge/jest.local-contract.config.cjs rename to packages/trueforge/jest.local-sandbox.contract.config.cjs diff --git a/packages/trueforge/jest.local-smoke.config.cjs b/packages/trueforge/jest.local-sandbox.smoke.config.cjs similarity index 100% rename from packages/trueforge/jest.local-smoke.config.cjs rename to packages/trueforge/jest.local-sandbox.smoke.config.cjs diff --git a/packages/trueforge/jest.unit.config.cjs b/packages/trueforge/jest.unit.config.cjs index 408d637e8..389e40499 100644 --- a/packages/trueforge/jest.unit.config.cjs +++ b/packages/trueforge/jest.unit.config.cjs @@ -39,6 +39,6 @@ module.exports = { maxWorkers: '50%', roots: ['/tests/unit'], testMatch: ['/tests/unit/**/*.test.ts'], - // SRT contract suites need a real host sandbox; run via `pnpm test:local-contract`. + // SRT contract suites need a real host sandbox; run via `pnpm test:local-sandbox:contract`. testPathIgnorePatterns: ['contract\\.test\\.ts$'], }; diff --git a/packages/trueforge/package.json b/packages/trueforge/package.json index 6af0925ba..f6157ba6a 100644 --- a/packages/trueforge/package.json +++ b/packages/trueforge/package.json @@ -49,9 +49,9 @@ "test:store:postgres": "jest --config jest.store.postgres.config.cjs", "test:store:sqlite": "jest --config jest.store.sqlite.config.cjs", "test": "pnpm run build:gen && NODE_OPTIONS='--conditions=trueforge-dev' node --env-file=.env.test ./node_modules/jest/bin/jest.js --config jest.unit.config.cjs", - "test:local-contract": "pnpm run build:gen && NODE_OPTIONS='--conditions=trueforge-dev' node --env-file=.env.test ./node_modules/jest/bin/jest.js --config jest.local-contract.config.cjs", - "smoke:local": "pnpm run build:gen && NODE_OPTIONS='--conditions=trueforge-dev' jest --config jest.local-smoke.config.cjs --runInBand --forceExit tests/sandbox/local/smoke.test.ts", - "smoke:local:lima": "bash scripts/local-sandbox/smoke-lima.sh", + "test:local-sandbox:contract": "pnpm run build:gen && NODE_OPTIONS='--conditions=trueforge-dev' node --env-file=.env.test ./node_modules/jest/bin/jest.js --config jest.local-sandbox.contract.config.cjs", + "smoke:local-sandbox": "pnpm run build:gen && NODE_OPTIONS='--conditions=trueforge-dev' jest --config jest.local-sandbox.smoke.config.cjs --runInBand --forceExit tests/sandbox/local/smoke.test.ts", + "smoke:local-sandbox:lima": "bash scripts/local-sandbox/smoke-lima.sh", "probe:loopback": "pnpm exec tsx scripts/local-sandbox/probe-loopback.ts", "typecheck": "pnpm run build:gen && tsc --noEmit && tsc --noEmit -p tests/db/tsconfig.json && tsc --noEmit -p tests/unit/tsconfig.json" }, diff --git a/packages/trueforge/lima/local-sandbox.yaml b/packages/trueforge/scripts/local-sandbox/lima.yaml similarity index 91% rename from packages/trueforge/lima/local-sandbox.yaml rename to packages/trueforge/scripts/local-sandbox/lima.yaml index 109f495b4..3c1cb6bc6 100644 --- a/packages/trueforge/lima/local-sandbox.yaml +++ b/packages/trueforge/scripts/local-sandbox/lima.yaml @@ -1,5 +1,5 @@ # Minimal Lima guest for local-sandbox Linux SRT smoke. -# Mounts the package root at the same absolute host path. +# Mounts the workspace root at the same absolute host path. cpus: 1 memory: '2GiB' disk: '20GiB' @@ -10,7 +10,7 @@ images: - location: 'https://cloud-images.ubuntu.com/releases/24.04/release/ubuntu-24.04-server-cloudimg-amd64.img' arch: 'x86_64' -# `location` is rewritten to an absolute host path by scripts/smoke-lima.sh +# `location` is rewritten to an absolute host path by smoke-lima.sh mounts: - location: '__LOCAL_SANDBOX_ROOT__' writable: true diff --git a/packages/trueforge/scripts/local-sandbox/smoke-lima.sh b/packages/trueforge/scripts/local-sandbox/smoke-lima.sh index ef6c99828..7a3f8f2a3 100755 --- a/packages/trueforge/scripts/local-sandbox/smoke-lima.sh +++ b/packages/trueforge/scripts/local-sandbox/smoke-lima.sh @@ -1,10 +1,12 @@ #!/usr/bin/env bash -# Create/start a minimal Lima VM and run `pnpm smoke:local` for Linux SRT coverage. +# Create/start a minimal Lima VM and run `pnpm smoke:local-sandbox` for Linux SRT coverage. set -euo pipefail -ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../../.." && pwd)" +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +# packages/trueforge/scripts/local-sandbox → workspace root (pnpm install --filter). +ROOT="$(cd "${SCRIPT_DIR}/../../../.." && pwd)" INSTANCE="${LIMA_INSTANCE:-local-sandbox-poc}" -YAML_TEMPLATE="${ROOT}/packages/trueforge/lima/local-sandbox.yaml" +YAML_TEMPLATE="${SCRIPT_DIR}/lima.yaml" if ! command -v limactl >/dev/null 2>&1; then echo "limactl not found; install Lima first (e.g. brew install lima)" >&2 @@ -32,5 +34,5 @@ limactl shell "${INSTANCE}" -- bash -lc " set -euo pipefail cd $(printf '%q' "${ROOT}") CI=true pnpm install --no-frozen-lockfile - pnpm --filter @truefoundry/trueforge smoke:local + pnpm --filter @truefoundry/trueforge smoke:local-sandbox " diff --git a/packages/trueforge/src/sandbox/local/provider/LocalSandboxProvider.ts b/packages/trueforge/src/sandbox/local/provider/LocalSandboxProvider.ts index 587515af2..2e66e3c10 100644 --- a/packages/trueforge/src/sandbox/local/provider/LocalSandboxProvider.ts +++ b/packages/trueforge/src/sandbox/local/provider/LocalSandboxProvider.ts @@ -359,11 +359,6 @@ export class LocalSandboxProvider implements SandboxProvider { return; } await initSrt({ platform: this.support.platform }); - this.logger.info('LocalSandboxProvider initialized SRT', { - rootPath: this.sandboxRootPathParent, - shell: this.support.shell, - python: this.support.python, - }); this.srtInitialized = true; } @@ -424,9 +419,17 @@ export class LocalSandboxProvider implements SandboxProvider { async createSandbox(params?: { sessionId?: string }): Promise<{ sandboxId: string }> { await this.ensureSrt(); - const sandboxId = await createSandbox( - join(this.sandboxRootPathParent, localSandboxSessionSegment(params?.sessionId), ulid().toLowerCase()), + const sandboxPath = join( + this.sandboxRootPathParent, + localSandboxSessionSegment(params?.sessionId), + ulid().toLowerCase(), ); + const sandboxId = await createSandbox(sandboxPath); + this.logger.info('LocalSandboxProvider created sandbox', { + sandboxId, + shell: this.support.shell, + python: this.support.python, + }); await mkdir(this.getToolResultDumpDir(sandboxId), { recursive: true, mode: 0o700 }); await mkdir(this.getFileUploadsDir(sandboxId), { recursive: true, mode: 0o700 }); await mkdir(this.getSkillsDir(sandboxId), { recursive: true, mode: 0o700 }); diff --git a/packages/trueforge/tests/unit/sandbox/local/provider/contract.test.ts b/packages/trueforge/tests/unit/sandbox/local/provider/provider.contract.test.ts similarity index 100% rename from packages/trueforge/tests/unit/sandbox/local/provider/contract.test.ts rename to packages/trueforge/tests/unit/sandbox/local/provider/provider.contract.test.ts From 116df0064f6116083a5678fc6e27c43de46f028c Mon Sep 17 00:00:00 2001 From: Chirag Jain Date: Tue, 18 Aug 2026 01:01:28 +0530 Subject: [PATCH 10/10] Remove plan file --- .../local_sandbox_server_0829652e.plan.md | 257 ------------------ 1 file changed, 257 deletions(-) delete mode 100644 .cursor/plans/local_sandbox_server_0829652e.plan.md diff --git a/.cursor/plans/local_sandbox_server_0829652e.plan.md b/.cursor/plans/local_sandbox_server_0829652e.plan.md deleted file mode 100644 index 860fc03ca..000000000 --- a/.cursor/plans/local_sandbox_server_0829652e.plan.md +++ /dev/null @@ -1,257 +0,0 @@ ---- -name: Local sandbox server -overview: Fold local-sandbox into packages/trueforge and wire it as standalone in-memory fallback (capabilities on, no settings GET/DB). Fancy sandbox ids v1:type:raw; SandboxProvider.type; cross-type not carried forward. -todos: - - id: move-into-server - content: Copy current local-sandbox into trueforge; wire + verify green; ask developer before deleting packages/local-sandbox - status: completed - - id: schema-catalog - content: Settings/catalog API stay Daytona-only (no local wire type); no synthetic local GET - status: completed - - id: remove-tenant-ownership - content: Delete validateSandboxOwnedByTenant + SandboxTenantMismatchError + all call sites (Sandbox x2, turns download) + core export; fix OpenAPI 403 copy - status: completed - - id: local-ids - content: Path sandboxIds; {data}/sandboxes + {tmpdir}/tf_cms; Sandbox owns v1:type:raw id helpers; SandboxProvider.type - status: completed - - id: server-factory - content: Runtime LocalSandboxProvider fallback; cache isSupported at boot; capabilities enabled when fallback; GET 404 if no row; carry-forward by type - status: completed - - id: recreate-missing - content: Same-type missing sandbox → recreate; cross-type → omit existing id (new create). Prefer type gate over blind restore - status: completed - - id: ui-adapter - content: "UI test: capabilities sandbox on with empty settings → Daytona still Available; no local settings row" - status: completed - - id: tests - content: Capabilities on without DB row; GET 404 empty; PUT Daytona; v1 id + carry-forward; same-type recreate - status: completed - - id: changeset - content: Add .changeset for trueforge-core + trueforge (+ trueforge-ui if UI tests/adapter change) - status: completed -isProject: false ---- - -# Local sandbox server integration - -## Product rules (locked) - -- **Local only when `STANDALONE=true`** (from [`packages/trueforge/src/config.ts`](packages/trueforge/src/config.ts)). When `STANDALONE=false`, there is no local fallback path — only a Daytona DB row can enable sandbox (same as today). -- **No DB upsert for local.** The `sandbox_provider` table stays empty until the user configures Daytona. Local is an **in-memory runtime fallback** when there is no row and the host supports it. -- **GET settings does not return local** — no row → **404** as today. Local is invisible on the settings API. -- **Capabilities:** when local fallback applies, report sandbox (and skills) **enabled** even with no DB row. -- **PUT stays Daytona-only.** First Daytona PUT upgrades off implicit local. -- **Session continuity:** persisted `sandbox_id` uses `v1:provider_type:raw_id`. When starting a turn, if the id is `v1:`-prefixed and `provider_type` ≠ current provider → **do not carry forward** (create fresh). If `v1:` is absent (legacy) → carry forward as today. Same-type missing remote/local root → recreate (below). - -## Current gaps - -```mermaid -flowchart TD - Cap[GET capabilities] --> Check[checkSnapshotStatus] - Check --> Row{sandbox_provider row?} - Row -->|no| Disabled[sandbox disabled] - Row -->|yes| DaytonaOnly[always toDaytonaSandboxProvider] - Turn[Turn sandboxProvider] --> Resolve[resolveSandboxProvider] - Resolve --> DaytonaOnly -``` - -- Manifest/schema/catalog are Daytona-only ([`sandboxProvider.ts`](packages/trueforge/src/schemas/sandboxProvider.ts), [`sandbox-catalog.yaml`](packages/trueforge/catalog/sandbox-catalog.yaml)). -- [`resolveSandboxProvider`](packages/trueforge/src/runtime/sessionResources.ts) always builds Daytona. -- [`LocalSandboxProvider.createSandbox`](packages/local-sandbox/src/provider/LocalSandboxProvider.ts) returns an absolute path as `sandboxId`. Tenant-prefix ownership checks are not needed for local (or Daytona session reattach): ids are not client-supplied. -- Settings UI already maps GET 404 → empty list and shows Available when `providers.length === 0`. Keep that: capabilities-on + empty settings must still show Daytona in Available so users can upgrade. UI work is a regression test, not a new hide/show rule. - -## Design — interface / method surface - -### Move `@truefoundry/local-sandbox` into `packages/trueforge` - -Local sandbox is server-only (standalone). **Copy first, delete later.** Copy the **current** `packages/local-sandbox` tree (already has `build:gen` / `sandboxScripts.gen.ts`, `mcp_client_local.py`, `getClientInstall`) — not an older snapshot. - -1. **Copy** sources into [`packages/trueforge/src/sandbox/local/`](packages/trueforge/src/sandbox/local/) (provider, core, schemas, local Python client, codegen). Unit/contract tests → [`packages/trueforge/tests/unit/sandbox/local/`](packages/trueforge/tests/unit/sandbox/local/) so [`jest.unit.config.cjs`](packages/trueforge/jest.unit.config.cjs) (`tests/unit/**/*.test.ts`) picks them up. Smoke/lima/probe stay as **scripts** on `@truefoundry/trueforge` (`smoke:local`, `smoke:local:lima`) — do not put `smoke.test.ts` on the unit Jest run. -2. Add `@anthropic-ai/sandbox-runtime` (and any other local-sandbox deps) to [`packages/trueforge/package.json`](packages/trueforge/package.json). Wire local script codegen into trueforge `build:gen` (or a sibling `build:gen:local-sandbox` that `build` / `typecheck` / `test` invoke). Gitignore the generated `sandboxScripts.gen.ts` under trueforge (same as today’s local-sandbox gitignore). -3. Wire resolve / capabilities / main to the **trueforge copy**. Prove green: trueforge typecheck + unit tests + local smoke via the new scripts; standalone fallback works. -4. **Stop and ask the developer** before deleting `packages/local-sandbox`. Do **not** remove the top-level package until explicitly confirmed. -5. After confirmation: remove `packages/local-sandbox`, drop the root [`package.json`](package.json) `typecheck` filter for `@truefoundry/local-sandbox`, refresh the lockfile, and drop any remaining `@truefoundry/local-sandbox` imports. `local-sandbox` is not on the CI test matrix today (only root typecheck); after the fold, unit tests run as part of the `trueforge` package job. - -Do not delete the top-level package in the same step as the first copy — keep it until the in-server path is verified **and** the developer approves removal. - -Local UDS `mcp_client` stays under `packages/trueforge/src/sandbox/local/` (tightly coupled); not merged with product NATS `mcp_client.py`. - -### New: sandbox ref helpers — [`packages/trueforge-core/src/core/sandbox/sandboxRef.ts`](packages/trueforge-core/src/core/sandbox/sandboxRef.ts) (name OK to adjust) - -```ts -export interface SandboxRefParts { - /** Provider kind from `SandboxProvider.type` (e.g. `daytona`, `local`) — plain string, not a closed union. */ - providerType: string; - rawId: string; -} - -/** `v1:type:raw` — raw may contain `:` (split only on first two colons after version). */ -export function formatSandboxId(parts: SandboxRefParts): string; - -/** - * Parse fancy id. No `v1:` prefix → `{ kind: 'legacy', rawId: fullString }`. - * Malformed `v1:` (too few segments) → `{ kind: 'legacy', rawId: fullString }` so carry-forward stays safe. - */ -export function parseSandboxId( - sandboxId: string, -): { kind: 'v1'; parts: SandboxRefParts } | { kind: 'legacy'; rawId: string }; - -/** Carry-forward gate for turn admit / download. */ -export function existingSandboxIdForProvider(params: { - existingSandboxId: string | undefined; - currentProviderType: string; -}): string | undefined; -``` - -Export from [`packages/trueforge-core/src/core/index.ts`](packages/trueforge-core/src/core/index.ts). - -### [`SandboxProvider`](packages/trueforge-core/src/core/sandbox/provider/Provider.ts) - -```ts -export interface SandboxProvider { - /** Stable provider kind used in fancy sandbox ids and carry-forward (plain string). */ - readonly type: string; - // ...existing methods unchanged; createSandbox still returns raw id only -} -``` - -- [`DaytonaSandboxProvider`](packages/trueforge-core/src/core/sandbox/provider/DaytonaProvider.ts): `readonly type = 'daytona'` -- [`LocalSandboxProvider`](packages/trueforge/src/sandbox/local/provider/LocalSandboxProvider.ts): `readonly type = 'local'` -- [`TFYSandboxProvider`](packages/trueforge-core/src/core/sandbox/provider/TFYSandboxProvider.ts) must set `type` as well - -`createSandbox(): Promise<{ sandboxId: string }>` still returns **raw** id only. - -### [`Sandbox` / `SandboxOptions`](packages/trueforge-core/src/core/sandbox/Sandbox.ts) - -No separate `providerType` / `providerName` on options — read `provider.type`. - -Behavior changes (methods unchanged externally): - -- Constructor: drop `validateSandboxOwnedByTenant`; if `existingSandboxId` set, store fancy or legacy as session id; compute **raw** via `parseSandboxId` for provider calls. -- `ensureSandboxCreated`: on create, `formatSandboxId({ providerType: provider.type, rawId })` before `SANDBOX_CREATED` / `SandboxInfo`. -- All `provider.*` calls use **raw** id only. -- Same-type missing: on `SandboxNotAvailableError` from provider while reattaching, clear existing, `createSandbox()`, emit new fancy id (recreate path). This path does **not** exist today — `ensureSandboxCreated` just reuses `existingSandboxInfo`. - -Remove dead: ownership-only `tenantName` usage (keep `TFY_TENANT_NAME` in `execExtraEnv` only if still needed for Daytona/agent env). - -### Server resolve / build - -[`resolveSandboxProvider`](packages/trueforge/src/runtime/sessionResources.ts) return type becomes: - -```ts -Promise; -// provider.type discriminates daytona vs local -``` - -- DB Daytona row → `DaytonaSandboxProvider` (`type: 'daytona'`) -- No row + `STANDALONE` + cached support probe is supported → `LocalSandboxProvider` (`type: 'local'`, no store write) -- Else → `undefined` - -**Cache `LocalSandboxProvider.isSupported()` once** (process start or first use). The probe inits SRT and creates a temp sandbox — do not call it on every GET `/capabilities`, `validateAgentSpec`, or turn. - -[`buildTurnSandbox`](packages/trueforge/src/runtime/sessionResources.ts): - -```ts -export function buildTurnSandbox(input: { - provider: SandboxProvider; - logger: Logger; - gitSkills: readonly GitSkill[]; - fileDownloadEnabled: boolean; - existingSandboxId?: string | undefined; // gate with existingSandboxIdForProvider({ ..., currentProviderType: provider.type }) - tracing: AgentTracing; - tenantName: string; // DaytonaSandboxProvider construction / optional TFY_TENANT_NAME for Daytona only -}): Sandbox; -``` - -Call sites ([`turns.ts`](packages/trueforge/src/apis/turns.ts) factory + download): - -- `existingSandboxIdForProvider({ existingSandboxId, currentProviderType: provider.type })` before `buildTurnSandbox`. -- Download: `parseSandboxId` → raw → `provider.downloadFile({ sandboxId: raw, path })`. - -### Schemas — [`sandboxProvider.ts`](packages/trueforge/src/schemas/sandboxProvider.ts) - -- **No `type: 'local'` on the wire.** GET/PUT/catalog stay Daytona-only (current schemas). -- Local exists only as runtime `SandboxProvider.type === 'local'`, not as a settings manifest. - -### Settings / capabilities / status helpers - -- [`sandboxProviders.ts` GET](packages/trueforge/src/apis/sandboxProviders.ts): **unchanged** — no row → 404 (do **not** synthesize local). -- PUT: Daytona-only; first PUT inserts Daytona row (upgrades off implicit local). -- [`capabilities.ts`](packages/trueforge/src/apis/capabilities.ts) / status helper: no row + `STANDALONE` + cached support → sandbox/skills **enabled** (`ready`) without Daytona SDK or store write. -- [`validateAgentSpec`](packages/trueforge/src/runtime/sessionResources.ts): sandbox/skills OK when resolve would return a provider (row **or** local fallback). Existing unit tests that require a DB row must be updated for the standalone+supported case. - -### Config + process lifecycle - -[`config.ts`](packages/trueforge/src/config.ts) — **derived only** (no new user-facing env vars). Fields live on **`StandaloneServerConfiguration` only** (local fallback is `STANDALONE=true`-only): - -- `LOCAL_SANDBOX_ROOT_PARENT` = `join(envPaths('trueforge', { suffix: '' }).data, 'sandboxes')` — same `{ suffix: '' }` as `SQLITE_PATH` so sandboxes sit next to the DB, not under `trueforge-nodejs`. -- `CODE_MODE_SOCKET_PARENT` = `join(os.tmpdir(), 'tf_cms')` - -Reads go through `configuration`, not `process.env`. - -[`main.ts`](packages/trueforge/src/main.ts) (standalone only): - -- `prepareCodeModeSocketParent()` at **every** standalone startup (including `tsx watch` restarts): exists → warn; `rm` + `mkdir 0700`. This is the reliability path for leftover sockets. -- `mkdir` sandboxes parent as needed (no delete on shutdown). -- Shutdown `rm` of `tf_cms` only in the **existing** production drain hook (`NODE_ENV !== 'development'`). Do **not** add a special watch-mode shutdown — watch already skips drain so `tsx` can restart; the next start’s `prepare` cleans leftovers. - -Probe cache: run `LocalSandboxProvider.isSupported()` once during standalone boot (after socket-parent prepare) and pass the result into resolve/capabilities. If unsupported, local fallback is off (capabilities stay disabled until a Daytona row exists). - -### Local provider (in trueforge) - -[`LocalSandboxProvider`](packages/trueforge/src/sandbox/local/provider/LocalSandboxProvider.ts) (after move): - -- `readonly type = 'local'`. -- Options unchanged shape (no `tenantName`); construct with derived paths + cached `support`. -- **New work:** ops on a missing/nonexistent root must throw `SandboxNotAvailableError` (today they become `SandboxFileNotFoundError` or generic errors). That is what enables Sandbox recreate. -- `createSandbox` still returns absolute path raw id. -- No separate `@truefoundry/local-sandbox` dependency after the copy is the runtime path. - -### UI / adapter - -Adapter already maps GET 404 → `[]` ([`sandboxProviderCatalog.ts`](packages/trueforge-ui/src/plugins/trueforge-agent-server-adapter/catalogs/sandboxProviderCatalog.ts)); Available stays visible when the list is empty. - -- Settings GET 404 + capabilities `sandbox.enabled` → no configured provider row; **Daytona stays in Available**. -- After Daytona PUT → normal Daytona configured UI. -- No adapter mapping for `type: 'local'` settings payload (none returned). -- Composer/agent UI already keys off capabilities for sandbox/skills enablement. -- Add a UI test for empty settings + capabilities on → Daytona Available; after Daytona → configured. - -### Removals — delete `validateSandboxOwnedByTenant` from the codebase - -Full delete (no shim, no “Daytona-only” keep): - -| Location | Action | -|---|---| -| [`SandboxErrors.ts`](packages/trueforge-core/src/core/sandbox/SandboxErrors.ts) | Delete `validateSandboxOwnedByTenant` and `SandboxTenantMismatchError` | -| [`core/index.ts`](packages/trueforge-core/src/core/index.ts) | Remove export | -| [`Sandbox.ts`](packages/trueforge-core/src/core/sandbox/Sandbox.ts) constructor | Remove call + import | -| [`Sandbox.ts`](packages/trueforge-core/src/core/sandbox/Sandbox.ts) `ensureSandboxCreated` | Remove call after `createSandbox` | -| [`turns.ts`](packages/trueforge/src/apis/turns.ts) download handler | Remove call + import | -| [`turnRoutes.ts`](packages/trueforge/src/routes/turnRoutes.ts) download 403 description | Drop “sandbox belongs to another tenant” wording | - -Rationale: `sandbox_id` is never client-supplied; download already authorizes via session tenant + `checkTurnAccess` + turn loaded through that session. - -### Changeset - -Published-package change (`trueforge-core`, `trueforge`, and `trueforge-ui` if the adapter/test lands there). Add a `.changeset/*.md` via `pnpm changeset`. - -## Tests - -- Assert no remaining references to `validateSandboxOwnedByTenant` / `SandboxTenantMismatchError`. -- Empty store + standalone + cached support → capabilities sandbox/skills **enabled**; GET settings still **404**; store still empty. -- PUT Daytona on empty works; GET then Daytona; capabilities still enabled via row. -- Fancy id helpers + Sandbox wrap/unwrap; carry-forward drops on type mismatch; legacy non-`v1:` still carried. -- Same-type missing → recreate + new id in snapshot (`SandboxNotAvailableError` from provider). -- Download unwraps fancy id to raw before `provider.downloadFile` (import helpers). -- UI: empty settings + capabilities on → Daytona Available; after Daytona → configured. -- Local contract tests under `packages/trueforge/tests/unit/sandbox/local/`. Smoke stays on `pnpm --filter @truefoundry/trueforge smoke:local` (not unit Jest). - -## Out of scope - -- Merging product NATS `mcp_client.py` with the local UDS client — keep the local Python client **under** `packages/trueforge/src/sandbox/local/` (tightly coupled to UDS transport); do not unify modules. -- Multi-provider rows (still singleton per tenant). -- Persisting or returning `type: 'local'` on settings/catalog API. -- Local upsert / local PUT / synthetic local GET. -- Removing `packages/local-sandbox` without an explicit developer go-ahead after the trueforge copy is green. -- Special-casing watch-mode shutdown for `tf_cms` (startup `prepare` covers leftovers).