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/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/.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/.gitignore b/.gitignore index eb5ff3960..6ba4f9250 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 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/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": { diff --git a/package.json b/package.json index 66cb49e74..8440ec19f 100644 --- a/package.json +++ b/package.json @@ -41,12 +41,15 @@ "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", "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/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/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/package.json b/packages/local-sandbox/package.json deleted file mode 100644 index 2e683c2a3..000000000 --- a/packages/local-sandbox/package.json +++ /dev/null @@ -1,49 +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", - "fixtures" - ], - "engines": { - "node": ">=22" - }, - "scripts": { - "build": "tsc -p tsconfig.build.json", - "build:smoke": "tsc -p tsconfig.smoke.json", - "typecheck": "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", - "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/src/index.ts b/packages/local-sandbox/src/index.ts deleted file mode 100644 index 6837f1448..000000000 --- a/packages/local-sandbox/src/index.ts +++ /dev/null @@ -1,5 +0,0 @@ -export { CodeModeUdsTransport } 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/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/index.ts b/packages/trueforge-core/src/core/index.ts index 03be317a9..52bc61c85 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'; @@ -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 fa4696cae..a1b1f389b 100644 --- a/packages/trueforge-core/src/core/sandbox/Sandbox.ts +++ b/packages/trueforge-core/src/core/sandbox/Sandbox.ts @@ -16,15 +16,24 @@ import { import type { AgentTracing } from '../tracing/AgentTracing'; import { extractErrorLogFields } from '../util/errorLogFields'; import { CodeModeDispatcher } from './codeMode/CodeModeDispatcher'; -import type { CodeModeTransport } from './codeMode/CodeModeTransport'; -import { SANDBOX_FILE_UPLOADS_DIR } from './constants'; +import { type CodeModeClientInstall, type CodeModeTransport } from './codeMode/CodeModeTransport'; import { ensureExecSuccess, shellEscape, type SandboxProvider } from './provider/Provider'; -import { validateNoPathTraversal, validateSandboxOwnedByTenant } from './SandboxErrors'; -import { sandboxScripts } from './sandboxScripts.gen'; +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 { SKILLS_DIR } from './skills/constants'; +import { dirname, join } from 'node:path'; 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; } @@ -82,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). */ @@ -105,15 +116,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 +141,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'), }), @@ -195,11 +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 tenantName: string; + private readonly sessionId?: string | undefined; private existingSandboxInfo: SandboxInfo | undefined; // Cached promise to prevent concurrent sub-agents from creating duplicate sandboxes. private sandboxCreationPromise?: Promise | undefined; @@ -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({ @@ -230,25 +241,30 @@ 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.sessionId = options.sessionId; this.skillMounter = options.skillMounter; this.fileDownloadEnabled = options.fileDownloadEnabled ?? false; 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; 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); + } + + /** 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) { @@ -282,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; @@ -312,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.`, ); } @@ -441,6 +457,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; @@ -448,15 +471,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 }; - }) + .createSandbox(this.sessionId === undefined ? undefined : { sessionId: this.sessionId }) + .then(({ sandboxId }) => ({ + sandbox_id: formatSandboxId({ providerType: this.provider.type, rawId: sandboxId }), + })) .catch((e: unknown) => { this.sandboxCreationPromise = undefined; throw e; @@ -464,62 +484,147 @@ 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(), execExtraEnv: this.execExtraEnv, codeModeEnv, + mcpClientInstall: this.mcpClientInstall, }); 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 }; } @@ -528,26 +633,30 @@ 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 }; } 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 }), }; } @@ -583,7 +692,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, @@ -593,36 +702,65 @@ export class Sandbox extends LocalToolMCP { } private async initSandboxEnvironment(): Promise { - const toolResultDumpDir = this.provider.getToolResultDumpDir(this.requiredSandboxInfo.sandbox_id); + 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'); - 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(fileUploadsDir), shellEscape(toolResultDumpDir), shellEscape(skillsDir)]; + 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. - const skillInit = this.skillMounter?.getSandboxInit(); + const skillInit = this.skillMounter?.getSandboxInit({ + skillsDir, + gitDownloaderPath: this.provider.getGitDownloaderPath(sandboxId), + }); if (skillInit) { 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 ${skillsDir}` + : `Sandbox initialized: MCP client at ${install.remotePath}; skills dir ${skillsDir}`, ); await this.writeGitCredentials(); 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/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..f0b810d7e 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(): 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/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 e07273a62..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 { @@ -100,6 +101,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; @@ -488,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 b2325359c..a0c9a632f 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`; @@ -73,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; @@ -81,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 7b51354fe..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'; @@ -42,6 +43,7 @@ interface StatResult { } export class TFYSandboxProvider implements SandboxProvider { + readonly type = 'tfy'; private readonly serverUrl: string; private readonly natsBridgeUrl: string; private readonly tenantName: string; @@ -199,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/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/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 65cc0b19c..68966faed 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(), @@ -64,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 new file mode 100644 index 000000000..27673b0ae --- /dev/null +++ b/packages/trueforge-core/tests/core/sandbox/Sandbox.ids.test.ts @@ -0,0 +1,110 @@ +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`, + 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(), + ...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/Sandbox.paths.test.ts b/packages/trueforge-core/tests/core/sandbox/Sandbox.paths.test.ts new file mode 100644 index 000000000..a6a474605 --- /dev/null +++ b/packages/trueforge-core/tests/core/sandbox/Sandbox.paths.test.ts @@ -0,0 +1,84 @@ +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('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 94209fcee..88f9c8181 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' }), @@ -27,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: () => { @@ -64,6 +68,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({ 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-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-sandbox.contract.config.cjs b/packages/trueforge/jest.local-sandbox.contract.config.cjs new file mode 100644 index 000000000..34cc0cc61 --- /dev/null +++ b/packages/trueforge/jest.local-sandbox.contract.config.cjs @@ -0,0 +1,40 @@ +/** @type {import('jest').Config} */ +module.exports = { + 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-sandbox.smoke.config.cjs b/packages/trueforge/jest.local-sandbox.smoke.config.cjs new file mode 100644 index 000000000..330ee2292 --- /dev/null +++ b/packages/trueforge/jest.local-sandbox.smoke.config.cjs @@ -0,0 +1,40 @@ +/** @type {import('jest').Config} */ +module.exports = { + 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/jest.unit.config.cjs b/packages/trueforge/jest.unit.config.cjs index f2331ae7e..389e40499 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-sandbox:contract`. + testPathIgnorePatterns: ['contract\\.test\\.ts$'], }; diff --git a/packages/trueforge/package.json b/packages/trueforge/package.json index d8dfbafd9..f6157ba6a 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-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" }, "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/local-sandbox/lima/local-sandbox.yaml b/packages/trueforge/scripts/local-sandbox/lima.yaml similarity index 91% rename from packages/local-sandbox/lima/local-sandbox.yaml rename to packages/trueforge/scripts/local-sandbox/lima.yaml index 109f495b4..3c1cb6bc6 100644 --- a/packages/local-sandbox/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/local-sandbox/scripts/probe-loopback.ts b/packages/trueforge/scripts/local-sandbox/probe-loopback.ts similarity index 100% rename from packages/local-sandbox/scripts/probe-loopback.ts rename to packages/trueforge/scripts/local-sandbox/probe-loopback.ts diff --git a/packages/local-sandbox/scripts/smoke-lima.sh b/packages/trueforge/scripts/local-sandbox/smoke-lima.sh similarity index 71% rename from packages/local-sandbox/scripts/smoke-lima.sh rename to packages/trueforge/scripts/local-sandbox/smoke-lima.sh index a0a57b96d..7a3f8f2a3 100755 --- a/packages/local-sandbox/scripts/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` 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}/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 @@ -31,6 +33,6 @@ echo "running Linux smoke inside ${INSTANCE}..." limactl shell "${INSTANCE}" -- bash -lc " set -euo pipefail cd $(printf '%q' "${ROOT}") - CI=true pnpm install --ignore-workspace --no-frozen-lockfile - pnpm smoke + CI=true pnpm install --no-frozen-lockfile + pnpm --filter @truefoundry/trueforge smoke:local-sandbox " 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..9565629a2 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'; @@ -128,6 +129,7 @@ function createTurnResolver(deps: { logger: Logger; signal: AbortSignal; userRef: string; + sessionId: string; }): TurnResourceResolver { const { mcpServerStore, @@ -139,6 +141,7 @@ function createTurnResolver(deps: { logger, signal, userRef, + sessionId, } = deps; return new TurnResourceResolver({ llm: async name => { @@ -185,10 +188,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 +216,8 @@ function createTurnResolver(deps: { logger, gitSkills, fileDownloadEnabled: spec.config.sandbox.file_downloads, - existingSandboxId, + existingSandboxId: carriedSandboxId, + sessionId, tracing, tenantName: TENANT_ID, }); @@ -431,9 +439,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), @@ -508,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/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..d132bb674 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,24 @@ 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.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' }); } @@ -342,6 +366,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..e898dc869 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,35 @@ 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, + 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, logger, - build_metadata: record.build_metadata, }); } /** * 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; @@ -241,6 +257,7 @@ export function buildTurnSandbox(input: { gitSkills: readonly GitSkill[]; fileDownloadEnabled: boolean; existingSandboxId?: string | undefined; + sessionId: string; tracing: AgentTracing; tenantName: string; }): Sandbox { @@ -248,12 +265,11 @@ 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, 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 +357,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/local-sandbox/src/core/CodeModeUdsTransport.ts b/packages/trueforge/src/sandbox/local/core/CodeModeUdsTransport.ts similarity index 85% rename from packages/local-sandbox/src/core/CodeModeUdsTransport.ts rename to packages/trueforge/src/sandbox/local/core/CodeModeUdsTransport.ts index 03d20badb..f200f53b5 100644 --- a/packages/local-sandbox/src/core/CodeModeUdsTransport.ts +++ b/packages/trueforge/src/sandbox/local/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,17 +16,34 @@ 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'; -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; +/** 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). @@ -52,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. @@ -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/frame.ts b/packages/trueforge/src/sandbox/local/core/frame.ts similarity index 100% rename from packages/local-sandbox/src/core/frame.ts rename to packages/trueforge/src/sandbox/local/core/frame.ts diff --git a/packages/local-sandbox/src/core/hostRun.ts b/packages/trueforge/src/sandbox/local/core/hostRun.ts similarity index 89% rename from packages/local-sandbox/src/core/hostRun.ts rename to packages/trueforge/src/sandbox/local/core/hostRun.ts index ae469bd39..8d87d3bcf 100644 --- a/packages/local-sandbox/src/core/hostRun.ts +++ b/packages/trueforge/src/sandbox/local/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 { realpathSync } from 'node:fs'; +import { mkdir, rm } 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'; 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( @@ -65,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 { @@ -100,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; @@ -122,6 +128,7 @@ function denySharedDefaultWritePaths(): string[] { const ALLOW_READ_BY_PLATFORM = { darwin: [ '/opt/homebrew/bin', + '/usr/local', '/usr/bin', '/bin', '/usr/sbin', @@ -188,7 +195,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, @@ -295,9 +308,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 { @@ -512,10 +527,3 @@ export async function runSupervisorSession(params: { }); }); } - -/** Copy the MCP client fixture into the sandbox (isolation: 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); - return dest; -} diff --git a/packages/trueforge/src/sandbox/local/index.ts b/packages/trueforge/src/sandbox/local/index.ts new file mode 100644 index 000000000..829c692fe --- /dev/null +++ b/packages/trueforge/src/sandbox/local/index.ts @@ -0,0 +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, + LocalSandboxSupportProbeAttempt, + LocalSandboxSupportResult, +} from './provider/LocalSandboxProvider.js'; diff --git a/packages/local-sandbox/src/provider/LocalSandboxProvider.ts b/packages/trueforge/src/sandbox/local/provider/LocalSandboxProvider.ts similarity index 62% rename from packages/local-sandbox/src/provider/LocalSandboxProvider.ts rename to packages/trueforge/src/sandbox/local/provider/LocalSandboxProvider.ts index 53e82e315..2e66e3c10 100644 --- a/packages/local-sandbox/src/provider/LocalSandboxProvider.ts +++ b/packages/trueforge/src/sandbox/local/provider/LocalSandboxProvider.ts @@ -11,14 +11,17 @@ import type { 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 { dirname, isAbsolute, join, resolve, sep } from 'node:path'; +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, @@ -27,8 +30,10 @@ import { removeSandbox, resetSrt, resolveCommandOnHost, + resolvePythonExecutableOnHost, runSupervisorSession, type LocalSandboxPlatform, + type SessionResult, } from '../core/hostRun.js'; import { XferFileInfoSchema, type XferFileInfo } from '../schemas/xferFileInfo.js'; @@ -43,9 +48,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; @@ -61,20 +151,32 @@ 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). */ -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; +} + +/** 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; private readonly codeModeSocketParentPath: string; private readonly support: LocalSandboxSupported; 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 = { @@ -89,15 +191,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) { @@ -110,6 +213,7 @@ export class LocalSandboxProvider implements SandboxProvider { for (const name of SHELL_CANDIDATES) { const resolved = await resolveCommandOnHost({ platform, name }); if (resolved === undefined) { + attempts.push({ kind: 'shell', name, resolved: undefined }); continue; } const probe = await runSupervisorSession({ @@ -119,49 +223,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) { + 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); @@ -186,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 { @@ -227,6 +362,13 @@ export class LocalSandboxProvider implements SandboxProvider { 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); @@ -275,15 +417,27 @@ 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 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 }); return { sandboxId }; } async exec(params: SandboxExecParams): Promise { + this.ensureSandboxRoot(params.sandboxId); try { await this.ensureSrt(); const sandboxRootPath = params.sandboxId; @@ -310,6 +464,9 @@ export class LocalSandboxProvider implements SandboxProvider { 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 }; } @@ -335,11 +492,24 @@ 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(); 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,8 +531,9 @@ 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 { + this.ensureSandboxRoot(params.sandboxId); await this.ensureSrt(); if (params.content.length > this.fileMaxBytesForDownload) { throw new SandboxFileTooLargeError(params.remotePath, params.content.length, this.fileMaxBytesForDownload); @@ -370,13 +541,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/src/schemas/jsonMessage.ts b/packages/trueforge/src/sandbox/local/schemas/jsonMessage.ts similarity index 100% rename from packages/local-sandbox/src/schemas/jsonMessage.ts rename to packages/trueforge/src/sandbox/local/schemas/jsonMessage.ts diff --git a/packages/local-sandbox/src/schemas/xferFileInfo.ts b/packages/trueforge/src/sandbox/local/schemas/xferFileInfo.ts similarity index 100% rename from packages/local-sandbox/src/schemas/xferFileInfo.ts rename to packages/trueforge/src/sandbox/local/schemas/xferFileInfo.ts 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/local-sandbox/test/smoke.test.ts b/packages/trueforge/tests/sandbox/local/smoke.test.ts similarity index 96% rename from packages/local-sandbox/test/smoke.test.ts rename to packages/trueforge/tests/sandbox/local/smoke.test.ts index eeab2a57e..babef6c19 100644 --- a/packages/local-sandbox/test/smoke.test.ts +++ b/packages/trueforge/tests/sandbox/local/smoke.test.ts @@ -14,21 +14,21 @@ 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 { createLogger } from 'winston'; +import { CodeModeUdsTransport, installMcpFixture } from '../../../src/sandbox/local/core/CodeModeUdsTransport.js'; import { commandPath, createSandbox, - installMcpFixture, MAX_OUTPUT_BYTES, platformAllowRead, registerCodeModeSocketPath, removeSandbox, runSupervisorSession, unregisterCodeModeSocketPath, -} from '../src/core/hostRun.js'; -import { LocalSandboxProvider } from '../src/provider/LocalSandboxProvider.js'; +} 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 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'; @@ -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')), @@ -212,6 +216,7 @@ function makeDemoToolSet(params: { onRequest?: () => void }): IToolSet { async function withCodeModeTransport(params: { codeModeSocketParentPath: string; + sandboxRootPath: string; maxMessageBytes?: number; onProtocolError?: (message: string) => void; onRequest?: () => void; @@ -226,13 +231,19 @@ 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); + 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(); @@ -250,6 +261,7 @@ async function smokeCodeMode(params: { await withCodeModeTransport({ codeModeSocketParentPath: params.codeModeSocketParentPath, + sandboxRootPath: params.sandboxRootPath, onRequest: () => { toolRequests += 1; }, @@ -266,18 +278,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: `mcp-client 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)'); }, }); @@ -285,6 +299,7 @@ async function smokeCodeMode(params: { let oversizeError: string | undefined; await withCodeModeTransport({ codeModeSocketParentPath: params.codeModeSocketParentPath, + sandboxRootPath: params.sandboxRootPath, maxMessageBytes: oversizeCap, onProtocolError: message => { oversizeError = message; @@ -317,6 +332,7 @@ async function smokeCodeMode(params: { let badJsonError: string | undefined; await withCodeModeTransport({ codeModeSocketParentPath: params.codeModeSocketParentPath, + sandboxRootPath: params.sandboxRootPath, onProtocolError: message => { badJsonError = message; }, @@ -347,6 +363,7 @@ async function smokeCodeMode(params: { await withCodeModeTransport({ codeModeSocketParentPath: params.codeModeSocketParentPath, + sandboxRootPath: params.sandboxRootPath, onRequest: () => { toolRequests += 1; }, @@ -355,7 +372,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 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, }); @@ -372,6 +402,7 @@ async function smokeCodeMode(params: { const beforeMissing = toolRequests; await withCodeModeTransport({ codeModeSocketParentPath: params.codeModeSocketParentPath, + sandboxRootPath: params.sandboxRootPath, onRequest: () => { toolRequests += 1; }, @@ -383,7 +414,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 mcp-client call-tool demo ping '${JSON.stringify({ message: 'x' })}'; then`, ' echo "expected missing-sock failure" >&2', ' exit 1', 'fi', @@ -403,6 +434,7 @@ async function smokeCodeMode(params: { let holdPid: number | undefined; await withCodeModeTransport({ codeModeSocketParentPath: params.codeModeSocketParentPath, + sandboxRootPath: params.sandboxRootPath, onRequest: () => { hostInjected += 1; }, @@ -1594,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/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/local-sandbox/test/codeModeUdsTransport.contract.test.ts b/packages/trueforge/tests/unit/sandbox/local/codeModeUdsTransport.contract.test.ts similarity index 92% rename from packages/local-sandbox/test/codeModeUdsTransport.contract.test.ts rename to packages/trueforge/tests/unit/sandbox/local/codeModeUdsTransport.contract.test.ts index 1d269c924..11c5999c0 100644 --- a/packages/local-sandbox/test/codeModeUdsTransport.contract.test.ts +++ b/packages/trueforge/tests/unit/sandbox/local/codeModeUdsTransport.contract.test.ts @@ -1,6 +1,5 @@ /** - * Node UDS binder for the harness Code Mode transport contract suite. - * Lives in local-sandbox only — no product/server import of this package. + * Node UDS binder for the Code Mode transport contract suite. */ import { CodeModeDispatcher, @@ -16,9 +15,9 @@ 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'; +} 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 = { 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..fb2ced2fd --- /dev/null +++ b/packages/trueforge/tests/unit/sandbox/local/core/resolvePythonExecutableOnHost.test.ts @@ -0,0 +1,39 @@ +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('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..cb97baff7 --- /dev/null +++ b/packages/trueforge/tests/unit/sandbox/local/provider/layout.test.ts @@ -0,0 +1,12 @@ +import { localSandboxSessionSegment } from '../../../../../src/sandbox/local/provider/LocalSandboxProvider'; + +describe('localSandboxSessionSegment', () => { + it('keeps a single-segment session id and rejects missing or unsafe values', () => { + expect(localSandboxSessionSegment('sess_1')).toBe('sess_1'); + 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/missingRoot.test.ts b/packages/trueforge/tests/unit/sandbox/local/provider/missingRoot.test.ts new file mode 100644 index 000000000..202b74bcc --- /dev/null +++ b/packages/trueforge/tests/unit/sandbox/local/provider/missingRoot.test.ts @@ -0,0 +1,27 @@ +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', () => { + 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' }, + logger: createLogger({ silent: true }), + }); + 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/local-sandbox/test/provider/contract.test.ts b/packages/trueforge/tests/unit/sandbox/local/provider/provider.contract.test.ts similarity index 70% rename from packages/local-sandbox/test/provider/contract.test.ts rename to packages/trueforge/tests/unit/sandbox/local/provider/provider.contract.test.ts index 4b0f55944..7a7b6b0be 100644 --- a/packages/local-sandbox/test/provider/contract.test.ts +++ b/packages/trueforge/tests/unit/sandbox/local/provider/provider.contract.test.ts @@ -1,12 +1,16 @@ 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'; +import { createLogger } from 'winston'; +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); } @@ -18,6 +22,7 @@ describe('LocalSandboxProvider (SandboxProvider contract)', () => { sandboxRootPathParent, codeModeSocketParentPath, support, + logger: createLogger({ silent: true }), }); return { provider, 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..bb062c254 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -110,42 +110,11 @@ importers: specifier: ^2.0.2 version: 2.0.2(monaco-editor@0.52.2) - packages/local-sandbox: + packages/trueforge: 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: '@daytona/sdk': specifier: ^0.204.1 version: 0.204.1(debug@4.4.3(supports-color@8.1.1))(supports-color@8.1.1)