From 013b2e548fdcb660333ab8b645c08591bcfd31d5 Mon Sep 17 00:00:00 2001 From: jpierrevd Date: Thu, 20 Aug 2026 19:22:24 +0100 Subject: [PATCH 1/7] feat(sandbox): add structured direct execution --- aws/microvm-agent/agent.mjs | 204 ++++++++++++++++++- cli/src/sandbox-layer.ts | 14 ++ src/core/orchestrator.ts | 196 ++++++++++++------ src/core/orchestrator/types.ts | 2 + src/deployment/deployment-layer.ts | 17 ++ src/deployment/load-layer.ts | 62 +++++- src/harness/pi-tools.ts | 4 +- src/sandbox/aws-microvm-api.ts | 71 ++++++- src/sandbox/aws-sandbox.ts | 19 ++ src/sandbox/local-sandbox.ts | 56 ++++++ src/sandbox/sandbox-routing.ts | 7 + src/sandbox/sandbox.ts | 12 ++ src/sandbox/scoped-exec.ts | 201 ++++++++++++++++++ src/sandbox/smolmachines-sandbox.ts | 11 + src/sandbox/sprites-sandbox.ts | 302 ++++++++++++++++++++++++++++ src/security/secret-masking.ts | 13 +- src/tools/primitives.ts | 2 +- src/wiring.ts | 1 + test/agent-computer-profile.test.ts | 1 + test/device-flow-persist.test.ts | 101 ++++++---- test/run-direct-agent.test.ts | 210 +++++++++++++++++++ test/run-direct-contract.test.ts | 175 ++++++++++++++++ test/run-direct-sprites.test.ts | 233 +++++++++++++++++++++ test/secret-masking.test.ts | 8 + 24 files changed, 1818 insertions(+), 104 deletions(-) create mode 100644 src/sandbox/scoped-exec.ts create mode 100644 test/run-direct-agent.test.ts create mode 100644 test/run-direct-contract.test.ts create mode 100644 test/run-direct-sprites.test.ts diff --git a/aws/microvm-agent/agent.mjs b/aws/microvm-agent/agent.mjs index a084f365c..d93306ecd 100644 --- a/aws/microvm-agent/agent.mjs +++ b/aws/microvm-agent/agent.mjs @@ -1,11 +1,22 @@ import http from "node:http"; -import { execFile } from "node:child_process"; +import { execFile, spawn } from "node:child_process"; import fs from "node:fs"; import path from "node:path"; const PORT = Number(process.env.AGENT_PORT || 8080); const MAX_BUFFER = 256 * 1024 * 1024; const START_MS = Date.now(); +const MAX_DIRECT_INPUT = 16 * 1024 * 1024; +const MAX_DIRECT_OUTPUT = 64 * 1024 * 1024; +const DEFAULT_DIRECT_OUTPUT = 4 * 1024 * 1024; +const DIRECT_RUNTIME_PATH = "/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin"; +const DIRECT_DYNAMIC_ENV_KEYS = new Set([ + "AGENT_API_URL", + "AGENT_API_TOKEN", + "AGENT_OAUTH_CONSENT_TOKEN", + "AGENT_CREDENTIAL_TOKEN", + "AGENT_OUTBOX", +]); function readBody(req, cap = MAX_BUFFER) { return new Promise((resolve, reject) => { @@ -50,6 +61,196 @@ async function handleExec(req, res) { ); } +function directPath(value) { + return ( + typeof value === "string" && + value.startsWith("/") && + value !== "/" && + !value.endsWith("/") && + !value.includes("\\") && + !value.includes("\0") && + value + .split("/") + .slice(1) + .every((part) => part.length > 0 && part !== "." && part !== "..") + ); +} + +function confinedPath(rootDir, requested) { + if (!directPath(rootDir) || !directPath(requested)) return null; + const root = path.posix.normalize(rootDir); + const candidate = path.posix.normalize(requested); + const relative = path.posix.relative(root, candidate); + if (relative === ".." || relative.startsWith("../") || path.posix.isAbsolute(relative)) return null; + return candidate; +} + +function boundedDirectNumber(value, fallback, cap) { + const result = value === undefined ? fallback : Number(value); + if (!Number.isSafeInteger(result) || result < 0 || result > cap) return null; + return result; +} + +async function handleExecv(req, res) { + const body = JSON.parse((await readBody(req)).toString("utf8") || "{}"); + if ( + !Array.isArray(body.argv) || + body.argv.length === 0 || + body.argv.length > 4096 || + body.argv.some((arg) => typeof arg !== "string" || arg.includes("\0")) + ) { + return send(res, 400, { error: "missing argv" }); + } + if (!directPath(body.argv[0])) return send(res, 400, { error: "argv[0] must be a canonical executable path" }); + if (body.executablePath !== undefined && body.executablePath !== body.argv[0]) { + return send(res, 400, { error: "executablePath must equal argv[0]" }); + } + const rootDir = body.rootDir; + if (!directPath(rootDir)) return send(res, 400, { error: "rootDir must be absolute" }); + const cwd = body.cwd === undefined ? rootDir : confinedPath(rootDir, body.cwd); + if (!cwd) return send(res, 400, { error: "cwd must stay inside rootDir" }); + const suppliedEnv = body.env === undefined ? {} : body.env; + if (!suppliedEnv || typeof suppliedEnv !== "object" || Array.isArray(suppliedEnv)) { + return send(res, 400, { error: "env must be an object" }); + } + const dynamicEnvKeys = body.dynamicEnvKeys === undefined ? [] : body.dynamicEnvKeys; + if ( + !Array.isArray(dynamicEnvKeys) || + dynamicEnvKeys.some((key) => typeof key !== "string" || !DIRECT_DYNAMIC_ENV_KEYS.has(key)) || + new Set(dynamicEnvKeys).size !== dynamicEnvKeys.length + ) { + return send(res, 400, { error: "invalid dynamic env keys" }); + } + const allowedEnvKeys = body.allowedEnvKeys === undefined ? [] : body.allowedEnvKeys; + if ( + !Array.isArray(allowedEnvKeys) || + allowedEnvKeys.some( + (key) => + typeof key !== "string" || !/^[A-Za-z_][A-Za-z0-9_]*$/.test(key) || key === "PATH" || key.startsWith("AGENT_"), + ) || + new Set(allowedEnvKeys).size !== allowedEnvKeys.length + ) { + return send(res, 400, { error: "invalid allowed env keys" }); + } + for (const [key, value] of Object.entries(suppliedEnv)) { + if ( + !/^[A-Za-z_][A-Za-z0-9_]*$/.test(key) || + (key === "PATH" && value !== DIRECT_RUNTIME_PATH) || + (key !== "PATH" && !dynamicEnvKeys.includes(key) && !allowedEnvKeys.includes(key)) || + (key.startsWith("AGENT_") && !dynamicEnvKeys.includes(key)) || + (dynamicEnvKeys.includes(key) && !DIRECT_DYNAMIC_ENV_KEYS.has(key)) || + typeof value !== "string" || + value.includes("\0") + ) { + return send(res, 400, { error: "invalid env" }); + } + } + const timeoutMs = boundedDirectNumber(body.timeoutMs, 600000, 24 * 60 * 60 * 1000); + const stdoutMaxBytes = boundedDirectNumber(body.stdoutMaxBytes, DEFAULT_DIRECT_OUTPUT, MAX_DIRECT_OUTPUT); + const stderrMaxBytes = boundedDirectNumber(body.stderrMaxBytes, DEFAULT_DIRECT_OUTPUT, MAX_DIRECT_OUTPUT); + if (timeoutMs === null || stdoutMaxBytes === null || stderrMaxBytes === null) { + return send(res, 400, { error: "invalid direct execution limits" }); + } + let stdin; + if (body.stdinB64 !== undefined) { + if (typeof body.stdinB64 !== "string") return send(res, 400, { error: "stdinB64 must be a string" }); + stdin = Buffer.from(body.stdinB64, "base64"); + if (stdin.length > MAX_DIRECT_INPUT) return send(res, 400, { error: "stdin exceeds the direct input limit" }); + } + let rootReal; + let cwdReal; + let executableReal; + try { + rootReal = fs.realpathSync(rootDir); + cwdReal = fs.realpathSync(cwd); + executableReal = fs.realpathSync(body.argv[0]); + if (cwdReal !== rootReal && !cwdReal.startsWith(`${rootReal}${path.sep}`)) { + return send(res, 400, { error: "cwd escapes rootDir" }); + } + if (executableReal !== body.argv[0] || !fs.statSync(executableReal).isFile()) { + return send(res, 400, { error: "executable must be a canonical installed file" }); + } + } catch { + return send(res, 400, { error: "direct path is not available" }); + } + const child = spawn(body.argv[0], body.argv.slice(1), { + cwd: cwdReal, + env: { ...suppliedEnv }, + detached: true, + stdio: ["pipe", "pipe", "pipe"], + }); + const stdout = []; + const stderr = []; + let timedOut = false; + let outputLimitExceeded = false; + let finished = false; + const killTree = () => { + if (finished || !child.pid) return; + try { + process.kill(-child.pid, "SIGKILL"); + } catch { + child.kill("SIGKILL"); + } + }; + const timer = setTimeout( + () => { + if (!finished) { + timedOut = true; + killTree(); + } + }, + Math.max(1, timeoutMs), + ); + const abort = () => { + killTree(); + }; + const take = (target, chunk, limit, stream) => { + const remaining = Math.max(0, limit - target.size); + if (remaining > 0) target.parts.push(chunk.subarray(0, remaining)); + target.size += chunk.length; + if (target.size > limit) { + outputLimitExceeded = true; + stream.destroy(); + killTree(); + } + }; + req.once("aborted", abort); + res.once("close", abort); + const outState = { parts: stdout, size: 0 }; + const errState = { parts: stderr, size: 0 }; + child.stdout.on("data", (chunk) => take(outState, chunk, stdoutMaxBytes, child.stdout)); + child.stderr.on("data", (chunk) => take(errState, chunk, stderrMaxBytes, child.stderr)); + child.stdin.end(stdin); + child.once("error", () => { + if (finished) return; + finished = true; + clearTimeout(timer); + req.removeListener("aborted", abort); + res.removeListener("close", abort); + send(res, 200, { stdout: "", stderr: "direct executable could not be started", code: 127, timedOut: false }); + }); + child.once("close", (code, signal) => { + if (finished) return; + finished = true; + clearTimeout(timer); + req.removeListener("aborted", abort); + res.removeListener("close", abort); + let outputCode = code === null ? 1 : code; + if (outputLimitExceeded) outputCode = 122; + if (timedOut) outputCode = 124; + send(res, 200, { + stdout: Buffer.concat(stdout).toString("utf8"), + stderr: Buffer.concat(stderr).toString("utf8"), + code: outputCode, + timedOut, + stdoutTruncated: outState.size > stdoutMaxBytes, + stderrTruncated: errState.size > stderrMaxBytes, + outputLimitExceeded, + signal: signal ?? undefined, + }); + }); +} + async function handleWrite(req, res) { const body = JSON.parse((await readBody(req)).toString("utf8") || "{}"); if (typeof body.path !== "string" || typeof body.b64 !== "string") @@ -82,6 +283,7 @@ const server = http.createServer((req, res) => { uptimeSec: Math.round((Date.now() - START_MS) / 1000), }); if (req.method === "POST" && route === "/exec") return handleExec(req, res); + if (req.method === "POST" && route === "/execv") return handleExecv(req, res); if (req.method === "POST" && route === "/write") return handleWrite(req, res); if (req.method === "POST" && route === "/read") return handleRead(req, res); return send(res, 404, { error: "not found", route }); diff --git a/cli/src/sandbox-layer.ts b/cli/src/sandbox-layer.ts index 49d184cce..359803008 100644 --- a/cli/src/sandbox-layer.ts +++ b/cli/src/sandbox-layer.ts @@ -56,6 +56,7 @@ export interface ToolDescriptor { advertise?: string; hints?: string[]; egress?: string[]; + commandEnv?: string[]; auth?: ToolAuthDescriptor; approvals?: ToolApproval[]; install?: { binary?: string }; @@ -102,6 +103,19 @@ export function parseToolDescriptor(raw: string, sourcePath: string): ToolDescri out.egress = d["egress"]; } + if (d["commandEnv"] !== undefined) { + if ( + !isStringArray(d["commandEnv"]) || + d["commandEnv"].some((key) => !SPLIT_ENV_KEY_RE.test(key) || /^AGENT_/i.test(key) || key === "PATH") + ) { + throw new Error(`${sourcePath}: "commandEnv" must be an array of non-reserved environment variable names`); + } + if (new Set(d["commandEnv"]).size !== d["commandEnv"].length) { + throw new Error(`${sourcePath}: "commandEnv" must not contain duplicate names`); + } + out.commandEnv = d["commandEnv"]; + } + if (d["auth"] !== undefined) out.auth = parseAuth(d["auth"], sourcePath); if (d["approvals"] !== undefined) out.approvals = parseApprovals(d["approvals"], sourcePath); diff --git a/src/core/orchestrator.ts b/src/core/orchestrator.ts index d6071d572..1faa0c221 100644 --- a/src/core/orchestrator.ts +++ b/src/core/orchestrator.ts @@ -24,7 +24,8 @@ import type { DirectoryStore, DirectoryChannel, DirectoryMember } from "../direc import { resolveEnvironmentId } from "../environments/environment-store.ts"; import type { GapPhase, LeaseAttempt, SessionStore } from "../sessions/session-store.ts"; import { isOverheardEntry } from "../sessions/session-store.ts"; -import { supportsProcessSessions, supportsScopeProfile } from "../sandbox/sandbox.ts"; +import { CapabilityUnsupportedError, supportsProcessSessions, supportsScopeProfile } from "../sandbox/sandbox.ts"; +import { DIRECT_DYNAMIC_ENV_KEYS, type ScopedCommand } from "../sandbox/scoped-exec.ts"; import { createBackgroundBroker } from "../connectors/background-exec-broker.ts"; import { createMonitorBroker, readBackgroundOutputTail } from "../monitors/monitor-broker.ts"; import { isPollSurface, isSilentPollReply } from "../triggers/run-trigger.ts"; @@ -1017,7 +1018,7 @@ export function createOrchestrator(deps: OrchestratorDeps): Orchestrator { } const cutoverModeOf = (service: string): DeviceFlowCutoverMode => cutoverModes.get(service) ?? "legacy"; const quarantinedServices = brokeredTools - .filter((tool) => cutoverModeOf(tool.service) === "ephemeral_only") + .filter((tool) => cutoverModeOf(tool.service) !== "legacy") .map((tool) => tool.service); const brokerCutoverServices = brokeredTools .filter((tool) => cutoverModeOf(tool.service) !== "legacy") @@ -1314,16 +1315,32 @@ export function createOrchestrator(deps: OrchestratorDeps): Orchestrator { swallow("gap-work emit", e); } }; - const ephemeralOnlyDenyRules = brokeredTools - .filter((candidate) => cutoverModeOf(candidate.service) === "ephemeral_only") - .map((tool) => ({ - pattern: `(^|[\\s;&|()])${tool.binary.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}($|[\\s;&|()])`, + const brokerCutoverDenyRules = brokeredTools + .filter((candidate) => cutoverModeOf(candidate.service) !== "legacy") + .flatMap((tool) => [tool.binary, `/usr/local/bin/${tool.binary}`]) + .map((binary) => ({ + pattern: `(^|[\\s;&|()])${binary.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}($|[\\s;&|()])`, decision: "deny" as const, - reason: `credential-bearing service ${tool.service} must be run with credential_exec`, + reason: `credential-bearing executable ${binary} must be run with credential_exec`, })); - const commandPolicy = ephemeralOnlyDenyRules.length - ? { ...resolution.commandPolicy, rules: [...ephemeralOnlyDenyRules, ...resolution.commandPolicy.rules] } - : resolution.commandPolicy; + const directOnlyDenyRules = [ + ...new Set( + (deps.deploymentLayer?.directTools ?? []) + .filter((tool) => tool.directOnly) + .flatMap((tool) => [tool.binary, `/usr/local/bin/${tool.binary}`]), + ), + ].map((binary) => ({ + pattern: `(^|[\\s;&|()])${binary.replace(/[.*+?^${}()|[\\]\\]/g, "\\$&")}($|[\\s;&|()])`, + decision: "deny" as const, + reason: `descriptor-owned service ${binary} must be run with credential_exec`, + })); + const commandPolicy = + directOnlyDenyRules.length || brokerCutoverDenyRules.length + ? { + ...resolution.commandPolicy, + rules: [...directOnlyDenyRules, ...brokerCutoverDenyRules, ...resolution.commandPolicy.rules], + } + : resolution.commandPolicy; const layerCommandRules = [...(deps.deploymentLayer?.commandRules ?? [])]; const reachAvailable = !!deps.reachExec && !!deps.directory && conversation.kind === "dm"; const { @@ -1795,6 +1812,10 @@ export function createOrchestrator(deps: OrchestratorDeps): Orchestrator { `[orchestrator] trigger delivery has no surface tools (missing deliveries store?) — reply would be lost session=${session.id}`, ); + const directProfile = deps.sandbox.profileFor + ? await deps.sandbox.profileFor(memoryScopeId) + : deps.sandbox.profile; + const directExecutionAvailable = !!deps.sandbox.runDirect && directProfile.directExecution === true; const tools = createToolContext({ sandbox: deps.sandbox, provision, @@ -1825,26 +1846,42 @@ export function createOrchestrator(deps: OrchestratorDeps): Orchestrator { auditLog: deps.auditLog, createdBy: actor.id, ...(() => { + const directTools = deps.deploymentLayer?.directTools ?? []; const available = - strictReadOnly || actor.type !== "internal" + strictReadOnly || input.background === true || actor.type !== "internal" || !directExecutionAvailable ? [] - : brokeredTools.filter( - (tool) => cutoverModeOf(tool.service) !== "legacy" && deps.layerBrokerFor?.(tool), - ); + : directTools.filter((tool) => { + const brokered = brokeredTools.find( + (candidate) => candidate.service === tool.service || candidate.binary === tool.binary, + ); + return ( + !brokered || (cutoverModeOf(brokered.service) !== "legacy" && !!deps.layerBrokerFor?.(brokered)) + ); + }); if (!available.length) return {}; return { credentialExecServices: available.map(({ service, binary }) => ({ service, binary })), credentialExec: async ( service: string, args: string[], - opts?: { timeoutSeconds?: number; signal?: AbortSignal }, + opts?: { timeoutSeconds?: number; signal?: AbortSignal; stdin?: string }, ) => { const tool = available.find((candidate) => candidate.service === service); - if (!tool || cutoverModeOf(service) === "legacy") { + if (!tool) { throw new Error(`credential_exec service is unavailable: ${service}`); } - const broker = deps.layerBrokerFor?.(tool); - if (!broker) throw new Error(`credential_exec broker is unavailable: ${service}`); + const runDirect = deps.sandbox.runDirect; + if (!runDirect || !directExecutionAvailable) { + throw new CapabilityUnsupportedError(directProfile.backend, "structured credential execution"); + } + const declaredEnvKeys = deps.deploymentLayer?.commandEnvByExecutable?.[tool.binary]; + if (!declaredEnvKeys) { + throw new Error(`credential_exec environment is not declared for ${service}`); + } + const brokered = brokeredTools.find( + (candidate) => candidate.service === tool.service || candidate.binary === tool.binary, + ); + const broker = brokered ? deps.layerBrokerFor?.(brokered) : undefined; const composed = [shq(tool.binary), ...args.map(shq)].join(" "); const gate = evaluateCommandWithLayer( composed, @@ -1861,28 +1898,58 @@ export function createOrchestrator(deps: OrchestratorDeps): Orchestrator { gate.approvalKey, ); } - let aws; + let awsEnv: Record = {}; + if (broker) { + let aws; + try { + aws = await broker.credsForActor(actor.id); + } catch { + const mode = cutoverModeOf(service); + deps.credentialUsage?.record({ + slug: service, + host: "sts.amazonaws.com", + status: mode === "ephemeral_only" ? "ephemeral_failed_closed" : "prefer_ephemeral_failed_closed", + scopeLabel: scopeId, + principalId: actor.id, + }); + throw new Error(`credential_exec could not vend credentials for ${service}`); + } + if (aws) { + awsEnv = { + AWS_ACCESS_KEY_ID: aws.accessKeyId, + AWS_SECRET_ACCESS_KEY: aws.secretAccessKey, + AWS_SESSION_TOKEN: aws.sessionToken, + AWS_REGION: aws.region, + AWS_DEFAULT_REGION: aws.region, + }; + } + } + const directEnv: Record = {}; try { - aws = await broker.credsForActor(actor.id); + for (const key of declaredEnvKeys) { + const value = key in awsEnv ? awsEnv[key] : await deps.secretSource?.get(key); + if (value !== undefined) directEnv[key] = value; + } } catch { - deps.credentialUsage?.record({ - slug: service, - host: "sts.amazonaws.com", - status: cutoverModeOf(service) === "ephemeral_only" ? "ephemeral_failed_closed" : "legacy_fallback", - scopeLabel: scopeId, - principalId: actor.id, - }); - throw new Error(`credential_exec could not vend credentials for ${service}`); + throw new Error(`credential_exec could not resolve environment for ${service}`); } - const awsEnv = { - AWS_ACCESS_KEY_ID: aws.accessKeyId, - AWS_SECRET_ACCESS_KEY: aws.secretAccessKey, - AWS_SESSION_TOKEN: aws.sessionToken, - AWS_REGION: aws.region, - AWS_DEFAULT_REGION: aws.region, + const dynamicEnv = Object.fromEntries( + DIRECT_DYNAMIC_ENV_KEYS.flatMap((key) => + connectorEnv[key] !== undefined ? [[key, connectorEnv[key]]] : [], + ), + ); + const executablePath = `/usr/local/bin/${tool.binary}`; + const direct: ScopedCommand = { + argv: [executablePath, ...args], + executablePath, + allowedEnvKeys: declaredEnvKeys, }; - const mask = createSecretValueMasker(awsEnv); + const mask = createSecretValueMasker( + { ...directEnv, ...dynamicEnv }, + { minimumLength: 1, maskNonSecretKeys: true }, + ); let handle; + let ownsHandle = false; let result: Awaited> | undefined; let runError: unknown; let cleanupError: unknown; @@ -1890,46 +1957,53 @@ export function createOrchestrator(deps: OrchestratorDeps): Orchestrator { handle = await deps.sandbox.provision( resolution.layers.filter((layer) => layer.mode === "ro" && layer.mountPath === "global"), { - env: awsEnv, egress: resolution.egress, ...(egressTokenForTurn ? { egressToken: egressTokenForTurn } : {}), scratch: { key: `credential-exec:${session.id}:${randomUUID()}` }, routeScopeId: memoryScopeId, }, ); - deps.credentialUsage?.record({ - slug: service, - host: "sts.amazonaws.com", - status: "ephemeral_vended", - scopeLabel: scopeId, - principalId: actor.id, - }); - deps.auditLog.record({ - at: Date.now(), - principalId: actor.id, - action: "credential.materialize", - resource: `${service} (ephemeral broker)`, - scopeLabel: scopeId, - }); + ownsHandle = true; + if (broker) { + deps.credentialUsage?.record({ + slug: service, + host: "sts.amazonaws.com", + status: "ephemeral_vended", + scopeLabel: scopeId, + principalId: actor.id, + }); + deps.auditLog.record({ + at: Date.now(), + principalId: actor.id, + action: "credential.materialize", + resource: `${service} (ephemeral broker)`, + scopeLabel: scopeId, + }); + } else { + deps.auditLog.record({ + at: Date.now(), + principalId: actor.id, + action: "credential.direct_exec", + resource: `${service} (descriptor-owned environment)`, + scopeLabel: scopeId, + }); + } const requestedMs = opts?.timeoutSeconds == null ? deps.execTimeoutMs : opts.timeoutSeconds * 1000; const timeoutMs = requestedMs != null && deps.execTimeoutCeilingMs != null ? Math.min(requestedMs, deps.execTimeoutCeilingMs) : requestedMs; - result = await deps.sandbox.run( - handle, - composed, - timeoutMs !== undefined || opts?.signal - ? { - ...(timeoutMs !== undefined ? { timeoutMs } : {}), - ...(opts?.signal ? { signal: opts.signal } : {}), - } - : undefined, - ); + result = await runDirect(handle, direct, { + env: directEnv, + ...(Object.keys(dynamicEnv).length ? { dynamicEnv } : {}), + ...(opts?.stdin !== undefined ? { stdin: opts.stdin } : {}), + ...(timeoutMs !== undefined ? { timeoutMs } : {}), + ...(opts?.signal ? { signal: opts.signal } : {}), + }); } catch (error) { runError = error; } finally { - if (handle) { + if (handle && ownsHandle) { let lastError: unknown; for (let attempt = 1; attempt <= 3; attempt++) { try { diff --git a/src/core/orchestrator/types.ts b/src/core/orchestrator/types.ts index a68404b8a..bd8ebc6b1 100644 --- a/src/core/orchestrator/types.ts +++ b/src/core/orchestrator/types.ts @@ -60,6 +60,7 @@ import type { DeployService } from "../../deploy/deploy-service.ts"; import type { AclStore } from "../../acl/acl-store.ts"; import type { ChannelPolicyStore } from "../../surface-cache/channel-policy-store.ts"; import type { SurfaceCache } from "../../surface-cache/types.ts"; +import type { SecretSource } from "../../credentials/secret-source.ts"; export interface OrchestratorInput extends Omit< TurnRequest, @@ -169,6 +170,7 @@ export interface OrchestratorDeps { layerBrokerFor?: (tool: BrokeredLayerTool) => AwsRoleBroker | undefined; brokeredTools?: readonly BrokeredLayerTool[]; deploymentLayer?: DeploymentLayerRuntime; + secretSource?: SecretSource; surfaceContext?: SurfaceContextPuller; surfaceSearch?: SurfaceSearchStore; surfaceCache?: SurfaceCache; diff --git a/src/deployment/deployment-layer.ts b/src/deployment/deployment-layer.ts index 16d3e24fd..c348e8787 100644 --- a/src/deployment/deployment-layer.ts +++ b/src/deployment/deployment-layer.ts @@ -34,6 +34,7 @@ export interface ToolDescriptor { advertise?: string; hints?: string[]; egress?: string[]; + commandEnv?: string[]; auth?: ToolAuthDescriptor; approvals?: ToolApproval[]; install?: { binary?: string }; @@ -93,6 +94,22 @@ export function parseToolDescriptor(raw: string, sourcePath: string): ToolDescri out.egress = d["egress"] as string[]; } + if (d["commandEnv"] !== undefined) { + if ( + !Array.isArray(d["commandEnv"]) || + d["commandEnv"].some( + (key) => typeof key !== "string" || !SPLIT_ENV_KEY_RE.test(key) || /^AGENT_/i.test(key) || key === "PATH", + ) + ) { + throw new Error(`${sourcePath}: "commandEnv" must be an array of non-reserved environment variable names`); + } + const keys = d["commandEnv"] as string[]; + if (new Set(keys).size !== keys.length) { + throw new Error(`${sourcePath}: "commandEnv" must not contain duplicate names`); + } + out.commandEnv = keys; + } + if (d["auth"] !== undefined) out.auth = parseAuth(d["auth"], sourcePath); if (d["approvals"] !== undefined) out.approvals = parseApprovals(d["approvals"], sourcePath); diff --git a/src/deployment/load-layer.ts b/src/deployment/load-layer.ts index 5630df17e..e37ad54e6 100644 --- a/src/deployment/load-layer.ts +++ b/src/deployment/load-layer.ts @@ -19,10 +19,19 @@ export interface DeploymentLayerRuntime { hints: string[]; credentialPaths: ToolCredentialPath[]; splitEnvTemplates: Record[]; + commandEnvByExecutable: Record; commandRules: CommandRule[]; + directTools: DirectLayerTool[]; brokeredTools: BrokeredLayerTool[]; } +interface DirectLayerTool { + service: string; + binary: string; + roots: string[]; + directOnly: boolean; +} + export interface BrokeredLayerTool { service: string; binary: string; @@ -30,6 +39,14 @@ export interface BrokeredLayerTool { broker: ToolCredentialBroker; } +const AWS_BROKER_COMMAND_ENV = [ + "AWS_ACCESS_KEY_ID", + "AWS_SECRET_ACCESS_KEY", + "AWS_SESSION_TOKEN", + "AWS_REGION", + "AWS_DEFAULT_REGION", +] as const; + export function emptyDeploymentLayer(): DeploymentLayerRuntime { return { dir: "", @@ -39,7 +56,9 @@ export function emptyDeploymentLayer(): DeploymentLayerRuntime { hints: [], credentialPaths: [], splitEnvTemplates: [], + commandEnvByExecutable: {}, commandRules: [], + directTools: [], brokeredTools: [], }; } @@ -75,6 +94,41 @@ function toolService(tool: ToolDescriptor, why: string): string { export function resolvedDeploymentLayer(dir: string, tools: ToolDescriptor[]): DeploymentLayerRuntime { assertDisjointCredentialLinks(tools); + const commandEnvByExecutable: Record = {}; + for (const tool of tools) { + const keys = tool.commandEnv ?? (tool.auth?.broker?.kind === "aws-role" ? AWS_BROKER_COMMAND_ENV : undefined); + if (keys === undefined) continue; + const binary = tool.install?.binary ?? tool.id; + const prior = commandEnvByExecutable[binary]; + if (prior && (prior.length !== keys.length || prior.some((key, index) => key !== keys[index]))) { + throw new Error( + `deployment layer tools declare conflicting commandEnv mappings for executable ${JSON.stringify(binary)}`, + ); + } + commandEnvByExecutable[binary] = [...keys]; + } + const directTools: DirectLayerTool[] = tools + .filter((tool) => tool.commandEnv !== undefined || tool.auth?.broker?.kind === "aws-role") + .map((tool) => ({ + service: tool.auth ? toolService(tool, "direct execution") : tool.id, + binary: tool.install?.binary ?? tool.id, + roots: (tool.auth?.credentialPaths ?? []).map((entry) => entry.path), + directOnly: tool.commandEnv !== undefined, + })); + const directServices = new Set(); + const directBinaries = new Set(); + for (const tool of directTools) { + if (directServices.has(tool.service)) { + throw new Error(`deployment layer declares conflicting direct tools for service ${JSON.stringify(tool.service)}`); + } + if (directBinaries.has(tool.binary)) { + throw new Error( + `deployment layer declares conflicting direct tools for executable ${JSON.stringify(tool.binary)}`, + ); + } + directServices.add(tool.service); + directBinaries.add(tool.binary); + } const withAuth = tools.filter((t) => t.auth); const brokered = withAuth.filter((t) => t.auth!.broker); if (brokered.length > 1) { @@ -97,12 +151,14 @@ export function resolvedDeploymentLayer(dir: string, tools: ToolDescriptor[]): D ...new Map(withAuth.flatMap((t) => t.auth!.credentialPaths ?? []).map((entry) => [entry.path, entry])).values(), ], splitEnvTemplates: withAuth.flatMap((t) => (t.auth!.splitEnv ? [t.auth!.splitEnv] : [])), + commandEnvByExecutable, commandRules: tools.flatMap((tool) => (tool.approvals ?? []).map((approval) => ({ ...compileApproval(tool.install?.binary ?? tool.id, approval), ...(approval.reason ? { reason: approval.reason } : {}), })), ), + directTools, brokeredTools: brokered.map((t) => { const service = toolService(t, "a credential broker"); return { @@ -126,11 +182,15 @@ export function replaceDeploymentLayer(target: DeploymentLayerRuntime, source: D "hints", "credentialPaths", "splitEnvTemplates", - "commandRules", + "directTools", "brokeredTools", ] as const) { target[key].splice(0, target[key].length, ...(source[key] as never[])); } + target.commandEnvByExecutable = Object.fromEntries( + Object.entries(source.commandEnvByExecutable).map(([binary, keys]) => [binary, [...keys]]), + ); + target.commandRules.splice(0, target.commandRules.length, ...source.commandRules); } const JUNK_FILE = /^(?:\.DS_Store|Thumbs\.db|\._.*)$/; diff --git a/src/harness/pi-tools.ts b/src/harness/pi-tools.ts index 08bf6a583..cc5a90172 100644 --- a/src/harness/pi-tools.ts +++ b/src/harness/pi-tools.ts @@ -2695,9 +2695,10 @@ export function createPiTools(ref: ToolContextRef, opts?: PiToolsOptions): ToolD parameters: Type.Object({ service: Type.String({ enum: credentialExecServices.map(({ service }) => service) }), args: Type.Array(Type.String()), + stdin: Type.Optional(Type.String({ maxLength: 1024 * 1024 })), timeout_seconds: Type.Optional(Type.Integer({ minimum: 1, maximum: execCeilingSec })), }), - async execute(callId, params: { service: string; args: string[]; timeout_seconds?: number }) { + async execute(callId, params: { service: string; args: string[]; stdin?: string; timeout_seconds?: number }) { const tc = ref.current; await recordCall(callId, { tool: "credential_exec", service: params.service, args: params.args }); if (!tc?.credentialExec) { @@ -2711,6 +2712,7 @@ export function createPiTools(ref: ToolContextRef, opts?: PiToolsOptions): ToolD try { const result = await tc.credentialExec(params.service, params.args, { ...(params.timeout_seconds !== undefined ? { timeoutSeconds: params.timeout_seconds } : {}), + ...(params.stdin !== undefined ? { stdin: params.stdin } : {}), ...(ref.abortSignal ? { signal: ref.abortSignal } : {}), }); const parts = [result.stdout, result.stderr ? `[stderr]\n${result.stderr}` : ""].filter(Boolean).join("\n"); diff --git a/src/sandbox/aws-microvm-api.ts b/src/sandbox/aws-microvm-api.ts index 240969757..f5a4167a3 100644 --- a/src/sandbox/aws-microvm-api.ts +++ b/src/sandbox/aws-microvm-api.ts @@ -3,6 +3,7 @@ import { Sha256 } from "@aws-crypto/sha256-js"; import { defaultProvider } from "@aws-sdk/credential-provider-node"; import { sleep } from "../util/async.ts"; import { errMessage, swallow } from "../util/errors.ts"; +import type { DirectExecRequest } from "./scoped-exec.ts"; type CredentialProvider = () => Promise<{ accessKeyId: string; @@ -233,6 +234,7 @@ export interface VmFetchOptions { port?: number; timeoutMs?: number; fetchImpl?: typeof fetch; + signal?: AbortSignal; } export async function vmFetch( @@ -251,7 +253,7 @@ export async function vmFetch( ...(payload ? { "content-type": "application/json" } : {}), }, ...(payload ? { body: payload } : {}), - signal: AbortSignal.timeout(opts.timeoutMs ?? 120_000), + signal: AbortSignal.any([AbortSignal.timeout(opts.timeoutMs ?? 120_000), ...(opts.signal ? [opts.signal] : [])]), }); return { status: res.status, text: await res.text() }; } @@ -267,6 +269,10 @@ interface MicrovmExecResult { stderr: string; code: number; timedOut: boolean; + stdoutTruncated?: boolean; + stderrTruncated?: boolean; + outputLimitExceeded?: boolean; + signal?: string; } export interface MicrovmClient { @@ -277,8 +283,10 @@ export interface MicrovmClient { path: string, body?: unknown, timeoutMs?: number, + signal?: AbortSignal, ): Promise<{ status: number; text: string }>; execRaw(id: string, endpoint: string, cmd: string, timeoutSec: number): Promise; + execvRaw(id: string, endpoint: string, request: DirectExecRequest, signal?: AbortSignal): Promise; writeAbs(id: string, endpoint: string, absPath: string, data: Uint8Array): Promise; waitDaemon(id: string, endpoint: string): Promise; ensureRunning(id: string, endpoint: string): Promise; @@ -304,6 +312,7 @@ export function createMicrovmClient(api: AwsMicrovmApi, opts: MicrovmClientOptio path: string, body?: unknown, timeoutMs?: number, + signal?: AbortSignal, ): Promise<{ status: number; text: string }> { const send = async (): Promise<{ status: number; text: string }> => vmFetch(endpoint, await tokenFor(id), path, { @@ -311,6 +320,7 @@ export function createMicrovmClient(api: AwsMicrovmApi, opts: MicrovmClientOptio body, port: agentPort, ...(timeoutMs ? { timeoutMs } : {}), + ...(signal ? { signal } : {}), ...fetchOpt, }); let res = await send(); @@ -328,6 +338,54 @@ export function createMicrovmClient(api: AwsMicrovmApi, opts: MicrovmClientOptio return { stdout: j.stdout ?? "", stderr: j.stderr ?? "", code: j.code, timedOut: !!j.timedOut }; } + async function execvRaw( + id: string, + endpoint: string, + request: DirectExecRequest, + signal?: AbortSignal, + ): Promise { + const res = await daemon( + id, + endpoint, + "/execv", + { + argv: request.argv, + cwd: request.cwd, + rootDir: request.rootDir, + env: request.env, + allowedEnvKeys: request.allowedEnvKeys, + dynamicEnvKeys: request.dynamicEnvKeys, + ...(request.stdin ? { stdinB64: Buffer.from(request.stdin).toString("base64") } : {}), + timeoutMs: request.timeoutMs, + stdoutMaxBytes: request.stdoutMaxBytes, + stderrMaxBytes: request.stderrMaxBytes, + }, + request.timeoutMs + 15_000, + signal, + ); + if (res.status !== 200) throw new Error(`microVM direct exec failed (${res.status}): ${res.text.slice(0, 300)}`); + const j = JSON.parse(res.text) as { + stdout?: string; + stderr?: string; + code: number; + timedOut?: boolean; + stdoutTruncated?: boolean; + stderrTruncated?: boolean; + outputLimitExceeded?: boolean; + signal?: string; + }; + return { + stdout: j.stdout ?? "", + stderr: j.stderr ?? "", + code: j.code, + timedOut: !!j.timedOut, + ...(j.stdoutTruncated ? { stdoutTruncated: true } : {}), + ...(j.stderrTruncated ? { stderrTruncated: true } : {}), + ...(j.outputLimitExceeded ? { outputLimitExceeded: true } : {}), + ...(j.signal ? { signal: j.signal } : {}), + }; + } + async function writeAbs(id: string, endpoint: string, absPath: string, data: Uint8Array): Promise { const res = await daemon(id, endpoint, "/write", { path: absPath, b64: Buffer.from(data).toString("base64") }); if (res.status !== 200) @@ -371,5 +429,14 @@ export function createMicrovmClient(api: AwsMicrovmApi, opts: MicrovmClientOptio await waitDaemon(id, endpoint); } - return { tokenFor, daemon, execRaw, writeAbs, waitDaemon, ensureRunning, evict: (id) => void tokenById.delete(id) }; + return { + tokenFor, + daemon, + execRaw, + execvRaw, + writeAbs, + waitDaemon, + ensureRunning, + evict: (id) => void tokenById.delete(id), + }; } diff --git a/src/sandbox/aws-sandbox.ts b/src/sandbox/aws-sandbox.ts index 55a1270a1..3d8efbacb 100644 --- a/src/sandbox/aws-sandbox.ts +++ b/src/sandbox/aws-sandbox.ts @@ -22,6 +22,7 @@ import type { SandboxHandle, TeardownOptions, } from "./sandbox.ts"; +import { directRequest, type DirectExecOptions, type ScopedCommand } from "./scoped-exec.ts"; import { visibleNotInstalled, visibleTools } from "./sandbox.ts"; import { ephemeralCredLinkPaths, @@ -305,6 +306,7 @@ export function createAwsSandbox(workspace: WorkspaceStore, opts: AwsSandboxOpti backend: "aws-microvm", writablePersistence: "snapshot_to_workspace", processSessions: true, + directExecution: true, egressEnforcement: "none", spec: { os: "Amazon Linux 2023, glibc", @@ -418,6 +420,23 @@ export function createAwsSandbox(workspace: WorkspaceStore, opts: AwsSandboxOpti return execRaw(handle.id, script, timeoutSec); }, + async runDirect(handle: SandboxHandle, command: ScopedCommand, execOpts?: DirectExecOptions): Promise { + const request = directRequest(handle.rootDir, command, execOpts); + execOpts?.signal?.throwIfAborted(); + await ensureRunning(handle.id); + const result = await client.execvRaw(handle.id, await resolveEndpoint(handle.id), request, execOpts?.signal); + return { + stdout: result.stdout, + stderr: result.stderr, + code: result.code, + timedOut: result.timedOut, + ...(result.stdoutTruncated ? { stdoutTruncated: true } : {}), + ...(result.stderrTruncated ? { stderrTruncated: true } : {}), + ...(result.outputLimitExceeded ? { outputLimitExceeded: true } : {}), + ...(result.signal ? { signal: result.signal } : {}), + }; + }, + async writeFileBytes(handle, relPath, data): Promise { await writeAbsBytes(handle.id, posixJoin(handle.rootDir, relPath), data); }, diff --git a/src/sandbox/local-sandbox.ts b/src/sandbox/local-sandbox.ts index ce8c97b4f..debe59b26 100644 --- a/src/sandbox/local-sandbox.ts +++ b/src/sandbox/local-sandbox.ts @@ -26,6 +26,7 @@ import type { SandboxHandle, TeardownOptions, } from "./sandbox.ts"; +import { directRequest, type DirectExecOptions, type ScopedCommand } from "./scoped-exec.ts"; const DEFAULT_LOCAL_SANDBOX_IMAGE = "qm-sandbox-local:latest"; const HOME_DIR = "/root"; @@ -211,6 +212,53 @@ export function createLocalSandbox(workspace: WorkspaceStore, opts: LocalSandbox return { stdout: j.stdout ?? "", stderr: j.stderr ?? "", code: j.code, timedOut: !!j.timedOut }; } + async function execDirectRaw( + name: string, + request: ReturnType, + signal?: AbortSignal, + ): Promise { + const res = await daemon( + name, + "/execv", + { + argv: request.argv, + cwd: request.cwd, + rootDir: request.rootDir, + env: request.env, + allowedEnvKeys: request.allowedEnvKeys, + dynamicEnvKeys: request.dynamicEnvKeys, + ...(request.stdin ? { stdinB64: Buffer.from(request.stdin).toString("base64") } : {}), + timeoutMs: request.timeoutMs, + stdoutMaxBytes: request.stdoutMaxBytes, + stderrMaxBytes: request.stderrMaxBytes, + }, + request.timeoutMs + 15_000, + signal, + ); + if (res.status !== 200) + throw new Error(`local sandbox direct exec failed (${res.status}): ${res.text.slice(0, 300)}`); + const j = JSON.parse(res.text) as { + stdout?: string; + stderr?: string; + code: number; + timedOut?: boolean; + stdoutTruncated?: boolean; + stderrTruncated?: boolean; + outputLimitExceeded?: boolean; + signal?: string; + }; + return { + stdout: j.stdout ?? "", + stderr: j.stderr ?? "", + code: j.code, + timedOut: !!j.timedOut, + ...(j.stdoutTruncated ? { stdoutTruncated: true } : {}), + ...(j.stderrTruncated ? { stderrTruncated: true } : {}), + ...(j.outputLimitExceeded ? { outputLimitExceeded: true } : {}), + ...(j.signal ? { signal: j.signal } : {}), + }; + } + async function writeAbsBytes(name: string, absPath: string, data: Uint8Array): Promise { const res = await daemon(name, "/write", { path: absPath, b64: Buffer.from(data).toString("base64") }, 120_000); if (res.status !== 200) @@ -319,6 +367,7 @@ export function createLocalSandbox(workspace: WorkspaceStore, opts: LocalSandbox backend: "local-docker", writablePersistence: "resident_disk", processSessions: true, + directExecution: true, egressEnforcement: "none", spec: { os: `Debian 12 (bookworm), glibc — local Docker container on a ${arch()} host (dev only)`, @@ -427,6 +476,13 @@ export function createLocalSandbox(workspace: WorkspaceStore, opts: LocalSandbox } }, + async runDirect(handle: SandboxHandle, command: ScopedCommand, execOpts?: DirectExecOptions): Promise { + const request = directRequest(handle.rootDir, command, execOpts); + execOpts?.signal?.throwIfAborted(); + await ensureRunning(handle.id); + return execDirectRaw(handle.id, request, execOpts?.signal); + }, + async writeFileBytes(handle, relPath, data): Promise { await writeAbsBytes(handle.id, posixJoin(handle.rootDir, relPath), data); }, diff --git a/src/sandbox/sandbox-routing.ts b/src/sandbox/sandbox-routing.ts index ccfb75e74..2046dea7b 100644 --- a/src/sandbox/sandbox-routing.ts +++ b/src/sandbox/sandbox-routing.ts @@ -13,6 +13,7 @@ import { type SandboxHandle, type TeardownOptions, } from "./sandbox.ts"; +import type { DirectExecOptions, ScopedCommand } from "./scoped-exec.ts"; export type SandboxBackendName = "sprites" | "aws" | "local" | "smolmachines"; @@ -113,6 +114,12 @@ export function createSandboxRouter(opts: RoutingSandboxOptions): Sandbox { run(handle, command, execOpts?: ExecOptions): Promise { return forHandle(handle).run(handle, command, execOpts); }, + ...(some((s) => typeof s.runDirect === "function") + ? { + runDirect: (handle: SandboxHandle, command: ScopedCommand, execOpts?: DirectExecOptions) => + requireCap(forHandle(handle), "runDirect", handle.scopeId).runDirect(handle, command, execOpts), + } + : {}), readFile(handle, relPath) { return forHandle(handle).readFile(handle, relPath); }, diff --git a/src/sandbox/sandbox.ts b/src/sandbox/sandbox.ts index b010863c5..37465c3d1 100644 --- a/src/sandbox/sandbox.ts +++ b/src/sandbox/sandbox.ts @@ -1,4 +1,5 @@ import type { EgressPolicy, WorkspaceLayer } from "../types.ts"; +import type { DirectExecOptions, ScopedCommand } from "./scoped-exec.ts"; export interface SandboxHandle { id: string; @@ -34,6 +35,7 @@ export interface AgentComputerProfile { backend: string; writablePersistence: WritablePersistence; processSessions: boolean; + directExecution?: boolean; egressEnforcement?: EgressEnforcement; spec?: AgentComputerSpec; } @@ -74,6 +76,10 @@ export interface ExecResult { stderr: string; code: number; timedOut: boolean; + stdoutTruncated?: boolean; + stderrTruncated?: boolean; + outputLimitExceeded?: boolean; + signal?: string; } export interface ExecOptions { @@ -138,6 +144,7 @@ export interface Sandbox { profileFor?(scopeId: string): Promise; provision(layers: WorkspaceLayer[], opts?: ProvisionOptions): Promise; run(handle: SandboxHandle, command: string, opts?: ExecOptions): Promise; + runDirect?(handle: SandboxHandle, command: ScopedCommand, opts?: DirectExecOptions): Promise; readFile(handle: SandboxHandle, relPath: string): Promise; writeFile(handle: SandboxHandle, relPath: string, data: string): Promise; writeFileBytes(handle: SandboxHandle, relPath: string, data: Uint8Array): Promise; @@ -204,9 +211,14 @@ export function supportsProcessSessions(sandbox: Sandbox): sandbox is ProcessSan ); } +export function supportsDirectExecution(sandbox: Sandbox): sandbox is Sandbox & Required> { + return sandbox.profile.directExecution === true && typeof sandbox.runDirect === "function"; +} + const SANDBOX_CAPABILITIES: ReadonlyArray<{ label: string; supported: (s: Sandbox) => boolean }> = [ { label: "process sessions (background work, dev servers)", supported: supportsProcessSessions }, { label: "home backup (publish, resident-auth capture)", supported: supportsAgentComputerBackup }, + { label: "structured direct execution", supported: supportsDirectExecution }, ]; const ENFORCEMENT_RANK: Record = { none: 0, ip_port: 1, domain: 2 }; diff --git a/src/sandbox/scoped-exec.ts b/src/sandbox/scoped-exec.ts new file mode 100644 index 000000000..733f4b313 --- /dev/null +++ b/src/sandbox/scoped-exec.ts @@ -0,0 +1,201 @@ +import { posix } from "node:path"; + +const DIRECT_DEFAULT_TIMEOUT_MS = 600_000; +const DIRECT_DEFAULT_STDIN_MAX_BYTES = 1 * 1024 * 1024; +const DIRECT_DEFAULT_STDOUT_MAX_BYTES = 4 * 1024 * 1024; +const DIRECT_DEFAULT_STDERR_MAX_BYTES = 4 * 1024 * 1024; +const DIRECT_MAX_STDIN_BYTES = 16 * 1024 * 1024; +const DIRECT_MAX_OUTPUT_BYTES = 64 * 1024 * 1024; +export const DIRECT_RUNTIME_PATH = "/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin"; +export const DIRECT_DYNAMIC_ENV_KEYS = [ + "AGENT_API_URL", + "AGENT_API_TOKEN", + "AGENT_OAUTH_CONSENT_TOKEN", + "AGENT_CREDENTIAL_TOKEN", + "AGENT_OUTBOX", +] as const; + +export interface ScopedCommand { + argv: readonly string[]; + executablePath?: string; + cwd?: string; + env?: Readonly>; + allowedEnvKeys?: readonly string[]; + stdin?: string | Uint8Array; +} + +export interface DirectExecOptions { + timeoutMs?: number; + signal?: AbortSignal; + cwd?: string; + env?: Readonly>; + dynamicEnv?: Readonly>; + allowedEnvKeys?: readonly string[]; + stdin?: string | Uint8Array; + stdinMaxBytes?: number; + stdoutMaxBytes?: number; + stderrMaxBytes?: number; +} + +export interface DirectExecRequest { + argv: string[]; + cwd: string; + rootDir: string; + env: Record; + allowedEnvKeys: string[]; + dynamicEnvKeys: string[]; + stdin?: Uint8Array; + timeoutMs: number; + stdoutMaxBytes: number; + stderrMaxBytes: number; +} + +function invalid(label: string, detail: string): Error { + return new Error(`${label} ${detail}`); +} + +function assertString(value: unknown, label: string): asserts value is string { + if (typeof value !== "string" || value.includes("\0")) throw invalid(label, "must be a NUL-free string"); +} + +function assertCanonicalExecutablePath(value: string, label = "executable path"): void { + assertString(value, label); + if (!value.startsWith("/")) throw invalid(label, "must be absolute"); + if (value === "/" || value.endsWith("/") || value.includes("\\")) { + throw invalid(label, "must be a canonical absolute path"); + } + const parts = value.split("/").slice(1); + if (parts.some((part) => part.length === 0 || part === "." || part === "..")) { + throw invalid(label, "must be a canonical absolute path"); + } +} + +function assertConfinedPath(rootDir: string, requested: string, label: string): string { + assertString(rootDir, "root directory"); + assertString(requested, label); + if (!rootDir.startsWith("/") || rootDir.endsWith("/") || rootDir.includes("\0")) { + throw invalid("root directory", "must be an absolute path"); + } + const root = posix.normalize(rootDir); + const candidate = requested.startsWith("/") ? posix.normalize(requested) : posix.join(root, requested); + const relative = posix.relative(root, candidate); + if (relative === ".." || relative.startsWith("../") || posix.isAbsolute(relative)) { + throw invalid(label, "must stay inside the workspace root"); + } + if (requested.split("/").some((part) => part === "..")) { + throw invalid(label, "must not contain parent traversal"); + } + return candidate; +} + +function assertEnvKey(key: string, label: string): void { + if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(key)) throw invalid(label, `contains an invalid key ${JSON.stringify(key)}`); + if (/^AGENT_/i.test(key)) throw invalid(label, `contains a reserved key ${JSON.stringify(key)}`); + if (key === "PATH") throw invalid(label, `contains a runtime-owned key ${JSON.stringify(key)}`); +} + +function assertDynamicEnvKey(key: string, label: string): void { + if (!(DIRECT_DYNAMIC_ENV_KEYS as readonly string[]).includes(key)) { + throw invalid(label, `contains an unknown dynamic key ${JSON.stringify(key)}`); + } +} + +function boundedBytes(value: number | undefined, fallback: number, label: string, ceiling: number): number { + const out = value ?? fallback; + if (!Number.isSafeInteger(out) || out < 0 || out > ceiling) + throw invalid(label, `must be an integer between 0 and ${ceiling}`); + return out; +} + +function bytes(value: string | Uint8Array | undefined, label: string, cap: number): Uint8Array | undefined { + if (value === undefined) return undefined; + const out = typeof value === "string" ? Buffer.from(value, "utf8") : new Uint8Array(value); + if (out.length > cap) throw invalid(label, `exceeds the ${cap}-byte limit`); + return out; +} + +function mergeEnv( + command: ScopedCommand, + opts: DirectExecOptions, +): { env: Record; allowedEnvKeys: string[]; dynamicEnvKeys: string[] } { + const raw = { ...command.env, ...opts.env }; + const dynamicEnv = opts.dynamicEnv ?? {}; + const dynamicEnvKeys = Object.keys(dynamicEnv); + for (const key of dynamicEnvKeys) { + assertDynamicEnvKey(key, "dynamic environment"); + assertString(dynamicEnv[key], `dynamic environment value ${key}`); + if (key in raw) throw invalid("dynamic environment", `key ${JSON.stringify(key)} is also static`); + raw[key] = dynamicEnv[key]!; + } + const allowed = opts.allowedEnvKeys ?? command.allowedEnvKeys ?? []; + const allowedEnvKeys: string[] = []; + if (allowed !== undefined) { + const keys = new Set(); + for (const key of allowed) { + assertString(key, "environment allowlist key"); + assertEnvKey(key, "environment allowlist"); + if (keys.has(key)) throw invalid("environment allowlist", `contains a duplicate key ${JSON.stringify(key)}`); + keys.add(key); + allowedEnvKeys.push(key); + } + for (const key of Object.keys(raw)) { + if (!dynamicEnvKeys.includes(key) && !keys.has(key)) { + throw invalid("environment", `key ${JSON.stringify(key)} is not allowed`); + } + } + } + for (const [key, value] of Object.entries(raw)) { + if (!dynamicEnvKeys.includes(key)) assertEnvKey(key, "environment"); + assertString(value, `environment value ${key}`); + } + return { env: raw, allowedEnvKeys, dynamicEnvKeys }; +} + +export function directRequest( + rootDir: string, + command: ScopedCommand, + opts: DirectExecOptions = {}, +): DirectExecRequest { + if (!Array.isArray(command.argv) || command.argv.length === 0) { + throw invalid("direct argv", "must contain an executable"); + } + const argv = [...command.argv]; + for (const [index, arg] of argv.entries()) assertString(arg, `direct argv[${index}]`); + const executable = command.executablePath ?? argv[0]!; + assertCanonicalExecutablePath(executable); + if (argv[0] !== executable) throw invalid("direct argv[0]", "must equal the descriptor-owned executable path"); + const cwd = assertConfinedPath(rootDir, opts.cwd ?? command.cwd ?? rootDir, "direct cwd"); + const timeoutMs = boundedBytes(opts.timeoutMs, DIRECT_DEFAULT_TIMEOUT_MS, "direct timeout", 24 * 60 * 60 * 1000); + const stdinCap = boundedBytes( + opts.stdinMaxBytes, + DIRECT_DEFAULT_STDIN_MAX_BYTES, + "direct stdin cap", + DIRECT_MAX_STDIN_BYTES, + ); + const stdoutMaxBytes = boundedBytes( + opts.stdoutMaxBytes, + DIRECT_DEFAULT_STDOUT_MAX_BYTES, + "direct stdout cap", + DIRECT_MAX_OUTPUT_BYTES, + ); + const stderrMaxBytes = boundedBytes( + opts.stderrMaxBytes, + DIRECT_DEFAULT_STDERR_MAX_BYTES, + "direct stderr cap", + DIRECT_MAX_OUTPUT_BYTES, + ); + const stdin = bytes(opts.stdin ?? command.stdin, "direct stdin", stdinCap); + const merged = mergeEnv(command, opts); + return { + argv, + cwd, + rootDir: posix.normalize(rootDir), + env: { PATH: DIRECT_RUNTIME_PATH, ...merged.env }, + allowedEnvKeys: merged.allowedEnvKeys, + dynamicEnvKeys: merged.dynamicEnvKeys, + ...(stdin ? { stdin } : {}), + timeoutMs, + stdoutMaxBytes, + stderrMaxBytes, + }; +} diff --git a/src/sandbox/smolmachines-sandbox.ts b/src/sandbox/smolmachines-sandbox.ts index 78f426cd2..f6ffd216f 100644 --- a/src/sandbox/smolmachines-sandbox.ts +++ b/src/sandbox/smolmachines-sandbox.ts @@ -25,6 +25,7 @@ import type { BlobTransferStore } from "../persistence/blob-transfer.ts"; import { CAPABILITY_HEADER } from "../api/contract.ts"; import { killableScript, killScript } from "./exec-kill.ts"; import { visibleNotInstalled, visibleTools } from "./sandbox.ts"; +import { CapabilityUnsupportedError } from "./sandbox.ts"; import { spriteScopeName } from "./sprites-sandbox.ts"; import type { AgentComputerProfile, @@ -35,6 +36,7 @@ import type { SandboxHandle, TeardownOptions, } from "./sandbox.ts"; +import type { DirectExecOptions, ScopedCommand } from "./scoped-exec.ts"; const HOME_DIR = "/root"; const WORKSPACE_BASENAME = "workspace"; @@ -373,6 +375,7 @@ export function createSmolmachinesSandbox(workspace: WorkspaceStore, opts: Smolm backend: "smolmachines", writablePersistence: "resident_disk", processSessions: true, + directExecution: false, egressEnforcement: "none", spec: { os: "Debian 12 — smolmachines microVM (auto-stops when idle; the whole disk persists)", @@ -537,6 +540,14 @@ export function createSmolmachinesSandbox(workspace: WorkspaceStore, opts: Smolm } }, + async runDirect( + _handle: SandboxHandle, + _command: ScopedCommand, + _execOpts?: DirectExecOptions, + ): Promise { + throw new CapabilityUnsupportedError(profile.backend, "structured direct execution"); + }, + async writeFileBytes(handle, relPath, data): Promise { await writeAbsBytes(handle.id, posixJoin(handle.rootDir, relPath), data); }, diff --git a/src/sandbox/sprites-sandbox.ts b/src/sandbox/sprites-sandbox.ts index c9fd517be..71d43d107 100644 --- a/src/sandbox/sprites-sandbox.ts +++ b/src/sandbox/sprites-sandbox.ts @@ -24,6 +24,7 @@ import { ephemeralCredLinkPaths } from "../credentials/resident-paths.ts"; import { shortHash } from "../util/crypto.ts"; import { killableScript, killScript } from "./exec-kill.ts"; import { visibleNotInstalled, visibleTools } from "./sandbox.ts"; +import { CapabilityUnsupportedError } from "./sandbox.ts"; import type { AgentComputerProfile, ExecOptions, @@ -33,6 +34,7 @@ import type { SandboxHandle, TeardownOptions, } from "./sandbox.ts"; +import { directRequest, type DirectExecOptions, type ScopedCommand } from "./scoped-exec.ts"; const HOME_DIR = "/home/sprite"; const WORKSPACE_BASENAME = "workspace"; @@ -45,11 +47,132 @@ const RESTART_TIMEOUT_MS = 60_000; const CHECK_TIMEOUT_MS = 30_000; const GUEST_PROBE_TIMEOUT_SEC = 15; const DEFAULT_SPRITES_BASE_URL = "https://api.sprites.dev"; +const DIRECT_HELPER_RESPONSE_MAX_BYTES = 256 * 1024 * 1024; +export const DIRECT_HELPER_EXECUTABLE = "/usr/local/bin/node"; +export const DIRECT_HELPER_SCRIPT = String.raw` +const fs = require("node:fs"); +const path = require("node:path"); +const childProcess = require("node:child_process"); +const dynamicKeys = new Set(["AGENT_API_URL", "AGENT_API_TOKEN", "AGENT_OAUTH_CONSENT_TOKEN", "AGENT_CREDENTIAL_TOKEN", "AGENT_OUTBOX"]); +const runtimePath = "/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin"; +const inputCap = 16 * 1024 * 1024; +const outputCap = 64 * 1024 * 1024; +const requestCap = 128 * 1024 * 1024; +const resultCap = 256 * 1024 * 1024; +const dynamicKey = (key) => dynamicKeys.has(key); +const canonical = (value) => typeof value === "string" && value.startsWith("/") && value !== "/" && !value.endsWith("/") && !value.includes("\\") && !value.includes("\0") && value.split("/").slice(1).every((part) => part.length > 0 && part !== "." && part !== ".."); +const bounded = (value, fallback, cap) => { + const result = value === undefined ? fallback : Number(value); + return Number.isSafeInteger(result) && result >= 0 && result <= cap ? result : null; +}; +const output = (value) => { + const encoded = JSON.stringify(value); + process.stdout.write(encoded.length > resultCap ? JSON.stringify({ error: "direct helper result exceeds limit" }) : encoded); +}; +const run = (raw) => { + let request; + try { + request = JSON.parse(raw); + } catch { + return output({ error: "invalid direct request" }); + } + if (!request || !Array.isArray(request.argv) || request.argv.length === 0 || request.argv.length > 4096 || request.argv.some((arg) => typeof arg !== "string" || arg.includes("\0")) || !canonical(request.argv[0])) return output({ error: "invalid direct argv" }); + if (!canonical(request.rootDir) || !canonical(request.cwd)) return output({ error: "invalid direct path" }); + const root = path.posix.normalize(request.rootDir); + const cwd = path.posix.normalize(request.cwd); + const relative = path.posix.relative(root, cwd); + if (relative === ".." || relative.startsWith("../") || path.posix.isAbsolute(relative) || request.cwd.split("/").includes("..")) return output({ error: "direct cwd escapes rootDir" }); + const dynamicEnvKeys = request.dynamicEnvKeys === undefined ? [] : request.dynamicEnvKeys; + if (!Array.isArray(dynamicEnvKeys) || dynamicEnvKeys.some((key) => typeof key !== "string" || !dynamicKey(key)) || new Set(dynamicEnvKeys).size !== dynamicEnvKeys.length) return output({ error: "invalid dynamic env keys" }); + const allowedEnvKeys = request.allowedEnvKeys === undefined ? [] : request.allowedEnvKeys; + if (!Array.isArray(allowedEnvKeys) || allowedEnvKeys.some((key) => typeof key !== "string" || !/^[A-Za-z_][A-Za-z0-9_]*$/.test(key) || key === "PATH" || key.startsWith("AGENT_")) || new Set(allowedEnvKeys).size !== allowedEnvKeys.length) return output({ error: "invalid allowed env keys" }); + if (!request.env || typeof request.env !== "object" || Array.isArray(request.env)) return output({ error: "invalid direct env" }); + for (const [key, value] of Object.entries(request.env)) { + if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(key) || (key === "PATH" && value !== runtimePath) || (key !== "PATH" && !dynamicEnvKeys.includes(key) && !allowedEnvKeys.includes(key)) || (key.startsWith("AGENT_") && !dynamicEnvKeys.includes(key)) || typeof value !== "string" || value.includes("\0")) return output({ error: "invalid direct env" }); + } + const timeoutMs = bounded(request.timeoutMs, 600000, 86400000); + const stdoutMaxBytes = bounded(request.stdoutMaxBytes, 4 * 1024 * 1024, outputCap); + const stderrMaxBytes = bounded(request.stderrMaxBytes, 4 * 1024 * 1024, outputCap); + if (timeoutMs === null || stdoutMaxBytes === null || stderrMaxBytes === null) return output({ error: "invalid direct limits" }); + let stdin = Buffer.alloc(0); + if (request.stdinB64 !== undefined) { + if (typeof request.stdinB64 !== "string") return output({ error: "invalid direct stdin" }); + stdin = Buffer.from(request.stdinB64, "base64"); + if (stdin.length > inputCap) return output({ error: "direct stdin exceeds limit" }); + } + let rootReal; + let cwdReal; + let executableReal; + try { + rootReal = fs.realpathSync(root); + cwdReal = fs.realpathSync(cwd); + executableReal = fs.realpathSync(request.argv[0]); + if (cwdReal !== rootReal && !cwdReal.startsWith(rootReal + path.sep)) return output({ error: "direct cwd escapes rootDir" }); + if (executableReal !== request.argv[0] || !fs.statSync(executableReal).isFile()) return output({ error: "direct executable is not canonical" }); + } catch { + return output({ error: "direct path is not available" }); + } + const stdout = []; + const stderr = []; + let stdoutBytes = 0; + let stderrBytes = 0; + let timedOut = false; + let outputLimitExceeded = false; + let finished = false; + const child = childProcess.spawn(request.argv[0], request.argv.slice(1), { cwd: cwdReal, env: { ...request.env }, detached: true, stdio: ["pipe", "pipe", "pipe"] }); + const killTree = () => { + if (finished || !child.pid) return; + try { process.kill(-child.pid, "SIGKILL"); } catch { child.kill("SIGKILL"); } + }; + const timer = setTimeout(() => { + if (!finished) { + timedOut = true; + killTree(); + } + }, Math.max(1, timeoutMs)); + const take = (parts, current, chunk, limit, stream) => { + const remaining = Math.max(0, limit - current); + if (remaining) parts.push(chunk.subarray(0, remaining)); + const next = current + chunk.length; + if (next > limit) { + outputLimitExceeded = true; + stream.destroy(); + killTree(); + } + return next; + }; + child.stdout.on("data", (chunk) => { stdoutBytes = take(stdout, stdoutBytes, chunk, stdoutMaxBytes, child.stdout); }); + child.stderr.on("data", (chunk) => { stderrBytes = take(stderr, stderrBytes, chunk, stderrMaxBytes, child.stderr); }); + child.stdin.end(stdin); + child.on("error", (error) => { + if (finished) return; + finished = true; + clearTimeout(timer); + output({ error: "direct helper could not start executable" }); + }); + child.on("close", (code, signal) => { + if (finished) return; + finished = true; + clearTimeout(timer); + const resultCode = timedOut ? 124 : outputLimitExceeded ? 122 : code === null ? 1 : code; + output({ stdoutB64: Buffer.concat(stdout).toString("base64"), stderrB64: Buffer.concat(stderr).toString("base64"), code: resultCode, timedOut, outputLimitExceeded, stdoutTruncated: stdoutBytes > stdoutMaxBytes, stderrTruncated: stderrBytes > stderrMaxBytes, signal: signal || undefined }); + }); +}; +const chunks = []; +let size = 0; +process.stdin.on("data", (chunk) => { + size += chunk.length; + if (size > requestCap) process.exit(400); + chunks.push(chunk); +}); +process.stdin.on("end", () => run(Buffer.concat(chunks).toString("utf8"))); +`; export interface SpritesClientLike { getSprite(name: string): Promise; createSprite(name: string): Promise; deleteSprite(name: string): Promise; + sprite?(name: string): unknown; } export interface SpritesSandboxOptions { @@ -86,6 +209,7 @@ export function createSpritesSandbox(workspace: WorkspaceStore, opts: SpritesSan const baseUrl = (opts.baseUrl ?? DEFAULT_SPRITES_BASE_URL).replace(/\/+$/, ""); const prefix = opts.namePrefix ?? "qm"; const defaultTimeoutSec = opts.defaultTimeoutSec ?? 600; + const nativeDirect = Boolean(opts.token && typeof fetchImpl === "function"); const workspaceDir = `${HOME_DIR}/${WORKSPACE_BASENAME}`; const provisionQueue = createKeyedQueue(); @@ -100,6 +224,166 @@ export function createSpritesSandbox(workspace: WorkspaceStore, opts: SpritesSan stderr: Buffer; } + async function execDirectNative( + name: string, + request: { + argv: string[]; + cwd: string; + rootDir: string; + env: Record; + allowedEnvKeys: string[]; + dynamicEnvKeys: string[]; + stdin?: Uint8Array; + timeoutMs: number; + stdoutMaxBytes: number; + stderrMaxBytes: number; + }, + signal?: AbortSignal, + ): Promise< + RawExec & { + timedOut: boolean; + outputLimitExceeded: boolean; + stdoutTruncated: boolean; + stderrTruncated: boolean; + signal?: string; + } + > { + const url = new URL(`${baseUrl}/v1/sprites/${encodeURIComponent(name)}/exec`); + for (const arg of [DIRECT_HELPER_EXECUTABLE, "-e", DIRECT_HELPER_SCRIPT]) url.searchParams.append("cmd", arg); + url.searchParams.set("path", DIRECT_HELPER_EXECUTABLE); + url.searchParams.set("stdin", "true"); + url.searchParams.set("tty", "false"); + url.searchParams.set("max_run_after_disconnect", "0s"); + const payload = Buffer.from( + JSON.stringify({ + argv: request.argv, + rootDir: request.rootDir, + cwd: request.cwd, + env: request.env, + allowedEnvKeys: request.allowedEnvKeys, + dynamicEnvKeys: request.dynamicEnvKeys, + ...(request.stdin ? { stdinB64: Buffer.from(request.stdin).toString("base64") } : {}), + timeoutMs: request.timeoutMs, + stdoutMaxBytes: request.stdoutMaxBytes, + stderrMaxBytes: request.stderrMaxBytes, + }), + ); + const timeoutSignal = AbortSignal.timeout(request.timeoutMs + EXIT_GRACE_MS); + const requestSignal = signal ? AbortSignal.any([signal, timeoutSignal]) : timeoutSignal; + let response: Response; + try { + response = await fetchImpl(url.toString(), { + method: "POST", + headers: { authorization: `Bearer ${opts.token ?? ""}`, "content-type": "application/octet-stream" }, + body: payload, + signal: requestSignal, + }); + } catch (error) { + if (signal?.aborted) throw signal.reason ?? error; + if (timeoutSignal.aborted) { + return { + rc: 124, + stdout: Buffer.alloc(0), + stderr: Buffer.alloc(0), + timedOut: true, + outputLimitExceeded: false, + stdoutTruncated: false, + stderrTruncated: false, + }; + } + throw new Error("sprites direct execution request failed", { cause: error }); + } + if (!response.ok || !response.body) throw new Error("sprites direct execution request failed"); + const timeoutResult = (): RawExec & { + timedOut: boolean; + outputLimitExceeded: boolean; + stdoutTruncated: boolean; + stderrTruncated: boolean; + signal?: string; + } => ({ + rc: 124, + stdout: Buffer.alloc(0), + stderr: Buffer.alloc(0), + timedOut: true, + outputLimitExceeded: false, + stdoutTruncated: false, + stderrTruncated: false, + }); + const responseChunks: Buffer[] = []; + let responseBytes = 0; + const reader = response.body.getReader(); + try { + for (;;) { + const { value, done } = await reader.read(); + if (done) break; + if (!value?.length) continue; + responseBytes += value.length; + if (responseBytes > DIRECT_HELPER_RESPONSE_MAX_BYTES) + throw new Error("sprites direct execution response exceeded limit"); + responseChunks.push(Buffer.from(value)); + } + } catch (error) { + if (signal?.aborted) throw signal.reason ?? error; + if (timeoutSignal.aborted) return timeoutResult(); + throw new Error("sprites direct execution response failed", { cause: error }); + } finally { + await reader.cancel().catch(() => undefined); + } + const responseBytesAll = Buffer.concat(responseChunks); + const stdout: Buffer[] = []; + let exitCode = -1; + let offset = 0; + while (offset < responseBytesAll.length) { + const frameType = responseBytesAll[offset++]!; + if (frameType === 3) { + if (offset >= responseBytesAll.length) + throw new Error("sprites direct execution returned an invalid exit frame"); + exitCode = responseBytesAll[offset++]!; + continue; + } + if (frameType !== 1 && frameType !== 2) throw new Error("sprites direct execution returned an unsupported frame"); + const start = offset; + while (offset < responseBytesAll.length && responseBytesAll[offset]! >= 4) offset++; + const frame = responseBytesAll.subarray(start, offset); + if (frameType === 1) stdout.push(frame); + } + if (exitCode < 0) throw new Error("sprites direct execution returned no exit frame"); + let envelope: { + stdoutB64?: string; + stderrB64?: string; + code?: number; + timedOut?: boolean; + outputLimitExceeded?: boolean; + stdoutTruncated?: boolean; + stderrTruncated?: boolean; + signal?: string; + error?: string; + }; + try { + envelope = JSON.parse(Buffer.concat(stdout).toString("utf8")) as typeof envelope; + } catch { + throw new Error("sprites direct helper returned an invalid result"); + } + if (envelope.error) throw new Error(`sprites direct helper failed: ${envelope.error}`); + if ( + typeof envelope.code !== "number" || + typeof envelope.stdoutB64 !== "string" || + typeof envelope.stderrB64 !== "string" + ) { + throw new Error("sprites direct helper returned an invalid result"); + } + return { + rc: envelope.code, + stdout: Buffer.from(envelope.stdoutB64, "base64"), + stderr: Buffer.from(envelope.stderrB64, "base64"), + timedOut: !!envelope.timedOut, + outputLimitExceeded: !!envelope.outputLimitExceeded, + stdoutTruncated: !!envelope.stdoutTruncated, + stderrTruncated: !!envelope.stderrTruncated, + ...(envelope.signal ? { signal: envelope.signal } : {}), + }; + } + async function postExec(name: string, argv: string[], timeoutSec: number, body?: Uint8Array): Promise { const qs = new URLSearchParams(); if (body) qs.append("stdin", "true"); @@ -297,6 +581,7 @@ export function createSpritesSandbox(workspace: WorkspaceStore, opts: SpritesSan backend: "sprites", writablePersistence: "resident_disk", processSessions: true, + directExecution: nativeDirect, egressEnforcement: opts.egressProxyUrl ? "domain" : "none", spec: { os: "Ubuntu 26.04 LTS — Fly Sprite microVM (auto-sleeps when idle; the whole disk persists)", @@ -457,6 +742,23 @@ export function createSpritesSandbox(workspace: WorkspaceStore, opts: SpritesSan } }, + async runDirect(handle: SandboxHandle, command: ScopedCommand, execOpts?: DirectExecOptions): Promise { + if (!nativeDirect) throw new CapabilityUnsupportedError(profile.backend, "structured direct execution"); + const request = directRequest(handle.rootDir, command, execOpts); + execOpts?.signal?.throwIfAborted(); + const result = await execDirectNative(handle.id, request, execOpts?.signal); + return { + stdout: result.stdout.toString("utf8"), + stderr: result.stderr.toString("utf8"), + code: result.rc, + timedOut: result.timedOut, + ...(result.stdoutTruncated ? { stdoutTruncated: true } : {}), + ...(result.stderrTruncated ? { stderrTruncated: true } : {}), + ...(result.outputLimitExceeded ? { outputLimitExceeded: true } : {}), + ...(result.signal ? { signal: result.signal } : {}), + }; + }, + async writeFileBytes(handle, relPath, data): Promise { await writeAbsBytes(handle.id, posixJoin(handle.rootDir, relPath), data); }, diff --git a/src/security/secret-masking.ts b/src/security/secret-masking.ts index d433bd0a7..62c06f5af 100644 --- a/src/security/secret-masking.ts +++ b/src/security/secret-masking.ts @@ -13,10 +13,19 @@ const NON_SECRET_ENV_KEYS = new Set([ const MIN_MASKABLE_LENGTH = 8; -export function createSecretValueMasker(env: Record | undefined): (text: string) => string { +export interface SecretValueMaskerOptions { + minimumLength?: number; + maskNonSecretKeys?: boolean; +} + +export function createSecretValueMasker( + env: Record | undefined, + options: SecretValueMaskerOptions = {}, +): (text: string) => string { + const minimumLength = options.minimumLength ?? MIN_MASKABLE_LENGTH; const variants: Array<{ needle: string; label: string }> = []; for (const [key, value] of Object.entries(env ?? {})) { - if (NON_SECRET_ENV_KEYS.has(key) || value.length < MIN_MASKABLE_LENGTH) continue; + if ((!options.maskNonSecretKeys && NON_SECRET_ENV_KEYS.has(key)) || value.length < minimumLength) continue; variants.push({ needle: value, label: key }); const uri = encodeURIComponent(value); if (uri !== value) variants.push({ needle: uri, label: key }); diff --git a/src/tools/primitives.ts b/src/tools/primitives.ts index d3ea49029..e64c9ebd5 100644 --- a/src/tools/primitives.ts +++ b/src/tools/primitives.ts @@ -156,7 +156,7 @@ export interface ToolContext extends SurfaceToolDeps { credentialExec?( service: string, args: string[], - opts?: { timeoutSeconds?: number; signal?: AbortSignal }, + opts?: { timeoutSeconds?: number; signal?: AbortSignal; stdin?: string }, ): Promise; execute( command: string, diff --git a/src/wiring.ts b/src/wiring.ts index f49b80a81..60056434e 100644 --- a/src/wiring.ts +++ b/src/wiring.ts @@ -1057,6 +1057,7 @@ export function buildApp( layerBrokerFor, brokeredTools, deploymentLayer, + secretSource, }; const orchestrator = createOrchestrator(orchestratorDeps); diff --git a/test/agent-computer-profile.test.ts b/test/agent-computer-profile.test.ts index f2a933562..2cc4a1761 100644 --- a/test/agent-computer-profile.test.ts +++ b/test/agent-computer-profile.test.ts @@ -42,6 +42,7 @@ test("the sprites sandbox declares the Agent Computer contract (persistent per-s backend: "sprites", writablePersistence: "resident_disk", processSessions: true, + directExecution: true, egressEnforcement: "none", }); assert.match(spec?.os ?? "", /Ubuntu/); diff --git a/test/device-flow-persist.test.ts b/test/device-flow-persist.test.ts index f7bf2bc8c..396a4daeb 100644 --- a/test/device-flow-persist.test.ts +++ b/test/device-flow-persist.test.ts @@ -69,6 +69,17 @@ function acmecliBrokeredLayer(binary?: string, approvals?: Array<{ pattern: stri return dir; } +function installStructuredCredentialExecTestRunner(built: ReturnType): void { + built.sandbox.runDirect = async (_handle, _command, opts) => ({ + stdout: Object.entries(opts?.env ?? {}) + .map(([key, value]) => `${key}=${value}`) + .join("\n"), + stderr: "", + code: 0, + timedOut: false, + }); +} + test("deviceFlowCredOwner: the person on their own personal box, the scope on a shared box", () => { assert.equal(deviceFlowCredOwner(scopeId("personal", "U1"), "U1"), "U1"); assert.equal(deviceFlowCredOwner(scopeId("channel", "C1"), "U1"), scopeId("channel", "C1")); @@ -109,6 +120,7 @@ test("personal ephemeral-only credentials run only through credential_exec and a }, }, ); + installStructuredCredentialExecTestRunner(built); const personal = scopeId("personal", actor.externalId); const conversation = { kind: "dm" as const, @@ -176,6 +188,7 @@ test("credential_exec honors deployment approval rules before vending credential }, }, ); + installStructuredCredentialExecTestRunner(built); const personal = scopeId("personal", actor.externalId); const conversation = { kind: "dm" as const, threadRef: "dm:credexec-approval", audience: [actor] }; await built.deviceFlowCutover.set(personal, "acmecli", "ephemeral_only", "security@example.com"); @@ -236,6 +249,7 @@ test("a scope allow rule cannot override the ephemeral_only direct-execution den }, }, ); + installStructuredCredentialExecTestRunner(built); const personal = scopeId("personal", actor.externalId); built.config.setCommandPolicy(personal, { mode: "denylist", @@ -589,28 +603,33 @@ test("a capture failure is logged as an error event and does NOT fail the turn", }); test("shared ACMECLI cutover isolates brokered STS without shrinking the existing scopeShared owner union", async () => { + const brokerActors: string[] = []; const acmecliBroker = createAwsRoleBroker({ roleArn: "arn:aws:iam::123456789012:role/acmecli-broker", region: "us-west-2", sessionActions: ["execute-api:Invoke"], - assumeRole: async ({ RoleSessionName }) => ({ - Credentials: { - AccessKeyId: `AKIA_${RoleSessionName}`, - SecretAccessKey: `secret_${RoleSessionName}`, - SessionToken: `session_${RoleSessionName}`, - Expiration: new Date(Date.now() + 3_600_000), - }, - }), + assumeRole: async ({ RoleSessionName }) => { + brokerActors.push(RoleSessionName); + return { + Credentials: { + AccessKeyId: `AKIA_${RoleSessionName}`, + SecretAccessKey: `secret_${RoleSessionName}`, + SessionToken: `session_${RoleSessionName}`, + Expiration: new Date(Date.now() + 3_600_000), + }, + }; + }, }); const built = buildApp( testConfig({ dataDir: mkdtempSync(join(tmpdir(), "dfp-owner-box-")), signingSecret: "device-flow-test-secret", sharedOwnerAuthIsolation: true, - deploymentLayerDir: acmecliBrokeredLayer(), + deploymentLayerDir: acmecliBrokeredLayer("acmecli"), }), { credentialBrokers: { acmecli: acmecliBroker } }, ); + installStructuredCredentialExecTestRunner(built); const bob = { externalId: "BOB" }; const alice = { externalId: "ALICE" }; const room = scopeId("channel", "C-owner-auth"); @@ -660,14 +679,25 @@ test("shared ACMECLI cutover isolates brokered STS without shrinking the existin surface: "cron", actor: bob, conversation: { ...conversation, threadRef: "ch:C-owner-auth:brokered-acmecli" }, - text: "!owner mkdir -p /tmp/bin; printf '%s\\n' '#!/bin/sh' 'printf \"%s\" \"$AWS_ACCESS_KEY_ID\"' > /tmp/bin/acmecli; chmod +x /tmp/bin/acmecli; export PATH=\"/tmp/bin:$PATH\"; printf '%s|' \"$AWS_ACCESS_KEY_ID\"; acmecli", + text: "!credential acmecli []", triggered: true, ownerKeychainUnion: true, }); - assert.equal( - brokeredAcmecli.reply, - "AKIA_BOB_GENERAL|AKIA_BOB_GENERAL", - "prefer-ephemeral direct execution retains the owner's legacy fallback without broker vending", + assert.match(brokeredAcmecli.reply ?? "", //); + assert.doesNotMatch(brokeredAcmecli.reply ?? "", /AKIA_BOB(?:_GENERAL)?/); + + const shellAcmecli = await built.app.turn({ + surface: "cron", + actor: bob, + conversation: { ...conversation, threadRef: "ch:C-owner-auth:shell-acmecli" }, + text: "!owner acmecli", + triggered: true, + ownerKeychainUnion: true, + }); + assert.match( + `${shellAcmecli.reason ?? ""} ${shellAcmecli.reply ?? ""}`, + /credential_exec/, + "a cutover tool is no longer wrapped into an owner shell command", ); const unpoisoned = await built.app.turn({ @@ -683,9 +713,8 @@ test("shared ACMECLI cutover isolates brokered STS without shrinking the existin assert.ok( ownerAudit.some((event) => event.action === "keychain.materialize" && event.resource.includes("owner-auth box")), ); - assert.equal( - ownerAudit.some((event) => event.action === "credential.materialize"), - false, + assert.ok( + ownerAudit.some((event) => event.action === "credential.materialize" && event.resource.includes("acmecli")), ); const scoped = await built.app.turn({ @@ -699,8 +728,8 @@ test("shared ACMECLI cutover isolates brokered STS without shrinking the existin assert.equal(scoped.status, "ok", scoped.reason); assert.equal( scoped.reply, - "unset|unset|absent|found", - "prefer-isolated keeps resident ACMECLI as a live fallback without placing Bob's private credentials on the room", + "unset|unset|absent|absent", + "prefer-ephemeral quarantines resident ACMECLI instead of exposing it to the shared shell", ); const poisoned = await built.app.turn({ @@ -724,13 +753,13 @@ test("shared ACMECLI cutover isolates brokered STS without shrinking the existin surface: "slack", actor: alice, conversation: { ...conversation, threadRef: "ch:C-owner-auth:alice-acmecli" }, - text: '!owner mkdir -p /tmp/bin; printf \'%s\\n\' \'#!/bin/sh\' \'printf "%s" "$AWS_ACCESS_KEY_ID"\' > /tmp/bin/acmecli; chmod +x /tmp/bin/acmecli; export PATH="/tmp/bin:$PATH"; acmecli; printf \'|%s|%s\' "${NPM_TOKEN-unset}" "$(test -e ~/.config/acmecorp/auth.json && echo found || echo absent)"', + text: "!credential acmecli []", }); assert.equal(aliceAcmecli.status, "ok", aliceAcmecli.reason); - assert.equal( - aliceAcmecli.reply, - "|unset|absent", - "direct execution has no brokered identity and no access to Bob's keychain", + assert.match(aliceAcmecli.reply ?? "", //); + assert.ok( + brokerActors.some((name) => name.includes("ALICE")), + "structured ACMECLI vending binds the acting user without Bob's keychain", ); assert.equal( ff.names().some((n) => n.includes("scratch")), @@ -768,14 +797,15 @@ test("shared ACMECLI cutover isolates brokered STS without shrinking the existin "ephemeral-only removes already-materialized legacy files without deleting the stored record", ); const acmecliUsage = await built.credentialUsage.list({ slug: "acmecli" }); - assert.equal( + assert.ok( acmecliUsage.some((row) => row.status === "ephemeral_vended"), - false, + "explicit structured calls vend ephemeral identity without adding it to shell state", ); const legacyUsage = await built.credentialUsage.list({ slug: "keychain:acmecli" }); - assert.ok( + assert.equal( legacyUsage.some((row) => row.status === "legacy_retained"), - "prefer-isolated records that resident fallback remains present", + false, + "prefer-ephemeral never records or materializes resident fallback", ); const realMaterializeOwnFiles = built.keychain!.materializeOwnFiles.bind(built.keychain!); @@ -880,13 +910,13 @@ test("shared ACMECLI cutover isolates brokered STS without shrinking the existin ); }); -test("prefer-isolated keeps legacy ACMECLI when STS vending fails; isolated-only fails closed", async () => { +test("all nonlegacy broker modes quarantine resident credentials and fail closed when vending fails", async () => { const built = buildApp( testConfig({ dataDir: mkdtempSync(join(tmpdir(), "dfp-acmecli-fallback-")), signingSecret: "device-flow-test-secret", sharedOwnerAuthIsolation: true, - deploymentLayerDir: acmecliBrokeredLayer(), + deploymentLayerDir: acmecliBrokeredLayer("acmecli"), }), { credentialBrokers: { @@ -901,6 +931,7 @@ test("prefer-isolated keeps legacy ACMECLI when STS vending fails; isolated-only }, }, ); + installStructuredCredentialExecTestRunner(built); const room = scopeId("channel", "C-acmecli-fallback"); await built.keychain!.save({ ownerId: room, @@ -925,9 +956,9 @@ test("prefer-isolated keeps legacy ACMECLI when STS vending fails; isolated-only surface: "slack", actor, conversation: { ...conversation, threadRef: "ch:C-acmecli-fallback:prefer" }, - text: "!run cat ~/.acmecli/session.json", + text: "!run test -e ~/.acmecli/session.json && echo found || echo absent", }); - assert.equal(fallback.reply, "legacy_ok"); + assert.equal(fallback.reply, "absent"); await assert.rejects( built.app.turn({ surface: "slack", @@ -969,7 +1000,7 @@ test("prefer-isolated keeps legacy ACMECLI when STS vending fails; isolated-only /could not vend credentials/, ); const usage = await built.credentialUsage.list({ slug: "acmecli" }); - assert.ok(usage.some((row) => row.status === "legacy_fallback")); + assert.ok(usage.some((row) => row.status === "prefer_ephemeral_failed_closed")); assert.ok(usage.some((row) => row.status === "ephemeral_failed_closed")); }); @@ -1017,9 +1048,9 @@ test("a nonlegacy policy never places brokered STS on a shared room when isolati surface: "slack", actor, conversation: { ...conversation, threadRef: "ch:C-acmecli-flag-off:prefer" }, - text: '!run printf \'%s|%s\' "${AWS_ACCESS_KEY_ID-unset}" "$(cat ~/.acmecli/session.json)"', + text: '!run printf \'%s|%s\' "${AWS_ACCESS_KEY_ID-unset}" "$(test -e ~/.acmecli/session.json && echo found || echo absent)"', }); - assert.equal(prefer.reply, "unset|legacy_ok"); + assert.equal(prefer.reply, "unset|absent"); await built.deviceFlowCutover.set(room, "acmecli", "ephemeral_only", "security@example.com"); const only = await built.app.turn({ diff --git a/test/run-direct-agent.test.ts b/test/run-direct-agent.test.ts new file mode 100644 index 000000000..5f08f506d --- /dev/null +++ b/test/run-direct-agent.test.ts @@ -0,0 +1,210 @@ +import { once } from "node:events"; +import { spawn, type ChildProcess } from "node:child_process"; +import { chmodSync, mkdtempSync, mkdirSync, realpathSync, rmSync, symlinkSync, writeFileSync } from "node:fs"; +import { createServer } from "node:net"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { after, before, test } from "node:test"; +import assert from "node:assert/strict"; +import { DIRECT_RUNTIME_PATH } from "../src/sandbox/scoped-exec.ts"; + +const agentPath = join(process.cwd(), "aws/microvm-agent/agent.mjs"); +const executablePath = realpathSync(process.execPath); +let daemon: ChildProcess; +let endpoint: string; +let rootDir: string; + +async function freePort(): Promise { + const server = createServer(); + await new Promise((resolve, reject) => { + server.once("error", reject); + server.listen(0, "127.0.0.1", () => resolve()); + }); + const address = server.address(); + if (!address || typeof address === "string") throw new Error("port allocation failed"); + const port = address.port; + await new Promise((resolve, reject) => { + server.close((error) => (error ? reject(error) : resolve())); + }); + return port; +} + +async function post( + path: string, + body: Record, + signal?: AbortSignal, +): Promise<{ status: number; value: any }> { + const response = await fetch(`${endpoint}${path}`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(body), + ...(signal ? { signal } : {}), + }); + const text = await response.text(); + return { status: response.status, value: text ? JSON.parse(text) : null }; +} + +function directBody(overrides: Record = {}): Record { + return { + argv: [executablePath, "-e", "process.stdout.write('ok')"], + executablePath, + rootDir, + cwd: rootDir, + env: {}, + allowedEnvKeys: [], + ...overrides, + }; +} + +before(async () => { + rootDir = mkdtempSync(join(tmpdir(), "qm-direct-agent-")); + mkdirSync(join(rootDir, "sub")); + const port = await freePort(); + endpoint = `http://127.0.0.1:${port}`; + daemon = spawn(executablePath, [agentPath], { env: { AGENT_PORT: String(port) }, stdio: ["ignore", "pipe", "pipe"] }); + const deadline = Date.now() + 5_000; + for (;;) { + try { + const response = await fetch(`${endpoint}/health`); + if (response.ok) return; + } catch { + // The daemon may not have bound its loopback port yet. + } + if (Date.now() >= deadline) throw new Error("direct execution daemon did not start"); + await new Promise((resolve) => setTimeout(resolve, 20)); + } +}); + +after(async () => { + daemon.kill("SIGKILL"); + if (daemon.exitCode === null) await once(daemon, "exit").catch(() => undefined); + rmSync(rootDir, { recursive: true, force: true }); +}); + +test("execv preserves literal argv, stdin, cwd, and excludes ambient environment", async () => { + const args = [ + ";", + "&&", + "||", + "|", + ">", + "<", + "$VAR", + "${VAR}", + "$(echo no)", + "`echo no`", + "*", + "line\nnext", + "'quoted'", + '"double"', + ]; + const script = + "process.stdout.write(JSON.stringify({args:process.argv.slice(1),stdin:require('node:fs').readFileSync(0,'utf8'),cwd:process.cwd(),only:process.env.DIRECT_ONLY,dynamic:process.env.AGENT_API_TOKEN,ambient:process.env.DIRECT_AMBIENT??null,keys:Object.keys(process.env).filter(key=>!key.startsWith('__CF_')).sort()}))"; + const stdin = "stdin ; && $VAR\n"; + const result = await post( + "/execv", + directBody({ + argv: [executablePath, "-e", script, ...args], + env: { DIRECT_ONLY: "yes", AGENT_API_TOKEN: "cap" }, + allowedEnvKeys: ["DIRECT_ONLY"], + dynamicEnvKeys: ["AGENT_API_TOKEN"], + cwd: join(rootDir, "sub"), + stdinB64: Buffer.from(stdin).toString("base64"), + }), + ); + assert.equal(result.status, 200); + assert.equal(result.value.code, 0); + assert.deepEqual(JSON.parse(result.value.stdout), { + args, + stdin, + cwd: realpathSync(join(rootDir, "sub")), + only: "yes", + dynamic: "cap", + ambient: null, + keys: ["AGENT_API_TOKEN", "DIRECT_ONLY"], + }); +}); + +test("execv starts descriptor scripts through the fixed non-inherited runtime PATH", async () => { + const scriptPath = join(rootDir, "descriptor-tool"); + writeFileSync(scriptPath, "#!/usr/bin/env node\nprocess.stdout.write(process.env.PATH || 'missing')\n"); + chmodSync(scriptPath, 0o755); + const executable = realpathSync(scriptPath); + const result = await post( + "/execv", + directBody({ argv: [executable], executablePath: executable, env: { PATH: DIRECT_RUNTIME_PATH } }), + ); + assert.equal(result.status, 200); + assert.equal(result.value.code, 0); + assert.equal(result.value.stdout, DIRECT_RUNTIME_PATH); +}); + +test("execv rejects reserved and invalid paths and environment", async () => { + assert.equal((await post("/execv", directBody({ env: { AGENT_SECRET: "x" } }))).status, 400); + assert.equal((await post("/execv", directBody({ env: { AGENT_API_TOKEN: "x" } }))).status, 400); + assert.equal((await post("/execv", directBody({ env: { PATH: "/tmp" } }))).status, 400); + assert.equal((await post("/execv", directBody({ env: { FOO: "x" } }))).status, 400); + assert.equal((await post("/execv", directBody({ argv: ["relative", "-e", ""] }))).status, 400); + assert.equal((await post("/execv", directBody({ cwd: join(rootDir, "..") }))).status, 400); + const outside = mkdtempSync(join(tmpdir(), "qm-direct-outside-")); + const escape = join(rootDir, "escape"); + const tool = join(rootDir, "tool"); + symlinkSync(outside, escape); + symlinkSync(executablePath, tool); + try { + assert.equal((await post("/execv", directBody({ cwd: escape }))).status, 400); + assert.equal((await post("/execv", directBody({ argv: [tool, "-e", ""] }))).status, 400); + } finally { + rmSync(outside, { recursive: true, force: true }); + } +}); + +test("execv reports nonzero exit, timeout, and bounded output", async () => { + const nonzero = await post("/execv", directBody({ argv: [executablePath, "-e", "process.exit(9)"] })); + assert.equal(nonzero.value.code, 9); + const timed = await post( + "/execv", + directBody({ argv: [executablePath, "-e", "setTimeout(()=>{},5000)"], timeoutMs: 50 }), + ); + assert.equal(timed.value.code, 124); + assert.equal(timed.value.timedOut, true); + const capped = await post( + "/execv", + directBody({ argv: [executablePath, "-e", "process.stdout.write('x'.repeat(100))"], stdoutMaxBytes: 7 }), + ); + assert.equal(capped.value.code, 122); + assert.equal(capped.value.stdout, "xxxxxxx"); + assert.equal(capped.value.stdoutTruncated, true); + assert.equal(capped.value.outputLimitExceeded, true); +}); + +test("execv does not expose spawn error details", async () => { + const marker = "secret-spawn-error-marker"; + const executable = join(realpathSync(rootDir), "bad-executable"); + writeFileSync(executable, `#!/not/installed/${marker}\n`); + chmodSync(executable, 0o755); + const result = await post("/execv", directBody({ argv: [executable], executablePath: executable })); + assert.equal(result.status, 200); + assert.equal(result.value.code, 127); + assert.equal(result.value.stderr, "direct executable could not be started"); + assert.equal(JSON.stringify(result.value).includes(marker), false); +}); + +test("execv abort closes the request and terminates the child", async () => { + const controller = new AbortController(); + const request = post( + "/execv", + directBody({ argv: [executablePath, "-e", "setTimeout(()=>{},5000)"], timeoutMs: 5000 }), + controller.signal, + ); + setTimeout(() => controller.abort(), 50); + await assert.rejects(request); + await new Promise((resolve) => setTimeout(resolve, 100)); +}); + +test("legacy exec route still evaluates the legacy command string", async () => { + const result = await post("/exec", { cmd: "printf '%s' legacy", timeoutSec: 1 }); + assert.equal(result.status, 200); + assert.equal(result.value.stdout, "legacy"); + assert.equal(result.value.code, 0); +}); diff --git a/test/run-direct-contract.test.ts b/test/run-direct-contract.test.ts new file mode 100644 index 000000000..4b393ff35 --- /dev/null +++ b/test/run-direct-contract.test.ts @@ -0,0 +1,175 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; +import { parseToolDescriptor } from "../src/deployment/deployment-layer.ts"; +import { resolvedDeploymentLayer } from "../src/deployment/load-layer.ts"; +import { createMemoryMap } from "../src/persistence/durable-map.ts"; +import { + CapabilityUnsupportedError, + supportsDirectExecution, + type AgentComputerProfile, + type ExecResult, + type Sandbox, + type SandboxHandle, +} from "../src/sandbox/sandbox.ts"; +import { createSandboxRouter } from "../src/sandbox/sandbox-routing.ts"; +import { DIRECT_RUNTIME_PATH, directRequest, type ScopedCommand } from "../src/sandbox/scoped-exec.ts"; +import { createSecretValueMasker } from "../src/security/secret-masking.ts"; +import type { WorkspaceLayer } from "../src/types.ts"; + +function fakeBackend(name: string, direct: boolean): Sandbox { + const profile: AgentComputerProfile = { + backend: name, + writablePersistence: "resident_disk", + processSessions: true, + directExecution: direct, + }; + const base: Partial = { + profile, + async provision(): Promise { + return { id: name, rootDir: `/${name}/workspace`, backend: name }; + }, + async run(): Promise { + return { stdout: name, stderr: "", code: 0, timedOut: false }; + }, + async teardown(): Promise {}, + async readFile(): Promise { + return null; + }, + async writeFile(): Promise {}, + async writeFileBytes(): Promise {}, + async readFileBytes(): Promise { + return null; + }, + async listDir(): Promise { + return []; + }, + async removeDir(): Promise {}, + }; + if (direct) { + base.runDirect = async (): Promise => ({ stdout: name, stderr: "", code: 0, timedOut: false }); + } + return base as Sandbox; +} + +function layers(scopeId: string): WorkspaceLayer[] { + return [{ scopeId: scopeId as WorkspaceLayer["scopeId"], mountPath: "/", mode: "rw" }]; +} + +test("directRequest enforces descriptor executable, confined cwd, and explicit env allowlists", () => { + const command: ScopedCommand = { + argv: ["/usr/local/bin/tool", "literal ; && $(echo no)"], + executablePath: "/usr/local/bin/tool", + allowedEnvKeys: ["PUBLIC_URL", "TOKEN"], + }; + const request = directRequest("/workspace", command, { + env: { PUBLIC_URL: "https://example.test", TOKEN: "secret" }, + dynamicEnv: { AGENT_API_TOKEN: "cap" }, + cwd: "subdir", + stdin: "literal\n", + timeoutMs: 100, + stdoutMaxBytes: 7, + stderrMaxBytes: 8, + }); + assert.deepEqual(request.argv, command.argv); + assert.equal(request.cwd, "/workspace/subdir"); + assert.deepEqual(request.env, { + PATH: DIRECT_RUNTIME_PATH, + PUBLIC_URL: "https://example.test", + TOKEN: "secret", + AGENT_API_TOKEN: "cap", + }); + assert.deepEqual(request.dynamicEnvKeys, ["AGENT_API_TOKEN"]); + assert.deepEqual(request.allowedEnvKeys, ["PUBLIC_URL", "TOKEN"]); + assert.equal(Buffer.from(request.stdin ?? []).toString(), "literal\n"); + assert.equal(request.timeoutMs, 100); + assert.equal(request.stdoutMaxBytes, 7); + assert.equal(request.stderrMaxBytes, 8); + assert.throws(() => directRequest("/workspace", { argv: ["tool"] }), /absolute/); + assert.throws(() => directRequest("/workspace", command, { env: { PUBLIC_URL: "x", UNKNOWN: "y" } }), /not allowed/); + assert.throws( + () => directRequest("/workspace", { ...command, allowedEnvKeys: ["AGENT_TOKEN"] }, { env: { AGENT_TOKEN: "x" } }), + /reserved/, + ); + assert.throws( + () => directRequest("/workspace", { ...command, allowedEnvKeys: ["PATH"] }, { env: { PATH: "/tmp" } }), + /runtime-owned/, + ); + assert.throws( + () => directRequest("/workspace", command, { dynamicEnv: { AGENT_UNKNOWN: "x" } }), + /unknown dynamic key/, + ); + assert.throws(() => directRequest("/workspace", command, { cwd: "../outside" }), /stay inside/); +}); + +test("deployment descriptors own complete per-executable command environment keys", () => { + const descriptor = parseToolDescriptor( + JSON.stringify({ id: "acmecli", commandEnv: ["PUBLIC_URL", "TOKEN"], install: { binary: "acme" } }), + "tool.json", + ); + assert.deepEqual(descriptor.commandEnv, ["PUBLIC_URL", "TOKEN"]); + assert.throws( + () => parseToolDescriptor(JSON.stringify({ id: "acmecli", commandEnv: ["AGENT_TOKEN"] }), "tool.json"), + /reserved/, + ); + assert.throws( + () => parseToolDescriptor(JSON.stringify({ id: "acmecli", commandEnv: ["PATH"] }), "tool.json"), + /non-reserved/, + ); + assert.throws( + () => parseToolDescriptor(JSON.stringify({ id: "acmecli", commandEnv: ["TOKEN", "TOKEN"] }), "tool.json"), + /duplicate/, + ); + const layer = resolvedDeploymentLayer("/layer", [descriptor]); + assert.deepEqual(layer.commandEnvByExecutable, { acme: ["PUBLIC_URL", "TOKEN"] }); + assert.deepEqual(layer.directTools, [{ service: "acmecli", binary: "acme", roots: [], directOnly: true }]); + const brokered = parseToolDescriptor( + JSON.stringify({ + id: "awscli", + install: { binary: "aws" }, + auth: { + check: "aws sts get-caller-identity", + reauth: "aws sso login", + credentialPaths: [], + broker: { + kind: "aws-role", + roleArnEnv: "AWSCLI_ROLE_ARN", + region: "us-west-2", + sessionActions: ["execute-api:Invoke"], + }, + }, + }), + "brokered-tool.json", + ); + const brokeredLayer = resolvedDeploymentLayer("/layer", [brokered]); + assert.deepEqual(brokeredLayer.commandEnvByExecutable.aws, [ + "AWS_ACCESS_KEY_ID", + "AWS_SECRET_ACCESS_KEY", + "AWS_SESSION_TOKEN", + "AWS_REGION", + "AWS_DEFAULT_REGION", + ]); + assert.deepEqual(brokeredLayer.directTools, [{ service: "awscli", binary: "aws", roots: [], directOnly: false }]); + const publicOutput = createSecretValueMasker({ API_TOKEN: "token-value" })("https://example.test:993 token-value"); + assert.equal(publicOutput, "https://example.test:993 "); + assert.throws( + () => resolvedDeploymentLayer("/layer", [descriptor, { ...descriptor, id: "other" }]), + /conflicting direct tools for executable/, + ); +}); + +test("routing exposes direct execution only when a backend implements it and refuses unsupported routes", async () => { + const routes = createMemoryMap<{ backend: "sprites" | "aws" }>(); + await routes.put("scope:sprites", { backend: "sprites" }); + const router = createSandboxRouter({ + backends: { aws: fakeBackend("aws", true), sprites: fakeBackend("sprites", false) }, + routes, + defaultBackend: "aws", + }); + assert.ok(router.runDirect); + assert.equal(supportsDirectExecution(fakeBackend("sprites", false)), false); + const handle = await router.provision(layers("scope:sprites")); + await assert.rejects( + async () => router.runDirect!(handle, { argv: ["/usr/local/bin/tool"] }), + (error: unknown) => error instanceof CapabilityUnsupportedError, + ); +}); diff --git a/test/run-direct-sprites.test.ts b/test/run-direct-sprites.test.ts new file mode 100644 index 000000000..76a312886 --- /dev/null +++ b/test/run-direct-sprites.test.ts @@ -0,0 +1,233 @@ +import assert from "node:assert/strict"; +import { spawn } from "node:child_process"; +import { chmodSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { once } from "node:events"; +import { test } from "node:test"; +import { join } from "node:path"; +import { + createSpritesSandbox, + DIRECT_HELPER_EXECUTABLE, + DIRECT_HELPER_SCRIPT, +} from "../src/sandbox/sprites-sandbox.ts"; +import { DIRECT_RUNTIME_PATH } from "../src/sandbox/scoped-exec.ts"; +import type { WorkspaceStore } from "../src/workspace/workspace-store.ts"; + +function framedResponse(stdout: string, code = 0, split = false): Response { + const envelope = Buffer.from( + JSON.stringify({ + stdoutB64: Buffer.from(stdout).toString("base64"), + stderrB64: "", + code, + timedOut: false, + outputLimitExceeded: false, + stdoutTruncated: false, + stderrTruncated: false, + }), + ); + const first = Buffer.concat([Buffer.from([1]), envelope]); + const second = Buffer.from([3, code]); + const body = new ReadableStream({ + start(controller) { + if (split) { + controller.enqueue(first.subarray(0, 2)); + controller.enqueue(Buffer.concat([first.subarray(2), second])); + } else { + controller.enqueue(first); + controller.enqueue(second); + } + controller.close(); + }, + }); + return new Response(body, { status: 200 }); +} + +test("Sprites direct POST keeps executable args and secrets in structured stdin", async () => { + let seenUrl = ""; + let seenInit: RequestInit | undefined; + const sandbox = createSpritesSandbox({} as WorkspaceStore, { + token: "test-token", + baseUrl: "https://sprites.example", + client: { + getSprite: async () => ({}), + createSprite: async () => ({}), + deleteSprite: async () => {}, + }, + fetchImpl: async (url, init) => { + seenUrl = String(url); + seenInit = init; + return framedResponse("ok"); + }, + }); + const result = await sandbox.runDirect!( + { id: "sprite", rootDir: "/workspace" }, + { + argv: ["/usr/bin/printf", "literal ; && $(echo no)"], + executablePath: "/usr/bin/printf", + allowedEnvKeys: ["STATIC_SECRET"], + }, + { + env: { STATIC_SECRET: "secret-value" }, + dynamicEnv: { AGENT_API_TOKEN: "cap-value" }, + stdin: "stdin-value", + stdoutMaxBytes: 7, + stderrMaxBytes: 8, + timeoutMs: 100, + }, + ); + assert.equal(result.stdout, "ok"); + const parsedUrl = new URL(seenUrl); + assert.equal(parsedUrl.protocol, "https:"); + assert.deepEqual(parsedUrl.searchParams.getAll("cmd").slice(0, 2), [DIRECT_HELPER_EXECUTABLE, "-e"]); + assert.equal(parsedUrl.searchParams.get("path"), DIRECT_HELPER_EXECUTABLE); + assert.equal(parsedUrl.searchParams.get("stdin"), "true"); + assert.equal(parsedUrl.searchParams.get("max_run_after_disconnect"), "0s"); + assert.equal(seenUrl.includes("secret-value"), false); + assert.equal(seenUrl.includes("STATIC_SECRET"), false); + assert.equal(seenUrl.includes("literal%20%3B%20%26%26"), false); + assert.equal(seenUrl.includes("env="), false); + assert.ok(seenInit); + assert.equal(seenInit.method, "POST"); + assert.equal((seenInit.headers as Record)["authorization"], "Bearer test-token"); + assert.equal((seenInit.headers as Record)["content-type"], "application/octet-stream"); + assert.equal(seenInit.signal instanceof AbortSignal, true); + const request = JSON.parse(Buffer.from(seenInit.body as Uint8Array).toString("utf8")) as { + argv: string[]; + env: Record; + dynamicEnvKeys: string[]; + allowedEnvKeys: string[]; + stdinB64: string; + }; + assert.deepEqual(request.argv, ["/usr/bin/printf", "literal ; && $(echo no)"]); + assert.deepEqual(request.env, { + PATH: DIRECT_RUNTIME_PATH, + STATIC_SECRET: "secret-value", + AGENT_API_TOKEN: "cap-value", + }); + assert.deepEqual(request.dynamicEnvKeys, ["AGENT_API_TOKEN"]); + assert.deepEqual(request.allowedEnvKeys, ["STATIC_SECRET"]); + assert.equal(Buffer.from(request.stdinB64, "base64").toString(), "stdin-value"); +}); + +test("Sprites direct parser handles split and coalesced HTTP frames", async () => { + const sandbox = createSpritesSandbox({} as WorkspaceStore, { + token: "test-token", + client: { + getSprite: async () => ({}), + createSprite: async () => ({}), + deleteSprite: async () => {}, + }, + fetchImpl: async () => framedResponse("split-safe", 0, true), + }); + const result = await sandbox.runDirect!( + { id: "sprite", rootDir: "/workspace" }, + { argv: ["/usr/bin/printf"], executablePath: "/usr/bin/printf" }, + ); + assert.equal(result.stdout, "split-safe"); +}); + +test("Sprites direct POST combines caller abort with authenticated fetch", async () => { + let seenSignal: AbortSignal | undefined; + const sandbox = createSpritesSandbox({} as WorkspaceStore, { + token: "test-token", + client: { + getSprite: async () => ({}), + createSprite: async () => {}, + deleteSprite: async () => {}, + }, + fetchImpl: async (_url, init) => { + seenSignal = init?.signal ?? undefined; + return await new Promise((_resolve, reject) => { + init?.signal?.addEventListener("abort", () => reject(init.signal?.reason), { once: true }); + }); + }, + }); + const controller = new AbortController(); + const request = sandbox.runDirect!( + { id: "sprite", rootDir: "/workspace" }, + { argv: ["/usr/bin/printf"], executablePath: "/usr/bin/printf" }, + { signal: controller.signal }, + ); + controller.abort(); + await assert.rejects(request); + assert.equal(seenSignal?.aborted, true); +}); + +test("Sprites helper path follows the image's copied Node runtime", () => { + const dockerfile = readFileSync("fly/Dockerfile", "utf8"); + assert.match(dockerfile, /COPY --from=node-runtime \/usr\/local\/ \/usr\/local\//); + assert.equal(DIRECT_HELPER_EXECUTABLE, "/usr/local/bin/node"); +}); + +test("Sprites helper executes a bounded structured request with exact child env", async () => { + const executablePath = process.execPath; + const rootDir = process.cwd(); + const child = spawn(executablePath, ["-e", DIRECT_HELPER_SCRIPT], { + env: { HELPER_AMBIENT: "should-not-reach-target" }, + stdio: ["pipe", "pipe", "pipe"], + }); + const stdout: Buffer[] = []; + child.stdout.on("data", (chunk: Buffer) => stdout.push(chunk)); + child.stdin.end( + JSON.stringify({ + argv: [ + executablePath, + "-e", + "process.stdout.write(JSON.stringify({args:process.argv.slice(1),stdin:require('node:fs').readFileSync(0,'utf8'),cwd:process.cwd(),env:Object.fromEntries(Object.entries(process.env).filter(([key])=>key==='VISIBLE'||key==='AGENT_API_TOKEN'||key==='HELPER_AMBIENT'))}))", + "literal ; && $(echo no)", + ], + rootDir, + cwd: rootDir, + env: { VISIBLE: "yes", AGENT_API_TOKEN: "cap" }, + allowedEnvKeys: ["VISIBLE"], + dynamicEnvKeys: ["AGENT_API_TOKEN"], + stdinB64: Buffer.from("input $VAR").toString("base64"), + timeoutMs: 1000, + stdoutMaxBytes: 1024, + stderrMaxBytes: 1024, + }), + ); + await once(child, "close"); + const envelope = JSON.parse(Buffer.concat(stdout).toString("utf8")) as { + stdoutB64?: string; + code?: number; + error?: string; + }; + assert.equal(envelope.error, undefined); + assert.equal(envelope.code, 0); + assert.deepEqual(JSON.parse(Buffer.from(envelope.stdoutB64!, "base64").toString("utf8")), { + args: ["literal ; && $(echo no)"], + stdin: "input $VAR", + cwd: rootDir, + env: { VISIBLE: "yes", AGENT_API_TOKEN: "cap" }, + }); +}); + +test("Sprites helper does not expose spawn error details", async () => { + const child = spawn(process.execPath, ["-e", DIRECT_HELPER_SCRIPT], { stdio: ["pipe", "pipe", "ignore"] }); + const stdout: Buffer[] = []; + child.stdout.on("data", (chunk: Buffer) => stdout.push(chunk)); + const marker = "secret-argv-marker"; + const executablePath = join(process.cwd(), `.direct-helper-${marker}`); + writeFileSync(executablePath, `#!/not/installed/${marker}\n`); + chmodSync(executablePath, 0o755); + try { + child.stdin.end( + JSON.stringify({ + argv: [executablePath], + rootDir: process.cwd(), + cwd: process.cwd(), + env: {}, + dynamicEnvKeys: [], + timeoutMs: 100, + stdoutMaxBytes: 10, + stderrMaxBytes: 10, + }), + ); + await once(child, "close"); + const envelope = JSON.parse(Buffer.concat(stdout).toString("utf8")) as { error?: string }; + assert.equal(envelope.error, "direct helper could not start executable"); + assert.equal(JSON.stringify(envelope).includes(marker), false); + } finally { + rmSync(executablePath, { force: true }); + } +}); diff --git a/test/secret-masking.test.ts b/test/secret-masking.test.ts index bc6b5bcac..e59cd8187 100644 --- a/test/secret-masking.test.ts +++ b/test/secret-masking.test.ts @@ -44,6 +44,14 @@ test("plumbing keys and short values are not masked", () => { assert.equal(mask(cmd), cmd); }); +test("strict direct-execution masking covers arbitrary keys and short values", () => { + const mask = createSecretValueMasker( + { FOO: "x7", AGENT_API_URL: "https://core.example.test" }, + { minimumLength: 1, maskNonSecretKeys: true }, + ); + assert.equal(mask("x7 https://core.example.test"), " "); +}); + test("regex metacharacters in a secret cannot break the replacement", () => { const value = "a+b(c)$[d]*e^f.g|h?12"; const mask = createSecretValueMasker({ WEIRD_KEY: value }); From 3aeb55724c8f61305dcf796c713e7d2a706f617f Mon Sep 17 00:00:00 2001 From: jpierrevd Date: Thu, 20 Aug 2026 22:31:46 +0100 Subject: [PATCH 2/7] fix(sandbox): omit unsupported HTTP tty flag --- src/sandbox/sprites-sandbox.ts | 1 - test/run-direct-sprites.test.ts | 1 + 2 files changed, 1 insertion(+), 1 deletion(-) diff --git a/src/sandbox/sprites-sandbox.ts b/src/sandbox/sprites-sandbox.ts index 71d43d107..542a3f945 100644 --- a/src/sandbox/sprites-sandbox.ts +++ b/src/sandbox/sprites-sandbox.ts @@ -252,7 +252,6 @@ export function createSpritesSandbox(workspace: WorkspaceStore, opts: SpritesSan for (const arg of [DIRECT_HELPER_EXECUTABLE, "-e", DIRECT_HELPER_SCRIPT]) url.searchParams.append("cmd", arg); url.searchParams.set("path", DIRECT_HELPER_EXECUTABLE); url.searchParams.set("stdin", "true"); - url.searchParams.set("tty", "false"); url.searchParams.set("max_run_after_disconnect", "0s"); const payload = Buffer.from( JSON.stringify({ diff --git a/test/run-direct-sprites.test.ts b/test/run-direct-sprites.test.ts index 76a312886..40a0be2b3 100644 --- a/test/run-direct-sprites.test.ts +++ b/test/run-direct-sprites.test.ts @@ -80,6 +80,7 @@ test("Sprites direct POST keeps executable args and secrets in structured stdin" assert.deepEqual(parsedUrl.searchParams.getAll("cmd").slice(0, 2), [DIRECT_HELPER_EXECUTABLE, "-e"]); assert.equal(parsedUrl.searchParams.get("path"), DIRECT_HELPER_EXECUTABLE); assert.equal(parsedUrl.searchParams.get("stdin"), "true"); + assert.equal(parsedUrl.searchParams.has("tty"), false); assert.equal(parsedUrl.searchParams.get("max_run_after_disconnect"), "0s"); assert.equal(seenUrl.includes("secret-value"), false); assert.equal(seenUrl.includes("STATIC_SECRET"), false); From 5520f83ec7325af00eafb816c28aef470ab376c9 Mon Sep 17 00:00:00 2001 From: jpierrevd Date: Thu, 20 Aug 2026 23:18:13 +0100 Subject: [PATCH 3/7] fix(sandbox): use available Sprites helper runtime --- src/sandbox/sprites-sandbox.ts | 295 +++++++++++++++++++------------- test/run-direct-sprites.test.ts | 81 ++++++++- 2 files changed, 249 insertions(+), 127 deletions(-) diff --git a/src/sandbox/sprites-sandbox.ts b/src/sandbox/sprites-sandbox.ts index 542a3f945..d073c8aa6 100644 --- a/src/sandbox/sprites-sandbox.ts +++ b/src/sandbox/sprites-sandbox.ts @@ -48,124 +48,177 @@ const CHECK_TIMEOUT_MS = 30_000; const GUEST_PROBE_TIMEOUT_SEC = 15; const DEFAULT_SPRITES_BASE_URL = "https://api.sprites.dev"; const DIRECT_HELPER_RESPONSE_MAX_BYTES = 256 * 1024 * 1024; -export const DIRECT_HELPER_EXECUTABLE = "/usr/local/bin/node"; +export const DIRECT_HELPER_EXECUTABLE = "/usr/bin/python3"; export const DIRECT_HELPER_SCRIPT = String.raw` -const fs = require("node:fs"); -const path = require("node:path"); -const childProcess = require("node:child_process"); -const dynamicKeys = new Set(["AGENT_API_URL", "AGENT_API_TOKEN", "AGENT_OAUTH_CONSENT_TOKEN", "AGENT_CREDENTIAL_TOKEN", "AGENT_OUTBOX"]); -const runtimePath = "/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin"; -const inputCap = 16 * 1024 * 1024; -const outputCap = 64 * 1024 * 1024; -const requestCap = 128 * 1024 * 1024; -const resultCap = 256 * 1024 * 1024; -const dynamicKey = (key) => dynamicKeys.has(key); -const canonical = (value) => typeof value === "string" && value.startsWith("/") && value !== "/" && !value.endsWith("/") && !value.includes("\\") && !value.includes("\0") && value.split("/").slice(1).every((part) => part.length > 0 && part !== "." && part !== ".."); -const bounded = (value, fallback, cap) => { - const result = value === undefined ? fallback : Number(value); - return Number.isSafeInteger(result) && result >= 0 && result <= cap ? result : null; -}; -const output = (value) => { - const encoded = JSON.stringify(value); - process.stdout.write(encoded.length > resultCap ? JSON.stringify({ error: "direct helper result exceeds limit" }) : encoded); -}; -const run = (raw) => { - let request; - try { - request = JSON.parse(raw); - } catch { - return output({ error: "invalid direct request" }); - } - if (!request || !Array.isArray(request.argv) || request.argv.length === 0 || request.argv.length > 4096 || request.argv.some((arg) => typeof arg !== "string" || arg.includes("\0")) || !canonical(request.argv[0])) return output({ error: "invalid direct argv" }); - if (!canonical(request.rootDir) || !canonical(request.cwd)) return output({ error: "invalid direct path" }); - const root = path.posix.normalize(request.rootDir); - const cwd = path.posix.normalize(request.cwd); - const relative = path.posix.relative(root, cwd); - if (relative === ".." || relative.startsWith("../") || path.posix.isAbsolute(relative) || request.cwd.split("/").includes("..")) return output({ error: "direct cwd escapes rootDir" }); - const dynamicEnvKeys = request.dynamicEnvKeys === undefined ? [] : request.dynamicEnvKeys; - if (!Array.isArray(dynamicEnvKeys) || dynamicEnvKeys.some((key) => typeof key !== "string" || !dynamicKey(key)) || new Set(dynamicEnvKeys).size !== dynamicEnvKeys.length) return output({ error: "invalid dynamic env keys" }); - const allowedEnvKeys = request.allowedEnvKeys === undefined ? [] : request.allowedEnvKeys; - if (!Array.isArray(allowedEnvKeys) || allowedEnvKeys.some((key) => typeof key !== "string" || !/^[A-Za-z_][A-Za-z0-9_]*$/.test(key) || key === "PATH" || key.startsWith("AGENT_")) || new Set(allowedEnvKeys).size !== allowedEnvKeys.length) return output({ error: "invalid allowed env keys" }); - if (!request.env || typeof request.env !== "object" || Array.isArray(request.env)) return output({ error: "invalid direct env" }); - for (const [key, value] of Object.entries(request.env)) { - if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(key) || (key === "PATH" && value !== runtimePath) || (key !== "PATH" && !dynamicEnvKeys.includes(key) && !allowedEnvKeys.includes(key)) || (key.startsWith("AGENT_") && !dynamicEnvKeys.includes(key)) || typeof value !== "string" || value.includes("\0")) return output({ error: "invalid direct env" }); - } - const timeoutMs = bounded(request.timeoutMs, 600000, 86400000); - const stdoutMaxBytes = bounded(request.stdoutMaxBytes, 4 * 1024 * 1024, outputCap); - const stderrMaxBytes = bounded(request.stderrMaxBytes, 4 * 1024 * 1024, outputCap); - if (timeoutMs === null || stdoutMaxBytes === null || stderrMaxBytes === null) return output({ error: "invalid direct limits" }); - let stdin = Buffer.alloc(0); - if (request.stdinB64 !== undefined) { - if (typeof request.stdinB64 !== "string") return output({ error: "invalid direct stdin" }); - stdin = Buffer.from(request.stdinB64, "base64"); - if (stdin.length > inputCap) return output({ error: "direct stdin exceeds limit" }); - } - let rootReal; - let cwdReal; - let executableReal; - try { - rootReal = fs.realpathSync(root); - cwdReal = fs.realpathSync(cwd); - executableReal = fs.realpathSync(request.argv[0]); - if (cwdReal !== rootReal && !cwdReal.startsWith(rootReal + path.sep)) return output({ error: "direct cwd escapes rootDir" }); - if (executableReal !== request.argv[0] || !fs.statSync(executableReal).isFile()) return output({ error: "direct executable is not canonical" }); - } catch { - return output({ error: "direct path is not available" }); - } - const stdout = []; - const stderr = []; - let stdoutBytes = 0; - let stderrBytes = 0; - let timedOut = false; - let outputLimitExceeded = false; - let finished = false; - const child = childProcess.spawn(request.argv[0], request.argv.slice(1), { cwd: cwdReal, env: { ...request.env }, detached: true, stdio: ["pipe", "pipe", "pipe"] }); - const killTree = () => { - if (finished || !child.pid) return; - try { process.kill(-child.pid, "SIGKILL"); } catch { child.kill("SIGKILL"); } - }; - const timer = setTimeout(() => { - if (!finished) { - timedOut = true; - killTree(); - } - }, Math.max(1, timeoutMs)); - const take = (parts, current, chunk, limit, stream) => { - const remaining = Math.max(0, limit - current); - if (remaining) parts.push(chunk.subarray(0, remaining)); - const next = current + chunk.length; - if (next > limit) { - outputLimitExceeded = true; - stream.destroy(); - killTree(); - } - return next; - }; - child.stdout.on("data", (chunk) => { stdoutBytes = take(stdout, stdoutBytes, chunk, stdoutMaxBytes, child.stdout); }); - child.stderr.on("data", (chunk) => { stderrBytes = take(stderr, stderrBytes, chunk, stderrMaxBytes, child.stderr); }); - child.stdin.end(stdin); - child.on("error", (error) => { - if (finished) return; - finished = true; - clearTimeout(timer); - output({ error: "direct helper could not start executable" }); - }); - child.on("close", (code, signal) => { - if (finished) return; - finished = true; - clearTimeout(timer); - const resultCode = timedOut ? 124 : outputLimitExceeded ? 122 : code === null ? 1 : code; - output({ stdoutB64: Buffer.concat(stdout).toString("base64"), stderrB64: Buffer.concat(stderr).toString("base64"), code: resultCode, timedOut, outputLimitExceeded, stdoutTruncated: stdoutBytes > stdoutMaxBytes, stderrTruncated: stderrBytes > stderrMaxBytes, signal: signal || undefined }); - }); -}; -const chunks = []; -let size = 0; -process.stdin.on("data", (chunk) => { - size += chunk.length; - if (size > requestCap) process.exit(400); - chunks.push(chunk); -}); -process.stdin.on("end", () => run(Buffer.concat(chunks).toString("utf8"))); +import base64 +import json +import os +import re +import signal +import subprocess +import sys +import threading +import time + +dynamic_keys = {"AGENT_API_URL", "AGENT_API_TOKEN", "AGENT_OAUTH_CONSENT_TOKEN", "AGENT_CREDENTIAL_TOKEN", "AGENT_OUTBOX"} +runtime_path = "/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin" +input_cap = 16 * 1024 * 1024 +output_cap = 64 * 1024 * 1024 +request_cap = 128 * 1024 * 1024 +result_cap = 256 * 1024 * 1024 +env_name = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*$") + +def output(value): + encoded = json.dumps(value, separators=(",", ":")) + if len(encoded.encode()) > result_cap: + encoded = json.dumps({"error": "direct helper result exceeds limit"}, separators=(",", ":")) + sys.stdout.write(encoded) + +def canonical(value): + return isinstance(value, str) and value.startswith("/") and value != "/" and not value.endswith("/") and "\\" not in value and "\0" not in value and all(part not in ("", ".", "..") for part in value.split("/")[1:]) + +def bounded(value, fallback, cap): + result = fallback if value is None else value + return result if isinstance(result, int) and not isinstance(result, bool) and 0 <= result <= cap else None + +def fail(message): + output({"error": message}) + raise SystemExit(0) + +raw = sys.stdin.buffer.read(request_cap + 1) +if len(raw) > request_cap: + raise SystemExit(400) +try: + request = json.loads(raw) +except Exception: + fail("invalid direct request") + +argv = request.get("argv") if isinstance(request, dict) else None +if not isinstance(argv, list) or not argv or len(argv) > 4096 or any(not isinstance(arg, str) or "\0" in arg for arg in argv) or not canonical(argv[0]): + fail("invalid direct argv") +root_dir = request.get("rootDir") +cwd = request.get("cwd") +if not canonical(root_dir) or not canonical(cwd): + fail("invalid direct path") +root = os.path.normpath(root_dir) +workdir = os.path.normpath(cwd) +relative = os.path.relpath(workdir, root) +if relative == ".." or relative.startswith("../") or os.path.isabs(relative) or ".." in cwd.split("/"): + fail("direct cwd escapes rootDir") +dynamic_env_keys = request.get("dynamicEnvKeys", []) +if not isinstance(dynamic_env_keys, list) or len(set(dynamic_env_keys)) != len(dynamic_env_keys) or any(not isinstance(key, str) or key not in dynamic_keys for key in dynamic_env_keys): + fail("invalid dynamic env keys") +allowed_env_keys = request.get("allowedEnvKeys", []) +if not isinstance(allowed_env_keys, list) or len(set(allowed_env_keys)) != len(allowed_env_keys) or any(not isinstance(key, str) or not env_name.fullmatch(key) or key == "PATH" or key.startswith("AGENT_") for key in allowed_env_keys): + fail("invalid allowed env keys") +env = request.get("env") +if not isinstance(env, dict): + fail("invalid direct env") +for key, value in env.items(): + if not isinstance(key, str) or not env_name.fullmatch(key) or (key == "PATH" and value != runtime_path) or (key != "PATH" and key not in dynamic_env_keys and key not in allowed_env_keys) or (key.startswith("AGENT_") and key not in dynamic_env_keys) or not isinstance(value, str) or "\0" in value: + fail("invalid direct env") +timeout_ms = bounded(request.get("timeoutMs"), 600000, 86400000) +stdout_max = bounded(request.get("stdoutMaxBytes"), 4 * 1024 * 1024, output_cap) +stderr_max = bounded(request.get("stderrMaxBytes"), 4 * 1024 * 1024, output_cap) +if timeout_ms is None or stdout_max is None or stderr_max is None: + fail("invalid direct limits") +try: + stdin = base64.b64decode(request.get("stdinB64", ""), validate=True) +except Exception: + fail("invalid direct stdin") +if len(stdin) > input_cap: + fail("direct stdin exceeds limit") +try: + root_real = os.path.realpath(root) + cwd_real = os.path.realpath(workdir) + executable_real = os.path.realpath(argv[0]) + if cwd_real != root_real and not cwd_real.startswith(root_real + os.sep): + fail("direct cwd escapes rootDir") + if executable_real != argv[0] or not os.path.isfile(executable_real): + fail("direct executable is not canonical") +except SystemExit: + raise +except Exception: + fail("direct path is not available") +try: + child = subprocess.Popen(argv, cwd=cwd_real, env=env, stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.PIPE, start_new_session=True) +except Exception: + fail("direct helper could not start executable") + +stdout_parts = [] +stderr_parts = [] +totals = {"stdout": 0, "stderr": 0} +limit_exceeded = threading.Event() + +def kill_tree(): + try: + os.killpg(child.pid, signal.SIGKILL) + except Exception: + try: + child.kill() + except Exception: + pass + +def read_stream(name, stream, parts, limit): + while True: + chunk = stream.read(65536) + if not chunk: + return + remaining = max(0, limit - totals[name]) + if remaining: + parts.append(chunk[:remaining]) + totals[name] += len(chunk) + if totals[name] > limit: + limit_exceeded.set() + kill_tree() + +def write_stdin(): + try: + child.stdin.write(stdin) + child.stdin.close() + except Exception: + pass + +threads = [ + threading.Thread(target=read_stream, args=("stdout", child.stdout, stdout_parts, stdout_max), daemon=True), + threading.Thread(target=read_stream, args=("stderr", child.stderr, stderr_parts, stderr_max), daemon=True), + threading.Thread(target=write_stdin, daemon=True), +] +for thread in threads: + thread.start() +deadline = time.monotonic() + max(1, timeout_ms) / 1000 +timed_out = False +while child.poll() is None: + if limit_exceeded.is_set(): + kill_tree() + break + if time.monotonic() >= deadline: + timed_out = True + kill_tree() + break + time.sleep(0.005) +child.wait() +for thread in threads: + thread.join(timeout=1) +return_code = 124 if timed_out else 122 if limit_exceeded.is_set() else child.returncode if child.returncode >= 0 else 1 +signal_name = None +if child.returncode < 0: + try: + signal_name = signal.Signals(-child.returncode).name + except Exception: + signal_name = None +result = { + "stdoutB64": base64.b64encode(b"".join(stdout_parts)).decode("ascii"), + "stderrB64": base64.b64encode(b"".join(stderr_parts)).decode("ascii"), + "code": return_code, + "timedOut": timed_out, + "outputLimitExceeded": limit_exceeded.is_set(), + "stdoutTruncated": totals["stdout"] > stdout_max, + "stderrTruncated": totals["stderr"] > stderr_max, +} +if signal_name: + result["signal"] = signal_name +output(result) `; export interface SpritesClientLike { @@ -249,7 +302,7 @@ export function createSpritesSandbox(workspace: WorkspaceStore, opts: SpritesSan } > { const url = new URL(`${baseUrl}/v1/sprites/${encodeURIComponent(name)}/exec`); - for (const arg of [DIRECT_HELPER_EXECUTABLE, "-e", DIRECT_HELPER_SCRIPT]) url.searchParams.append("cmd", arg); + for (const arg of [DIRECT_HELPER_EXECUTABLE, "-c", DIRECT_HELPER_SCRIPT]) url.searchParams.append("cmd", arg); url.searchParams.set("path", DIRECT_HELPER_EXECUTABLE); url.searchParams.set("stdin", "true"); url.searchParams.set("max_run_after_disconnect", "0s"); @@ -696,7 +749,11 @@ export function createSpritesSandbox(workspace: WorkspaceStore, opts: SpritesSan try { // Scratch boxes are credential-free and wiped at release; they don't get the links. const credLinks = scratch ? "" : ` && ${ephemeralCredLinkScript(HOME_DIR, opts.credentialPaths ?? [])}`; - const prep = await execRaw(name, `mkdir -p ${shq(workspaceDir)}${credLinks}`, 60); + const prep = await execRaw( + name, + `${shq(DIRECT_HELPER_EXECUTABLE)} -c ${shq("")} && mkdir -p ${shq(workspaceDir)}${credLinks}`, + 60, + ); if (prep.code !== 0) throw new Error(`sprites provision prep failed: ${(prep.stderr || prep.stdout).slice(0, 200)}`); diff --git a/test/run-direct-sprites.test.ts b/test/run-direct-sprites.test.ts index 40a0be2b3..63453487a 100644 --- a/test/run-direct-sprites.test.ts +++ b/test/run-direct-sprites.test.ts @@ -1,6 +1,6 @@ import assert from "node:assert/strict"; import { spawn } from "node:child_process"; -import { chmodSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { chmodSync, rmSync, writeFileSync } from "node:fs"; import { once } from "node:events"; import { test } from "node:test"; import { join } from "node:path"; @@ -77,7 +77,7 @@ test("Sprites direct POST keeps executable args and secrets in structured stdin" assert.equal(result.stdout, "ok"); const parsedUrl = new URL(seenUrl); assert.equal(parsedUrl.protocol, "https:"); - assert.deepEqual(parsedUrl.searchParams.getAll("cmd").slice(0, 2), [DIRECT_HELPER_EXECUTABLE, "-e"]); + assert.deepEqual(parsedUrl.searchParams.getAll("cmd").slice(0, 2), [DIRECT_HELPER_EXECUTABLE, "-c"]); assert.equal(parsedUrl.searchParams.get("path"), DIRECT_HELPER_EXECUTABLE); assert.equal(parsedUrl.searchParams.get("stdin"), "true"); assert.equal(parsedUrl.searchParams.has("tty"), false); @@ -153,16 +153,34 @@ test("Sprites direct POST combines caller abort with authenticated fetch", async assert.equal(seenSignal?.aborted, true); }); -test("Sprites helper path follows the image's copied Node runtime", () => { - const dockerfile = readFileSync("fly/Dockerfile", "utf8"); - assert.match(dockerfile, /COPY --from=node-runtime \/usr\/local\/ \/usr\/local\//); - assert.equal(DIRECT_HELPER_EXECUTABLE, "/usr/local/bin/node"); +test("Sprites provision fails closed when the fixed helper runtime is unavailable", async () => { + let prepScript = ""; + const stderr = Buffer.from("direct helper runtime unavailable"); + const envelope = Buffer.from(`127 0 ${stderr.length}\n${stderr.toString("base64")}\n`); + const sandbox = createSpritesSandbox({} as WorkspaceStore, { + token: "test-token", + client: { + getSprite: async () => { + throw new Error("missing"); + }, + createSprite: async () => ({}), + deleteSprite: async () => {}, + }, + fetchImpl: async (input) => { + const url = new URL(String(input)); + prepScript = url.searchParams.getAll("cmd").at(-1) ?? ""; + return new Response(Buffer.concat([Buffer.from([1]), envelope, Buffer.from([3, 0])]), { status: 200 }); + }, + }); + await assert.rejects(sandbox.provision([]), /sprites provision prep failed: direct helper runtime unavailable/); + assert.match(prepScript, new RegExp(DIRECT_HELPER_EXECUTABLE.replaceAll("/", "\\/"))); + assert.match(prepScript, / -c /); }); test("Sprites helper executes a bounded structured request with exact child env", async () => { const executablePath = process.execPath; const rootDir = process.cwd(); - const child = spawn(executablePath, ["-e", DIRECT_HELPER_SCRIPT], { + const child = spawn(DIRECT_HELPER_EXECUTABLE, ["-c", DIRECT_HELPER_SCRIPT], { env: { HELPER_AMBIENT: "should-not-reach-target" }, stdio: ["pipe", "pipe", "pipe"], }); @@ -203,8 +221,55 @@ test("Sprites helper executes a bounded structured request with exact child env" }); }); +test("Sprites helper enforces timeout and output limits", async () => { + const runHelper = async (request: Record) => { + const child = spawn(DIRECT_HELPER_EXECUTABLE, ["-c", DIRECT_HELPER_SCRIPT], { + stdio: ["pipe", "pipe", "ignore"], + }); + const stdout: Buffer[] = []; + child.stdout.on("data", (chunk: Buffer) => stdout.push(chunk)); + child.stdin.end(JSON.stringify(request)); + await once(child, "close"); + return JSON.parse(Buffer.concat(stdout).toString("utf8")) as { + stdoutB64: string; + code: number; + timedOut: boolean; + outputLimitExceeded: boolean; + stdoutTruncated: boolean; + }; + }; + const base = { + rootDir: process.cwd(), + cwd: process.cwd(), + env: { PATH: "/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin" }, + allowedEnvKeys: [], + dynamicEnvKeys: [], + stderrMaxBytes: 1024, + }; + const timed = await runHelper({ + ...base, + argv: [process.execPath, "-e", "setTimeout(()=>{},5000)"], + timeoutMs: 20, + stdoutMaxBytes: 1024, + }); + assert.equal(timed.code, 124); + assert.equal(timed.timedOut, true); + const limited = await runHelper({ + ...base, + argv: [process.execPath, "-e", "process.stdout.write('x'.repeat(4096))"], + timeoutMs: 1000, + stdoutMaxBytes: 32, + }); + assert.equal(limited.code, 122); + assert.equal(limited.outputLimitExceeded, true); + assert.equal(limited.stdoutTruncated, true); + assert.equal(Buffer.from(limited.stdoutB64, "base64").length, 32); +}); + test("Sprites helper does not expose spawn error details", async () => { - const child = spawn(process.execPath, ["-e", DIRECT_HELPER_SCRIPT], { stdio: ["pipe", "pipe", "ignore"] }); + const child = spawn(DIRECT_HELPER_EXECUTABLE, ["-c", DIRECT_HELPER_SCRIPT], { + stdio: ["pipe", "pipe", "ignore"], + }); const stdout: Buffer[] = []; child.stdout.on("data", (chunk: Buffer) => stdout.push(chunk)); const marker = "secret-argv-marker"; From 5493142f9eb98bc78daed6c4f2ac432d3a4bd0b1 Mon Sep 17 00:00:00 2001 From: jpierrevd Date: Thu, 20 Aug 2026 23:35:31 +0100 Subject: [PATCH 4/7] fix(sandbox): terminate direct execution descendants --- src/sandbox/sprites-sandbox.ts | 42 ++++++++++++++---------- test/run-direct-sprites.test.ts | 58 ++++++++++++++++++++++++++++++++- 2 files changed, 82 insertions(+), 18 deletions(-) diff --git a/src/sandbox/sprites-sandbox.ts b/src/sandbox/sprites-sandbox.ts index d073c8aa6..d5bc6beb2 100644 --- a/src/sandbox/sprites-sandbox.ts +++ b/src/sandbox/sprites-sandbox.ts @@ -106,10 +106,10 @@ relative = os.path.relpath(workdir, root) if relative == ".." or relative.startswith("../") or os.path.isabs(relative) or ".." in cwd.split("/"): fail("direct cwd escapes rootDir") dynamic_env_keys = request.get("dynamicEnvKeys", []) -if not isinstance(dynamic_env_keys, list) or len(set(dynamic_env_keys)) != len(dynamic_env_keys) or any(not isinstance(key, str) or key not in dynamic_keys for key in dynamic_env_keys): +if not isinstance(dynamic_env_keys, list) or any(not isinstance(key, str) or key not in dynamic_keys for key in dynamic_env_keys) or len(set(dynamic_env_keys)) != len(dynamic_env_keys): fail("invalid dynamic env keys") allowed_env_keys = request.get("allowedEnvKeys", []) -if not isinstance(allowed_env_keys, list) or len(set(allowed_env_keys)) != len(allowed_env_keys) or any(not isinstance(key, str) or not env_name.fullmatch(key) or key == "PATH" or key.startswith("AGENT_") for key in allowed_env_keys): +if not isinstance(allowed_env_keys, list) or any(not isinstance(key, str) or not env_name.fullmatch(key) or key == "PATH" or key.startswith("AGENT_") for key in allowed_env_keys) or len(set(allowed_env_keys)) != len(allowed_env_keys): fail("invalid allowed env keys") env = request.get("env") if not isinstance(env, dict): @@ -149,6 +149,8 @@ stdout_parts = [] stderr_parts = [] totals = {"stdout": 0, "stderr": 0} limit_exceeded = threading.Event() +stdout_done = threading.Event() +stderr_done = threading.Event() def kill_tree(): try: @@ -159,18 +161,21 @@ def kill_tree(): except Exception: pass -def read_stream(name, stream, parts, limit): - while True: - chunk = stream.read(65536) - if not chunk: - return - remaining = max(0, limit - totals[name]) - if remaining: - parts.append(chunk[:remaining]) - totals[name] += len(chunk) - if totals[name] > limit: - limit_exceeded.set() - kill_tree() +def read_stream(name, stream, parts, limit, done): + try: + while True: + chunk = stream.read(65536) + if not chunk: + return + remaining = max(0, limit - totals[name]) + if remaining: + parts.append(chunk[:remaining]) + totals[name] += len(chunk) + if totals[name] > limit: + limit_exceeded.set() + kill_tree() + finally: + done.set() def write_stdin(): try: @@ -180,15 +185,15 @@ def write_stdin(): pass threads = [ - threading.Thread(target=read_stream, args=("stdout", child.stdout, stdout_parts, stdout_max), daemon=True), - threading.Thread(target=read_stream, args=("stderr", child.stderr, stderr_parts, stderr_max), daemon=True), + threading.Thread(target=read_stream, args=("stdout", child.stdout, stdout_parts, stdout_max, stdout_done), daemon=True), + threading.Thread(target=read_stream, args=("stderr", child.stderr, stderr_parts, stderr_max, stderr_done), daemon=True), threading.Thread(target=write_stdin, daemon=True), ] for thread in threads: thread.start() deadline = time.monotonic() + max(1, timeout_ms) / 1000 timed_out = False -while child.poll() is None: +while True: if limit_exceeded.is_set(): kill_tree() break @@ -196,6 +201,9 @@ while child.poll() is None: timed_out = True kill_tree() break + if child.poll() is not None and stdout_done.is_set() and stderr_done.is_set(): + kill_tree() + break time.sleep(0.005) child.wait() for thread in threads: diff --git a/test/run-direct-sprites.test.ts b/test/run-direct-sprites.test.ts index 63453487a..91258a628 100644 --- a/test/run-direct-sprites.test.ts +++ b/test/run-direct-sprites.test.ts @@ -1,6 +1,6 @@ import assert from "node:assert/strict"; import { spawn } from "node:child_process"; -import { chmodSync, rmSync, writeFileSync } from "node:fs"; +import { chmodSync, existsSync, rmSync, writeFileSync } from "node:fs"; import { once } from "node:events"; import { test } from "node:test"; import { join } from "node:path"; @@ -266,6 +266,62 @@ test("Sprites helper enforces timeout and output limits", async () => { assert.equal(Buffer.from(limited.stdoutB64, "base64").length, 32); }); +test("Sprites helper keeps descendant supervision until the process group is terminated", async () => { + const marker = join(process.cwd(), ".direct-helper-descendant-marker"); + rmSync(marker, { force: true }); + const descendant = `setTimeout(()=>require('node:fs').writeFileSync(${JSON.stringify(marker)},'unexpected'),300);setTimeout(()=>{},1000)`; + const leader = `require('node:child_process').spawn(process.execPath,['-e',${JSON.stringify(descendant)}],{stdio:['ignore','inherit','inherit']})`; + const child = spawn(DIRECT_HELPER_EXECUTABLE, ["-c", DIRECT_HELPER_SCRIPT], { + stdio: ["pipe", "pipe", "ignore"], + }); + const stdout: Buffer[] = []; + child.stdout.on("data", (chunk: Buffer) => stdout.push(chunk)); + child.stdin.end( + JSON.stringify({ + argv: [process.execPath, "-e", leader], + rootDir: process.cwd(), + cwd: process.cwd(), + env: { PATH: "/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin" }, + allowedEnvKeys: [], + dynamicEnvKeys: [], + timeoutMs: 50, + stdoutMaxBytes: 1024, + stderrMaxBytes: 1024, + }), + ); + await once(child, "close"); + const envelope = JSON.parse(Buffer.concat(stdout).toString("utf8")) as { code: number; timedOut: boolean }; + assert.equal(envelope.code, 124); + assert.equal(envelope.timedOut, true); + await new Promise((resolve) => setTimeout(resolve, 400)); + assert.equal(existsSync(marker), false); + rmSync(marker, { force: true }); +}); + +test("Sprites helper rejects malformed environment key arrays with a structured error", async () => { + const child = spawn(DIRECT_HELPER_EXECUTABLE, ["-c", DIRECT_HELPER_SCRIPT], { + stdio: ["pipe", "pipe", "ignore"], + }); + const stdout: Buffer[] = []; + child.stdout.on("data", (chunk: Buffer) => stdout.push(chunk)); + child.stdin.end( + JSON.stringify({ + argv: [process.execPath], + rootDir: process.cwd(), + cwd: process.cwd(), + env: {}, + allowedEnvKeys: [], + dynamicEnvKeys: [{}], + timeoutMs: 100, + stdoutMaxBytes: 10, + stderrMaxBytes: 10, + }), + ); + await once(child, "close"); + const envelope = JSON.parse(Buffer.concat(stdout).toString("utf8")) as { error?: string }; + assert.equal(envelope.error, "invalid dynamic env keys"); +}); + test("Sprites helper does not expose spawn error details", async () => { const child = spawn(DIRECT_HELPER_EXECUTABLE, ["-c", DIRECT_HELPER_SCRIPT], { stdio: ["pipe", "pipe", "ignore"], From a7a5f99614dc3b5466f3dcb10845f147ed7a0bc8 Mon Sep 17 00:00:00 2001 From: jpierrevd Date: Fri, 21 Aug 2026 01:01:09 +0100 Subject: [PATCH 5/7] fix(sandbox): contain detached direct descendants --- src/sandbox/sprites-sandbox.ts | 63 +++++++++++++++++++++++++++++++++ test/run-direct-sprites.test.ts | 46 +++++++++++++++++++++++- 2 files changed, 108 insertions(+), 1 deletion(-) diff --git a/src/sandbox/sprites-sandbox.ts b/src/sandbox/sprites-sandbox.ts index d5bc6beb2..2f61c2117 100644 --- a/src/sandbox/sprites-sandbox.ts +++ b/src/sandbox/sprites-sandbox.ts @@ -51,6 +51,7 @@ const DIRECT_HELPER_RESPONSE_MAX_BYTES = 256 * 1024 * 1024; export const DIRECT_HELPER_EXECUTABLE = "/usr/bin/python3"; export const DIRECT_HELPER_SCRIPT = String.raw` import base64 +import ctypes import json import os import re @@ -67,6 +68,7 @@ output_cap = 64 * 1024 * 1024 request_cap = 128 * 1024 * 1024 result_cap = 256 * 1024 * 1024 env_name = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*$") +helper_pid = os.getpid() def output(value): encoded = json.dumps(value, separators=(",", ":")) @@ -85,6 +87,48 @@ def fail(message): output({"error": message}) raise SystemExit(0) +def enable_subreaper(): + if sys.platform != "linux": + return + try: + with open("/proc/self/stat", "rb") as stat_file: + stat_file.read(1) + if ctypes.CDLL(None, use_errno=True).prctl(36, 1, 0, 0, 0) != 0: + fail("direct helper containment unavailable") + except SystemExit: + raise + except Exception: + fail("direct helper containment unavailable") + +def descendants(): + if sys.platform != "linux": + return set() + parents = {} + try: + entries = os.scandir("/proc") + except Exception: + return set() + with entries: + for entry in entries: + if not entry.name.isdigit(): + continue + try: + with open(f"/proc/{entry.name}/stat", "r", encoding="utf-8") as stat_file: + stat = stat_file.read() + fields = stat[stat.rfind(")") + 2:].split() + parents[int(entry.name)] = int(fields[1]) + except Exception: + continue + result = set() + changed = True + while changed: + changed = False + for pid, parent in parents.items(): + if pid != helper_pid and pid not in result and (parent == helper_pid or parent in result): + result.add(pid) + changed = True + return result + raw = sys.stdin.buffer.read(request_cap + 1) if len(raw) > request_cap: raise SystemExit(400) @@ -140,6 +184,7 @@ except SystemExit: raise except Exception: fail("direct path is not available") +enable_subreaper() try: child = subprocess.Popen(argv, cwd=cwd_real, env=env, stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.PIPE, start_new_session=True) except Exception: @@ -160,6 +205,24 @@ def kill_tree(): child.kill() except Exception: pass + if sys.platform == "linux": + for _ in range(32): + found = descendants() + if not found: + break + for pid in found: + try: + os.kill(pid, signal.SIGKILL) + except Exception: + pass + for pid in found: + if pid == child.pid: + continue + try: + os.waitpid(pid, os.WNOHANG) + except Exception: + pass + time.sleep(0.005) def read_stream(name, stream, parts, limit, done): try: diff --git a/test/run-direct-sprites.test.ts b/test/run-direct-sprites.test.ts index 91258a628..59fd48870 100644 --- a/test/run-direct-sprites.test.ts +++ b/test/run-direct-sprites.test.ts @@ -1,6 +1,6 @@ import assert from "node:assert/strict"; import { spawn } from "node:child_process"; -import { chmodSync, existsSync, rmSync, writeFileSync } from "node:fs"; +import { chmodSync, existsSync, readFileSync, rmSync, writeFileSync } from "node:fs"; import { once } from "node:events"; import { test } from "node:test"; import { join } from "node:path"; @@ -298,6 +298,50 @@ test("Sprites helper keeps descendant supervision until the process group is ter rmSync(marker, { force: true }); }); +test( + "Sprites helper terminates detached descendants that escape the child process group", + { skip: process.platform !== "linux" }, + async () => { + const marker = join(process.cwd(), ".direct-helper-detached-marker"); + const pidFile = join(process.cwd(), ".direct-helper-detached-pid"); + rmSync(marker, { force: true }); + rmSync(pidFile, { force: true }); + const detached = `setTimeout(()=>{if(process.env.SYNTHETIC_CREDENTIAL==='present')require('node:fs').writeFileSync(${JSON.stringify(marker)},'unexpected')},300);setTimeout(()=>{},1000)`; + const leader = `const child=require('node:child_process').spawn(process.execPath,['-e',${JSON.stringify(detached)}],{detached:true,stdio:'ignore',env:process.env});require('node:fs').writeFileSync(${JSON.stringify(pidFile)},String(child.pid));child.unref()`; + const child = spawn(DIRECT_HELPER_EXECUTABLE, ["-c", DIRECT_HELPER_SCRIPT], { + stdio: ["pipe", "pipe", "ignore"], + }); + const stdout: Buffer[] = []; + child.stdout.on("data", (chunk: Buffer) => stdout.push(chunk)); + child.stdin.end( + JSON.stringify({ + argv: [process.execPath, "-e", leader], + rootDir: process.cwd(), + cwd: process.cwd(), + env: { + PATH: "/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin", + SYNTHETIC_CREDENTIAL: "present", + }, + allowedEnvKeys: ["SYNTHETIC_CREDENTIAL"], + dynamicEnvKeys: [], + timeoutMs: 1000, + stdoutMaxBytes: 1024, + stderrMaxBytes: 1024, + }), + ); + await once(child, "close"); + const envelope = JSON.parse(Buffer.concat(stdout).toString("utf8")) as { code: number; timedOut: boolean }; + assert.equal(envelope.code, 0); + assert.equal(envelope.timedOut, false); + const detachedPid = Number.parseInt(readFileSync(pidFile, "utf8"), 10); + assert.throws(() => process.kill(detachedPid, 0), { code: "ESRCH" }); + await new Promise((resolve) => setTimeout(resolve, 400)); + assert.equal(existsSync(marker), false); + rmSync(marker, { force: true }); + rmSync(pidFile, { force: true }); + }, +); + test("Sprites helper rejects malformed environment key arrays with a structured error", async () => { const child = spawn(DIRECT_HELPER_EXECUTABLE, ["-c", DIRECT_HELPER_SCRIPT], { stdio: ["pipe", "pipe", "ignore"], From fc8279947db38be3476085a965f47f626476b2af Mon Sep 17 00:00:00 2001 From: jpierrevd Date: Fri, 21 Aug 2026 01:35:18 +0100 Subject: [PATCH 6/7] fix(sandbox): serialize direct process cleanup --- src/sandbox/sprites-sandbox.ts | 54 ++++++++++++++++++--------------- test/run-direct-sprites.test.ts | 9 ++++++ 2 files changed, 39 insertions(+), 24 deletions(-) diff --git a/src/sandbox/sprites-sandbox.ts b/src/sandbox/sprites-sandbox.ts index 2f61c2117..5aab152aa 100644 --- a/src/sandbox/sprites-sandbox.ts +++ b/src/sandbox/sprites-sandbox.ts @@ -196,33 +196,36 @@ totals = {"stdout": 0, "stderr": 0} limit_exceeded = threading.Event() stdout_done = threading.Event() stderr_done = threading.Event() +child_lock = threading.Lock() def kill_tree(): - try: - os.killpg(child.pid, signal.SIGKILL) - except Exception: - try: - child.kill() - except Exception: - pass - if sys.platform == "linux": - for _ in range(32): - found = descendants() - if not found: - break - for pid in found: - try: - os.kill(pid, signal.SIGKILL) - except Exception: - pass - for pid in found: - if pid == child.pid: - continue + with child_lock: + if child.returncode is None: + try: + os.killpg(child.pid, signal.SIGKILL) + except Exception: try: - os.waitpid(pid, os.WNOHANG) + child.kill() except Exception: pass - time.sleep(0.005) + if sys.platform == "linux": + for _ in range(32): + found = descendants() + if not found: + break + for pid in found: + try: + os.kill(pid, signal.SIGKILL) + except Exception: + pass + for pid in found: + if pid == child.pid: + continue + try: + os.waitpid(pid, os.WNOHANG) + except Exception: + pass + time.sleep(0.005) def read_stream(name, stream, parts, limit, done): try: @@ -264,11 +267,14 @@ while True: timed_out = True kill_tree() break - if child.poll() is not None and stdout_done.is_set() and stderr_done.is_set(): + with child_lock: + child_finished = child.poll() is not None + if child_finished and stdout_done.is_set() and stderr_done.is_set(): kill_tree() break time.sleep(0.005) -child.wait() +with child_lock: + child.wait() for thread in threads: thread.join(timeout=1) return_code = 124 if timed_out else 122 if limit_exceeded.is_set() else child.returncode if child.returncode >= 0 else 1 diff --git a/test/run-direct-sprites.test.ts b/test/run-direct-sprites.test.ts index 59fd48870..8f4194347 100644 --- a/test/run-direct-sprites.test.ts +++ b/test/run-direct-sprites.test.ts @@ -298,6 +298,15 @@ test("Sprites helper keeps descendant supervision until the process group is ter rmSync(marker, { force: true }); }); +test("Sprites helper serializes process reaping with process-group termination", () => { + assert.match( + DIRECT_HELPER_SCRIPT, + /with child_lock:\n\s+if child\.returncode is None:\n\s+try:\n\s+os\.killpg\(child\.pid, signal\.SIGKILL\)/, + ); + assert.match(DIRECT_HELPER_SCRIPT, /with child_lock:\n\s+child_finished = child\.poll\(\) is not None/); + assert.match(DIRECT_HELPER_SCRIPT, /with child_lock:\n\s+child\.wait\(\)/); +}); + test( "Sprites helper terminates detached descendants that escape the child process group", { skip: process.platform !== "linux" }, From c0cbe06ba79edb114635a23f4acd639423dec17e Mon Sep 17 00:00:00 2001 From: jpierrevd Date: Fri, 21 Aug 2026 04:44:05 +0100 Subject: [PATCH 7/7] fix(sandbox): terminate cancelled direct sessions --- src/sandbox/sprites-sandbox.ts | 224 ++++++++++++++++++++++++--- test/run-direct-sprites.test.ts | 262 +++++++++++++++++++++++++++++++- 2 files changed, 454 insertions(+), 32 deletions(-) diff --git a/src/sandbox/sprites-sandbox.ts b/src/sandbox/sprites-sandbox.ts index 5aab152aa..54d544a7d 100644 --- a/src/sandbox/sprites-sandbox.ts +++ b/src/sandbox/sprites-sandbox.ts @@ -55,6 +55,7 @@ import ctypes import json import os import re +import select import signal import subprocess import sys @@ -68,7 +69,17 @@ output_cap = 64 * 1024 * 1024 request_cap = 128 * 1024 * 1024 result_cap = 256 * 1024 * 1024 env_name = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*$") +kill_uid_pattern = re.compile(r"^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$") helper_pid = os.getpid() +kill_uid = sys.argv[1] if len(sys.argv) == 2 else None +if not isinstance(kill_uid, str) or not kill_uid_pattern.fullmatch(kill_uid): + raise SystemExit(400) +cancel_requested = threading.Event() + +def request_cancel(_signum, _frame): + cancel_requested.set() + +signal.signal(signal.SIGTERM, request_cancel) def output(value): encoded = json.dumps(value, separators=(",", ":")) @@ -129,7 +140,17 @@ def descendants(): changed = True return result -raw = sys.stdin.buffer.read(request_cap + 1) +raw = bytearray() +while len(raw) <= request_cap: + if cancel_requested.is_set(): + fail("direct helper cancelled") + readable, _, _ = select.select([sys.stdin.buffer], [], [], 0.05) + if not readable: + continue + chunk = os.read(sys.stdin.fileno(), min(65536, request_cap + 1 - len(raw))) + if not chunk: + break + raw.extend(chunk) if len(raw) > request_cap: raise SystemExit(400) try: @@ -185,6 +206,8 @@ except SystemExit: except Exception: fail("direct path is not available") enable_subreaper() +if cancel_requested.is_set(): + fail("direct helper cancelled") try: child = subprocess.Popen(argv, cwd=cwd_real, env=env, stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.PIPE, start_new_session=True) except Exception: @@ -259,7 +282,12 @@ for thread in threads: thread.start() deadline = time.monotonic() + max(1, timeout_ms) / 1000 timed_out = False +cancelled = False while True: + if cancel_requested.is_set(): + cancelled = True + kill_tree() + break if limit_exceeded.is_set(): kill_tree() break @@ -277,7 +305,7 @@ with child_lock: child.wait() for thread in threads: thread.join(timeout=1) -return_code = 124 if timed_out else 122 if limit_exceeded.is_set() else child.returncode if child.returncode >= 0 else 1 +return_code = 130 if cancelled else 124 if timed_out else 122 if limit_exceeded.is_set() else child.returncode if child.returncode >= 0 else 1 signal_name = None if child.returncode < 0: try: @@ -378,8 +406,11 @@ export function createSpritesSandbox(workspace: WorkspaceStore, opts: SpritesSan signal?: string; } > { + if (signal?.aborted) throw signal.reason ?? new Error("sprites direct execution aborted"); + const killUid = randomUUID(); + const helperCommand = `import sys;sys.argv.append(${JSON.stringify(killUid)});${DIRECT_HELPER_SCRIPT}`; const url = new URL(`${baseUrl}/v1/sprites/${encodeURIComponent(name)}/exec`); - for (const arg of [DIRECT_HELPER_EXECUTABLE, "-c", DIRECT_HELPER_SCRIPT]) url.searchParams.append("cmd", arg); + for (const arg of [DIRECT_HELPER_EXECUTABLE, "-c", helperCommand]) url.searchParams.append("cmd", arg); url.searchParams.set("path", DIRECT_HELPER_EXECUTABLE); url.searchParams.set("stdin", "true"); url.searchParams.set("max_run_after_disconnect", "0s"); @@ -399,6 +430,17 @@ export function createSpritesSandbox(workspace: WorkspaceStore, opts: SpritesSan ); const timeoutSignal = AbortSignal.timeout(request.timeoutMs + EXIT_GRACE_MS); const requestSignal = signal ? AbortSignal.any([signal, timeoutSignal]) : timeoutSignal; + const cleanupRemote = async (): Promise => { + try { + await killDirectSession(name, killUid); + } catch (cleanupError) { + throw new Error("sprites direct cancellation cleanup failed", { cause: cleanupError }); + } + }; + const cancelRemote = async (error: unknown): Promise => { + await cleanupRemote(); + throw signal?.reason ?? error; + }; let response: Response; try { response = await fetchImpl(url.toString(), { @@ -408,8 +450,9 @@ export function createSpritesSandbox(workspace: WorkspaceStore, opts: SpritesSan signal: requestSignal, }); } catch (error) { - if (signal?.aborted) throw signal.reason ?? error; + if (signal?.aborted) return await cancelRemote(error); if (timeoutSignal.aborted) { + await cleanupRemote(); return { rc: 124, stdout: Buffer.alloc(0), @@ -420,9 +463,13 @@ export function createSpritesSandbox(workspace: WorkspaceStore, opts: SpritesSan stderrTruncated: false, }; } + await cleanupRemote(); throw new Error("sprites direct execution request failed", { cause: error }); } - if (!response.ok || !response.body) throw new Error("sprites direct execution request failed"); + if (!response.ok || !response.body) { + await cleanupRemote(); + throw new Error("sprites direct execution request failed"); + } const timeoutResult = (): RawExec & { timedOut: boolean; outputLimitExceeded: boolean; @@ -452,31 +499,41 @@ export function createSpritesSandbox(workspace: WorkspaceStore, opts: SpritesSan responseChunks.push(Buffer.from(value)); } } catch (error) { - if (signal?.aborted) throw signal.reason ?? error; - if (timeoutSignal.aborted) return timeoutResult(); + if (signal?.aborted) return await cancelRemote(error); + if (timeoutSignal.aborted) { + await cleanupRemote(); + return timeoutResult(); + } + await cleanupRemote(); throw new Error("sprites direct execution response failed", { cause: error }); } finally { await reader.cancel().catch(() => undefined); } - const responseBytesAll = Buffer.concat(responseChunks); const stdout: Buffer[] = []; - let exitCode = -1; - let offset = 0; - while (offset < responseBytesAll.length) { - const frameType = responseBytesAll[offset++]!; - if (frameType === 3) { - if (offset >= responseBytesAll.length) - throw new Error("sprites direct execution returned an invalid exit frame"); - exitCode = responseBytesAll[offset++]!; - continue; + try { + const responseBytesAll = Buffer.concat(responseChunks); + let exitCode = -1; + let offset = 0; + while (offset < responseBytesAll.length) { + const frameType = responseBytesAll[offset++]!; + if (frameType === 3) { + if (offset >= responseBytesAll.length) + throw new Error("sprites direct execution returned an invalid exit frame"); + exitCode = responseBytesAll[offset++]!; + continue; + } + if (frameType !== 1 && frameType !== 2) + throw new Error("sprites direct execution returned an unsupported frame"); + const start = offset; + while (offset < responseBytesAll.length && responseBytesAll[offset]! >= 4) offset++; + const frame = responseBytesAll.subarray(start, offset); + if (frameType === 1) stdout.push(frame); } - if (frameType !== 1 && frameType !== 2) throw new Error("sprites direct execution returned an unsupported frame"); - const start = offset; - while (offset < responseBytesAll.length && responseBytesAll[offset]! >= 4) offset++; - const frame = responseBytesAll.subarray(start, offset); - if (frameType === 1) stdout.push(frame); + if (exitCode < 0) throw new Error("sprites direct execution returned no exit frame"); + } catch (error) { + await cleanupRemote(); + throw error; } - if (exitCode < 0) throw new Error("sprites direct execution returned no exit frame"); let envelope: { stdoutB64?: string; stderrB64?: string; @@ -490,7 +547,16 @@ export function createSpritesSandbox(workspace: WorkspaceStore, opts: SpritesSan }; try { envelope = JSON.parse(Buffer.concat(stdout).toString("utf8")) as typeof envelope; - } catch { + } catch (error) { + await cleanupRemote(); + throw new Error("sprites direct helper returned an invalid result", { cause: error }); + } + if (!envelope || typeof envelope !== "object") { + await cleanupRemote(); + throw new Error("sprites direct helper returned an invalid result"); + } + if (envelope.error !== undefined && typeof envelope.error !== "string") { + await cleanupRemote(); throw new Error("sprites direct helper returned an invalid result"); } if (envelope.error) throw new Error(`sprites direct helper failed: ${envelope.error}`); @@ -499,6 +565,7 @@ export function createSpritesSandbox(workspace: WorkspaceStore, opts: SpritesSan typeof envelope.stdoutB64 !== "string" || typeof envelope.stderrB64 !== "string" ) { + await cleanupRemote(); throw new Error("sprites direct helper returned an invalid result"); } return { @@ -513,6 +580,115 @@ export function createSpritesSandbox(workspace: WorkspaceStore, opts: SpritesSan }; } + async function killDirectSession(name: string, killUid: string): Promise { + const sessionsUrl = `${baseUrl}/v1/sprites/${encodeURIComponent(name)}/exec`; + const deadline = Date.now() + 15_000; + for (let attempt = 0; attempt < 50 && Date.now() < deadline; attempt++) { + const attemptTimeout = Math.max(1, Math.min(2_000, deadline - Date.now())); + const response = await fetchImpl(sessionsUrl, { + method: "GET", + headers: { authorization: `Bearer ${opts.token ?? ""}` }, + signal: AbortSignal.timeout(attemptTimeout), + }); + if (!response.ok) throw new Error("sprites direct session lookup failed"); + let sessions: unknown; + try { + sessions = await response.json(); + } catch { + throw new Error("sprites direct session lookup failed"); + } + let listed: unknown[] | null = null; + if (Array.isArray(sessions)) listed = sessions; + else if ( + sessions && + typeof sessions === "object" && + Array.isArray((sessions as { sessions?: unknown }).sessions) + ) { + listed = (sessions as { sessions: unknown[] }).sessions; + } + if (!listed) throw new Error("sprites direct session lookup failed"); + const matches = listed.filter( + (session): session is { id: string | number; command: string; is_active: true } => + !!session && + typeof session === "object" && + (session as { is_active?: unknown }).is_active === true && + typeof (session as { command?: unknown }).command === "string" && + (session as { command: string }).command.includes(killUid) && + ["string", "number"].includes(typeof (session as { id?: unknown }).id), + ); + if (matches.length > 1) throw new Error("sprites direct session identity is ambiguous"); + if (matches.length === 1) { + const sessionId = String(matches[0]!.id); + if (!/^[A-Za-z0-9_-]{1,128}$/.test(sessionId)) throw new Error("sprites direct session identity is invalid"); + const killUrl = new URL( + `${baseUrl}/v1/sprites/${encodeURIComponent(name)}/exec/${encodeURIComponent(sessionId)}/kill`, + ); + killUrl.searchParams.set("signal", "SIGTERM"); + killUrl.searchParams.set("timeout", "10s"); + const killed = await fetchImpl(killUrl.toString(), { + method: "POST", + headers: { authorization: `Bearer ${opts.token ?? ""}` }, + signal: AbortSignal.timeout(15_000), + }); + if (!killed.ok) throw new Error("sprites direct session kill failed"); + const events = await readKillEvents(killed); + const completionCount = events.filter((event) => event.type === "complete").length; + if ( + events.length === 0 || + events.some((event) => event.type === "error" || event.type === "timeout") || + completionCount !== 1 || + events.at(-1)?.type !== "complete" || + typeof events.at(-1)?.exit_code !== "number" + ) { + throw new Error("sprites direct session kill failed"); + } + return; + } + await new Promise((resolve) => setTimeout(resolve, 100)); + } + throw new Error("sprites direct session not found"); + } + + async function readKillEvents(response: Response): Promise> { + if (!response.body) throw new Error("sprites direct session kill failed"); + const chunks: Buffer[] = []; + let total = 0; + const reader = response.body.getReader(); + try { + for (;;) { + const { value, done } = await reader.read(); + if (done) break; + if (!value?.length) continue; + total += value.length; + if (total > 1024 * 1024) throw new Error("sprites direct session kill failed"); + chunks.push(Buffer.from(value)); + } + } finally { + await reader.cancel().catch(() => undefined); + } + const text = Buffer.concat(chunks).toString("utf8").trim(); + if (!text) throw new Error("sprites direct session kill failed"); + let parsed: unknown; + try { + parsed = text.startsWith("[") ? JSON.parse(text) : text.split("\n").map((line) => JSON.parse(line)); + } catch { + throw new Error("sprites direct session kill failed"); + } + if ( + !Array.isArray(parsed) || + parsed.some( + (event) => + !event || + typeof event !== "object" || + typeof (event as { type?: unknown }).type !== "string" || + !["signal", "timeout", "exited", "killed", "error", "complete"].includes((event as { type: string }).type), + ) + ) { + throw new Error("sprites direct session kill failed"); + } + return parsed as Array<{ type: string; exit_code?: unknown }>; + } + async function postExec(name: string, argv: string[], timeoutSec: number, body?: Uint8Array): Promise { const qs = new URLSearchParams(); if (body) qs.append("stdin", "true"); diff --git a/test/run-direct-sprites.test.ts b/test/run-direct-sprites.test.ts index 8f4194347..17a38bc21 100644 --- a/test/run-direct-sprites.test.ts +++ b/test/run-direct-sprites.test.ts @@ -1,5 +1,6 @@ import assert from "node:assert/strict"; import { spawn } from "node:child_process"; +import { randomUUID } from "node:crypto"; import { chmodSync, existsSync, readFileSync, rmSync, writeFileSync } from "node:fs"; import { once } from "node:events"; import { test } from "node:test"; @@ -77,7 +78,9 @@ test("Sprites direct POST keeps executable args and secrets in structured stdin" assert.equal(result.stdout, "ok"); const parsedUrl = new URL(seenUrl); assert.equal(parsedUrl.protocol, "https:"); - assert.deepEqual(parsedUrl.searchParams.getAll("cmd").slice(0, 2), [DIRECT_HELPER_EXECUTABLE, "-c"]); + const directArgs = parsedUrl.searchParams.getAll("cmd"); + assert.deepEqual(directArgs.slice(0, 2), [DIRECT_HELPER_EXECUTABLE, "-c"]); + assert.match(directArgs[2] ?? "", /^import sys;sys\.argv\.append\("[0-9a-f-]{36}"\);/); assert.equal(parsedUrl.searchParams.get("path"), DIRECT_HELPER_EXECUTABLE); assert.equal(parsedUrl.searchParams.get("stdin"), "true"); assert.equal(parsedUrl.searchParams.has("tty"), false); @@ -128,6 +131,9 @@ test("Sprites direct parser handles split and coalesced HTTP frames", async () = test("Sprites direct POST combines caller abort with authenticated fetch", async () => { let seenSignal: AbortSignal | undefined; + let directKillUid = ""; + let killUrl = ""; + let calls = 0; const sandbox = createSpritesSandbox({} as WorkspaceStore, { token: "test-token", client: { @@ -135,8 +141,24 @@ test("Sprites direct POST combines caller abort with authenticated fetch", async createSprite: async () => {}, deleteSprite: async () => {}, }, - fetchImpl: async (_url, init) => { + fetchImpl: async (url, init) => { + calls++; + if (calls === 2) { + assert.equal(init?.method, "GET"); + return new Response( + JSON.stringify({ + sessions: [{ id: 1847, command: `/usr/bin/python3 -c helper ${directKillUid}`, is_active: true }], + }), + { status: 200, headers: { "content-type": "application/json" } }, + ); + } + if (calls === 3) { + killUrl = String(url); + assert.equal(init?.method, "POST"); + return new Response('{"type":"signal"}\n{"type":"complete","exit_code":130}\n', { status: 200 }); + } seenSignal = init?.signal ?? undefined; + directKillUid = new URL(String(url)).searchParams.getAll("cmd")[2]?.match(/[0-9a-f]{8}-[0-9a-f-]{27}/)?.[0] ?? ""; return await new Promise((_resolve, reject) => { init?.signal?.addEventListener("abort", () => reject(init.signal?.reason), { once: true }); }); @@ -151,6 +173,152 @@ test("Sprites direct POST combines caller abort with authenticated fetch", async controller.abort(); await assert.rejects(request); assert.equal(seenSignal?.aborted, true); + assert.equal(calls, 3); + const cleanup = new URL(killUrl); + assert.equal(cleanup.pathname, "/v1/sprites/sprite/exec/1847/kill"); + assert.equal(cleanup.searchParams.get("signal"), "SIGTERM"); + assert.equal(cleanup.searchParams.get("timeout"), "10s"); +}); + +for (const [label, killBody] of [ + ["error event", '{"type":"error","message":"session may still be active"}\n'], + [ + "timeout escalation", + '{"type":"signal"}\n{"type":"timeout"}\n{"type":"killed"}\n{"type":"complete","exit_code":137}\n', + ], + ["missing completion", '{"type":"signal"}\n{"type":"exited","exit_code":130}\n'], +] as const) { + test(`Sprites direct cancellation fails closed on kill ${label}`, async () => { + let calls = 0; + let directKillUid = ""; + const sandbox = createSpritesSandbox({} as WorkspaceStore, { + token: "test-token", + client: { + getSprite: async () => ({}), + createSprite: async () => ({}), + deleteSprite: async () => {}, + }, + fetchImpl: async (url, init) => { + calls++; + if (calls === 1) { + directKillUid = + new URL(String(url)).searchParams.getAll("cmd")[2]?.match(/[0-9a-f]{8}-[0-9a-f-]{27}/)?.[0] ?? ""; + return await new Promise((_resolve, reject) => { + init?.signal?.addEventListener("abort", () => reject(init.signal?.reason), { once: true }); + }); + } + if (calls === 2) { + return new Response( + JSON.stringify([{ id: "session-1", command: `helper ${directKillUid}`, is_active: true }]), + { status: 200 }, + ); + } + return new Response(killBody, { status: 200 }); + }, + }); + const controller = new AbortController(); + const request = sandbox.runDirect!( + { id: "sprite", rootDir: "/workspace" }, + { argv: ["/usr/bin/printf"], executablePath: "/usr/bin/printf" }, + { signal: controller.signal }, + ); + controller.abort(); + await assert.rejects(request, /sprites direct cancellation cleanup failed/); + assert.equal(calls, 3); + }); +} + +test("Sprites direct protocol failure terminates the identified remote session", async () => { + let calls = 0; + let directKillUid = ""; + const sandbox = createSpritesSandbox({} as WorkspaceStore, { + token: "test-token", + client: { + getSprite: async () => ({}), + createSprite: async () => ({}), + deleteSprite: async () => {}, + }, + fetchImpl: async (url) => { + calls++; + if (calls === 1) { + directKillUid = + new URL(String(url)).searchParams.getAll("cmd")[2]?.match(/[0-9a-f]{8}-[0-9a-f-]{27}/)?.[0] ?? ""; + return new Response(new Uint8Array([1, ...Buffer.from("unterminated")])); + } + if (calls === 2) { + return new Response( + JSON.stringify([{ id: "session-2", command: `helper ${directKillUid}`, is_active: true }]), + { status: 200 }, + ); + } + return new Response('[{"type":"signal"},{"type":"complete","exit_code":130}]', { status: 200 }); + }, + }); + await assert.rejects( + sandbox.runDirect!( + { id: "sprite", rootDir: "/workspace" }, + { argv: ["/usr/bin/printf"], executablePath: "/usr/bin/printf" }, + ), + /sprites direct execution returned no exit frame/, + ); + assert.equal(calls, 3); +}); + +test("Sprites direct cancellation fails closed when the remote session cannot be identified", async () => { + let calls = 0; + const sandbox = createSpritesSandbox({} as WorkspaceStore, { + token: "test-token", + client: { + getSprite: async () => ({}), + createSprite: async () => ({}), + deleteSprite: async () => {}, + }, + fetchImpl: async (_url, init) => { + calls++; + if (calls === 1) { + return await new Promise((_resolve, reject) => { + init?.signal?.addEventListener("abort", () => reject(init.signal?.reason), { once: true }); + }); + } + return new Response("not-json", { status: 200 }); + }, + }); + const controller = new AbortController(); + const request = sandbox.runDirect!( + { id: "sprite", rootDir: "/workspace" }, + { argv: ["/usr/bin/printf"], executablePath: "/usr/bin/printf" }, + { signal: controller.signal }, + ); + controller.abort(); + await assert.rejects(request, /sprites direct cancellation cleanup failed/); + assert.equal(calls, 2); +}); + +test("Sprites direct rejects an already-aborted caller without starting a remote session", async () => { + let calls = 0; + const sandbox = createSpritesSandbox({} as WorkspaceStore, { + token: "test-token", + client: { + getSprite: async () => ({}), + createSprite: async () => ({}), + deleteSprite: async () => {}, + }, + fetchImpl: async () => { + calls++; + return framedResponse("unexpected"); + }, + }); + const controller = new AbortController(); + controller.abort(new Error("caller cancelled before dispatch")); + await assert.rejects( + sandbox.runDirect!( + { id: "sprite", rootDir: "/workspace" }, + { argv: ["/usr/bin/printf"], executablePath: "/usr/bin/printf" }, + { signal: controller.signal }, + ), + /caller cancelled before dispatch/, + ); + assert.equal(calls, 0); }); test("Sprites provision fails closed when the fixed helper runtime is unavailable", async () => { @@ -180,7 +348,7 @@ test("Sprites provision fails closed when the fixed helper runtime is unavailabl test("Sprites helper executes a bounded structured request with exact child env", async () => { const executablePath = process.execPath; const rootDir = process.cwd(); - const child = spawn(DIRECT_HELPER_EXECUTABLE, ["-c", DIRECT_HELPER_SCRIPT], { + const child = spawn(DIRECT_HELPER_EXECUTABLE, ["-c", DIRECT_HELPER_SCRIPT, randomUUID()], { env: { HELPER_AMBIENT: "should-not-reach-target" }, stdio: ["pipe", "pipe", "pipe"], }); @@ -223,7 +391,7 @@ test("Sprites helper executes a bounded structured request with exact child env" test("Sprites helper enforces timeout and output limits", async () => { const runHelper = async (request: Record) => { - const child = spawn(DIRECT_HELPER_EXECUTABLE, ["-c", DIRECT_HELPER_SCRIPT], { + const child = spawn(DIRECT_HELPER_EXECUTABLE, ["-c", DIRECT_HELPER_SCRIPT, randomUUID()], { stdio: ["pipe", "pipe", "ignore"], }); const stdout: Buffer[] = []; @@ -266,12 +434,90 @@ test("Sprites helper enforces timeout and output limits", async () => { assert.equal(Buffer.from(limited.stdoutB64, "base64").length, 32); }); +test("Sprites helper handles remote SIGTERM by terminating the supervised command", async () => { + const ready = join(process.cwd(), ".direct-helper-cancel-ready"); + const marker = join(process.cwd(), ".direct-helper-cancel-marker"); + rmSync(ready, { force: true }); + rmSync(marker, { force: true }); + const command = `const fs=require('node:fs');fs.writeFileSync(${JSON.stringify(ready)},'ready');setTimeout(()=>fs.writeFileSync(${JSON.stringify(marker)},'unexpected'),500);setTimeout(()=>{},2000)`; + const child = spawn(DIRECT_HELPER_EXECUTABLE, ["-c", DIRECT_HELPER_SCRIPT, randomUUID()], { + stdio: ["pipe", "pipe", "ignore"], + }); + const stdout: Buffer[] = []; + child.stdout.on("data", (chunk: Buffer) => stdout.push(chunk)); + child.stdin.end( + JSON.stringify({ + argv: [process.execPath, "-e", command], + rootDir: process.cwd(), + cwd: process.cwd(), + env: { PATH: "/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin" }, + allowedEnvKeys: [], + dynamicEnvKeys: [], + timeoutMs: 5000, + stdoutMaxBytes: 1024, + stderrMaxBytes: 1024, + }), + ); + try { + for (let attempt = 0; attempt < 100 && !existsSync(ready); attempt++) { + await new Promise((resolve) => setTimeout(resolve, 10)); + } + assert.equal(existsSync(ready), true); + child.kill("SIGTERM"); + await once(child, "close"); + const envelope = JSON.parse(Buffer.concat(stdout).toString("utf8")) as { code?: number }; + assert.equal(envelope.code, 130); + await new Promise((resolve) => setTimeout(resolve, 600)); + assert.equal(existsSync(marker), false); + } finally { + if (child.exitCode === null) child.kill("SIGKILL"); + rmSync(ready, { force: true }); + rmSync(marker, { force: true }); + } +}); + +test("Sprites helper handles SIGTERM while structured stdin remains open", async () => { + const child = spawn(DIRECT_HELPER_EXECUTABLE, ["-c", DIRECT_HELPER_SCRIPT, randomUUID()], { + stdio: ["pipe", "pipe", "ignore"], + }); + const stdout: Buffer[] = []; + child.stdout.on("data", (chunk: Buffer) => stdout.push(chunk)); + child.stdin.write('{"argv":'); + try { + // The Python interpreter must finish installing its SIGTERM handler before + // the test delivers the signal; stdin deliberately remains incomplete. + await new Promise((resolve) => setTimeout(resolve, 300)); + child.kill("SIGTERM"); + const closed = await Promise.race([ + once(child, "close").then(() => true), + new Promise((resolve) => setTimeout(() => resolve(false), 1_000)), + ]); + assert.equal(closed, true); + const envelope = JSON.parse(Buffer.concat(stdout).toString("utf8")) as { error?: string }; + assert.equal(envelope.error, "direct helper cancelled"); + } finally { + if (child.exitCode === null) child.kill("SIGKILL"); + } +}); + +test("Sprites helper rejects an invalid remote cancellation identity before execution", async () => { + const child = spawn(DIRECT_HELPER_EXECUTABLE, ["-c", DIRECT_HELPER_SCRIPT, "not-a-valid-identity"], { + stdio: ["pipe", "pipe", "ignore"], + }); + const stdout: Buffer[] = []; + child.stdout.on("data", (chunk: Buffer) => stdout.push(chunk)); + child.stdin.end("{}"); + const [code] = (await once(child, "close")) as [number, NodeJS.Signals | null]; + assert.notEqual(code, 0); + assert.equal(Buffer.concat(stdout).length, 0); +}); + test("Sprites helper keeps descendant supervision until the process group is terminated", async () => { const marker = join(process.cwd(), ".direct-helper-descendant-marker"); rmSync(marker, { force: true }); const descendant = `setTimeout(()=>require('node:fs').writeFileSync(${JSON.stringify(marker)},'unexpected'),300);setTimeout(()=>{},1000)`; const leader = `require('node:child_process').spawn(process.execPath,['-e',${JSON.stringify(descendant)}],{stdio:['ignore','inherit','inherit']})`; - const child = spawn(DIRECT_HELPER_EXECUTABLE, ["-c", DIRECT_HELPER_SCRIPT], { + const child = spawn(DIRECT_HELPER_EXECUTABLE, ["-c", DIRECT_HELPER_SCRIPT, randomUUID()], { stdio: ["pipe", "pipe", "ignore"], }); const stdout: Buffer[] = []; @@ -317,7 +563,7 @@ test( rmSync(pidFile, { force: true }); const detached = `setTimeout(()=>{if(process.env.SYNTHETIC_CREDENTIAL==='present')require('node:fs').writeFileSync(${JSON.stringify(marker)},'unexpected')},300);setTimeout(()=>{},1000)`; const leader = `const child=require('node:child_process').spawn(process.execPath,['-e',${JSON.stringify(detached)}],{detached:true,stdio:'ignore',env:process.env});require('node:fs').writeFileSync(${JSON.stringify(pidFile)},String(child.pid));child.unref()`; - const child = spawn(DIRECT_HELPER_EXECUTABLE, ["-c", DIRECT_HELPER_SCRIPT], { + const child = spawn(DIRECT_HELPER_EXECUTABLE, ["-c", DIRECT_HELPER_SCRIPT, randomUUID()], { stdio: ["pipe", "pipe", "ignore"], }); const stdout: Buffer[] = []; @@ -352,7 +598,7 @@ test( ); test("Sprites helper rejects malformed environment key arrays with a structured error", async () => { - const child = spawn(DIRECT_HELPER_EXECUTABLE, ["-c", DIRECT_HELPER_SCRIPT], { + const child = spawn(DIRECT_HELPER_EXECUTABLE, ["-c", DIRECT_HELPER_SCRIPT, randomUUID()], { stdio: ["pipe", "pipe", "ignore"], }); const stdout: Buffer[] = []; @@ -376,7 +622,7 @@ test("Sprites helper rejects malformed environment key arrays with a structured }); test("Sprites helper does not expose spawn error details", async () => { - const child = spawn(DIRECT_HELPER_EXECUTABLE, ["-c", DIRECT_HELPER_SCRIPT], { + const child = spawn(DIRECT_HELPER_EXECUTABLE, ["-c", DIRECT_HELPER_SCRIPT, randomUUID()], { stdio: ["pipe", "pipe", "ignore"], }); const stdout: Buffer[] = [];