diff --git a/.changeset/20260826120230-regenerate-sdk-from-openapi.md b/.changeset/20260826120230-regenerate-sdk-from-openapi.md new file mode 100644 index 000000000..efd8ff00f --- /dev/null +++ b/.changeset/20260826120230-regenerate-sdk-from-openapi.md @@ -0,0 +1,5 @@ +--- +"@truefoundry/trueforge-sdk": patch +--- + +Regenerate SDK from updated OpenAPI spec. diff --git a/.changeset/strict-lilies-start.md b/.changeset/strict-lilies-start.md new file mode 100644 index 000000000..be48db677 --- /dev/null +++ b/.changeset/strict-lilies-start.md @@ -0,0 +1,8 @@ +--- +"@truefoundry/trueforge": minor +"@truefoundry/trueforge-core": minor +"@truefoundry/trueforge-ui": minor +--- + +Add OpenSandbox as a configurable sandbox provider. + \ No newline at end of file diff --git a/.github/fern/openapi/openapi.json b/.github/fern/openapi/openapi.json index 68e38c3cf..93b1fedea 100644 --- a/.github/fern/openapi/openapi.json +++ b/.github/fern/openapi/openapi.json @@ -517,44 +517,85 @@ ] }, "CatalogSandboxProvider": { - "additionalProperties": false, - "properties": { - "auto_archive_interval_in_minutes": { - "description": "Minutes before Daytona auto-archives the sandbox (0 disables).", - "minimum": 0, - "type": "integer" - }, - "auto_delete_interval_in_minutes": { - "description": "Minutes before Daytona auto-deletes the sandbox (0 disables).", - "minimum": 0, - "type": "integer" - }, - "auto_stop_interval_in_minutes": { - "description": "Minutes of idle time before Daytona auto-stops the sandbox (0 disables).", - "minimum": 0, - "type": "integer" - }, - "exec_timeout_ms": { - "description": "Default sandbox command exec timeout in milliseconds.", - "exclusiveMinimum": 0, - "type": "integer" + "oneOf": [ + { + "additionalProperties": false, + "properties": { + "auto_archive_interval_in_minutes": { + "description": "Minutes before Daytona auto-archives the sandbox (0 disables).", + "minimum": 0, + "type": "integer" + }, + "auto_delete_interval_in_minutes": { + "description": "Minutes before Daytona auto-deletes the sandbox (0 disables).", + "minimum": 0, + "type": "integer" + }, + "auto_stop_interval_in_minutes": { + "description": "Minutes of idle time before Daytona auto-stops the sandbox (0 disables).", + "minimum": 0, + "type": "integer" + }, + "exec_timeout_ms": { + "description": "Default sandbox command exec timeout in milliseconds.", + "exclusiveMinimum": 0, + "type": "integer" + }, + "type": { + "description": "Daytona sandbox provider.", + "enum": [ + "daytona" + ], + "type": "string" + } + }, + "required": [ + "type", + "exec_timeout_ms", + "auto_stop_interval_in_minutes", + "auto_archive_interval_in_minutes", + "auto_delete_interval_in_minutes" + ], + "type": "object" }, - "type": { - "description": "Daytona sandbox provider.", - "enum": [ - "daytona" + { + "additionalProperties": false, + "properties": { + "domain": { + "description": "OpenSandbox API host, optionally including a port, without a URL scheme.", + "minLength": 1, + "type": "string" + }, + "exec_timeout_ms": { + "description": "Default sandbox command exec timeout in milliseconds.", + "exclusiveMinimum": 0, + "type": "integer" + }, + "protocol": { + "default": "https", + "description": "Protocol used to reach the OpenSandbox API.", + "enum": [ + "http", + "https" + ], + "type": "string" + }, + "type": { + "description": "OpenSandbox sandbox provider.", + "enum": [ + "opensandbox" + ], + "type": "string" + } + }, + "required": [ + "type", + "domain", + "exec_timeout_ms" ], - "type": "string" + "type": "object" } - }, - "required": [ - "type", - "exec_timeout_ms", - "auto_stop_interval_in_minutes", - "auto_archive_interval_in_minutes", - "auto_delete_interval_in_minutes" - ], - "type": "object" + ] }, "CatalogSkill": { "additionalProperties": false, @@ -2404,6 +2445,21 @@ ], "type": "object" }, + "OpenSandboxProviderAuth": { + "additionalProperties": false, + "description": "OpenSandbox authentication credentials.", + "properties": { + "api_key": { + "description": "OpenSandbox API key. Responses are redacted; on PUT, a real value sets/rotates and a redacted value keeps the stored key.", + "minLength": 1, + "type": "string" + } + }, + "required": [ + "api_key" + ], + "type": "object" + }, "PreviousTurnIdInput": { "anyOf": [ { @@ -2733,48 +2789,93 @@ "type": "object" }, "SandboxProviderManifest": { - "additionalProperties": false, - "properties": { - "auth": { - "$ref": "#/components/schemas/DaytonaSandboxProviderAuth" - }, - "auto_archive_interval_in_minutes": { - "description": "Minutes before Daytona auto-archives the sandbox (0 disables).", - "minimum": 0, - "type": "integer" - }, - "auto_delete_interval_in_minutes": { - "description": "Minutes before Daytona auto-deletes the sandbox (0 disables).", - "minimum": 0, - "type": "integer" - }, - "auto_stop_interval_in_minutes": { - "description": "Minutes of idle time before Daytona auto-stops the sandbox (0 disables).", - "minimum": 0, - "type": "integer" - }, - "exec_timeout_ms": { - "description": "Default sandbox command exec timeout in milliseconds.", - "exclusiveMinimum": 0, - "type": "integer" + "oneOf": [ + { + "additionalProperties": false, + "properties": { + "auth": { + "$ref": "#/components/schemas/DaytonaSandboxProviderAuth" + }, + "auto_archive_interval_in_minutes": { + "description": "Minutes before Daytona auto-archives the sandbox (0 disables).", + "minimum": 0, + "type": "integer" + }, + "auto_delete_interval_in_minutes": { + "description": "Minutes before Daytona auto-deletes the sandbox (0 disables).", + "minimum": 0, + "type": "integer" + }, + "auto_stop_interval_in_minutes": { + "description": "Minutes of idle time before Daytona auto-stops the sandbox (0 disables).", + "minimum": 0, + "type": "integer" + }, + "exec_timeout_ms": { + "description": "Default sandbox command exec timeout in milliseconds.", + "exclusiveMinimum": 0, + "type": "integer" + }, + "type": { + "description": "Daytona sandbox provider.", + "enum": [ + "daytona" + ], + "type": "string" + } + }, + "required": [ + "type", + "auth", + "exec_timeout_ms", + "auto_stop_interval_in_minutes", + "auto_archive_interval_in_minutes", + "auto_delete_interval_in_minutes" + ], + "type": "object" }, - "type": { - "description": "Daytona sandbox provider.", - "enum": [ - "daytona" + { + "additionalProperties": false, + "properties": { + "auth": { + "$ref": "#/components/schemas/OpenSandboxProviderAuth" + }, + "domain": { + "description": "OpenSandbox API host, optionally including a port, without a URL scheme.", + "minLength": 1, + "type": "string" + }, + "exec_timeout_ms": { + "description": "Default sandbox command exec timeout in milliseconds.", + "exclusiveMinimum": 0, + "type": "integer" + }, + "protocol": { + "default": "https", + "description": "Protocol used to reach the OpenSandbox API.", + "enum": [ + "http", + "https" + ], + "type": "string" + }, + "type": { + "description": "OpenSandbox sandbox provider.", + "enum": [ + "opensandbox" + ], + "type": "string" + } + }, + "required": [ + "type", + "auth", + "domain", + "exec_timeout_ms" ], - "type": "string" + "type": "object" } - }, - "required": [ - "type", - "auth", - "exec_timeout_ms", - "auto_stop_interval_in_minutes", - "auto_archive_interval_in_minutes", - "auto_delete_interval_in_minutes" - ], - "type": "object" + ] }, "Session": { "properties": { @@ -6739,7 +6840,7 @@ } } }, - "description": "Daytona rejected the provided API key." + "description": "The sandbox provider rejected the provided API key." } }, "summary": "Create or replace the sandbox provider", diff --git a/docker-compose.yml b/docker-compose.yml index 0b3f3224b..67a3f7333 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -50,6 +50,8 @@ services: NODE_ENV: production # Inside the container; host mapping above is 8791. PORT: 8790 + # Bind to all interfaces so healthcheck probe can reach the server. + HOST: 0.0.0.0 # Host-facing origin for MCP OAuth callbacks (mapped port, not container PORT). PUBLIC_BASE_URL: http://localhost:8791 # Compose network hosts — do not use packages/trueforge/.env localhost values. diff --git a/docs/openapi.json b/docs/openapi.json index 68e38c3cf..93b1fedea 100644 --- a/docs/openapi.json +++ b/docs/openapi.json @@ -517,44 +517,85 @@ ] }, "CatalogSandboxProvider": { - "additionalProperties": false, - "properties": { - "auto_archive_interval_in_minutes": { - "description": "Minutes before Daytona auto-archives the sandbox (0 disables).", - "minimum": 0, - "type": "integer" - }, - "auto_delete_interval_in_minutes": { - "description": "Minutes before Daytona auto-deletes the sandbox (0 disables).", - "minimum": 0, - "type": "integer" - }, - "auto_stop_interval_in_minutes": { - "description": "Minutes of idle time before Daytona auto-stops the sandbox (0 disables).", - "minimum": 0, - "type": "integer" - }, - "exec_timeout_ms": { - "description": "Default sandbox command exec timeout in milliseconds.", - "exclusiveMinimum": 0, - "type": "integer" + "oneOf": [ + { + "additionalProperties": false, + "properties": { + "auto_archive_interval_in_minutes": { + "description": "Minutes before Daytona auto-archives the sandbox (0 disables).", + "minimum": 0, + "type": "integer" + }, + "auto_delete_interval_in_minutes": { + "description": "Minutes before Daytona auto-deletes the sandbox (0 disables).", + "minimum": 0, + "type": "integer" + }, + "auto_stop_interval_in_minutes": { + "description": "Minutes of idle time before Daytona auto-stops the sandbox (0 disables).", + "minimum": 0, + "type": "integer" + }, + "exec_timeout_ms": { + "description": "Default sandbox command exec timeout in milliseconds.", + "exclusiveMinimum": 0, + "type": "integer" + }, + "type": { + "description": "Daytona sandbox provider.", + "enum": [ + "daytona" + ], + "type": "string" + } + }, + "required": [ + "type", + "exec_timeout_ms", + "auto_stop_interval_in_minutes", + "auto_archive_interval_in_minutes", + "auto_delete_interval_in_minutes" + ], + "type": "object" }, - "type": { - "description": "Daytona sandbox provider.", - "enum": [ - "daytona" + { + "additionalProperties": false, + "properties": { + "domain": { + "description": "OpenSandbox API host, optionally including a port, without a URL scheme.", + "minLength": 1, + "type": "string" + }, + "exec_timeout_ms": { + "description": "Default sandbox command exec timeout in milliseconds.", + "exclusiveMinimum": 0, + "type": "integer" + }, + "protocol": { + "default": "https", + "description": "Protocol used to reach the OpenSandbox API.", + "enum": [ + "http", + "https" + ], + "type": "string" + }, + "type": { + "description": "OpenSandbox sandbox provider.", + "enum": [ + "opensandbox" + ], + "type": "string" + } + }, + "required": [ + "type", + "domain", + "exec_timeout_ms" ], - "type": "string" + "type": "object" } - }, - "required": [ - "type", - "exec_timeout_ms", - "auto_stop_interval_in_minutes", - "auto_archive_interval_in_minutes", - "auto_delete_interval_in_minutes" - ], - "type": "object" + ] }, "CatalogSkill": { "additionalProperties": false, @@ -2404,6 +2445,21 @@ ], "type": "object" }, + "OpenSandboxProviderAuth": { + "additionalProperties": false, + "description": "OpenSandbox authentication credentials.", + "properties": { + "api_key": { + "description": "OpenSandbox API key. Responses are redacted; on PUT, a real value sets/rotates and a redacted value keeps the stored key.", + "minLength": 1, + "type": "string" + } + }, + "required": [ + "api_key" + ], + "type": "object" + }, "PreviousTurnIdInput": { "anyOf": [ { @@ -2733,48 +2789,93 @@ "type": "object" }, "SandboxProviderManifest": { - "additionalProperties": false, - "properties": { - "auth": { - "$ref": "#/components/schemas/DaytonaSandboxProviderAuth" - }, - "auto_archive_interval_in_minutes": { - "description": "Minutes before Daytona auto-archives the sandbox (0 disables).", - "minimum": 0, - "type": "integer" - }, - "auto_delete_interval_in_minutes": { - "description": "Minutes before Daytona auto-deletes the sandbox (0 disables).", - "minimum": 0, - "type": "integer" - }, - "auto_stop_interval_in_minutes": { - "description": "Minutes of idle time before Daytona auto-stops the sandbox (0 disables).", - "minimum": 0, - "type": "integer" - }, - "exec_timeout_ms": { - "description": "Default sandbox command exec timeout in milliseconds.", - "exclusiveMinimum": 0, - "type": "integer" + "oneOf": [ + { + "additionalProperties": false, + "properties": { + "auth": { + "$ref": "#/components/schemas/DaytonaSandboxProviderAuth" + }, + "auto_archive_interval_in_minutes": { + "description": "Minutes before Daytona auto-archives the sandbox (0 disables).", + "minimum": 0, + "type": "integer" + }, + "auto_delete_interval_in_minutes": { + "description": "Minutes before Daytona auto-deletes the sandbox (0 disables).", + "minimum": 0, + "type": "integer" + }, + "auto_stop_interval_in_minutes": { + "description": "Minutes of idle time before Daytona auto-stops the sandbox (0 disables).", + "minimum": 0, + "type": "integer" + }, + "exec_timeout_ms": { + "description": "Default sandbox command exec timeout in milliseconds.", + "exclusiveMinimum": 0, + "type": "integer" + }, + "type": { + "description": "Daytona sandbox provider.", + "enum": [ + "daytona" + ], + "type": "string" + } + }, + "required": [ + "type", + "auth", + "exec_timeout_ms", + "auto_stop_interval_in_minutes", + "auto_archive_interval_in_minutes", + "auto_delete_interval_in_minutes" + ], + "type": "object" }, - "type": { - "description": "Daytona sandbox provider.", - "enum": [ - "daytona" + { + "additionalProperties": false, + "properties": { + "auth": { + "$ref": "#/components/schemas/OpenSandboxProviderAuth" + }, + "domain": { + "description": "OpenSandbox API host, optionally including a port, without a URL scheme.", + "minLength": 1, + "type": "string" + }, + "exec_timeout_ms": { + "description": "Default sandbox command exec timeout in milliseconds.", + "exclusiveMinimum": 0, + "type": "integer" + }, + "protocol": { + "default": "https", + "description": "Protocol used to reach the OpenSandbox API.", + "enum": [ + "http", + "https" + ], + "type": "string" + }, + "type": { + "description": "OpenSandbox sandbox provider.", + "enum": [ + "opensandbox" + ], + "type": "string" + } + }, + "required": [ + "type", + "auth", + "domain", + "exec_timeout_ms" ], - "type": "string" + "type": "object" } - }, - "required": [ - "type", - "auth", - "exec_timeout_ms", - "auto_stop_interval_in_minutes", - "auto_archive_interval_in_minutes", - "auto_delete_interval_in_minutes" - ], - "type": "object" + ] }, "Session": { "properties": { @@ -6739,7 +6840,7 @@ } } }, - "description": "Daytona rejected the provided API key." + "description": "The sandbox provider rejected the provided API key." } }, "summary": "Create or replace the sandbox provider", diff --git a/packages/trueforge-core/package.json b/packages/trueforge-core/package.json index d340ef4a8..bdac1df61 100644 --- a/packages/trueforge-core/package.json +++ b/packages/trueforge-core/package.json @@ -107,6 +107,7 @@ "@ai-sdk/openai-compatible": "^3.0.18", "@ai-sdk/provider": "^4.0.4", "@ai-sdk/provider-utils": "^5.0.16", + "@alibaba-group/opensandbox": "^0.1.11", "@daytona/sdk": "^0.204.1", "@hono/zod-openapi": "^1.5.1", "@modelcontextprotocol/sdk": "^1.29.0", diff --git a/packages/trueforge-core/src/core/index.ts b/packages/trueforge-core/src/core/index.ts index 5ebbd311a..c03595e89 100644 --- a/packages/trueforge-core/src/core/index.ts +++ b/packages/trueforge-core/src/core/index.ts @@ -145,6 +145,8 @@ export type { CodeModeErrorSource, CodeModeReply, CodeModeRequest } from './sand export { DaytonaSandboxProvider } from './sandbox/provider/DaytonaProvider'; export type { DaytonaSandboxProviderOptions } from './sandbox/provider/DaytonaProvider'; export { absolutizeRelativeExecEnv } from './sandbox/provider/execEnv'; +export { OpenSandboxProvider } from './sandbox/provider/OpenSandboxProvider'; +export type { OpenSandboxProviderOptions } from './sandbox/provider/OpenSandboxProvider'; export { ensureExecSuccess, shellEscape } from './sandbox/provider/Provider'; export type { ExecErrorResult, diff --git a/packages/trueforge-core/src/core/sandbox/Sandbox.ts b/packages/trueforge-core/src/core/sandbox/Sandbox.ts index 2a8b0a774..fcfc8c839 100644 --- a/packages/trueforge-core/src/core/sandbox/Sandbox.ts +++ b/packages/trueforge-core/src/core/sandbox/Sandbox.ts @@ -370,11 +370,14 @@ export class Sandbox extends LocalToolMCP { Tool and functions are same. Tool and MCP tools are same. - Use \`from mcp_client import call_tool\` in code, or \`mcp-client\` in shell — both work from any directory with no setup. + Use \`from mcp_client import list_tools, call_tool\` in code, or \`mcp-client list-tools\` / \`mcp-client call-tool\` in shell — both work from any directory with no setup. + + MCP discovery in Code Mode MUST use \`list_tools(server)\` (or \`mcp-client list-tools server\`). This performs the MCP protocol \`tools/list\` operation; \`list_tools\` is not a remote tool and MUST NOT be passed to \`call_tool\`. For deferred MCP servers, the Agent MUST: 1. Discover tools via ${LIST_TOOLS_NAME} from ${DEFERRED_TOOLS_SERVER_ID}. Never call ${GET_TOOL_OUTPUT_SCHEMA_NAME}. 2. Discover inputSchema, outputSchema via ${GET_TOOL_INFO_NAME} for the selected tools. + The deferred-tools helpers are agent-side helpers only. Never pass ${LIST_TOOLS_NAME} as the tool name to ${GET_TOOL_OUTPUT_SCHEMA_NAME} or \`call_tool\`. For non-deferred MCP servers, the Agent MUST: 1. Discover outputSchema via ${GET_TOOL_OUTPUT_SCHEMA_NAME} for the selected tools. diff --git a/packages/trueforge-core/src/core/sandbox/provider/OpenSandboxProvider.ts b/packages/trueforge-core/src/core/sandbox/provider/OpenSandboxProvider.ts new file mode 100644 index 000000000..2fe12851e --- /dev/null +++ b/packages/trueforge-core/src/core/sandbox/provider/OpenSandboxProvider.ts @@ -0,0 +1,668 @@ +/** + * OpenSandboxProvider — SandboxProvider implementation backed by + * @alibaba-group/opensandbox (https://github.com/opensandbox-group/OpenSandbox). + * + * This was written against the SDK's real .d.ts files (v0.x, installed and inspected + * directly — the README alone doesn't cover the API surface this needs), not just the + * README. Key facts that shape the design below, and things that still need a decision + * from the team before this ships: + * + * 1. NO DAYTONA-STYLE IMAGE BUILD STEP EXISTS. + * Daytona's `buildImage()` registers a Dockerfile-built snapshot once and clones it + * per sandbox. OpenSandbox has no equivalent — `Sandbox.create({ image })` just pulls + * a raw image reference fresh every time. The only snapshot primitive is + * `SandboxManager.createSnapshot(sandboxId, { name })`, which checkpoints an *already + * running* sandbox's filesystem — it's a VM-style snapshot, not a build pipeline. + * + * To get Daytona-equivalent behavior (build once, clone many, observable build + * status), this provider spins up one throwaway sandbox from the raw image and + * snapshots it under a deterministic name derived from the image digest, then has + * `createSandbox()` clone from that snapshot instead of the raw image. + * + * OPEN QUESTION / RISK: unlike Daytona's snapshot-registration POST (which 409s on a + * duplicate name so concurrent replicas converge safely), `createSnapshot` here has no + * documented uniqueness guarantee on `name`. Two replicas racing `buildImage()` at + * startup could each create a same-named snapshot. Confirm server-side behavior before + * relying on this, or add a distributed lock around the create-throwaway-and-snapshot + * step (e.g. a DB advisory lock keyed by the derived build ref). + * + * 2. SANDBOX IDS ARE OPAQUE / SERVER-GENERATED — there's no client-chosen `name` field + * like Daytona's `${tenantName}.${uuid}`. `validateSandboxOwnedByTenant`'s string-prefix + * trick doesn't work here. This provider instead stamps `metadata[TENANT_METADATA_KEY]` + * at creation time and re-checks it via `getSandboxInfo` whenever a sandboxId arrives + * from outside this process (i.e. on every cache-miss reconnect). This is an extra + * round trip Daytona doesn't need — acceptable, but worth knowing about. + * + * 3. RECONNECT is `Sandbox.connect({ sandboxId })`; a *paused* sandbox must instead go + * through `Sandbox.resume({ sandboxId })` (connect will fail — execd isn't up while + * paused). `sandbox.resume()` (instance method) does the same for an in-hand instance + * and returns a fresh `Sandbox`, mirroring Daytona's `start()`-then-reuse pattern. + * + * 4. ERRORS: every HTTP-level failure is normalized to `SandboxApiException` with a + * `statusCode` (confirmed by reading the compiled adapter code, not just the types) — + * so `e.statusCode === 404` is exactly the Daytona `DaytonaError.statusCode === 404` + * pattern. + * + * 5. FILES: `files.getFileInfo([path])` returns `Record` with + * `{size, type}` for the pre-download size/dir check; `files.readBytes(path)` returns + * a `Uint8Array` (wrap in `Buffer.from`); `files.writeFiles([{path, data}])` accepts a + * `Buffer` directly for upload. + * + * 6. EXEC: `commands.run(cmd, {workingDirectory, envs, timeoutSeconds})` returns + * `{logs: {stdout, stderr}, exitCode}` — there's no single pre-merged "result" string + * like Daytona's `response.result`, so it's built here by concatenating stdout then + * stderr. + * + * 7. CODE MODE / SIGNED ENDPOINTS — THE BIGGEST OPEN QUESTION. Daytona's signed preview + * URL embeds an expiring token *in the URL*, so `httpUrlToWsUrl` is all that's needed. + * OpenSandbox's `getSignedEndpoint(port, expiresAt)` instead returns + * `{ endpoint, headers }` — auth is via **required headers on the handshake**, not a + * URL token. `CodeModeTransport.resolveHostUrl` (per DaytonaProvider's usage) only + * returns a URL string, with nowhere to plumb headers through. Before enabling + * `secureAccess: true` sandboxes with Code Mode, confirm one of: + * a) `CodeModeNatsTransport`'s underlying WS client (likely Node's `ws`) can accept + * a `headers` option — if so, `resolveHostUrl`'s contract needs extending to + * return headers alongside the URL, OR + * b) sandboxes created by this provider run with `secureAccess: false` and rely on + * network-level isolation (no public exposure of the NATS bridge port) instead. + * This draft takes option (b) as the safe default — see `createCodeModeTransport()`. + * + * Everything else follows DaytonaProvider's shape closely: a static cache of live + * `Sandbox` instances keyed by sandboxId, a recovery wrapper that resumes a paused + * sandbox and retries once, and the same `ExecResult` / error-type contracts the rest + * of trueforge-core already depends on. + */ + +import { + ConnectionConfig, + Sandbox, + SandboxApiException, + SandboxException, + SandboxManager, + type PlatformSpec, + type SandboxInfo, +} from '@alibaba-group/opensandbox'; +import { join } from 'node:path/posix'; +import type { Logger } from 'winston'; +import { extractErrorLogFields } from '../../util/errorLogFields'; +import { + SandboxFileNotFoundError, + SandboxFileTooLargeError, + SandboxNotAvailableError, + SandboxPathIsDirectoryError, +} from '../SandboxErrors'; +import type { CodeModeTransport } from '../codeMode/CodeModeTransport'; +import { CodeModeNatsTransport } from '../codeMode/nats/CodeModeNatsTransport'; +import { DEFAULT_SANDBOX_NATS_WS_PORT } from '../constants'; +import type { ExecResult, SandboxBuild, SandboxExecParams, SandboxFileInfo, SandboxProvider } from './Provider'; + +const HTTP_STATUS_NOT_FOUND = 404; + +/** Metadata key this provider stamps on every sandbox it creates, since OpenSandbox + * sandbox ids are opaque server-generated strings with no room for a tenant prefix. */ +const TENANT_METADATA_KEY = 'trueforge.tenant'; +/** Metadata key recording the release image the sandbox (or its golden snapshot) came from. */ +const IMAGE_METADATA_KEY = 'trueforge.image'; + +const GOLDEN_SNAPSHOT_NAME_PREFIX = 'trueforge-build-'; +/** Short-lived: only exists long enough to be snapshotted, then killed. */ +const THROWAWAY_SANDBOX_TIMEOUT_SECONDS = 300; + +/** Terminal-failure snapshot states: one stuck here never becomes ready on its own. */ +function isFailedSnapshotState(state: string): boolean { + return state === 'Failed'; +} + +function isReadySnapshotState(state: string): boolean { + return state === 'Ready'; +} + +/** + * The server enforces Kubernetes-label-value syntax on metadata values: alphanumeric/'-'/'_'/'.' + * only, must start and end alphanumeric, max 63 chars (confirmed directly from the server's + * SANDBOX::INVALID_METADATA_LABEL error, not just the client types). Raw image references like + * `registry.example.com/org/image:sha256-...` fail this on both ':' and '/', so anything derived + * from an image URI must be sanitized before going into `metadata`, not just the tenant name. + */ +function sanitizeMetadataValue(value: string): string { + const replaced = value.replace(/[^A-Za-z0-9_.-]/g, '_'); + const trimmed = replaced.replace(/^[^A-Za-z0-9]+/, '').replace(/[^A-Za-z0-9]+$/, ''); + const truncated = trimmed.slice(0, 63); + return truncated.length > 0 ? truncated : 'unknown'; +} + +/** Digest portion of a container image reference (the tag/digest after the final `:`). */ +function imageDigest(image: string): string { + const lastSegment = image.slice(image.lastIndexOf('/') + 1); + const colon = lastSegment.lastIndexOf(':'); + if (colon === -1) { + throw new Error(`Sandbox image reference has no tag/digest: ${image}`); + } + return lastSegment.slice(colon + 1); +} + +function deriveGoldenSnapshotName(digest: string): string { + return `${GOLDEN_SNAPSHOT_NAME_PREFIX}${digest}`; +} + +export interface OpenSandboxProviderOptions { + /** Domain (host[:port]) of the OpenSandbox API server, e.g. "api.opensandbox.io". */ + domain: string; + protocol?: 'http' | 'https'; + apiKey: string; + tenantName: string; + /** Release-owned sandbox image reference; snapshotted once into a golden snapshot and cloned per sandbox. */ + sandboxImage: string; + timeoutMs: number; + resourceLimits?: Record; + fileMaxBytesForDownload: number; + /** Defaults to the built-in sandbox NATS WebSocket port. */ + natsBridgePort?: number; + /** + * Timeout the SDK applies to each individual HTTP call to the OpenSandbox server — + * separate from `timeoutMs`, which is the sandbox's own idle/lifetime TTL. The SDK + * defaults this to 30s (confirmed in its compiled source, not just its docs), which is + * too tight for `Sandbox.create()` on a large release image: the server call is + * synchronous through image inspect -> container create -> container start (and, per + * this deployment's `[egress] mode = "dns"` config, a second sidecar container per + * sandbox), and running an amd64 image under QEMU emulation on Apple Silicon makes that + * meaningfully slower still. Defaults to 120s here; raise further if cold sandbox + * creation still times out on your infra. + */ + requestTimeoutSeconds?: number; + /** + * Target platform (os/arch) sandboxes are provisioned for. IMPORTANT: if omitted, the + * server defaults to the HOST machine's architecture, not the image's — confirmed by + * hitting this directly: on an arm64 Mac against an amd64-only release image, sandbox + * creation from a golden snapshot failed with a registry-pull 404, because the server + * looked for an arm64 variant of a snapshot that only exists as amd64 (the throwaway + * container was committed under whatever arch the image actually is). Set this + * explicitly to match your release image's real architecture — e.g. `{ os: 'linux', + * arch: 'amd64' }` for a typical CI-built image — rather than relying on the default, + * which only happens to be correct when the host and image architectures match. + */ + platform?: PlatformSpec; + /** + * Container entrypoint override. IMPORTANT: unlike plain Docker, the OpenSandbox SDK + * does NOT fall back to the image's own `ENTRYPOINT` when this is omitted — it always + * sends an explicit override, defaulting to `["tail", "-f", "/dev/null"]` (confirmed in + * the SDK's own docs). For a release image like trueforge-sandbox, whose real + * `ENTRYPOINT ["/usr/bin/supervisord", "-n"]` launches supervisord (which in turn + * autostarts the NATS bridge Code Mode depends on — see `nats.supervisor.conf`), leaving + * this unset means that real boot command silently never runs, and every sandbox comes + * up with a bare idle process instead. Confirmed live: without this set, `ps`-equivalent + * introspection showed only `sh`, `execd`, and `tail -f /dev/null` — no supervisord, no + * nats-server, nothing listening on the Code Mode bridge port. Set this to match + * whatever `sandboxImage`'s Dockerfile actually declares as `ENTRYPOINT`. + */ + entrypoint?: string[]; + logger: Logger; +} + +export class OpenSandboxProvider implements SandboxProvider { + readonly type = 'opensandbox'; + private readonly connectionConfig: ConnectionConfig; + private readonly manager: SandboxManager; + private readonly tenantName: string; + private readonly imageUri: string; + private readonly timeoutMs: number; + private readonly resourceLimits: Record | undefined; + private readonly platform: PlatformSpec | undefined; + private readonly entrypoint: string[] | undefined; + private readonly fileMaxBytesForDownload: number; + private readonly natsBridgePort: number; + private readonly logger: Logger; + + /** Resolved once buildImage()/getImageBuildStatus() sees the golden snapshot as Ready. */ + private readyGoldenSnapshotId: string | undefined; + /** + * Throwaway sandboxes are keyed by snapshot name because each API request constructs a fresh + * provider instance. See the fix note in buildImage()/getImageBuildStatus() — killing one too + * early races the server's background snapshot commit. Single-process tracking means a process + * restart can still leak a throwaway until its server-side TTL; multi-replica deployments need + * this state persisted or coordinated externally. + */ + private static readonly pendingThrowawaySandboxes = new Map(); + + private static readonly cachedSandboxes = new Map(); + // De-dupes concurrent recovery attempts on the same sandbox to a single resume+retry round-trip. + private static readonly inFlightRecoveries = new Map>(); + + constructor(options: OpenSandboxProviderOptions) { + this.connectionConfig = new ConnectionConfig({ + domain: options.domain, + protocol: options.protocol ?? 'https', + apiKey: options.apiKey, + requestTimeoutSeconds: options.requestTimeoutSeconds ?? 120, + }); + this.manager = SandboxManager.create({ connectionConfig: this.connectionConfig }); + this.tenantName = options.tenantName; + this.imageUri = options.sandboxImage; + this.timeoutMs = options.timeoutMs; + this.resourceLimits = options.resourceLimits; + this.platform = options.platform; + this.entrypoint = options.entrypoint; + this.fileMaxBytesForDownload = options.fileMaxBytesForDownload; + this.natsBridgePort = options.natsBridgePort ?? DEFAULT_SANDBOX_NATS_WS_PORT; + this.logger = options.logger.child({ module: 'OpenSandboxProvider' }); + } + + // --------------------------------------------------------------------------------- + // Image "build" (golden snapshot) — see file header, point 1. + // --------------------------------------------------------------------------------- + + private goldenSnapshotName(): string { + return deriveGoldenSnapshotName(imageDigest(this.imageUri)); + } + + private async findGoldenSnapshot(name: string) { + const { items } = await this.manager.listSnapshots({ name }); + // Defensive: `name` filtering is server-side per the SDK types, but if more than one + // comes back (e.g. a duplicate from the race described in the file header), prefer the + // most recently created one rather than erroring the whole build-status check. + return items.length === 0 + ? undefined + : items.reduce((latest, item) => (item.createdAt > latest.createdAt ? item : latest)); + } + + private toBuild(snapshotId: string | undefined, state: string, reason: string | undefined): SandboxBuild { + const metadata = { image_uri: this.imageUri, ...(snapshotId ? { snapshot_id: snapshotId } : {}) }; + if (isReadySnapshotState(state)) { + return { status: 'ready', reason: null, metadata }; + } + if (isFailedSnapshotState(state)) { + return { status: 'failed', reason: reason ?? `OpenSandbox golden snapshot build failed (${state}).`, metadata }; + } + return { + status: 'pending', + reason: reason ?? `OpenSandbox golden snapshot build in progress (${state}).`, + metadata, + }; + } + + async buildImage(): Promise { + const name = this.goldenSnapshotName(); + const existing = await this.findGoldenSnapshot(name); + + if (existing) { + if (isReadySnapshotState(existing.status.state)) { + this.readyGoldenSnapshotId = existing.id; + return this.toBuild(existing.id, existing.status.state, existing.status.message); + } + if (!isFailedSnapshotState(existing.status.state)) { + return this.toBuild(existing.id, existing.status.state, existing.status.message); + } + // A failed build keeps the deterministic name occupied and never self-heals; drop it + // and fall through to recreate. Another replica may already have deleted it — fine. + await this.manager.deleteSnapshot(existing.id).catch((error: unknown) => { + if (!(error instanceof SandboxApiException) || error.statusCode !== HTTP_STATUS_NOT_FOUND) { + throw error; + } + }); + } + + // TODO(confirm): no documented uniqueness guarantee on snapshot `name` — see file header + // point 1. If two replicas race here, both will spin up a throwaway sandbox and create a + // same-named snapshot. Consider wrapping this in a distributed lock keyed by `name`. + const throwaway = await Sandbox.create({ + connectionConfig: this.connectionConfig, + image: this.imageUri, + timeoutSeconds: THROWAWAY_SANDBOX_TIMEOUT_SECONDS, + metadata: { + [TENANT_METADATA_KEY]: sanitizeMetadataValue(this.tenantName), + [IMAGE_METADATA_KEY]: sanitizeMetadataValue(this.imageUri), + }, + // Must match createFreshSandbox()'s platform: the snapshot is committed from THIS + // container, so if this one's arch doesn't match what callers later request, every + // real sandbox creation from the resulting snapshot fails with a registry-pull 404 + // (confirmed live — see the option's doc comment on OpenSandboxProviderOptions). + ...(this.platform !== undefined && { platform: this.platform }), + }); + // IMPORTANT (confirmed against a real server, not theoretical): createSnapshot() registers + // the snapshot and returns quickly with status 'Creating' — the actual `docker commit` + // against the throwaway's container runs asynchronously afterward. Killing the throwaway + // here races that background job and fails with `docker.errors.NotFound: ... does not + // exist` inside the server's `_create_snapshot`. So we deliberately do NOT kill it now; + // getImageBuildStatus() kills it once polling shows the snapshot has left 'Creating'. + OpenSandboxProvider.pendingThrowawaySandboxes.set(name, throwaway); + const snapshot = await this.manager.createSnapshot(throwaway.id, { name }); + return this.toBuild(snapshot.id, snapshot.status.state, snapshot.status.message); + } + + /** Kills and releases a throwaway build sandbox once its snapshot is done with it (Ready or Failed). */ + private async releasePendingThrowaway(name: string): Promise { + const throwaway = OpenSandboxProvider.pendingThrowawaySandboxes.get(name); + if (!throwaway) { + return; + } + OpenSandboxProvider.pendingThrowawaySandboxes.delete(name); + await throwaway.kill().catch((error: unknown) => { + this.logger.warn('Failed to kill throwaway sandbox after snapshot completed', extractErrorLogFields(error)); + }); + await throwaway.close().catch(() => undefined); + } + + async getImageBuildStatus(): Promise { + const name = this.goldenSnapshotName(); + const existing = await this.findGoldenSnapshot(name); + if (!existing) { + return { + status: 'pending', + reason: 'OpenSandbox golden snapshot build not started.', + metadata: { image_uri: this.imageUri }, + }; + } + if (isReadySnapshotState(existing.status.state) || isFailedSnapshotState(existing.status.state)) { + // Snapshot has left 'Creating' — the throwaway's container is no longer needed by the + // server's commit job, so it's now safe to release it (if this process is the one that + // created it; see the field's doc comment for the multi-replica caveat). + await this.releasePendingThrowaway(name); + } + if (isReadySnapshotState(existing.status.state)) { + this.readyGoldenSnapshotId = existing.id; + } + return this.toBuild(existing.id, existing.status.state, existing.status.message); + } + + /** Resolves the golden snapshot id, looking it up if we haven't cached one yet. */ + private async resolveGoldenSnapshotId(): Promise { + if (this.readyGoldenSnapshotId) { + return this.readyGoldenSnapshotId; + } + const build = await this.getImageBuildStatus(); + if (build.status !== 'ready' || !this.readyGoldenSnapshotId) { + throw new Error( + `OpenSandbox golden snapshot for image '${this.imageUri}' is not ready (status: ${build.status}). Call buildImage() first.`, + ); + } + return this.readyGoldenSnapshotId; + } + + // --------------------------------------------------------------------------------- + // Sandbox lifecycle + // --------------------------------------------------------------------------------- + + private async validateOwnedByTenant(sandboxId: string, info?: SandboxInfo): Promise { + const sandboxInfo = + info ?? + (await this.manager.getSandboxInfo(sandboxId).catch((ex: unknown) => { + if (ex instanceof SandboxApiException && ex.statusCode === HTTP_STATUS_NOT_FOUND) { + throw new SandboxNotAvailableError(sandboxId); + } + throw ex; + })); + if (sandboxInfo.metadata?.[TENANT_METADATA_KEY] !== sanitizeMetadataValue(this.tenantName)) { + // Deliberately reported as "not available" rather than "forbidden" so callers can't + // use this to probe for the existence of another tenant's sandbox ids. + throw new SandboxNotAvailableError(sandboxId); + } + } + + private async restoreExistingSandbox(sandboxId: string): Promise { + let info: SandboxInfo; + try { + info = await this.manager.getSandboxInfo(sandboxId); + } catch (ex) { + if (ex instanceof SandboxApiException && ex.statusCode === HTTP_STATUS_NOT_FOUND) { + throw new SandboxNotAvailableError(sandboxId); + } + throw ex; + } + await this.validateOwnedByTenant(sandboxId, info); + + if (info.status.state === 'Paused') { + return Sandbox.resume({ sandboxId, connectionConfig: this.connectionConfig }); + } + return Sandbox.connect({ sandboxId, connectionConfig: this.connectionConfig }); + } + + private async getOrCreateSandbox(sandboxId?: string): Promise { + if (sandboxId) { + const cached = OpenSandboxProvider.cachedSandboxes.get(sandboxId); + if (cached) { + return cached; + } + } + + const sandbox = sandboxId ? await this.restoreExistingSandbox(sandboxId) : await this.createFreshSandbox(); + OpenSandboxProvider.cachedSandboxes.set(sandbox.id, sandbox); + return sandbox; + } + + private async createFreshSandbox(): Promise { + const snapshotId = await this.resolveGoldenSnapshotId(); + return Sandbox.create({ + connectionConfig: this.connectionConfig, + snapshotId, + // Conditional spread rather than `resource: this.resourceLimits` — with + // exactOptionalPropertyTypes, an optional property must be *absent* when there's no + // value, not present-and-set-to-undefined. + ...(this.resourceLimits !== undefined && { resource: this.resourceLimits }), + ...(this.platform !== undefined && { platform: this.platform }), + ...(this.entrypoint !== undefined && { entrypoint: this.entrypoint }), + metadata: { + [TENANT_METADATA_KEY]: sanitizeMetadataValue(this.tenantName), + [IMAGE_METADATA_KEY]: sanitizeMetadataValue(this.imageUri), + }, + // See file header point 7 — kept unsigned/no header-auth requirement for now. + secureAccess: false, + }); + } + + // Resumes a paused sandbox and returns the fresh instance; returns the same instance + // unchanged if it's already running. De-dupes concurrent callers on the same sandboxId. + private static async recoverSandbox(sandboxId: string, cached: Sandbox): Promise { + const existing = OpenSandboxProvider.inFlightRecoveries.get(sandboxId); + if (existing) { + return existing; + } + + const recovery = (async () => { + const info = await cached.getInfo(); + if (info.status.state === 'Running') { + return cached; + } + // resume() throws on unrecoverable states — per the server's documented lifecycle + // (Pending -> Running -> Paused/Resuming -> Stopping -> Terminated/Failed), a sandbox + // that's Terminated, Failed, or already Stopping can't be resumed; let it propagate. + return cached.resume(); + })().finally(() => { + OpenSandboxProvider.inFlightRecoveries.delete(sandboxId); + }); + + OpenSandboxProvider.inFlightRecoveries.set(sandboxId, recovery); + return recovery; + } + + private async executeWithSandboxRecovery( + sandboxId: string, + operation: (sandbox: Sandbox) => Promise, + ): Promise { + const sandbox = await this.getOrCreateSandbox(sandboxId); + try { + return await operation(sandbox); + } catch (originalError) { + if (!(originalError instanceof SandboxException)) { + throw originalError; + } + + let recovered: Sandbox; + try { + recovered = await OpenSandboxProvider.recoverSandbox(sandboxId, sandbox); + } catch (recoveryError) { + this.logger.error('Sandbox recovery failed', { + ...extractErrorLogFields(recoveryError), + originalError: extractErrorLogFields(originalError), + }); + throw new Error('Sandbox is unavailable; recovery attempt failed.', { cause: recoveryError }); + } + + if (recovered === sandbox) { + // Already running — recovery wasn't the fix, so the original error is genuine. + throw originalError; + } + + OpenSandboxProvider.cachedSandboxes.set(sandboxId, recovered); + try { + return await operation(recovered); + } catch (retryError) { + this.logger.error('Sandbox operation failed after successful recovery', { + ...extractErrorLogFields(retryError), + originalError: extractErrorLogFields(originalError), + }); + throw retryError; + } + } + } + + async createSandbox(): Promise<{ sandboxId: string }> { + const sandbox = await this.createFreshSandbox(); + OpenSandboxProvider.cachedSandboxes.set(sandbox.id, sandbox); + this.logger.debug(`Sandbox created: id=${sandbox.id}`); + return { sandboxId: sandbox.id }; + } + + // --------------------------------------------------------------------------------- + // Exec + // --------------------------------------------------------------------------------- + + async exec(params: SandboxExecParams): Promise { + try { + return await this.executeWithSandboxRecovery(params.sandboxId, async sandbox => { + const execution = await sandbox.commands.run(params.command, { + // Conditional spreads rather than `workingDirectory: params.cwd` etc — with + // exactOptionalPropertyTypes, RunCommandOpts's optional fields must be *absent* + // when there's no value, not present-and-set-to-undefined. + ...(params.cwd !== undefined && { workingDirectory: params.cwd }), + ...(params.env !== undefined && { envs: params.env }), + timeoutSeconds: params.timeoutSeconds ?? Math.ceil(this.timeoutMs / 1000), + }); + const stdout = execution.logs.stdout.map(m => m.text).join(''); + const stderr = execution.logs.stderr.map(m => m.text).join(''); + const result = stderr ? `${stdout}${stdout && '\n'}${stderr}` : stdout; + // exitCode is nullable in the SDK types (e.g. an execution the server never + // completed); treat a missing exit code as failure rather than silently + // reporting success — TODO(confirm): check with the OpenSandbox team when + // exitCode can legitimately be null for a foreground (non-backgrounded) run. + return { + success: true, + response: { exitCode: execution.exitCode ?? 1, result }, + }; + }); + } catch (e: unknown) { + OpenSandboxProvider.cachedSandboxes.delete(params.sandboxId); + if (e instanceof SandboxNotAvailableError) { + throw e; + } + this.logger.error('Sandbox execution error', extractErrorLogFields(e)); + const message = e instanceof Error ? e.message : 'Unknown error'; + return { success: false, error: message }; + } + } + + // --------------------------------------------------------------------------------- + // Files + // --------------------------------------------------------------------------------- + + private async getFileInfo(sandbox: Sandbox, path: string): Promise { + const infoByPath = await sandbox.files.getFileInfo([path]); + // Defensive: don't assume the response key matches `path` byte-for-byte (e.g. trailing + // slash normalization) — fall back to the single entry if there's exactly one. + const info = infoByPath[path] ?? Object.values(infoByPath)[0]; + if (!info) { + throw new SandboxFileNotFoundError(path); + } + return { size: info.size ?? 0, isDir: info.type === 'directory' }; + } + + async downloadFile(params: { sandboxId: string; path: string }): Promise { + try { + return await this.executeWithSandboxRecovery(params.sandboxId, async sandbox => { + const info = await this.getFileInfo(sandbox, params.path); + if (info.isDir) { + throw new SandboxPathIsDirectoryError(params.path); + } + if (info.size > this.fileMaxBytesForDownload) { + throw new SandboxFileTooLargeError(params.path, info.size, this.fileMaxBytesForDownload); + } + const bytes = await sandbox.files.readBytes(params.path); + return Buffer.from(bytes); + }); + } catch (e: unknown) { + if ( + e instanceof SandboxPathIsDirectoryError || + e instanceof SandboxFileTooLargeError || + e instanceof SandboxFileNotFoundError + ) { + throw e; + } + if (e instanceof SandboxApiException && e.statusCode === HTTP_STATUS_NOT_FOUND) { + throw new SandboxFileNotFoundError(params.path); + } + OpenSandboxProvider.cachedSandboxes.delete(params.sandboxId); + throw e; + } + } + + async uploadFile(params: { sandboxId: string; remotePath: string; content: Buffer }): Promise { + try { + await this.executeWithSandboxRecovery(params.sandboxId, async sandbox => { + await sandbox.files.writeFiles([{ path: params.remotePath, data: params.content }]); + }); + } catch (e: unknown) { + OpenSandboxProvider.cachedSandboxes.delete(params.sandboxId); + throw e; + } + } + + // --------------------------------------------------------------------------------- + // Code Mode — see file header point 7 before enabling secureAccess on these sandboxes. + // --------------------------------------------------------------------------------- + + createCodeModeTransport(): CodeModeTransport { + return new CodeModeNatsTransport({ + resolveHostUrl: async (sandboxId: string) => + this.executeWithSandboxRecovery(sandboxId, async sandbox => { + // Unsigned endpoint: sandboxes are created with `secureAccess: false`, so no + // header-based token is required here. If that changes, this transport contract + // needs to grow to carry `Endpoint.headers` through to the WS handshake. + const { endpoint } = await sandbox.getEndpoint(this.natsBridgePort); + const scheme = this.connectionConfig.protocol === 'https' ? 'wss' : 'ws'; + return `${scheme}://${endpoint}`; + }), + sandboxClientNatsUrl: `ws://localhost:${String(this.natsBridgePort)}`, + logger: this.logger, + mcpClientInstall: { + remotePath: join('/opt', 'tf', 'mcp-client', 'mcp_client.py'), + pathBinSymlink: join('/usr', 'local', 'bin', 'mcp-client'), + }, + }); + } + + getAdditionalInstructions(): string | undefined { + return undefined; + } + + // Assumes the OpenSandbox-compatible release image uses the same absolute layout as + // the Daytona image. If OpenSandbox sandboxes are built from a different base image, + // these need their own paths. + getToolResultDumpDir(): string { + return join('/opt', 'tf', 'tool-results'); + } + + getGitCredentialsPath(): string { + return join('/opt', 'tf', '.git-credentials'); + } + + getFileUploadsDir(): string { + return join('/opt', 'tf', '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/scripts/mcp_client.py b/packages/trueforge-core/src/core/sandbox/scripts/mcp_client.py index 35dc8edf8..79394585c 100644 --- a/packages/trueforge-core/src/core/sandbox/scripts/mcp_client.py +++ b/packages/trueforge-core/src/core/sandbox/scripts/mcp_client.py @@ -221,6 +221,11 @@ async def _get_tool(server: str, tool_name: str) -> Tool | None: return None +async def list_tools(server: str) -> list[str]: + """Return names from the MCP protocol tools/list operation for a server.""" + return [tool.name for tool in await _get_tools(server)] + + def _is_destructive(tool: Tool) -> bool: annotations = tool.annotations if annotations is None: @@ -285,13 +290,16 @@ async def call_tool(server: str, tool: str, body: dict[str, Any]) -> Any: return _project_call_tool_result(server, tool, result) -_USAGE = "mcp_client.py call-tool " +_USAGE = "mcp_client.py {list-tools|call-tool} ..." def _build_arg_parser() -> argparse.ArgumentParser: parser = argparse.ArgumentParser(prog="mcp_client.py", usage=_USAGE) sub = parser.add_subparsers(dest="cmd", required=True) + list_tools_p = sub.add_parser("list-tools", help="Discover tools through MCP tools/list") + list_tools_p.add_argument("server") + 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") @@ -305,7 +313,9 @@ def _build_arg_parser() -> argparse.ArgumentParser: async def _main() -> None: args = _build_arg_parser().parse_args() try: - if args.cmd == "call-tool": + if args.cmd == "list-tools": + print(json.dumps(await list_tools(args.server))) + elif 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: diff --git a/packages/trueforge-core/tests/core/sandbox/provider/OpenSandboxProvider.test.ts b/packages/trueforge-core/tests/core/sandbox/provider/OpenSandboxProvider.test.ts new file mode 100644 index 000000000..dae81744b --- /dev/null +++ b/packages/trueforge-core/tests/core/sandbox/provider/OpenSandboxProvider.test.ts @@ -0,0 +1,335 @@ +import winston from 'winston'; + +// Mock only Sandbox and SandboxManager (the pieces we call), while keeping the *real* +// exception classes so `instanceof SandboxApiException` checks in the provider still work. +// jest.requireActual is synchronous (unlike vitest's vi.importActual), so this factory +// doesn't need to be async. +jest.mock('@alibaba-group/opensandbox', () => { + const actual = jest.requireActual('@alibaba-group/opensandbox'); + return { + ...actual, + Sandbox: { create: jest.fn(), connect: jest.fn(), resume: jest.fn() }, + SandboxManager: { create: jest.fn() }, + }; +}); + +import { Sandbox, SandboxApiException, SandboxError, SandboxManager } from '@alibaba-group/opensandbox'; +import { OpenSandboxProvider } from '../../../../src/core/sandbox/provider/OpenSandboxProvider'; +import { + SandboxFileTooLargeError, + SandboxNotAvailableError, + SandboxPathIsDirectoryError, +} from '../../../../src/core/sandbox/SandboxErrors'; + +const logger = winston.createLogger({ transports: [] }); + +function fakeSandbox(overrides: Partial> = {}) { + return { + id: overrides['id'] ?? 'sbx-1', + getInfo: jest + .fn() + .mockResolvedValue({ status: { state: 'Running' }, metadata: { 'trueforge.tenant': 'tenant-a' } }), + resume: jest.fn(), + kill: jest.fn().mockResolvedValue(undefined), + close: jest.fn().mockResolvedValue(undefined), + commands: { run: jest.fn() }, + files: { getFileInfo: jest.fn(), readBytes: jest.fn(), writeFiles: jest.fn() }, + getEndpoint: jest.fn().mockResolvedValue({ endpoint: 'localhost:4444' }), + ...overrides, + }; +} + +function fakeManager(overrides: Partial> = {}) { + return { + listSnapshots: jest.fn().mockResolvedValue({ items: [] }), + getSandboxInfo: jest.fn(), + createSnapshot: jest.fn(), + deleteSnapshot: jest.fn().mockResolvedValue(undefined), + ...overrides, + }; +} + +function apiError(statusCode: number, message = 'boom') { + return new SandboxApiException({ + message, + statusCode, + error: new SandboxError(SandboxError.UNEXPECTED_RESPONSE, message), + }); +} + +function makeProvider() { + return new OpenSandboxProvider({ + domain: 'localhost:8080', + apiKey: 'test-key', + tenantName: 'tenant-a', + sandboxImage: 'tfy.jfrog.io/tfy-images/trueforge-sandbox:0dab475d3d20a8333cff41f25f88e7134c424cf9', + timeoutMs: 60_000, + fileMaxBytesForDownload: 1024, + logger, + }); +} + +beforeEach(() => { + jest.clearAllMocks(); + (SandboxManager.create as jest.Mock).mockReturnValue(fakeManager()); +}); + +describe('buildImage', () => { + it("creates a throwaway sandbox and snapshots it, WITHOUT killing it immediately (that races the server's async commit job)", async () => { + const manager = fakeManager({ + createSnapshot: jest.fn().mockResolvedValue({ id: 'snap-1', status: { state: 'Creating' } }), + }); + (SandboxManager.create as jest.Mock).mockReturnValue(manager); + const throwaway = fakeSandbox({ id: 'throwaway-1' }); + (Sandbox.create as jest.Mock).mockResolvedValue(throwaway); + + const provider = makeProvider(); + const build = await provider.buildImage(); + + expect(manager.createSnapshot).toHaveBeenCalledWith('throwaway-1', { + name: expect.stringContaining('trueforge-build-'), + }); + expect(throwaway.kill).not.toHaveBeenCalled(); + expect(build.status).toBe('pending'); + + (manager.listSnapshots as jest.Mock).mockResolvedValue({ + items: [{ id: 'snap-1', status: { state: 'Ready' }, createdAt: new Date() }], + }); + await provider.getImageBuildStatus(); + }); + + it('kills the throwaway sandbox once getImageBuildStatus sees the snapshot leave Creating', async () => { + const listSnapshots = jest + .fn() + .mockResolvedValueOnce({ items: [] }) + .mockResolvedValueOnce({ items: [{ id: 'snap-1', status: { state: 'Ready' }, createdAt: new Date() }] }); + const manager = fakeManager({ + createSnapshot: jest.fn().mockResolvedValue({ id: 'snap-1', status: { state: 'Creating' } }), + listSnapshots, + }); + (SandboxManager.create as jest.Mock).mockReturnValue(manager); + const throwaway = fakeSandbox({ id: 'throwaway-1' }); + (Sandbox.create as jest.Mock).mockResolvedValue(throwaway); + + const provider = makeProvider(); + await provider.buildImage(); + expect(throwaway.kill).not.toHaveBeenCalled(); + + const build = await new OpenSandboxProvider({ + domain: 'localhost:8080', + apiKey: 'test-key', + tenantName: 'tenant-a', + sandboxImage: 'tfy.jfrog.io/tfy-images/trueforge-sandbox:0dab475d3d20a8333cff41f25f88e7134c424cf9', + timeoutMs: 60_000, + fileMaxBytesForDownload: 1024, + logger, + }).getImageBuildStatus(); + expect(throwaway.kill).toHaveBeenCalled(); + expect(build.status).toBe('ready'); + }); + + it('returns ready without creating a throwaway sandbox when a Ready snapshot already exists', async () => { + const manager = fakeManager({ + listSnapshots: jest.fn().mockResolvedValue({ + items: [{ id: 'snap-existing', status: { state: 'Ready' }, createdAt: new Date() }], + }), + }); + (SandboxManager.create as jest.Mock).mockReturnValue(manager); + + const provider = makeProvider(); + const build = await provider.buildImage(); + + expect(Sandbox.create).not.toHaveBeenCalled(); + expect(build.status).toBe('ready'); + }); + + it('deletes a Failed snapshot and rebuilds rather than reporting failed forever', async () => { + const manager = fakeManager({ + listSnapshots: jest.fn().mockResolvedValue({ + items: [{ id: 'snap-dead', status: { state: 'Failed', message: 'oom' }, createdAt: new Date() }], + }), + createSnapshot: jest.fn().mockResolvedValue({ id: 'snap-new', status: { state: 'Creating' } }), + }); + (SandboxManager.create as jest.Mock).mockReturnValue(manager); + (Sandbox.create as jest.Mock).mockResolvedValue(fakeSandbox({ id: 'throwaway-2' })); + + const provider = makeProvider(); + const build = await provider.buildImage(); + + expect(manager.deleteSnapshot).toHaveBeenCalledWith('snap-dead'); + expect(build.status).toBe('pending'); + }); +}); + +describe('exec', () => { + it('maps stdout/stderr/exitCode and applies the provider exec timeout by default', async () => { + const sandbox = fakeSandbox(); + (sandbox.commands.run as jest.Mock).mockResolvedValue({ + logs: { stdout: [{ text: '1\n' }], stderr: [] }, + exitCode: 0, + }); + (Sandbox.create as jest.Mock).mockResolvedValue(sandbox); + const manager = fakeManager({ + listSnapshots: jest + .fn() + .mockResolvedValue({ items: [{ id: 'snap-1', status: { state: 'Ready' }, createdAt: new Date() }] }), + }); + (SandboxManager.create as jest.Mock).mockReturnValue(manager); + + const provider = makeProvider(); + await provider.createSandbox(); + const result = await provider.exec({ sandboxId: sandbox.id as string, command: 'echo 1' }); + + expect(result).toEqual({ success: true, response: { exitCode: 0, result: '1\n' } }); + const optsArg = (sandbox.commands.run as jest.Mock).mock.calls[0][1]; + expect(optsArg).toEqual({ timeoutSeconds: 60 }); + }); + + it('allows an individual command to override the provider exec timeout', async () => { + const sandbox = fakeSandbox(); + (sandbox.commands.run as jest.Mock).mockResolvedValue({ + logs: { stdout: [], stderr: [] }, + exitCode: 0, + }); + (Sandbox.create as jest.Mock).mockResolvedValue(sandbox); + const manager = fakeManager({ + listSnapshots: jest + .fn() + .mockResolvedValue({ items: [{ id: 'snap-1', status: { state: 'Ready' }, createdAt: new Date() }] }), + }); + (SandboxManager.create as jest.Mock).mockReturnValue(manager); + + const provider = makeProvider(); + await provider.createSandbox(); + await provider.exec({ sandboxId: sandbox.id as string, command: 'sleep 1', timeoutSeconds: 7 }); + + expect(sandbox.commands.run).toHaveBeenCalledWith('sleep 1', { timeoutSeconds: 7 }); + }); + + it('resumes a paused sandbox and retries once on a SandboxException', async () => { + const staleSandbox = fakeSandbox({ + commands: { run: jest.fn().mockRejectedValue(apiError(409, 'sandbox is paused')) }, + getInfo: jest + .fn() + .mockResolvedValue({ status: { state: 'Paused' }, metadata: { 'trueforge.tenant': 'tenant-a' } }), + }); + const freshSandbox = fakeSandbox({ + id: 'sbx-1', + commands: { run: jest.fn().mockResolvedValue({ logs: { stdout: [{ text: 'ok' }], stderr: [] }, exitCode: 0 }) }, + }); + (staleSandbox.resume as jest.Mock).mockResolvedValue(freshSandbox); + (Sandbox.create as jest.Mock).mockResolvedValue(staleSandbox); + const manager = fakeManager({ + listSnapshots: jest + .fn() + .mockResolvedValue({ items: [{ id: 'snap-1', status: { state: 'Ready' }, createdAt: new Date() }] }), + }); + (SandboxManager.create as jest.Mock).mockReturnValue(manager); + + const provider = makeProvider(); + await provider.createSandbox(); + const result = await provider.exec({ sandboxId: 'sbx-1', command: 'echo ok' }); + + expect(staleSandbox.resume).toHaveBeenCalled(); + expect(freshSandbox.commands.run).toHaveBeenCalled(); + expect(result).toEqual({ success: true, response: { exitCode: 0, result: 'ok' } }); + }); + + it('rethrows the original error when recovery finds the sandbox already Running (not a pause issue)', async () => { + const sandbox = fakeSandbox({ + commands: { run: jest.fn().mockRejectedValue(apiError(500, 'transient infra error')) }, + getInfo: jest + .fn() + .mockResolvedValue({ status: { state: 'Running' }, metadata: { 'trueforge.tenant': 'tenant-a' } }), + }); + (Sandbox.create as jest.Mock).mockResolvedValue(sandbox); + const manager = fakeManager({ + listSnapshots: jest + .fn() + .mockResolvedValue({ items: [{ id: 'snap-1', status: { state: 'Ready' }, createdAt: new Date() }] }), + }); + (SandboxManager.create as jest.Mock).mockReturnValue(manager); + + const provider = makeProvider(); + await provider.createSandbox(); + const result = await provider.exec({ sandboxId: sandbox.id as string, command: 'echo ok' }); + + expect(sandbox.resume).not.toHaveBeenCalled(); + expect(result.success).toBe(false); + }); +}); + +describe('tenant isolation', () => { + it('refuses to operate on a sandbox owned by a different tenant', async () => { + const manager = fakeManager({ + getSandboxInfo: jest + .fn() + .mockResolvedValue({ status: { state: 'Running' }, metadata: { 'trueforge.tenant': 'someone-else' } }), + }); + (SandboxManager.create as jest.Mock).mockReturnValue(manager); + + const provider = makeProvider(); + await expect(provider.exec({ sandboxId: 'not-mine', command: 'echo 1' })).rejects.toBeInstanceOf( + SandboxNotAvailableError, + ); + }); + + it('surfaces a missing sandbox as SandboxNotAvailableError, not a raw SDK exception', async () => { + const manager = fakeManager({ getSandboxInfo: jest.fn().mockRejectedValue(apiError(404)) }); + (SandboxManager.create as jest.Mock).mockReturnValue(manager); + + const provider = makeProvider(); + await expect(provider.downloadFile({ sandboxId: 'gone', path: '/tmp/f' })).rejects.not.toBeInstanceOf( + SandboxApiException, + ); + }); +}); + +describe('downloadFile guard rails', () => { + it('throws SandboxFileTooLargeError before reading bytes', async () => { + const sandbox = fakeSandbox({ + files: { + getFileInfo: jest.fn().mockResolvedValue({ '/big': { size: 999_999, type: 'file' } }), + readBytes: jest.fn(), + writeFiles: jest.fn(), + }, + }); + (Sandbox.create as jest.Mock).mockResolvedValue(sandbox); + const manager = fakeManager({ + listSnapshots: jest + .fn() + .mockResolvedValue({ items: [{ id: 'snap-1', status: { state: 'Ready' }, createdAt: new Date() }] }), + }); + (SandboxManager.create as jest.Mock).mockReturnValue(manager); + + const provider = makeProvider(); + await provider.createSandbox(); + await expect(provider.downloadFile({ sandboxId: sandbox.id as string, path: '/big' })).rejects.toBeInstanceOf( + SandboxFileTooLargeError, + ); + expect(sandbox.files.readBytes).not.toHaveBeenCalled(); + }); + + it('throws SandboxPathIsDirectoryError for directories', async () => { + const sandbox = fakeSandbox({ + files: { + getFileInfo: jest.fn().mockResolvedValue({ '/dir': { size: 0, type: 'directory' } }), + readBytes: jest.fn(), + writeFiles: jest.fn(), + }, + }); + (Sandbox.create as jest.Mock).mockResolvedValue(sandbox); + const manager = fakeManager({ + listSnapshots: jest + .fn() + .mockResolvedValue({ items: [{ id: 'snap-1', status: { state: 'Ready' }, createdAt: new Date() }] }), + }); + (SandboxManager.create as jest.Mock).mockReturnValue(manager); + + const provider = makeProvider(); + await provider.createSandbox(); + await expect(provider.downloadFile({ sandboxId: sandbox.id as string, path: '/dir' })).rejects.toBeInstanceOf( + SandboxPathIsDirectoryError, + ); + }); +}); diff --git a/packages/trueforge-core/tests/core/sandbox/provider/sandboxProviderContractSuite.ts b/packages/trueforge-core/tests/core/sandbox/provider/sandboxProviderContractSuite.ts index ca93ddb7c..ab904f74a 100644 --- a/packages/trueforge-core/tests/core/sandbox/provider/sandboxProviderContractSuite.ts +++ b/packages/trueforge-core/tests/core/sandbox/provider/sandboxProviderContractSuite.ts @@ -35,7 +35,15 @@ export function runSandboxProviderContractSuite( if (!result.success) { throw new Error('unreachable'); } - expect(result.response.result.trim().split('\n')[0]).toBe(sandboxId); + const cwd = result.response.result.trim().split('\n')[0]; + if (isAbsolute(sandboxId)) { + // Path-id backends (Local): the sandbox root IS the sandboxId itself. + expect(cwd).toBe(sandboxId); + } else { + // Opaque-id backends (Daytona, OpenSandbox): sandboxId isn't a filesystem path, + // so just confirm exec has a stable, non-empty default cwd. + expect(cwd).not.toBe(''); + } }); it('exec is stateful across calls in the same sandbox', async () => { diff --git a/packages/trueforge-sdk/src/api/types/CatalogSandboxProvider.ts b/packages/trueforge-sdk/src/api/types/CatalogSandboxProvider.ts index 65ae0a7f8..a0669a4f4 100644 --- a/packages/trueforge-sdk/src/api/types/CatalogSandboxProvider.ts +++ b/packages/trueforge-sdk/src/api/types/CatalogSandboxProvider.ts @@ -1,14 +1,7 @@ // This file was auto-generated by Fern from our API Definition. -export interface CatalogSandboxProvider { - /** Minutes before Daytona auto-archives the sandbox (0 disables). */ - autoArchiveIntervalInMinutes: number; - /** Minutes before Daytona auto-deletes the sandbox (0 disables). */ - autoDeleteIntervalInMinutes: number; - /** Minutes of idle time before Daytona auto-stops the sandbox (0 disables). */ - autoStopIntervalInMinutes: number; - /** Default sandbox command exec timeout in milliseconds. */ - execTimeoutMs: number; - /** Daytona sandbox provider. */ - type: "daytona"; -} +import type * as TrueForge from "../index.js"; + +export type CatalogSandboxProvider = + | TrueForge.CatalogSandboxProviderAutoArchiveIntervalInMinutes + | TrueForge.CatalogSandboxProviderDomain; diff --git a/packages/trueforge-sdk/src/api/types/CatalogSandboxProviderAutoArchiveIntervalInMinutes.ts b/packages/trueforge-sdk/src/api/types/CatalogSandboxProviderAutoArchiveIntervalInMinutes.ts new file mode 100644 index 000000000..6c8bbd0cf --- /dev/null +++ b/packages/trueforge-sdk/src/api/types/CatalogSandboxProviderAutoArchiveIntervalInMinutes.ts @@ -0,0 +1,14 @@ +// This file was auto-generated by Fern from our API Definition. + +export interface CatalogSandboxProviderAutoArchiveIntervalInMinutes { + /** Minutes before Daytona auto-archives the sandbox (0 disables). */ + autoArchiveIntervalInMinutes: number; + /** Minutes before Daytona auto-deletes the sandbox (0 disables). */ + autoDeleteIntervalInMinutes: number; + /** Minutes of idle time before Daytona auto-stops the sandbox (0 disables). */ + autoStopIntervalInMinutes: number; + /** Default sandbox command exec timeout in milliseconds. */ + execTimeoutMs: number; + /** Daytona sandbox provider. */ + type: "daytona"; +} diff --git a/packages/trueforge-sdk/src/api/types/CatalogSandboxProviderDomain.ts b/packages/trueforge-sdk/src/api/types/CatalogSandboxProviderDomain.ts new file mode 100644 index 000000000..09d0124fc --- /dev/null +++ b/packages/trueforge-sdk/src/api/types/CatalogSandboxProviderDomain.ts @@ -0,0 +1,14 @@ +// This file was auto-generated by Fern from our API Definition. + +import type * as TrueForge from "../index.js"; + +export interface CatalogSandboxProviderDomain { + /** OpenSandbox API host, optionally including a port, without a URL scheme. */ + domain: string; + /** Default sandbox command exec timeout in milliseconds. */ + execTimeoutMs: number; + /** Protocol used to reach the OpenSandbox API. */ + protocol?: TrueForge.CatalogSandboxProviderDomainProtocol; + /** OpenSandbox sandbox provider. */ + type: "opensandbox"; +} diff --git a/packages/trueforge-sdk/src/api/types/CatalogSandboxProviderDomainProtocol.ts b/packages/trueforge-sdk/src/api/types/CatalogSandboxProviderDomainProtocol.ts new file mode 100644 index 000000000..b224b9f15 --- /dev/null +++ b/packages/trueforge-sdk/src/api/types/CatalogSandboxProviderDomainProtocol.ts @@ -0,0 +1,9 @@ +// This file was auto-generated by Fern from our API Definition. + +/** Protocol used to reach the OpenSandbox API. */ +export const CatalogSandboxProviderDomainProtocol = { + Http: "http", + Https: "https", +} as const; +export type CatalogSandboxProviderDomainProtocol = + (typeof CatalogSandboxProviderDomainProtocol)[keyof typeof CatalogSandboxProviderDomainProtocol]; diff --git a/packages/trueforge-sdk/src/api/types/OpenSandboxProviderAuth.ts b/packages/trueforge-sdk/src/api/types/OpenSandboxProviderAuth.ts new file mode 100644 index 000000000..1402ceb84 --- /dev/null +++ b/packages/trueforge-sdk/src/api/types/OpenSandboxProviderAuth.ts @@ -0,0 +1,9 @@ +// This file was auto-generated by Fern from our API Definition. + +/** + * OpenSandbox authentication credentials. + */ +export interface OpenSandboxProviderAuth { + /** OpenSandbox API key. Responses are redacted; on PUT, a real value sets/rotates and a redacted value keeps the stored key. */ + apiKey: string; +} diff --git a/packages/trueforge-sdk/src/api/types/SandboxProviderManifest.ts b/packages/trueforge-sdk/src/api/types/SandboxProviderManifest.ts index d6cf60a3e..048d00408 100644 --- a/packages/trueforge-sdk/src/api/types/SandboxProviderManifest.ts +++ b/packages/trueforge-sdk/src/api/types/SandboxProviderManifest.ts @@ -2,16 +2,6 @@ import type * as TrueForge from "../index.js"; -export interface SandboxProviderManifest { - auth: TrueForge.DaytonaSandboxProviderAuth; - /** Minutes before Daytona auto-archives the sandbox (0 disables). */ - autoArchiveIntervalInMinutes: number; - /** Minutes before Daytona auto-deletes the sandbox (0 disables). */ - autoDeleteIntervalInMinutes: number; - /** Minutes of idle time before Daytona auto-stops the sandbox (0 disables). */ - autoStopIntervalInMinutes: number; - /** Default sandbox command exec timeout in milliseconds. */ - execTimeoutMs: number; - /** Daytona sandbox provider. */ - type: "daytona"; -} +export type SandboxProviderManifest = + | TrueForge.SandboxProviderManifestAutoArchiveIntervalInMinutes + | TrueForge.SandboxProviderManifestDomain; diff --git a/packages/trueforge-sdk/src/api/types/SandboxProviderManifestAutoArchiveIntervalInMinutes.ts b/packages/trueforge-sdk/src/api/types/SandboxProviderManifestAutoArchiveIntervalInMinutes.ts new file mode 100644 index 000000000..baa572262 --- /dev/null +++ b/packages/trueforge-sdk/src/api/types/SandboxProviderManifestAutoArchiveIntervalInMinutes.ts @@ -0,0 +1,17 @@ +// This file was auto-generated by Fern from our API Definition. + +import type * as TrueForge from "../index.js"; + +export interface SandboxProviderManifestAutoArchiveIntervalInMinutes { + auth: TrueForge.DaytonaSandboxProviderAuth; + /** Minutes before Daytona auto-archives the sandbox (0 disables). */ + autoArchiveIntervalInMinutes: number; + /** Minutes before Daytona auto-deletes the sandbox (0 disables). */ + autoDeleteIntervalInMinutes: number; + /** Minutes of idle time before Daytona auto-stops the sandbox (0 disables). */ + autoStopIntervalInMinutes: number; + /** Default sandbox command exec timeout in milliseconds. */ + execTimeoutMs: number; + /** Daytona sandbox provider. */ + type: "daytona"; +} diff --git a/packages/trueforge-sdk/src/api/types/SandboxProviderManifestDomain.ts b/packages/trueforge-sdk/src/api/types/SandboxProviderManifestDomain.ts new file mode 100644 index 000000000..106d78487 --- /dev/null +++ b/packages/trueforge-sdk/src/api/types/SandboxProviderManifestDomain.ts @@ -0,0 +1,15 @@ +// This file was auto-generated by Fern from our API Definition. + +import type * as TrueForge from "../index.js"; + +export interface SandboxProviderManifestDomain { + auth: TrueForge.OpenSandboxProviderAuth; + /** OpenSandbox API host, optionally including a port, without a URL scheme. */ + domain: string; + /** Default sandbox command exec timeout in milliseconds. */ + execTimeoutMs: number; + /** Protocol used to reach the OpenSandbox API. */ + protocol?: TrueForge.SandboxProviderManifestDomainProtocol; + /** OpenSandbox sandbox provider. */ + type: "opensandbox"; +} diff --git a/packages/trueforge-sdk/src/api/types/SandboxProviderManifestDomainProtocol.ts b/packages/trueforge-sdk/src/api/types/SandboxProviderManifestDomainProtocol.ts new file mode 100644 index 000000000..3513c6e5a --- /dev/null +++ b/packages/trueforge-sdk/src/api/types/SandboxProviderManifestDomainProtocol.ts @@ -0,0 +1,9 @@ +// This file was auto-generated by Fern from our API Definition. + +/** Protocol used to reach the OpenSandbox API. */ +export const SandboxProviderManifestDomainProtocol = { + Http: "http", + Https: "https", +} as const; +export type SandboxProviderManifestDomainProtocol = + (typeof SandboxProviderManifestDomainProtocol)[keyof typeof SandboxProviderManifestDomainProtocol]; diff --git a/packages/trueforge-sdk/src/api/types/index.ts b/packages/trueforge-sdk/src/api/types/index.ts index 18f7a281b..f77e321da 100644 --- a/packages/trueforge-sdk/src/api/types/index.ts +++ b/packages/trueforge-sdk/src/api/types/index.ts @@ -22,6 +22,9 @@ export * from "./CatalogMcpServer.js"; export * from "./CatalogModel.js"; export * from "./CatalogModelProvider.js"; export * from "./CatalogSandboxProvider.js"; +export * from "./CatalogSandboxProviderAutoArchiveIntervalInMinutes.js"; +export * from "./CatalogSandboxProviderDomain.js"; +export * from "./CatalogSandboxProviderDomainProtocol.js"; export * from "./CatalogSkill.js"; export * from "./CatalogWellKnownModelProvider.js"; export * from "./CatalogWellKnownModelProviderType.js"; @@ -111,6 +114,7 @@ export * from "./ModelProviderAuth.js"; export * from "./ModelProviderManifest.js"; export * from "./MoonshotModelProvider.js"; export * from "./OpenAiModelProvider.js"; +export * from "./OpenSandboxProviderAuth.js"; export * from "./PreviousTurnIdInput.js"; export * from "./RawToolCall.js"; export * from "./ReasoningEffort.js"; @@ -128,6 +132,9 @@ export * from "./SandboxCapability.js"; export * from "./SandboxConfig.js"; export * from "./SandboxCreatedEvent.js"; export * from "./SandboxProviderManifest.js"; +export * from "./SandboxProviderManifestAutoArchiveIntervalInMinutes.js"; +export * from "./SandboxProviderManifestDomain.js"; +export * from "./SandboxProviderManifestDomainProtocol.js"; export * from "./Session.js"; export * from "./SessionAgent.js"; export * from "./SessionAgentInline.js"; diff --git a/packages/trueforge-sdk/src/serialization/types/CatalogSandboxProvider.ts b/packages/trueforge-sdk/src/serialization/types/CatalogSandboxProvider.ts index a0ff46aa6..4481e7692 100644 --- a/packages/trueforge-sdk/src/serialization/types/CatalogSandboxProvider.ts +++ b/packages/trueforge-sdk/src/serialization/types/CatalogSandboxProvider.ts @@ -3,33 +3,17 @@ import type * as TrueForge from "../../api/index.js"; import * as core from "../../core/index.js"; import type * as serializers from "../index.js"; +import { CatalogSandboxProviderAutoArchiveIntervalInMinutes } from "./CatalogSandboxProviderAutoArchiveIntervalInMinutes.js"; +import { CatalogSandboxProviderDomain } from "./CatalogSandboxProviderDomain.js"; -export const CatalogSandboxProvider: core.serialization.ObjectSchema< +export const CatalogSandboxProvider: core.serialization.Schema< serializers.CatalogSandboxProvider.Raw, TrueForge.CatalogSandboxProvider -> = core.serialization.object({ - autoArchiveIntervalInMinutes: core.serialization.property( - "auto_archive_interval_in_minutes", - core.serialization.number(), - ), - autoDeleteIntervalInMinutes: core.serialization.property( - "auto_delete_interval_in_minutes", - core.serialization.number(), - ), - autoStopIntervalInMinutes: core.serialization.property( - "auto_stop_interval_in_minutes", - core.serialization.number(), - ), - execTimeoutMs: core.serialization.property("exec_timeout_ms", core.serialization.number()), - type: core.serialization.stringLiteral("daytona"), -}); +> = core.serialization.undiscriminatedUnion([ + CatalogSandboxProviderAutoArchiveIntervalInMinutes, + CatalogSandboxProviderDomain, +]); export declare namespace CatalogSandboxProvider { - export interface Raw { - auto_archive_interval_in_minutes: number; - auto_delete_interval_in_minutes: number; - auto_stop_interval_in_minutes: number; - exec_timeout_ms: number; - type: "daytona"; - } + export type Raw = CatalogSandboxProviderAutoArchiveIntervalInMinutes.Raw | CatalogSandboxProviderDomain.Raw; } diff --git a/packages/trueforge-sdk/src/serialization/types/CatalogSandboxProviderAutoArchiveIntervalInMinutes.ts b/packages/trueforge-sdk/src/serialization/types/CatalogSandboxProviderAutoArchiveIntervalInMinutes.ts new file mode 100644 index 000000000..487847536 --- /dev/null +++ b/packages/trueforge-sdk/src/serialization/types/CatalogSandboxProviderAutoArchiveIntervalInMinutes.ts @@ -0,0 +1,35 @@ +// This file was auto-generated by Fern from our API Definition. + +import type * as TrueForge from "../../api/index.js"; +import * as core from "../../core/index.js"; +import type * as serializers from "../index.js"; + +export const CatalogSandboxProviderAutoArchiveIntervalInMinutes: core.serialization.ObjectSchema< + serializers.CatalogSandboxProviderAutoArchiveIntervalInMinutes.Raw, + TrueForge.CatalogSandboxProviderAutoArchiveIntervalInMinutes +> = core.serialization.object({ + autoArchiveIntervalInMinutes: core.serialization.property( + "auto_archive_interval_in_minutes", + core.serialization.number(), + ), + autoDeleteIntervalInMinutes: core.serialization.property( + "auto_delete_interval_in_minutes", + core.serialization.number(), + ), + autoStopIntervalInMinutes: core.serialization.property( + "auto_stop_interval_in_minutes", + core.serialization.number(), + ), + execTimeoutMs: core.serialization.property("exec_timeout_ms", core.serialization.number()), + type: core.serialization.stringLiteral("daytona"), +}); + +export declare namespace CatalogSandboxProviderAutoArchiveIntervalInMinutes { + export interface Raw { + auto_archive_interval_in_minutes: number; + auto_delete_interval_in_minutes: number; + auto_stop_interval_in_minutes: number; + exec_timeout_ms: number; + type: "daytona"; + } +} diff --git a/packages/trueforge-sdk/src/serialization/types/CatalogSandboxProviderDomain.ts b/packages/trueforge-sdk/src/serialization/types/CatalogSandboxProviderDomain.ts new file mode 100644 index 000000000..e2f8a050e --- /dev/null +++ b/packages/trueforge-sdk/src/serialization/types/CatalogSandboxProviderDomain.ts @@ -0,0 +1,25 @@ +// This file was auto-generated by Fern from our API Definition. + +import type * as TrueForge from "../../api/index.js"; +import * as core from "../../core/index.js"; +import type * as serializers from "../index.js"; +import { CatalogSandboxProviderDomainProtocol } from "./CatalogSandboxProviderDomainProtocol.js"; + +export const CatalogSandboxProviderDomain: core.serialization.ObjectSchema< + serializers.CatalogSandboxProviderDomain.Raw, + TrueForge.CatalogSandboxProviderDomain +> = core.serialization.object({ + domain: core.serialization.string(), + execTimeoutMs: core.serialization.property("exec_timeout_ms", core.serialization.number()), + protocol: CatalogSandboxProviderDomainProtocol.optional(), + type: core.serialization.stringLiteral("opensandbox"), +}); + +export declare namespace CatalogSandboxProviderDomain { + export interface Raw { + domain: string; + exec_timeout_ms: number; + protocol?: CatalogSandboxProviderDomainProtocol.Raw | null; + type: "opensandbox"; + } +} diff --git a/packages/trueforge-sdk/src/serialization/types/CatalogSandboxProviderDomainProtocol.ts b/packages/trueforge-sdk/src/serialization/types/CatalogSandboxProviderDomainProtocol.ts new file mode 100644 index 000000000..6ecb44677 --- /dev/null +++ b/packages/trueforge-sdk/src/serialization/types/CatalogSandboxProviderDomainProtocol.ts @@ -0,0 +1,14 @@ +// This file was auto-generated by Fern from our API Definition. + +import type * as TrueForge from "../../api/index.js"; +import * as core from "../../core/index.js"; +import type * as serializers from "../index.js"; + +export const CatalogSandboxProviderDomainProtocol: core.serialization.Schema< + serializers.CatalogSandboxProviderDomainProtocol.Raw, + TrueForge.CatalogSandboxProviderDomainProtocol +> = core.serialization.enum_(["http", "https"]); + +export declare namespace CatalogSandboxProviderDomainProtocol { + export type Raw = "http" | "https"; +} diff --git a/packages/trueforge-sdk/src/serialization/types/OpenSandboxProviderAuth.ts b/packages/trueforge-sdk/src/serialization/types/OpenSandboxProviderAuth.ts new file mode 100644 index 000000000..09e2e2a64 --- /dev/null +++ b/packages/trueforge-sdk/src/serialization/types/OpenSandboxProviderAuth.ts @@ -0,0 +1,18 @@ +// This file was auto-generated by Fern from our API Definition. + +import type * as TrueForge from "../../api/index.js"; +import * as core from "../../core/index.js"; +import type * as serializers from "../index.js"; + +export const OpenSandboxProviderAuth: core.serialization.ObjectSchema< + serializers.OpenSandboxProviderAuth.Raw, + TrueForge.OpenSandboxProviderAuth +> = core.serialization.object({ + apiKey: core.serialization.property("api_key", core.serialization.string()), +}); + +export declare namespace OpenSandboxProviderAuth { + export interface Raw { + api_key: string; + } +} diff --git a/packages/trueforge-sdk/src/serialization/types/SandboxProviderManifest.ts b/packages/trueforge-sdk/src/serialization/types/SandboxProviderManifest.ts index 76a73d5d5..d0e067e44 100644 --- a/packages/trueforge-sdk/src/serialization/types/SandboxProviderManifest.ts +++ b/packages/trueforge-sdk/src/serialization/types/SandboxProviderManifest.ts @@ -3,36 +3,17 @@ import type * as TrueForge from "../../api/index.js"; import * as core from "../../core/index.js"; import type * as serializers from "../index.js"; -import { DaytonaSandboxProviderAuth } from "./DaytonaSandboxProviderAuth.js"; +import { SandboxProviderManifestAutoArchiveIntervalInMinutes } from "./SandboxProviderManifestAutoArchiveIntervalInMinutes.js"; +import { SandboxProviderManifestDomain } from "./SandboxProviderManifestDomain.js"; -export const SandboxProviderManifest: core.serialization.ObjectSchema< +export const SandboxProviderManifest: core.serialization.Schema< serializers.SandboxProviderManifest.Raw, TrueForge.SandboxProviderManifest -> = core.serialization.object({ - auth: DaytonaSandboxProviderAuth, - autoArchiveIntervalInMinutes: core.serialization.property( - "auto_archive_interval_in_minutes", - core.serialization.number(), - ), - autoDeleteIntervalInMinutes: core.serialization.property( - "auto_delete_interval_in_minutes", - core.serialization.number(), - ), - autoStopIntervalInMinutes: core.serialization.property( - "auto_stop_interval_in_minutes", - core.serialization.number(), - ), - execTimeoutMs: core.serialization.property("exec_timeout_ms", core.serialization.number()), - type: core.serialization.stringLiteral("daytona"), -}); +> = core.serialization.undiscriminatedUnion([ + SandboxProviderManifestAutoArchiveIntervalInMinutes, + SandboxProviderManifestDomain, +]); export declare namespace SandboxProviderManifest { - export interface Raw { - auth: DaytonaSandboxProviderAuth.Raw; - auto_archive_interval_in_minutes: number; - auto_delete_interval_in_minutes: number; - auto_stop_interval_in_minutes: number; - exec_timeout_ms: number; - type: "daytona"; - } + export type Raw = SandboxProviderManifestAutoArchiveIntervalInMinutes.Raw | SandboxProviderManifestDomain.Raw; } diff --git a/packages/trueforge-sdk/src/serialization/types/SandboxProviderManifestAutoArchiveIntervalInMinutes.ts b/packages/trueforge-sdk/src/serialization/types/SandboxProviderManifestAutoArchiveIntervalInMinutes.ts new file mode 100644 index 000000000..5a466dca7 --- /dev/null +++ b/packages/trueforge-sdk/src/serialization/types/SandboxProviderManifestAutoArchiveIntervalInMinutes.ts @@ -0,0 +1,38 @@ +// This file was auto-generated by Fern from our API Definition. + +import type * as TrueForge from "../../api/index.js"; +import * as core from "../../core/index.js"; +import type * as serializers from "../index.js"; +import { DaytonaSandboxProviderAuth } from "./DaytonaSandboxProviderAuth.js"; + +export const SandboxProviderManifestAutoArchiveIntervalInMinutes: core.serialization.ObjectSchema< + serializers.SandboxProviderManifestAutoArchiveIntervalInMinutes.Raw, + TrueForge.SandboxProviderManifestAutoArchiveIntervalInMinutes +> = core.serialization.object({ + auth: DaytonaSandboxProviderAuth, + autoArchiveIntervalInMinutes: core.serialization.property( + "auto_archive_interval_in_minutes", + core.serialization.number(), + ), + autoDeleteIntervalInMinutes: core.serialization.property( + "auto_delete_interval_in_minutes", + core.serialization.number(), + ), + autoStopIntervalInMinutes: core.serialization.property( + "auto_stop_interval_in_minutes", + core.serialization.number(), + ), + execTimeoutMs: core.serialization.property("exec_timeout_ms", core.serialization.number()), + type: core.serialization.stringLiteral("daytona"), +}); + +export declare namespace SandboxProviderManifestAutoArchiveIntervalInMinutes { + export interface Raw { + auth: DaytonaSandboxProviderAuth.Raw; + auto_archive_interval_in_minutes: number; + auto_delete_interval_in_minutes: number; + auto_stop_interval_in_minutes: number; + exec_timeout_ms: number; + type: "daytona"; + } +} diff --git a/packages/trueforge-sdk/src/serialization/types/SandboxProviderManifestDomain.ts b/packages/trueforge-sdk/src/serialization/types/SandboxProviderManifestDomain.ts new file mode 100644 index 000000000..42f9556ba --- /dev/null +++ b/packages/trueforge-sdk/src/serialization/types/SandboxProviderManifestDomain.ts @@ -0,0 +1,28 @@ +// This file was auto-generated by Fern from our API Definition. + +import type * as TrueForge from "../../api/index.js"; +import * as core from "../../core/index.js"; +import type * as serializers from "../index.js"; +import { OpenSandboxProviderAuth } from "./OpenSandboxProviderAuth.js"; +import { SandboxProviderManifestDomainProtocol } from "./SandboxProviderManifestDomainProtocol.js"; + +export const SandboxProviderManifestDomain: core.serialization.ObjectSchema< + serializers.SandboxProviderManifestDomain.Raw, + TrueForge.SandboxProviderManifestDomain +> = core.serialization.object({ + auth: OpenSandboxProviderAuth, + domain: core.serialization.string(), + execTimeoutMs: core.serialization.property("exec_timeout_ms", core.serialization.number()), + protocol: SandboxProviderManifestDomainProtocol.optional(), + type: core.serialization.stringLiteral("opensandbox"), +}); + +export declare namespace SandboxProviderManifestDomain { + export interface Raw { + auth: OpenSandboxProviderAuth.Raw; + domain: string; + exec_timeout_ms: number; + protocol?: SandboxProviderManifestDomainProtocol.Raw | null; + type: "opensandbox"; + } +} diff --git a/packages/trueforge-sdk/src/serialization/types/SandboxProviderManifestDomainProtocol.ts b/packages/trueforge-sdk/src/serialization/types/SandboxProviderManifestDomainProtocol.ts new file mode 100644 index 000000000..be3f05d96 --- /dev/null +++ b/packages/trueforge-sdk/src/serialization/types/SandboxProviderManifestDomainProtocol.ts @@ -0,0 +1,14 @@ +// This file was auto-generated by Fern from our API Definition. + +import type * as TrueForge from "../../api/index.js"; +import * as core from "../../core/index.js"; +import type * as serializers from "../index.js"; + +export const SandboxProviderManifestDomainProtocol: core.serialization.Schema< + serializers.SandboxProviderManifestDomainProtocol.Raw, + TrueForge.SandboxProviderManifestDomainProtocol +> = core.serialization.enum_(["http", "https"]); + +export declare namespace SandboxProviderManifestDomainProtocol { + export type Raw = "http" | "https"; +} diff --git a/packages/trueforge-sdk/src/serialization/types/index.ts b/packages/trueforge-sdk/src/serialization/types/index.ts index 18f7a281b..f77e321da 100644 --- a/packages/trueforge-sdk/src/serialization/types/index.ts +++ b/packages/trueforge-sdk/src/serialization/types/index.ts @@ -22,6 +22,9 @@ export * from "./CatalogMcpServer.js"; export * from "./CatalogModel.js"; export * from "./CatalogModelProvider.js"; export * from "./CatalogSandboxProvider.js"; +export * from "./CatalogSandboxProviderAutoArchiveIntervalInMinutes.js"; +export * from "./CatalogSandboxProviderDomain.js"; +export * from "./CatalogSandboxProviderDomainProtocol.js"; export * from "./CatalogSkill.js"; export * from "./CatalogWellKnownModelProvider.js"; export * from "./CatalogWellKnownModelProviderType.js"; @@ -111,6 +114,7 @@ export * from "./ModelProviderAuth.js"; export * from "./ModelProviderManifest.js"; export * from "./MoonshotModelProvider.js"; export * from "./OpenAiModelProvider.js"; +export * from "./OpenSandboxProviderAuth.js"; export * from "./PreviousTurnIdInput.js"; export * from "./RawToolCall.js"; export * from "./ReasoningEffort.js"; @@ -128,6 +132,9 @@ export * from "./SandboxCapability.js"; export * from "./SandboxConfig.js"; export * from "./SandboxCreatedEvent.js"; export * from "./SandboxProviderManifest.js"; +export * from "./SandboxProviderManifestAutoArchiveIntervalInMinutes.js"; +export * from "./SandboxProviderManifestDomain.js"; +export * from "./SandboxProviderManifestDomainProtocol.js"; export * from "./Session.js"; export * from "./SessionAgent.js"; export * from "./SessionAgentInline.js"; diff --git a/packages/trueforge-ui/src/containers/SettingsBuilder/ConfigureSandboxForm.tsx b/packages/trueforge-ui/src/containers/SettingsBuilder/ConfigureSandboxForm.tsx index c12c1957a..df40c614b 100644 --- a/packages/trueforge-ui/src/containers/SettingsBuilder/ConfigureSandboxForm.tsx +++ b/packages/trueforge-ui/src/containers/SettingsBuilder/ConfigureSandboxForm.tsx @@ -12,6 +12,8 @@ import type { SandboxProviderConfig } from '../../server/types.js'; export type SandboxConfigDraft = SandboxProviderConfig & { apiKey: string; + domain?: string; + protocol?: 'http' | 'https'; }; type ConfigureSandboxFormProps = { @@ -19,6 +21,7 @@ type ConfigureSandboxFormProps = { onOpenChange: (open: boolean) => void; onSave: (draft: SandboxConfigDraft) => void | Promise; title: string; + providerType: string; description?: string; /** Prefills config fields; apiKey is never autofilled. */ initialConfig?: SandboxProviderConfig | null; @@ -51,6 +54,7 @@ const ConfigureSandboxForm = ({ onOpenChange, onSave, title, + providerType, description, initialConfig = null, requireApiKey = true, @@ -62,6 +66,8 @@ const ConfigureSandboxForm = ({ const [autoArchiveIntervalInMinutes, setAutoArchiveIntervalInMinutes] = useState(''); const [autoDeleteIntervalInMinutes, setAutoDeleteIntervalInMinutes] = useState(''); const [apiKey, setApiKey] = useState(''); + const [domain, setDomain] = useState(''); + const [protocol, setProtocol] = useState<'http' | 'https'>('https'); const [advancedOpen, setAdvancedOpen] = useState(false); const resetForm = () => { @@ -70,6 +76,8 @@ const ConfigureSandboxForm = ({ setAutoArchiveIntervalInMinutes(''); setAutoDeleteIntervalInMinutes(''); setApiKey(''); + setDomain(''); + setProtocol('https'); setAdvancedOpen(false); }; @@ -81,6 +89,8 @@ const ConfigureSandboxForm = ({ setAutoArchiveIntervalInMinutes(String(config.autoArchiveIntervalInMinutes)); setAutoDeleteIntervalInMinutes(String(config.autoDeleteIntervalInMinutes)); setApiKey(''); + setDomain('domain' in config && typeof config.domain === 'string' ? config.domain : ''); + setProtocol('protocol' in config && config.protocol === 'http' ? 'http' : 'https'); setAdvancedOpen(false); }, [open, initialConfig]); @@ -94,17 +104,23 @@ const ConfigureSandboxForm = ({ const autoArchive = parseNonNegInt(autoArchiveIntervalInMinutes); const autoDelete = parseNonNegInt(autoDeleteIntervalInMinutes); const trimmedKey = apiKey.trim(); + const trimmedDomain = domain.trim(); + const isOpenSandbox = providerType === 'opensandbox'; const isValid = (!requireApiKey || !!trimmedKey) && execTimeout != null && - autoStop != null && - autoArchive != null && - autoDelete != null; + (isOpenSandbox || (autoStop != null && autoArchive != null && autoDelete != null)) && + (!isOpenSandbox || trimmedDomain !== ''); const handleSubmit = async (event: FormEvent) => { event.preventDefault(); - if (!isValid || busy || execTimeout == null || autoStop == null || autoArchive == null || autoDelete == null) { + if ( + !isValid || + busy || + execTimeout == null || + (!isOpenSandbox && (autoStop == null || autoArchive == null || autoDelete == null)) + ) { return; } @@ -112,10 +128,11 @@ const ConfigureSandboxForm = ({ await onSave({ // Snapshot/image is release-owned; the field is retained only for the external type. execTimeoutMs: execTimeout, - autoStopIntervalInMinutes: autoStop, - autoArchiveIntervalInMinutes: autoArchive, - autoDeleteIntervalInMinutes: autoDelete, + autoStopIntervalInMinutes: autoStop ?? 0, + autoArchiveIntervalInMinutes: autoArchive ?? 0, + autoDeleteIntervalInMinutes: autoDelete ?? 0, apiKey: trimmedKey, + ...(isOpenSandbox ? { domain: trimmedDomain, protocol } : {}), }); resetForm(); onOpenChange(false); @@ -155,7 +172,13 @@ const ConfigureSandboxForm = ({ onChange={event => { setApiKey(event.target.value); }} - placeholder={requireApiKey ? 'dtn_...' : 'Leave blank to keep existing'} + placeholder={ + requireApiKey + ? isOpenSandbox + ? 'Enter OpenSandbox API key' + : 'dtn_...' + : 'Leave blank to keep existing' + } autoFocus className={inputClassName} /> @@ -179,6 +202,38 @@ const ConfigureSandboxForm = ({ + {isOpenSandbox ? ( + <> +
+ + setDomain(event.target.value)} + placeholder="api.opensandbox.io" + className={inputClassName} + /> +
+
+ + +
+ + ) : null}
-
- - { - setAutoStopIntervalInMinutes(event.target.value); - }} - placeholder="15" - className={inputClassName} - /> -
+ {!isOpenSandbox ? ( +
+ + { + setAutoStopIntervalInMinutes(event.target.value); + }} + placeholder="15" + className={inputClassName} + /> +
+ ) : null} -
- - { - setAutoArchiveIntervalInMinutes(event.target.value); - }} - placeholder="10080" - className={inputClassName} - /> -
+ {!isOpenSandbox ? ( +
+ + { + setAutoArchiveIntervalInMinutes(event.target.value); + }} + placeholder="10080" + className={inputClassName} + /> +
+ ) : null} -
- - { - setAutoDeleteIntervalInMinutes(event.target.value); - }} - placeholder="43200" - className={inputClassName} - /> -
+ {!isOpenSandbox ? ( +
+ + { + setAutoDeleteIntervalInMinutes(event.target.value); + }} + placeholder="43200" + className={inputClassName} + /> +
+ ) : null}
diff --git a/packages/trueforge-ui/src/containers/SettingsBuilder/SandboxSettings.tsx b/packages/trueforge-ui/src/containers/SettingsBuilder/SandboxSettings.tsx index e2a2005e5..5266bd294 100644 --- a/packages/trueforge-ui/src/containers/SettingsBuilder/SandboxSettings.tsx +++ b/packages/trueforge-ui/src/containers/SettingsBuilder/SandboxSettings.tsx @@ -20,16 +20,15 @@ import ConfigureSandboxForm, { type SandboxConfigDraft } from './ConfigureSandbo const SNAPSHOT_STATUS_POLL_INTERVAL_MS = 10000; -const configFrom = ({ - execTimeoutMs, - autoStopIntervalInMinutes, - autoArchiveIntervalInMinutes, - autoDeleteIntervalInMinutes, -}: SandboxProviderConfig): SandboxProviderConfig => ({ - execTimeoutMs, - autoStopIntervalInMinutes, - autoArchiveIntervalInMinutes, - autoDeleteIntervalInMinutes, +type ProviderConfigWithEndpoint = SandboxProviderConfig & { domain?: string; protocol?: 'http' | 'https' }; + +const configFrom = (provider: ProviderConfigWithEndpoint): ProviderConfigWithEndpoint => ({ + execTimeoutMs: provider.execTimeoutMs, + ...(provider.domain !== undefined ? { domain: provider.domain } : {}), + ...(provider.protocol !== undefined ? { protocol: provider.protocol } : {}), + autoStopIntervalInMinutes: provider.autoStopIntervalInMinutes, + autoArchiveIntervalInMinutes: provider.autoArchiveIntervalInMinutes, + autoDeleteIntervalInMinutes: provider.autoDeleteIntervalInMinutes, }); const statusPresentation = (status: SandboxSnapshotSyncStatus['status']): { label: string; className: string } => { @@ -176,6 +175,7 @@ const SandboxSettings = () => { name: createEntry.name, type: createEntry.type, ...configFrom(draft), + ...('domain' in draft ? { domain: draft.domain, protocol: draft.protocol } : {}), apiKey: draft.apiKey, }); }, setFormError); @@ -192,6 +192,7 @@ const SandboxSettings = () => { await sandboxCatalog.updateSandboxProvider({ id: updateProvider.id, ...configFrom(draft), + ...('domain' in draft ? { domain: draft.domain, protocol: draft.protocol } : {}), ...(draft.apiKey ? { apiKey: draft.apiKey } : {}), }); }, setFormError); @@ -414,6 +415,13 @@ const SandboxSettings = () => { : 'Configure this sandbox provider. API key is never stored in the catalog.' } initialConfig={formInitialConfig} + providerType={ + updateProvider !== null && 'domain' in updateProvider + ? 'opensandbox' + : updateProvider !== null + ? 'daytona' + : (createEntry?.type ?? 'daytona') + } requireApiKey={!isUpdate} busy={busy} error={formError} diff --git a/packages/trueforge-ui/src/plugins/trueforge-agent-server-adapter/catalogs/sandboxProviderCatalog.ts b/packages/trueforge-ui/src/plugins/trueforge-agent-server-adapter/catalogs/sandboxProviderCatalog.ts index dc2da1a1d..f626626ff 100644 --- a/packages/trueforge-ui/src/plugins/trueforge-agent-server-adapter/catalogs/sandboxProviderCatalog.ts +++ b/packages/trueforge-ui/src/plugins/trueforge-agent-server-adapter/catalogs/sandboxProviderCatalog.ts @@ -3,8 +3,8 @@ * `/api/v1/settings/sandbox-providers` (singleton upsert, no delete). * * UI: multi-row providers with `id` / `catalogId` / `name` / flat `apiKey`. - * Harness: one Daytona provider per tenant; catalog YAML has no name — synthetic - * identity uses `type` (`daytona`) as id/catalogId and display name `Daytona`. + * Harness: one provider per tenant; catalog YAML has no name — synthetic identity + * uses `type` as id/catalogId and a provider-specific display name. */ import type { TrueForge } from '@truefoundry/trueforge-sdk'; import { TrueForgeApi } from '@truefoundry/trueforge-sdk'; @@ -21,18 +21,30 @@ export type UiSandboxProviderCatalogEntry = SandboxProviderCatalogEntry; export type UiSandboxProviderListEntry = SandboxProviderListEntry; const DAYTONA_TYPE = 'daytona'; -const DAYTONA_DISPLAY_NAME = 'Daytona'; +const OPENSANDBOX_TYPE = 'opensandbox'; function displayNameForType(type: string): string { - if (type === DAYTONA_TYPE) { - return DAYTONA_DISPLAY_NAME; - } - return type; + return type === DAYTONA_TYPE ? 'Daytona' : type === OPENSANDBOX_TYPE ? 'OpenSandbox' : type; } +type UiSandboxConfig = SandboxProviderConfig & { + domain?: string; + protocol?: 'http' | 'https'; +}; + export function configFromHarness( provider: TrueForgeApi.CatalogSandboxProvider | TrueForgeApi.SandboxProviderManifest, -): SandboxProviderConfig { +): UiSandboxConfig { + if (provider.type === OPENSANDBOX_TYPE) { + return { + execTimeoutMs: provider.execTimeoutMs, + autoStopIntervalInMinutes: 0, + autoArchiveIntervalInMinutes: 0, + autoDeleteIntervalInMinutes: 0, + domain: provider.domain, + protocol: provider.protocol ?? 'https', + }; + } return { execTimeoutMs: provider.execTimeoutMs, autoStopIntervalInMinutes: provider.autoStopIntervalInMinutes, @@ -94,8 +106,22 @@ export function toHarnessManifest( req: { type: string; apiKey: string; + domain?: string; + protocol?: 'http' | 'https'; } & SandboxProviderConfig, ): TrueForgeApi.SandboxProviderManifest { + if (req.type === OPENSANDBOX_TYPE) { + if (req.domain === undefined || req.domain.trim() === '') { + throw new Error('OpenSandbox domain is required'); + } + return { + type: OPENSANDBOX_TYPE, + execTimeoutMs: req.execTimeoutMs, + domain: req.domain, + protocol: req.protocol ?? 'https', + auth: { apiKey: req.apiKey }, + }; + } if (req.type !== DAYTONA_TYPE) { throw new Error(`Unsupported sandbox provider type: ${req.type}`); } @@ -120,6 +146,11 @@ export function createSandboxProviderCatalog(client: TrueForge): SandboxCatalogS return existing.data.manifest.auth.apiKey; } + async function resolveExistingManifest(): Promise { + const existing = await client.settings.sandboxProviders.get(); + return existing.data.manifest; + } + return { getSandboxProviderCatalog: async () => { const body = await client.catalogs.sandboxProviders.list(); @@ -148,20 +179,50 @@ export function createSandboxProviderCatalog(client: TrueForge): SandboxCatalogS autoStopIntervalInMinutes: req.autoStopIntervalInMinutes, autoArchiveIntervalInMinutes: req.autoArchiveIntervalInMinutes, autoDeleteIntervalInMinutes: req.autoDeleteIntervalInMinutes, + ...('domain' in req && typeof req.domain === 'string' ? { domain: req.domain } : {}), + ...('protocol' in req && (req.protocol === 'http' || req.protocol === 'https') + ? { protocol: req.protocol } + : {}), }), }); return toUiSandboxProvider(body.data.manifest); }, updateSandboxProvider: async req => { + const existingManifest = await resolveExistingManifest(); const apiKey = await resolveApiKey(req.apiKey); const body = await client.settings.sandboxProviders.createOrUpdate({ manifest: toHarnessManifest({ - type: DAYTONA_TYPE, + type: existingManifest.type, apiKey, execTimeoutMs: req.execTimeoutMs, - autoStopIntervalInMinutes: req.autoStopIntervalInMinutes, - autoArchiveIntervalInMinutes: req.autoArchiveIntervalInMinutes, - autoDeleteIntervalInMinutes: req.autoDeleteIntervalInMinutes, + autoStopIntervalInMinutes: + 'autoStopIntervalInMinutes' in req + ? req.autoStopIntervalInMinutes + : 'autoStopIntervalInMinutes' in existingManifest + ? existingManifest.autoStopIntervalInMinutes + : 0, + autoArchiveIntervalInMinutes: + 'autoArchiveIntervalInMinutes' in req + ? req.autoArchiveIntervalInMinutes + : 'autoArchiveIntervalInMinutes' in existingManifest + ? existingManifest.autoArchiveIntervalInMinutes + : 0, + autoDeleteIntervalInMinutes: + 'autoDeleteIntervalInMinutes' in req + ? req.autoDeleteIntervalInMinutes + : 'autoDeleteIntervalInMinutes' in existingManifest + ? existingManifest.autoDeleteIntervalInMinutes + : 0, + ...('domain' in req && typeof req.domain === 'string' + ? { domain: req.domain } + : 'domain' in existingManifest + ? { domain: existingManifest.domain } + : {}), + ...('protocol' in req && (req.protocol === 'http' || req.protocol === 'https') + ? { protocol: req.protocol } + : 'protocol' in existingManifest + ? { protocol: existingManifest.protocol } + : {}), }), }); return toUiSandboxProvider(body.data.manifest); diff --git a/packages/trueforge-ui/test/plugins/trueforge-agent-server-adapter/catalogs/sandboxProviderCatalog.test.ts b/packages/trueforge-ui/test/plugins/trueforge-agent-server-adapter/catalogs/sandboxProviderCatalog.test.ts index 59948064f..d86ab7d2e 100644 --- a/packages/trueforge-ui/test/plugins/trueforge-agent-server-adapter/catalogs/sandboxProviderCatalog.test.ts +++ b/packages/trueforge-ui/test/plugins/trueforge-agent-server-adapter/catalogs/sandboxProviderCatalog.test.ts @@ -25,6 +25,18 @@ describe('sandboxProviderCatalog mappers', () => { auth: { apiKey: 'dtn_secret' }, }; + const openSandboxCatalog = { + type: 'opensandbox' as const, + execTimeoutMs: 60000, + domain: 'api.opensandbox.io', + protocol: 'https' as const, + }; + + const openSandboxConfigured = { + ...openSandboxCatalog, + auth: { apiKey: 'osb_secret' }, + }; + function configuredResponse({ status, statusReason, @@ -68,6 +80,32 @@ describe('sandboxProviderCatalog mappers', () => { assert.equal('apiKey' in toUiSandboxProvider(harnessConfigured), false); }); + it('preserves OpenSandbox endpoint settings while using zero compatibility lifecycle values', () => { + assert.deepEqual(toUiCatalogEntry(openSandboxCatalog), { + id: 'opensandbox', + name: 'OpenSandbox', + type: 'opensandbox', + execTimeoutMs: 60000, + autoStopIntervalInMinutes: 0, + autoArchiveIntervalInMinutes: 0, + autoDeleteIntervalInMinutes: 0, + domain: 'api.opensandbox.io', + protocol: 'https', + }); + assert.deepEqual(toUiSandboxProvider(openSandboxConfigured), { + id: 'opensandbox', + name: 'OpenSandbox', + catalogId: 'opensandbox', + isConnected: true, + execTimeoutMs: 60000, + autoStopIntervalInMinutes: 0, + autoArchiveIntervalInMinutes: 0, + autoDeleteIntervalInMinutes: 0, + domain: 'api.opensandbox.io', + protocol: 'https', + }); + }); + it('wraps configured providers with snapshot sync status', () => { for (const status of [ TrueForgeApi.SandboxBuildStatus.Pending, @@ -110,6 +148,22 @@ describe('sandboxProviderCatalog mappers', () => { ); }); + it('round-trips OpenSandbox domain and protocol into the harness manifest', () => { + assert.deepEqual( + toHarnessManifest({ + type: 'opensandbox', + apiKey: 'osb_secret', + execTimeoutMs: 60000, + autoStopIntervalInMinutes: 0, + autoArchiveIntervalInMinutes: 0, + autoDeleteIntervalInMinutes: 0, + domain: 'api.opensandbox.io', + protocol: 'https', + }), + openSandboxConfigured, + ); + }); + it('rejects unsupported sandbox provider types', () => { assert.throws( () => diff --git a/packages/trueforge/catalog/sandbox-catalog.yaml b/packages/trueforge/catalog/sandbox-catalog.yaml index fc80d83aa..ddd708fa6 100644 --- a/packages/trueforge/catalog/sandbox-catalog.yaml +++ b/packages/trueforge/catalog/sandbox-catalog.yaml @@ -7,3 +7,7 @@ providers: auto_stop_interval_in_minutes: 5 auto_archive_interval_in_minutes: 60 auto_delete_interval_in_minutes: 7200 + - type: opensandbox + domain: api.opensandbox.io + protocol: https + exec_timeout_ms: 60000 diff --git a/packages/trueforge/src/apis/sandboxProviders.ts b/packages/trueforge/src/apis/sandboxProviders.ts index 9df3afa23..aed5104b6 100644 --- a/packages/trueforge/src/apis/sandboxProviders.ts +++ b/packages/trueforge/src/apis/sandboxProviders.ts @@ -7,7 +7,8 @@ import { getSandboxProviderRoute, putSandboxProviderRoute } from '../routes/sand import { checkSnapshotStatus, isDaytonaAuthError, - toDaytonaSandboxProvider, + isOpenSandboxAuthError, + toSandboxProvider, toSandboxStatus, } from '../sandbox/providerUtils'; import type { SandboxProviderManifest, UpdateSandboxProviderRequest } from '../schemas/sandboxProvider'; @@ -15,7 +16,9 @@ import { MissingStoredSecretError, resolveStoredSecretValue, toRedactedSecretVal import { TENANT_ID } from './sessions'; /** Cap the Daytona register round-trip so a slow/unreachable provider can't hold the request (or DB txn) open. */ -const BUILD_REQUEST_TIMEOUT_MS = 3_000; +const DAYTONA_BUILD_REQUEST_TIMEOUT_MS = 3_000; +/** OpenSandbox may start a local Docker container and snapshot it before returning. */ +const OPENSANDBOX_BUILD_REQUEST_TIMEOUT_MS = 150_000; export interface SandboxProvidersRouterDeps { sandboxProviderStore: ISandboxProviderStore; @@ -68,21 +71,21 @@ export function createSandboxProvidersRouter(deps: SandboxProvider }, }); try { - // NOTE: build (Daytona network I/O) runs inside the transaction for now; the design is being revisited. + // NOTE: provider build I/O runs inside the transaction for now; the design is being revisited. const { manifest, status } = await deps.withTransaction(async transaction => { const locked = await deps.sandboxProviderStore.getSandboxProviderForUpdate(TENANT_ID, transaction); const resolved = resolveManifest(locked); // Pass persisted build_metadata so a settings re-save does not start a new snapshot for a // bumped SANDBOX_IMAGE_URI (upgrades are unsupported — first configure has no metadata). - const provider = toDaytonaSandboxProvider({ + const provider = toSandboxProvider({ manifest: resolved, tenant_id: TENANT_ID, logger: deps.logger, ...(locked ? { build_metadata: locked.build_metadata } : {}), }); - const built = toSandboxStatus( - await withTimeout(provider.buildImage(), BUILD_REQUEST_TIMEOUT_MS, 'sandbox buildImage'), - ); + const buildTimeoutMs = + resolved.type === 'opensandbox' ? OPENSANDBOX_BUILD_REQUEST_TIMEOUT_MS : DAYTONA_BUILD_REQUEST_TIMEOUT_MS; + const built = toSandboxStatus(await withTimeout(provider.buildImage(), buildTimeoutMs, 'sandbox buildImage')); await deps.sandboxProviderStore.upsertSandboxProvider( { tenant_id: TENANT_ID, manifest: resolved, ...built }, transaction, @@ -103,8 +106,8 @@ export function createSandboxProvidersRouter(deps: SandboxProvider if (error instanceof MissingStoredSecretError) { return c.json({ error: { message: 'API key is required' } }, 400); } - if (isDaytonaAuthError(error)) { - return c.json({ error: { message: 'Daytona rejected the API key — check the credentials' } }, 422); + if (isDaytonaAuthError(error) || isOpenSandboxAuthError(error)) { + return c.json({ error: { message: 'Sandbox provider rejected the API key — check the credentials' } }, 422); } throw error; } diff --git a/packages/trueforge/src/apis/turns.ts b/packages/trueforge/src/apis/turns.ts index 26ec91bac..16fa6d450 100644 --- a/packages/trueforge/src/apis/turns.ts +++ b/packages/trueforge/src/apis/turns.ts @@ -193,8 +193,8 @@ function createTurnResolver(deps: { 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. + // A fresh remote sandbox is cloned from the release snapshot, so the build must be ready first. + // Restoring an existing remote sandbox never needs to create a new snapshot. // Local fallback has no image build. if (carriedSandboxId === undefined && provider.type !== 'local') { const status = await checkSnapshotStatus({ store: sandboxProviderStore, tenant_id: TENANT_ID, logger }); diff --git a/packages/trueforge/src/routes/sandboxProviderRoutes.ts b/packages/trueforge/src/routes/sandboxProviderRoutes.ts index c1be8d052..eaec64551 100644 --- a/packages/trueforge/src/routes/sandboxProviderRoutes.ts +++ b/packages/trueforge/src/routes/sandboxProviderRoutes.ts @@ -55,7 +55,7 @@ export const putSandboxProviderRoute = createRoute({ }, 422: { content: { 'application/json': { schema: RequestErrorResponseSchema } }, - description: 'Daytona rejected the provided API key.', + description: 'The sandbox provider rejected the provided API key.', }, }, }); diff --git a/packages/trueforge/src/runtime/sessionResources.ts b/packages/trueforge/src/runtime/sessionResources.ts index ee97d7246..90343d40c 100644 --- a/packages/trueforge/src/runtime/sessionResources.ts +++ b/packages/trueforge/src/runtime/sessionResources.ts @@ -24,7 +24,7 @@ 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 { toSandboxProvider } from '../sandbox/providerUtils'; import { resolveConfiguredMcpRequestHeaders } from '../schemas/mcpServer'; export interface McpConnection { @@ -233,9 +233,9 @@ export async function resolveSandboxProvider({ }): Promise { const record = await store.getSandboxProvider(tenant_id); 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({ + // Reuse the image recorded when this provider was built, so a running-binary image bump + // cannot redirect an existing tenant's sandboxes onto an unbuilt image. + return toSandboxProvider({ manifest: record.manifest, tenant_id, logger, diff --git a/packages/trueforge/src/sandbox/local/scripts/mcp_client_local.py b/packages/trueforge/src/sandbox/local/scripts/mcp_client_local.py index 9de816a54..c00d84217 100644 --- a/packages/trueforge/src/sandbox/local/scripts/mcp_client_local.py +++ b/packages/trueforge/src/sandbox/local/scripts/mcp_client_local.py @@ -175,6 +175,11 @@ async def _get_tool(server: str, tool_name: str) -> dict[str, Any] | None: return None +async def list_tools(server: str) -> list[str]: + """Return names from the MCP protocol tools/list operation for a server.""" + return [str(tool.get("name")) for tool in await _get_tools(server) if isinstance(tool.get("name"), str)] + + def _is_destructive(tool: dict[str, Any]) -> bool: annotations = tool.get("annotations") if annotations is None: @@ -249,13 +254,16 @@ async def call_tool(server: str, tool: str, body: dict[str, Any]) -> Any: return _project_call_tool_result(server, tool, raw) -_USAGE = "mcp_client_local.py call-tool " +_USAGE = "mcp_client_local.py {list-tools|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) + list_tools_p = sub.add_parser("list-tools", help="Discover tools through MCP tools/list") + list_tools_p.add_argument("server") + 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") @@ -267,7 +275,9 @@ def _build_arg_parser() -> argparse.ArgumentParser: async def _main() -> None: args = _build_arg_parser().parse_args() try: - if args.cmd == "call-tool": + if args.cmd == "list-tools": + print(json.dumps(await list_tools(args.server))) + elif 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: diff --git a/packages/trueforge/src/sandbox/providerUtils.ts b/packages/trueforge/src/sandbox/providerUtils.ts index 9189f7742..f8c8e8245 100644 --- a/packages/trueforge/src/sandbox/providerUtils.ts +++ b/packages/trueforge/src/sandbox/providerUtils.ts @@ -1,11 +1,18 @@ -/** Daytona provider construction + persisted build-status refresh (see checkSnapshotStatus). */ +/** Sandbox-provider construction + persisted build-status refresh (see checkSnapshotStatus). */ import { Daytona, DaytonaError } from '@daytona/sdk'; -import { DaytonaSandboxProvider, SANDBOX_IMAGE_URI, type SandboxBuild } from '@truefoundry/trueforge-core/core'; +import { + DaytonaSandboxProvider, + OpenSandboxProvider, + SANDBOX_IMAGE_URI, + type SandboxBuild, + type SandboxProvider, +} from '@truefoundry/trueforge-core/core'; import type { Logger } from 'winston'; import configuration from '../config'; import type { ISandboxProviderStore, SandboxProviderRecord } from '../db/sandboxProviderStore'; import { toDaytonaSandboxProviderInput, + toOpenSandboxProviderInput, type SandboxBuildMetadata, type SandboxProviderManifest, type SandboxStatus, @@ -16,6 +23,10 @@ export function isDaytonaAuthError(error: unknown): boolean { return error instanceof DaytonaError && (error.statusCode === 401 || error.statusCode === 403); } +export function isOpenSandboxAuthError(error: unknown): boolean { + return error instanceof Error && 'statusCode' in error && (error.statusCode === 401 || error.statusCode === 403); +} + /** * Builds the runtime provider for a stored manifest. No network I/O until a method is called. * @@ -30,7 +41,7 @@ export function toDaytonaSandboxProvider({ logger, build_metadata, }: { - manifest: SandboxProviderManifest; + manifest: Extract; tenant_id: string; logger: Logger; build_metadata?: SandboxBuildMetadata | null; @@ -48,6 +59,57 @@ export function toDaytonaSandboxProvider({ }); } +function toOpenSandboxProvider({ + manifest, + tenant_id, + logger, + build_metadata, +}: { + manifest: Extract; + tenant_id: string; + logger: Logger; + build_metadata?: SandboxBuildMetadata | null; +}): OpenSandboxProvider { + const settings = toOpenSandboxProviderInput(manifest); + return new OpenSandboxProvider({ + ...settings, + tenantName: tenant_id, + sandboxImage: build_metadata?.['image_uri'] ?? SANDBOX_IMAGE_URI, + fileMaxBytesForDownload: configuration.SANDBOX_FILE_MAX_BYTES_FOR_DOWNLOAD, + platform: { os: 'linux', arch: 'amd64' }, + entrypoint: ['/usr/bin/supervisord', '-n'], + logger, + }); +} + +/** Builds the concrete runtime provider selected by the stored manifest discriminator. */ +export function toSandboxProvider({ + manifest, + tenant_id, + logger, + build_metadata, +}: { + manifest: SandboxProviderManifest; + tenant_id: string; + logger: Logger; + build_metadata?: SandboxBuildMetadata | null; +}): SandboxProvider { + if (manifest.type === 'daytona') { + return toDaytonaSandboxProvider({ + manifest, + tenant_id, + logger, + ...(build_metadata !== undefined ? { build_metadata } : {}), + }); + } + return toOpenSandboxProvider({ + manifest, + tenant_id, + logger, + ...(build_metadata !== undefined ? { build_metadata } : {}), + }); +} + /** Maps a core `SandboxBuild` onto the persisted/wire status shape (metadata passes through). */ export function toSandboxStatus(build: SandboxBuild): SandboxStatus { return { @@ -90,7 +152,7 @@ export async function checkSnapshotStatus({ return persisted; } - const provider = toDaytonaSandboxProvider({ + const provider = toSandboxProvider({ manifest: record.manifest, tenant_id, logger, diff --git a/packages/trueforge/src/schemas/sandboxCatalog.ts b/packages/trueforge/src/schemas/sandboxCatalog.ts index d4e499350..b1e1dbc61 100644 --- a/packages/trueforge/src/schemas/sandboxCatalog.ts +++ b/packages/trueforge/src/schemas/sandboxCatalog.ts @@ -3,14 +3,16 @@ * configured provider manifests in sandboxProvider.ts. */ import { z } from '@hono/zod-openapi'; -import { DaytonaSandboxProviderSchema } from './sandboxProvider'; +import { DaytonaSandboxProviderSchema, OpenSandboxProviderSchema } from './sandboxProvider'; /** - * Catalog wire type. Single variant today (avoids one-member `oneOf` in OpenAPI). - * Widen to a discriminated union when a second provider ships. + * Catalog wire type selected by provider discriminator. */ -export const CatalogSandboxProviderSchema = DaytonaSandboxProviderSchema.omit({ auth: true }) - .strict() +export const CatalogSandboxProviderSchema = z + .discriminatedUnion('type', [ + DaytonaSandboxProviderSchema.omit({ auth: true }).strict(), + OpenSandboxProviderSchema.omit({ auth: true }).strict(), + ]) .openapi('CatalogSandboxProvider'); export const SandboxCatalogFileSchema = z diff --git a/packages/trueforge/src/schemas/sandboxProvider.ts b/packages/trueforge/src/schemas/sandboxProvider.ts index a856c9bdc..b78fa6b53 100644 --- a/packages/trueforge/src/schemas/sandboxProvider.ts +++ b/packages/trueforge/src/schemas/sandboxProvider.ts @@ -5,7 +5,7 @@ * Singleton per tenant — no identity `name` (unlike model providers / skills). */ import { z } from '@hono/zod-openapi'; -import type { DaytonaSandboxProviderOptions } from '@truefoundry/trueforge-core/core'; +import type { DaytonaSandboxProviderOptions, OpenSandboxProviderOptions } from '@truefoundry/trueforge-core/core'; const DaytonaSandboxProviderAuthSchema = z .object({ @@ -22,9 +22,8 @@ const DaytonaSandboxProviderAuthSchema = z /** * Daytona-backed sandbox provider config. Persisted as `sandbox_provider.manifest`. - * Left unnamed for OpenAPI so `SandboxProviderManifest` (its single-variant alias) - * is the one emitted component and the response `manifest` field is a plain `$ref` - * instead of an `allOf` wrapper. + * Left unnamed for OpenAPI so `SandboxProviderManifest` is the emitted union component + * used by the response `manifest` field. */ export const DaytonaSandboxProviderSchema = z .object({ @@ -49,12 +48,36 @@ export const DaytonaSandboxProviderSchema = z }) .strict(); +const OpenSandboxProviderAuthSchema = z + .object({ + api_key: z + .string() + .min(1) + .describe( + 'OpenSandbox API key. Responses are redacted; on PUT, a real value sets/rotates and a redacted value keeps the stored key.', + ), + }) + .strict() + .describe('OpenSandbox authentication credentials.') + .openapi('OpenSandboxProviderAuth'); + +/** OpenSandbox-backed sandbox provider configuration. */ +export const OpenSandboxProviderSchema = z + .object({ + type: z.literal('opensandbox').describe('OpenSandbox sandbox provider.'), + auth: OpenSandboxProviderAuthSchema, + domain: z.string().min(1).describe('OpenSandbox API host, optionally including a port, without a URL scheme.'), + protocol: z.enum(['http', 'https']).default('https').describe('Protocol used to reach the OpenSandbox API.'), + exec_timeout_ms: z.number().int().positive().describe('Default sandbox command exec timeout in milliseconds.'), + }) + .strict(); + /** - * Persisted jsonb: the provider config only (no build status). Single variant today — - * this alias carries the OpenAPI name so the spec emits one `SandboxProviderManifest` component. - * Widen to `z.discriminatedUnion('type', [...])` when a second provider ships. + * Persisted jsonb: the provider configuration only (no build status). */ -export const SandboxProviderManifestSchema = DaytonaSandboxProviderSchema.openapi('SandboxProviderManifest'); +export const SandboxProviderManifestSchema = z + .discriminatedUnion('type', [DaytonaSandboxProviderSchema, OpenSandboxProviderSchema]) + .openapi('SandboxProviderManifest'); /** Named enum so the generated SDK exposes a reusable `SandboxBuildStatus` type. */ export const SandboxBuildStatusSchema = z @@ -104,6 +127,7 @@ export const GetSandboxProviderResponseSchema = z /** Persisted jsonb — the provider config only (no build status). */ export type SandboxProviderManifest = z.infer; export type DaytonaSandboxProvider = z.infer; +export type OpenSandboxProvider = z.infer; export type SandboxBuildStatus = z.infer; export type SandboxBuildMetadata = z.infer; export type SandboxStatus = z.infer; @@ -111,7 +135,7 @@ export type ConfiguredSandboxProvider = z.infer; /** Wire/persisted snake_case → Daytona client credentials + provider settings. */ -export function toDaytonaSandboxProviderInput(manifest: SandboxProviderManifest): { +export function toDaytonaSandboxProviderInput(manifest: DaytonaSandboxProvider): { apiKey: string; } & Pick< DaytonaSandboxProviderOptions, @@ -125,3 +149,15 @@ export function toDaytonaSandboxProviderInput(manifest: SandboxProviderManifest) autoDeleteIntervalInMinutes: manifest.auto_delete_interval_in_minutes, }; } + +/** Wire/persisted snake_case → OpenSandbox client credentials + provider settings. */ +export function toOpenSandboxProviderInput( + manifest: OpenSandboxProvider, +): Pick { + return { + apiKey: manifest.auth.api_key, + domain: manifest.domain, + protocol: manifest.protocol, + timeoutMs: manifest.exec_timeout_ms, + }; +} diff --git a/packages/trueforge/tests/db/sandboxProviderStoreContractSuite.ts b/packages/trueforge/tests/db/sandboxProviderStoreContractSuite.ts index a337d1c0c..e3a645e7c 100644 --- a/packages/trueforge/tests/db/sandboxProviderStoreContractSuite.ts +++ b/packages/trueforge/tests/db/sandboxProviderStoreContractSuite.ts @@ -3,7 +3,7 @@ * Runs under jest against a fresh store per test (see backend test files). */ import type { ISandboxProviderStore, UpsertSandboxProviderInput } from '../../src/db/sandboxProviderStore'; -import type { SandboxBuildMetadata, SandboxProviderManifest } from '../../src/schemas/sandboxProvider'; +import type { DaytonaSandboxProvider, SandboxBuildMetadata } from '../../src/schemas/sandboxProvider'; const TENANT = 'default'; @@ -12,7 +12,7 @@ const BUILD_METADATA: SandboxBuildMetadata = { image_uri: 'tfy.jfrog.io/tfy-images/sandbox:029ea5ff', }; -function manifest(overrides: Partial = {}): SandboxProviderManifest { +function manifest(overrides: Partial = {}): DaytonaSandboxProvider { return { type: 'daytona', auth: { api_key: 'dtn-test' }, diff --git a/packages/trueforge/tests/unit/apis/sandboxProviders.test.ts b/packages/trueforge/tests/unit/apis/sandboxProviders.test.ts index fa2220862..94958cf7c 100644 --- a/packages/trueforge/tests/unit/apis/sandboxProviders.test.ts +++ b/packages/trueforge/tests/unit/apis/sandboxProviders.test.ts @@ -1,9 +1,9 @@ -// Stub the Daytona-touching helpers so the router never talks to Daytona: the PUT path builds via -// toDaytonaSandboxProvider, and the GET path refreshes via checkSnapshotStatus. isDaytonaAuthError +// Stub the provider factory so the router never talks to a remote sandbox service: the PUT path builds via +// toSandboxProvider, and the GET path refreshes via checkSnapshotStatus. isDaytonaAuthError // and toSandboxStatus stay real so the auth-error mapping and PUT wire shape are exercised. jest.mock('../../../src/sandbox/providerUtils', () => { const actual = jest.requireActual('../../../src/sandbox/providerUtils'); - return { ...actual, toDaytonaSandboxProvider: jest.fn(), checkSnapshotStatus: jest.fn() }; + return { ...actual, toSandboxProvider: jest.fn(), checkSnapshotStatus: jest.fn() }; }); import { DaytonaError } from '@daytona/sdk'; @@ -20,14 +20,14 @@ import { migrateSqliteToLatest } from '../../../src/db/migrateSqlite'; import type { ISandboxProviderStore } from '../../../src/db/sandboxProviderStore'; import { createSqliteDb } from '../../../src/db/sqlite/client'; import { SqliteSandboxProviderStore } from '../../../src/db/sqlite/sandbox-provider-store/SqliteSandboxProviderStore'; -import { checkSnapshotStatus, toDaytonaSandboxProvider } from '../../../src/sandbox/providerUtils'; +import { checkSnapshotStatus, toSandboxProvider } from '../../../src/sandbox/providerUtils'; import { toRedactedSecretValue } from '../../../src/utils/secretRedaction'; -const mockProviderFactory = toDaytonaSandboxProvider as jest.Mock; +const mockProviderFactory = toSandboxProvider as jest.Mock; const mockCheckStatus = checkSnapshotStatus as jest.Mock; const silentLogger = createLogger({ silent: true }); -const putBody = { +const daytonaPutBody = { type: 'daytona' as const, auth: { api_key: 'dtn-test-secret' }, exec_timeout_ms: 60000, @@ -36,6 +36,14 @@ const putBody = { auto_delete_interval_in_minutes: 7200, }; +const openSandboxPutBody = { + type: 'opensandbox' as const, + auth: { api_key: 'osb-test-secret' }, + domain: 'localhost:8080', + protocol: 'http' as const, + exec_timeout_ms: 60000, +}; + const IMAGE_URI = 'tfy.jfrog.io/tfy-images/truefoundry-utils-core-sandbox:029ea5ff'; const readyBuild: SandboxBuild = { status: 'ready', @@ -53,8 +61,8 @@ function wireResponse(manifest: Record) { } const putBodyWire = wireResponse({ - ...putBody, - auth: { api_key: toRedactedSecretValue(putBody.auth.api_key) }, + ...daytonaPutBody, + auth: { api_key: toRedactedSecretValue(daytonaPutBody.auth.api_key) }, }); function stubProvider(overrides: { buildImage?: jest.Mock; getImageBuildStatus?: jest.Mock } = {}) { @@ -136,7 +144,7 @@ describe('sandboxProviders router', () => { }); it('PUT builds the image + upserts, GET returns redacted auth plus live image status', async () => { - const put = await settingsRouter.request('/', putInit(putBody)); + const put = await settingsRouter.request('/', putInit(daytonaPutBody)); expect(put.status).toBe(200); expect(await put.json()).toEqual({ data: putBodyWire }); @@ -145,12 +153,12 @@ describe('sandboxProviders router', () => { expect(await get.json()).toEqual({ data: putBodyWire }); const stored = await sandboxProviderStore.getSandboxProvider(TENANT_ID); - expect(stored?.manifest).toEqual(putBody); + expect(stored?.manifest).toEqual(daytonaPutBody); }); it('GET surfaces an error (500) when the status refresh throws', async () => { const { settingsRouter: router } = await createRouters(); - expect((await router.request('/', putInit(putBody))).status).toBe(200); + expect((await router.request('/', putInit(daytonaPutBody))).status).toBe(200); mockCheckStatus.mockRejectedValue(new DaytonaError('unreachable', 500)); const get = await router.request('/'); @@ -161,7 +169,7 @@ describe('sandboxProviders router', () => { mockProviderFactory.mockReturnValue( stubProvider({ buildImage: jest.fn().mockRejectedValue(new DaytonaError('unauthorized', 401)) }), ); - const response = await settingsRouter.request('/', putInit(putBody)); + const response = await settingsRouter.request('/', putInit(daytonaPutBody)); expect(response.status).toBe(422); }); @@ -170,21 +178,33 @@ describe('sandboxProviders router', () => { mockProviderFactory.mockReturnValue( stubProvider({ buildImage: jest.fn().mockRejectedValue(new DaytonaError('forbidden', 403)) }), ); - expect((await router.request('/', putInit(putBody))).status).toBe(422); + expect((await router.request('/', putInit(daytonaPutBody))).status).toBe(422); expect((await router.request('/')).status).toBe(404); }); it('PUT rejects invalid bodies at the Zod layer', async () => { - const { auth: _auth, ...withoutAuth } = putBody; + const { auth: _auth, ...withoutAuth } = daytonaPutBody; const missingAuth = await settingsRouter.request('/', putInit(withoutAuth)); expect(missingAuth.status).toBe(400); - const badType = await settingsRouter.request('/', putInit({ ...putBody, type: 'unknown' })); + const badType = await settingsRouter.request('/', putInit({ ...daytonaPutBody, type: 'unknown' })); expect(badType.status).toBe(400); - const withSnapshotName = await settingsRouter.request('/', putInit({ ...putBody, snapshot_name: 'legacy' })); + const withSnapshotName = await settingsRouter.request('/', putInit({ ...daytonaPutBody, snapshot_name: 'legacy' })); expect(withSnapshotName.status).toBe(400); }); + + it('PUT accepts an opensandbox manifest through the real wire schema', async () => { + const { settingsRouter } = await createRouters(); + const response = await settingsRouter.request('/', putInit(openSandboxPutBody)); + expect(response.status).toBe(200); + expect(await response.json()).toEqual({ + data: wireResponse({ + ...openSandboxPutBody, + auth: { api_key: toRedactedSecretValue(openSandboxPutBody.auth.api_key) }, + }), + }); + }); }); describe('sandbox-provider secret redaction and strict PUT', () => { @@ -193,8 +213,8 @@ describe('sandbox-provider secret redaction and strict PUT', () => { const response = await settingsRouter.request( '/', putInit({ - ...putBody, - auth: { api_key: toRedactedSecretValue(putBody.auth.api_key) }, + ...daytonaPutBody, + auth: { api_key: toRedactedSecretValue(daytonaPutBody.auth.api_key) }, }), ); expect(response.status).toBe(400); @@ -203,45 +223,45 @@ describe('sandbox-provider secret redaction and strict PUT', () => { it('PUT with a redacted api_key keeps the stored secret', async () => { const { settingsRouter, sandboxProviderStore } = await createRouters(); - expect((await settingsRouter.request('/', putInit(putBody))).status).toBe(200); + expect((await settingsRouter.request('/', putInit(daytonaPutBody))).status).toBe(200); const redactedKeep = { - ...putBody, + ...daytonaPutBody, exec_timeout_ms: 120000, - auth: { api_key: toRedactedSecretValue(putBody.auth.api_key) }, + auth: { api_key: toRedactedSecretValue(daytonaPutBody.auth.api_key) }, }; const update = await settingsRouter.request('/', putInit(redactedKeep)); expect(update.status).toBe(200); expect(await update.json()).toEqual({ data: wireResponse(redactedKeep) }); const stored = await sandboxProviderStore.getSandboxProvider(TENANT_ID); - expect(stored?.manifest).toEqual({ ...putBody, exec_timeout_ms: 120000 }); + expect(stored?.manifest).toEqual({ ...daytonaPutBody, exec_timeout_ms: 120000 }); }); it('PUT with a different redacted api_key still keeps the stored secret', async () => { const { settingsRouter, sandboxProviderStore } = await createRouters(); - expect((await settingsRouter.request('/', putInit(putBody))).status).toBe(200); + expect((await settingsRouter.request('/', putInit(daytonaPutBody))).status).toBe(200); const keep = { - ...putBody, + ...daytonaPutBody, auth: { api_key: 'oth-***REDACTED***-xxx' }, }; const response = await settingsRouter.request('/', putInit(keep)); expect(response.status).toBe(200); expect(await response.json()).toEqual({ - data: wireResponse({ ...keep, auth: { api_key: toRedactedSecretValue(putBody.auth.api_key) } }), + data: wireResponse({ ...keep, auth: { api_key: toRedactedSecretValue(daytonaPutBody.auth.api_key) } }), }); const stored = await sandboxProviderStore.getSandboxProvider(TENANT_ID); - expect(stored?.manifest).toEqual(putBody); + expect(stored?.manifest).toEqual(daytonaPutBody); }); it('PUT with a real api_key rotates the stored secret', async () => { const { settingsRouter, sandboxProviderStore } = await createRouters(); - expect((await settingsRouter.request('/', putInit(putBody))).status).toBe(200); + expect((await settingsRouter.request('/', putInit(daytonaPutBody))).status).toBe(200); const rotatedKey = 'dtn-rotated-key'; - const rotated = { ...putBody, auth: { api_key: rotatedKey } }; + const rotated = { ...daytonaPutBody, auth: { api_key: rotatedKey } }; const update = await settingsRouter.request('/', putInit(rotated)); expect(update.status).toBe(200); expect(await update.json()).toEqual({ @@ -254,11 +274,11 @@ describe('sandbox-provider secret redaction and strict PUT', () => { it('PUT update reuses persisted build_metadata (no image upgrade on re-save)', async () => { const { settingsRouter } = await createRouters(); - expect((await settingsRouter.request('/', putInit(putBody))).status).toBe(200); + expect((await settingsRouter.request('/', putInit(daytonaPutBody))).status).toBe(200); expect(mockProviderFactory.mock.calls[0]?.[0]).not.toHaveProperty('build_metadata'); mockProviderFactory.mockClear(); - expect((await settingsRouter.request('/', putInit(putBody))).status).toBe(200); + expect((await settingsRouter.request('/', putInit(daytonaPutBody))).status).toBe(200); expect(mockProviderFactory).toHaveBeenCalledWith( expect.objectContaining({ build_metadata: readyBuild.metadata, diff --git a/packages/trueforge/tests/unit/sandbox/providerUtils.test.ts b/packages/trueforge/tests/unit/sandbox/providerUtils.test.ts new file mode 100644 index 000000000..f7b98e6e8 --- /dev/null +++ b/packages/trueforge/tests/unit/sandbox/providerUtils.test.ts @@ -0,0 +1,117 @@ +// packages/trueforge/tests/unit/sandbox/providerUtils.test.ts +// +// Mock only the two SDK provider constructors so tests never touch a real Daytona/OpenSandbox +// service — everything else in this module (toSandboxProvider's dispatch, the sandboxImage/ +// build_metadata fallback logic, the platform/entrypoint pinning) stays real and gets exercised. +jest.mock('@truefoundry/trueforge-core/core', () => { + const actual = jest.requireActual('@truefoundry/trueforge-core/core'); + return { + ...actual, + DaytonaSandboxProvider: jest.fn(), + OpenSandboxProvider: jest.fn(), + }; +}); + +import { DaytonaSandboxProvider, OpenSandboxProvider, SANDBOX_IMAGE_URI } from '@truefoundry/trueforge-core/core'; +import { createLogger } from 'winston'; +import { toSandboxProvider } from '../../../src/sandbox/providerUtils'; +import type { SandboxProviderManifest } from '../../../src/schemas/sandboxProvider'; + +const silentLogger = createLogger({ silent: true }); + +const openSandboxManifest: Extract = { + type: 'opensandbox', + auth: { api_key: 'osb-test' }, + domain: 'localhost:8080', + protocol: 'http', + exec_timeout_ms: 60000, +}; + +const daytonaManifest: Extract = { + type: 'daytona', + auth: { api_key: 'dtn-test' }, + exec_timeout_ms: 60000, + auto_stop_interval_in_minutes: 5, + auto_archive_interval_in_minutes: 60, + auto_delete_interval_in_minutes: 7200, +}; + +beforeEach(() => { + jest.mocked(DaytonaSandboxProvider).mockReset(); + jest.mocked(OpenSandboxProvider).mockReset(); +}); + +describe('toSandboxProvider', () => { + it( + 'constructs OpenSandboxProvider with the platform/entrypoint override the release image ' + + 'requires — regression guard: omitting either silently breaks sandbox creation ' + + '(wrong-arch snapshot pull 404) or Code Mode (container boots into a bare `tail -f ' + + '/dev/null` instead of running supervisord/nats-server), with no error surfaced anywhere', + () => { + toSandboxProvider({ + manifest: openSandboxManifest, + tenant_id: 'tenant-a', + logger: silentLogger, + }); + + expect(OpenSandboxProvider).toHaveBeenCalledWith( + expect.objectContaining({ + platform: { os: 'linux', arch: 'amd64' }, + entrypoint: ['/usr/bin/supervisord', '-n'], + }), + ); + }, + ); + + it('passes OpenSandbox manifest fields through to the constructor', () => { + toSandboxProvider({ + manifest: openSandboxManifest, + tenant_id: 'tenant-a', + logger: silentLogger, + }); + + expect(OpenSandboxProvider).toHaveBeenCalledWith( + expect.objectContaining({ + apiKey: 'osb-test', + domain: 'localhost:8080', + protocol: 'http', + tenantName: 'tenant-a', + timeoutMs: 60000, + }), + ); + }); + + it('defaults sandboxImage to SANDBOX_IMAGE_URI when no build_metadata is persisted yet', () => { + toSandboxProvider({ + manifest: openSandboxManifest, + tenant_id: 'tenant-a', + logger: silentLogger, + }); + + expect(OpenSandboxProvider).toHaveBeenCalledWith(expect.objectContaining({ sandboxImage: SANDBOX_IMAGE_URI })); + }); + + it('pins sandboxImage to the persisted build_metadata image_uri (no silent image upgrade on re-save)', () => { + toSandboxProvider({ + manifest: openSandboxManifest, + tenant_id: 'tenant-a', + logger: silentLogger, + build_metadata: { image_uri: 'pinned.example.com/sandbox:abc123', build_ref: 'ignored-for-opensandbox' }, + }); + + expect(OpenSandboxProvider).toHaveBeenCalledWith( + expect.objectContaining({ sandboxImage: 'pinned.example.com/sandbox:abc123' }), + ); + }); + + it('dispatches to DaytonaSandboxProvider for a daytona manifest (sanity check the discriminator still works)', () => { + toSandboxProvider({ + manifest: daytonaManifest, + tenant_id: 'tenant-a', + logger: silentLogger, + }); + + expect(DaytonaSandboxProvider).toHaveBeenCalled(); + expect(OpenSandboxProvider).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/trueforge/tests/unit/schemas/sandboxProvider.test.ts b/packages/trueforge/tests/unit/schemas/sandboxProvider.test.ts index 39837ad1c..b44ba5d06 100644 --- a/packages/trueforge/tests/unit/schemas/sandboxProvider.test.ts +++ b/packages/trueforge/tests/unit/schemas/sandboxProvider.test.ts @@ -1,8 +1,13 @@ -import { toDaytonaSandboxProviderInput, type SandboxProviderManifest } from '../../../src/schemas/sandboxProvider'; +import { + toDaytonaSandboxProviderInput, + toOpenSandboxProviderInput, + type DaytonaSandboxProvider, + type OpenSandboxProvider, +} from '../../../src/schemas/sandboxProvider'; describe('toDaytonaSandboxProviderInput', () => { it('maps a Daytona wire/DB manifest to apiKey plus provider settings', () => { - const manifest: SandboxProviderManifest = { + const manifest: DaytonaSandboxProvider = { type: 'daytona', auth: { api_key: 'dtn-test' }, exec_timeout_ms: 60_000, @@ -19,4 +24,21 @@ describe('toDaytonaSandboxProviderInput', () => { autoDeleteIntervalInMinutes: 7200, }); }); + + it('maps an OpenSandbox wire/DB manifest to SDK settings', () => { + const manifest: OpenSandboxProvider = { + type: 'opensandbox', + auth: { api_key: 'osb-test' }, + domain: 'localhost:8080', + protocol: 'http', + exec_timeout_ms: 60_000, + }; + + expect(toOpenSandboxProviderInput(manifest)).toEqual({ + apiKey: 'osb-test', + domain: 'localhost:8080', + protocol: 'http', + timeoutMs: 60_000, + }); + }); }); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f3f53f440..22fb44325 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -227,6 +227,9 @@ importers: '@ai-sdk/provider-utils': specifier: ^5.0.16 version: 5.0.20(zod@4.4.3) + '@alibaba-group/opensandbox': + specifier: ^0.1.11 + version: 0.1.11 '@daytona/sdk': specifier: ^0.204.1 version: 0.204.1(debug@4.4.3(supports-color@8.1.1))(supports-color@8.1.1) @@ -293,7 +296,7 @@ importers: version: 5.12.1(@opentelemetry/api@1.9.1) tsup: specifier: ^8.5.0 - version: 8.5.1(@swc/core@1.15.46)(jiti@2.7.0)(postcss@8.5.22)(supports-color@8.1.1)(tsx@4.23.1)(typescript@7.0.2)(yaml@2.9.0) + version: 8.5.1(@swc/core@1.15.46)(jiti@2.7.0)(postcss@8.5.22)(supports-color@8.1.1)(tsx@4.23.12)(typescript@7.0.2)(yaml@2.9.0) typescript: specifier: ^7.0.2 version: 7.0.2 @@ -317,7 +320,7 @@ importers: version: 5.9.3 vitest: specifier: ^4.1.1 - version: 4.1.10(@opentelemetry/api@1.9.1)(@types/node@20.19.43)(@vitest/coverage-v8@4.1.10)(jsdom@24.1.3(supports-color@8.1.1))(msw@2.11.2(@types/node@20.19.43)(typescript@5.9.3))(vite@6.4.3(@types/node@20.19.43)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.50.0)(tsx@4.23.1)(yaml@2.9.0)) + version: 4.1.10(@opentelemetry/api@1.9.1)(@types/node@20.19.43)(@vitest/coverage-v8@4.1.10)(jsdom@24.1.3(supports-color@8.1.1))(msw@2.11.2(@types/node@20.19.43)(typescript@5.9.3))(vite@6.4.3(@types/node@20.19.43)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.50.0)(tsx@4.23.12)(yaml@2.9.0)) webpack: specifier: ^5.105.4 version: 5.109.2(@swc/core@1.15.46)(esbuild@0.28.1)(lightningcss@1.32.0)(postcss@8.5.22) @@ -441,7 +444,7 @@ importers: version: 4.3.3 tsup: specifier: ^8.5.0 - version: 8.5.1(@swc/core@1.15.46)(jiti@2.7.0)(postcss@8.5.22)(supports-color@8.1.1)(tsx@4.23.1)(typescript@5.9.3)(yaml@2.9.0) + version: 8.5.1(@swc/core@1.15.46)(jiti@2.7.0)(postcss@8.5.22)(supports-color@8.1.1)(tsx@4.23.12)(typescript@5.9.3)(yaml@2.9.0) typescript: specifier: ^5.9.3 version: 5.9.3 @@ -450,10 +453,10 @@ importers: version: 8.65.0(eslint@9.39.5(jiti@2.7.0)(supports-color@8.1.1))(supports-color@8.1.1)(typescript@5.9.3) vite-plugin-svgr: specifier: ^5.2.0 - version: 5.2.0(rollup@4.62.2)(supports-color@8.1.1)(typescript@5.9.3)(vite@6.4.3(@types/node@20.19.43)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.50.0)(tsx@4.23.1)(yaml@2.9.0)) + version: 5.2.0(rollup@4.62.2)(supports-color@8.1.1)(typescript@5.9.3)(vite@6.4.3(@types/node@20.19.43)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.50.0)(tsx@4.23.12)(yaml@2.9.0)) vitest: specifier: ^4.1.9 - version: 4.1.10(@opentelemetry/api@1.9.1)(@types/node@20.19.43)(@vitest/coverage-v8@4.1.10)(jsdom@24.1.3(supports-color@8.1.1))(msw@2.11.2(@types/node@20.19.43)(typescript@5.9.3))(vite@6.4.3(@types/node@20.19.43)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.50.0)(tsx@4.23.1)(yaml@2.9.0)) + version: 4.1.10(@opentelemetry/api@1.9.1)(@types/node@20.19.43)(@vitest/coverage-v8@4.1.10)(jsdom@24.1.3(supports-color@8.1.1))(msw@2.11.2(@types/node@20.19.43)(typescript@5.9.3))(vite@6.4.3(@types/node@20.19.43)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.50.0)(tsx@4.23.12)(yaml@2.9.0)) packages: @@ -515,6 +518,10 @@ packages: resolution: {integrity: sha512-9WAerTaPU2jcVi8dh8x27tySEuLur1Kfg7Go74GuCmsC/2MDSvrTkrK8ZrXWjryEevKfcPPmO1enveE7eqlzoA==} engines: {node: '>=22'} + '@alibaba-group/opensandbox@0.1.11': + resolution: {integrity: sha512-xDJzs+5yYAPrje+z3GKUmXezpVq3kO/NyP8BM9JwzCbfuSr5vQyHJI3/Pu4FBGdb/xQnxMcWFlsKqC4/80OeiQ==} + engines: {node: '>=20'} + '@anthropic-ai/sandbox-runtime@0.0.71': resolution: {integrity: sha512-/ZMCavpMElD0ku2BlA95vezKUsVN0DD/wVd3WIEAfFjkTF2nsmzQA+MhejIWhuSUS9HpxMtTj57eFL+kdbKZ/A==} engines: {node: '>=20.11.0'} @@ -6098,6 +6105,12 @@ packages: zod: optional: true + openapi-fetch@0.14.1: + resolution: {integrity: sha512-l7RarRHxlEZYjMLd/PR0slfMVse2/vvIAGm75/F7J6MlQ8/b9uUQmUF2kCPrQhJqMXSxmYWObVgeYXbFYzZR+A==} + + openapi-typescript-helpers@0.0.15: + resolution: {integrity: sha512-opyTPaunsklCBpTK8JGef6mfPhLSnyy5a0IN9vKtx3+4aExf+KxEqYwIy3hqkedXIB97u357uLMJsOnm3GVjsw==} + openapi3-ts@4.6.0: resolution: {integrity: sha512-a4sfn6L2sIShhtzJqmjGrARvxAW/3F2BJDdyRVvNF9VhAsZSh5hSyI3a9TNvmzBxXmq66nY5LNT5bQcBxYAZZg==} @@ -7021,6 +7034,11 @@ packages: engines: {node: '>=18.0.0'} hasBin: true + tsx@4.23.12: + resolution: {integrity: sha512-FDf4L4sYzKtzWYhU/Xm0AQFdTjdIxNo9ElTf2mxXM6k8YMHXzYUe4yODVaXP4V9uMFbVg8c0qyBccK2OOxb45Q==} + engines: {node: '>=18.0.0'} + hasBin: true + tweetnacl@1.0.3: resolution: {integrity: sha512-6rt+RN7aOi1nGMyC4Xa5DdYiukl2UWCbcJft7YhxReBGQD7OAM8Pbxw6YMo4r2diNEA8FEmu32YOn9rhaiE5yw==} @@ -7553,6 +7571,11 @@ snapshots: dependencies: json-schema: 0.4.0 + '@alibaba-group/opensandbox@0.1.11': + dependencies: + openapi-fetch: 0.14.1 + undici: 7.29.0 + '@anthropic-ai/sandbox-runtime@0.0.71': dependencies: '@pondwader/socks5-server': 1.0.10 @@ -10999,7 +11022,7 @@ snapshots: obug: 2.1.4 std-env: 4.2.0 tinyrainbow: 3.1.1 - vitest: 4.1.10(@opentelemetry/api@1.9.1)(@types/node@20.19.43)(@vitest/coverage-v8@4.1.10)(jsdom@24.1.3(supports-color@8.1.1))(msw@2.11.2(@types/node@20.19.43)(typescript@5.9.3))(vite@6.4.3(@types/node@20.19.43)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.50.0)(tsx@4.23.1)(yaml@2.9.0)) + vitest: 4.1.10(@opentelemetry/api@1.9.1)(@types/node@20.19.43)(@vitest/coverage-v8@4.1.10)(jsdom@24.1.3(supports-color@8.1.1))(msw@2.11.2(@types/node@20.19.43)(typescript@5.9.3))(vite@6.4.3(@types/node@20.19.43)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.50.0)(tsx@4.23.12)(yaml@2.9.0)) '@vitest/expect@4.1.10': dependencies: @@ -11010,14 +11033,14 @@ snapshots: chai: 6.2.2 tinyrainbow: 3.1.1 - '@vitest/mocker@4.1.10(msw@2.11.2(@types/node@20.19.43)(typescript@5.9.3))(vite@6.4.3(@types/node@20.19.43)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.50.0)(tsx@4.23.1)(yaml@2.9.0))': + '@vitest/mocker@4.1.10(msw@2.11.2(@types/node@20.19.43)(typescript@5.9.3))(vite@6.4.3(@types/node@20.19.43)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.50.0)(tsx@4.23.12)(yaml@2.9.0))': dependencies: '@vitest/spy': 4.1.10 estree-walker: 3.0.3 magic-string: 0.30.21 optionalDependencies: msw: 2.11.2(@types/node@20.19.43)(typescript@5.9.3) - vite: 6.4.3(@types/node@20.19.43)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.50.0)(tsx@4.23.1)(yaml@2.9.0) + vite: 6.4.3(@types/node@20.19.43)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.50.0)(tsx@4.23.12)(yaml@2.9.0) '@vitest/pretty-format@4.1.10': dependencies: @@ -13857,6 +13880,12 @@ snapshots: ws: 8.21.3 zod: 4.4.3 + openapi-fetch@0.14.1: + dependencies: + openapi-typescript-helpers: 0.0.15 + + openapi-typescript-helpers@0.0.15: {} + openapi3-ts@4.6.0: dependencies: yaml: 2.9.0 @@ -14008,6 +14037,15 @@ snapshots: tsx: 4.23.1 yaml: 2.9.0 + postcss-load-config@6.0.1(jiti@2.7.0)(postcss@8.5.22)(tsx@4.23.12)(yaml@2.9.0): + dependencies: + lilconfig: 3.1.3 + optionalDependencies: + jiti: 2.7.0 + postcss: 8.5.22 + tsx: 4.23.12 + yaml: 2.9.0 + postcss@8.5.22: dependencies: nanoid: 3.3.16 @@ -14872,7 +14910,7 @@ snapshots: tslib@2.8.1: {} - tsup@8.5.1(@swc/core@1.15.46)(jiti@2.7.0)(postcss@8.5.22)(supports-color@8.1.1)(tsx@4.23.1)(typescript@5.9.3)(yaml@2.9.0): + tsup@8.5.1(@swc/core@1.15.46)(jiti@2.7.0)(postcss@8.5.22)(supports-color@8.1.1)(tsx@4.23.1)(typescript@7.0.2)(yaml@2.9.0): dependencies: bundle-require: 5.1.0(esbuild@0.27.7) cac: 6.7.14 @@ -14891,6 +14929,35 @@ snapshots: tinyexec: 0.3.2 tinyglobby: 0.2.17 tree-kill: 1.2.2 + optionalDependencies: + '@swc/core': 1.15.46 + postcss: 8.5.22 + typescript: 7.0.2 + transitivePeerDependencies: + - jiti + - supports-color + - tsx + - yaml + + tsup@8.5.1(@swc/core@1.15.46)(jiti@2.7.0)(postcss@8.5.22)(supports-color@8.1.1)(tsx@4.23.12)(typescript@5.9.3)(yaml@2.9.0): + dependencies: + bundle-require: 5.1.0(esbuild@0.27.7) + cac: 6.7.14 + chokidar: 4.0.3 + consola: 3.4.2 + debug: 4.4.3(supports-color@8.1.1) + esbuild: 0.27.7 + fix-dts-default-cjs-exports: 1.0.1 + joycon: 3.1.1 + picocolors: 1.1.1 + postcss-load-config: 6.0.1(jiti@2.7.0)(postcss@8.5.22)(tsx@4.23.12)(yaml@2.9.0) + resolve-from: 5.0.0 + rollup: 4.62.2 + source-map: 0.7.6 + sucrase: 3.35.1 + tinyexec: 0.3.2 + tinyglobby: 0.2.17 + tree-kill: 1.2.2 optionalDependencies: '@swc/core': 1.15.46 postcss: 8.5.22 @@ -14901,7 +14968,7 @@ snapshots: - tsx - yaml - tsup@8.5.1(@swc/core@1.15.46)(jiti@2.7.0)(postcss@8.5.22)(supports-color@8.1.1)(tsx@4.23.1)(typescript@7.0.2)(yaml@2.9.0): + tsup@8.5.1(@swc/core@1.15.46)(jiti@2.7.0)(postcss@8.5.22)(supports-color@8.1.1)(tsx@4.23.12)(typescript@7.0.2)(yaml@2.9.0): dependencies: bundle-require: 5.1.0(esbuild@0.27.7) cac: 6.7.14 @@ -14912,7 +14979,7 @@ snapshots: fix-dts-default-cjs-exports: 1.0.1 joycon: 3.1.1 picocolors: 1.1.1 - postcss-load-config: 6.0.1(jiti@2.7.0)(postcss@8.5.22)(tsx@4.23.1)(yaml@2.9.0) + postcss-load-config: 6.0.1(jiti@2.7.0)(postcss@8.5.22)(tsx@4.23.12)(yaml@2.9.0) resolve-from: 5.0.0 rollup: 4.62.2 source-map: 0.7.6 @@ -14936,6 +15003,13 @@ snapshots: optionalDependencies: fsevents: 2.3.3 + tsx@4.23.12: + dependencies: + esbuild: 0.28.1 + optionalDependencies: + fsevents: 2.3.3 + optional: true + tweetnacl@1.0.3: {} type-check@0.4.0: @@ -15171,18 +15245,18 @@ snapshots: dependencies: monaco-editor: 0.52.2 - vite-plugin-svgr@5.2.0(rollup@4.62.2)(supports-color@8.1.1)(typescript@5.9.3)(vite@6.4.3(@types/node@20.19.43)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.50.0)(tsx@4.23.1)(yaml@2.9.0)): + vite-plugin-svgr@5.2.0(rollup@4.62.2)(supports-color@8.1.1)(typescript@5.9.3)(vite@6.4.3(@types/node@20.19.43)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.50.0)(tsx@4.23.12)(yaml@2.9.0)): dependencies: '@rollup/pluginutils': 5.4.0(rollup@4.62.2) '@svgr/core': 8.1.0(supports-color@8.1.1)(typescript@5.9.3) '@svgr/plugin-jsx': 8.1.0(@svgr/core@8.1.0(supports-color@8.1.1)(typescript@5.9.3))(supports-color@8.1.1) - vite: 6.4.3(@types/node@20.19.43)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.50.0)(tsx@4.23.1)(yaml@2.9.0) + vite: 6.4.3(@types/node@20.19.43)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.50.0)(tsx@4.23.12)(yaml@2.9.0) transitivePeerDependencies: - rollup - supports-color - typescript - vite@6.4.3(@types/node@20.19.43)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.50.0)(tsx@4.23.1)(yaml@2.9.0): + vite@6.4.3(@types/node@20.19.43)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.50.0)(tsx@4.23.12)(yaml@2.9.0): dependencies: esbuild: 0.25.12 fdir: 6.5.0(picomatch@4.0.5) @@ -15196,7 +15270,7 @@ snapshots: jiti: 2.7.0 lightningcss: 1.32.0 terser: 5.50.0 - tsx: 4.23.1 + tsx: 4.23.12 yaml: 2.9.0 vite@6.4.3(@types/node@24.13.3)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.50.0)(tsx@4.23.1)(yaml@2.9.0): @@ -15216,10 +15290,10 @@ snapshots: tsx: 4.23.1 yaml: 2.9.0 - vitest@4.1.10(@opentelemetry/api@1.9.1)(@types/node@20.19.43)(@vitest/coverage-v8@4.1.10)(jsdom@24.1.3(supports-color@8.1.1))(msw@2.11.2(@types/node@20.19.43)(typescript@5.9.3))(vite@6.4.3(@types/node@20.19.43)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.50.0)(tsx@4.23.1)(yaml@2.9.0)): + vitest@4.1.10(@opentelemetry/api@1.9.1)(@types/node@20.19.43)(@vitest/coverage-v8@4.1.10)(jsdom@24.1.3(supports-color@8.1.1))(msw@2.11.2(@types/node@20.19.43)(typescript@5.9.3))(vite@6.4.3(@types/node@20.19.43)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.50.0)(tsx@4.23.12)(yaml@2.9.0)): dependencies: '@vitest/expect': 4.1.10 - '@vitest/mocker': 4.1.10(msw@2.11.2(@types/node@20.19.43)(typescript@5.9.3))(vite@6.4.3(@types/node@20.19.43)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.50.0)(tsx@4.23.1)(yaml@2.9.0)) + '@vitest/mocker': 4.1.10(msw@2.11.2(@types/node@20.19.43)(typescript@5.9.3))(vite@6.4.3(@types/node@20.19.43)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.50.0)(tsx@4.23.12)(yaml@2.9.0)) '@vitest/pretty-format': 4.1.10 '@vitest/runner': 4.1.10 '@vitest/snapshot': 4.1.10 @@ -15236,7 +15310,7 @@ snapshots: tinyexec: 1.3.0 tinyglobby: 0.2.17 tinyrainbow: 3.1.1 - vite: 6.4.3(@types/node@20.19.43)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.50.0)(tsx@4.23.1)(yaml@2.9.0) + vite: 6.4.3(@types/node@20.19.43)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.50.0)(tsx@4.23.12)(yaml@2.9.0) why-is-node-running: 2.3.0 optionalDependencies: '@opentelemetry/api': 1.9.1