diff --git a/src/credentials/execute-files.ts b/src/credentials/execute-files.ts index 1848dbfeec..10eb0a8fb4 100644 --- a/src/credentials/execute-files.ts +++ b/src/credentials/execute-files.ts @@ -1,4 +1,4 @@ -import type { Sandbox, SandboxHandle } from "../sandbox/sandbox.ts"; +import { scratchDirFor, type Sandbox, type SandboxHandle } from "../sandbox/sandbox.ts"; import type { ProcessRegistry } from "../processes/process-registry.ts"; import { parseTar } from "../sandbox/tar.ts"; import { shq } from "../util/shell.ts"; @@ -115,11 +115,13 @@ export async function prepareExecutionFiles( ); if (files.some((file) => roots.filter((group) => belongsTo(file.path, group)).length !== 1)) throw new Error("Requested credentials contain overlapping credential directories"); - const prefix = "/tmp/qm-credentials."; + const scratch = scratchDirFor(sandbox.profile); + const basename = "qm-credentials."; + const prefix = `${scratch}/${basename}`; const created = await runOrThrow( sandbox, handle, - `find /tmp/ -maxdepth 1 -name ${shq(`${prefix.slice(5)}*`)} -mmin +120 -exec rm -rf -- {} + 2>/dev/null; umask 077; d=$(mktemp -d ${shq(`${prefix}XXXXXXXXXX`)}) && printf '%s\\n' "\${d##*.}"`, + `mkdir -p ${shq(scratch)} 2>/dev/null; find ${shq(`${scratch}/`)} -maxdepth 1 -name ${shq(`${basename}*`)} -mmin +120 -exec rm -rf -- {} + 2>/dev/null; umask 077; d=$(mktemp -d ${shq(`${prefix}XXXXXXXXXX`)}) && printf '%s\\n' "\${d##*.}"`, "Could not create command credential directory", ); const suffix = created.stdout.trim(); diff --git a/src/sandbox/exec-file-ops.ts b/src/sandbox/exec-file-ops.ts index b03e396fbd..b540beeaa9 100644 --- a/src/sandbox/exec-file-ops.ts +++ b/src/sandbox/exec-file-ops.ts @@ -9,6 +9,7 @@ import { CAPABILITY_HEADER } from "../api/contract.ts"; import type { BlobTransferStore } from "../persistence/blob-transfer.ts"; import { + DEFAULT_SCRATCH_DIR, hasParentPathSegment, type AgentComputerExportArea, type AgentComputerExportEntry, @@ -158,6 +159,7 @@ export interface ExecExportDeps { exec: ExecFileOpsDeps["exec"]; readAbsBytes(id: string, absPath: string): Promise; defaultHomeDir: string; + scratchDir?: string; ephemeralCredentialPrefixes: readonly string[]; } @@ -170,6 +172,7 @@ export function createExecExport({ exec, readAbsBytes, defaultHomeDir, + scratchDir = DEFAULT_SCRATCH_DIR, ephemeralCredentialPrefixes, }: ExecExportDeps): ExecExport { const rootForArea = (handle: SandboxHandle, area: AgentComputerExportArea): string => @@ -208,7 +211,11 @@ export function createExecExport({ "-path '*/.cache'", "-path '*/.cache/*'", ]; - const made = await exec(handle.id, `mktemp /tmp/agent-computer-export.XXXXXX`, 60); + const made = await exec( + handle.id, + `mkdir -p ${shq(scratchDir)} 2>/dev/null; mktemp ${shq(`${scratchDir}/agent-computer-export.XXXXXX`)}`, + 60, + ); if (made.code !== 0) throw new Error(`${label} export ${area} mktemp failed: ${made.stderr}`); const tmp = made.stdout.trim(); try { diff --git a/src/sandbox/managed-agents-sandbox.ts b/src/sandbox/managed-agents-sandbox.ts index 0ebdf9afcf..adf8307ada 100644 --- a/src/sandbox/managed-agents-sandbox.ts +++ b/src/sandbox/managed-agents-sandbox.ts @@ -25,7 +25,7 @@ import { type CredentialPathSpec, } from "../credentials/resident-paths.ts"; import { killableScript, killScript } from "./exec-kill.ts"; -import { visibleNotInstalled, visibleTools } from "./sandbox.ts"; +import { hasParentPathSegment, scratchDirFor, visibleNotInstalled, visibleTools } from "./sandbox.ts"; import { sandboxScopeName } from "./exec-sandbox-base.ts"; import { ManagedAgentsCommandLostError, @@ -52,7 +52,9 @@ import type { TeardownOptions, } from "./sandbox.ts"; -const HOME_DIR = "/workspace/home"; +const WORKSPACE_ROOT = "/workspace"; +const HOME_DIR = `${WORKSPACE_ROOT}/home`; +const SCRATCH_DIR = `${WORKSPACE_ROOT}/.qm-scratch`; const WORKSPACE_BASENAME = "workspace"; const RO_LAYERS_TAR = ".ro-layers.tar"; const RO_LAYERS_MANIFEST = ".ro-layers.manifest"; @@ -64,6 +66,16 @@ const CHECKPOINT_LABEL = "qm teardown"; export const QM_TEMPLATE_TOOLS = ["jq", "rg", "unzip", "wget"]; +export function transferPath(absPath: string): string { + const inside = absPath === WORKSPACE_ROOT || absPath.startsWith(`${WORKSPACE_ROOT}/`); + if (!inside || hasParentPathSegment(absPath)) + throw new Error( + `do-managed-agents transfers files only under ${WORKSPACE_ROOT}, so it cannot reach ${absPath}. ` + + `Temporary files this backend has to read back or write belong in ${SCRATCH_DIR}, which exec and transfer both see.`, + ); + return absPath; +} + export interface StoredManagedAgentsSandbox { sessionId: string; sandboxId: string; @@ -299,6 +311,7 @@ export function createManagedAgentsSandbox(workspace: WorkspaceStore, opts: Mana processSessions: true, parksOnTeardown: true, egressEnforcement: opts.egressProxyUrl ? "domain" : "none", + scratchDir: SCRATCH_DIR, spec: { os: "Linux — DigitalOcean Managed Agents Firecracker microVM (provider pause preserves state; publish durable work to git or Files)", runtimes: ["Node", "Python 3"], @@ -325,9 +338,9 @@ export function createManagedAgentsSandbox(workspace: WorkspaceStore, opts: Mana const procSessions = createExecProcessSessions(procIo); const writeAbsBytes = (name: string, absPath: string, data: Uint8Array): Promise => - withSession(name, (session) => session.writeFileBytes(absPath, data)); + withSession(name, (session) => session.writeFileBytes(transferPath(absPath), data)); const readAbsBytes = (name: string, absPath: string): Promise => - withSession(name, (session) => session.readFileBytes(absPath)); + withSession(name, (session) => session.readFileBytes(transferPath(absPath))); const installLayerTools = opts.layerToolFiles ? createLayerToolInstaller(opts.layerToolFiles) : null; const execFileOps = createExecFileOps({ @@ -341,6 +354,7 @@ export function createManagedAgentsSandbox(workspace: WorkspaceStore, opts: Mana exec: (id, script, t) => execRaw(id, script, t), readAbsBytes, defaultHomeDir: HOME_DIR, + scratchDir: scratchDirFor(profile), ephemeralCredentialPrefixes: ephemeralCredLinkPaths(opts.credentialPaths ?? []).map(({ rel }) => rel), }); diff --git a/src/sandbox/sandbox.ts b/src/sandbox/sandbox.ts index 21527b07da..0afdf41d5f 100644 --- a/src/sandbox/sandbox.ts +++ b/src/sandbox/sandbox.ts @@ -62,15 +62,21 @@ export interface AgentComputerSpec { workdir?: string; } +export const DEFAULT_SCRATCH_DIR = "/tmp"; + export interface AgentComputerProfile { backend: string; writablePersistence: WritablePersistence; processSessions: boolean; parksOnTeardown?: boolean; egressEnforcement?: EgressEnforcement; + scratchDir?: string; spec?: AgentComputerSpec; } +export const scratchDirFor = (profile: Pick | undefined): string => + profile?.scratchDir ?? DEFAULT_SCRATCH_DIR; + export function effectiveEgressEnforcement( profile: AgentComputerProfile, controlPlane: { signingSecret?: string; apiBaseUrl?: string }, diff --git a/test/managed-agents-sandbox.test.ts b/test/managed-agents-sandbox.test.ts index d651e7474a..bb508156a0 100644 --- a/test/managed-agents-sandbox.test.ts +++ b/test/managed-agents-sandbox.test.ts @@ -7,6 +7,7 @@ import { pollProcess } from "../src/sandbox/process-poll.ts"; import { createManagedAgentsSandbox, QM_TEMPLATE_TOOLS, + transferPath, type StoredManagedAgentsSandbox, } from "../src/sandbox/managed-agents-sandbox.ts"; import { @@ -20,6 +21,7 @@ import type { HomeSnapshotStore } from "../src/sandbox/home-snapshot.ts"; import { supportsProcessSessions } from "../src/sandbox/sandbox.ts"; import { createMemoryMap, type DurableMap } from "../src/persistence/durable-map.ts"; import { scopeId } from "../src/types.ts"; +import { prepareExecutionFiles } from "../src/credentials/execute-files.ts"; import { installFakeManagedAgents, type FakeManagedAgents } from "./support/fake-managed-agents.ts"; import type { Sandbox } from "../src/sandbox/sandbox.ts"; @@ -385,6 +387,61 @@ test("session status wire values map onto backend states", () => { assert.equal(managedAgentsSessionState(undefined), "unspecified"); }); +const awsCredential = (value: string) => ({ + files: [{ path: ".aws/credentials", contentBase64: Buffer.from(value).toString("base64") }], + save: async (): Promise => {}, +}); + +test("transfers outside the workspace fail locally and name the path", () => { + for (const bad of ["/tmp/qm-credentials.abc/home/.aws/credentials", "/etc/hosts", "/workspace/../tmp/x", "relative"]) + assert.throws( + () => transferPath(bad), + (e: Error) => e.message.includes(`cannot reach ${bad}.`), + ); + assert.equal(transferPath("/workspace/.qm-scratch/export.tar"), "/workspace/.qm-scratch/export.tar"); +}); + +test("the guest refuses a transfer outside the workspace, the way the platform does", async () => { + await sandbox.provision(layers); + const current = fake.current(scopeName()); + assert.ok(current); + const session = await fake.client.connect(current!.sessionId); + await assert.rejects(() => session.writeFileBytes("/tmp/nope.txt", new Uint8Array([1])), /path outside workspace/); + await assert.rejects(() => session.readFileBytes("/tmp/nope.txt"), /path outside workspace/); +}); + +test("per-command credential files stage where the backend can transfer them", async () => { + const h = await sandbox.provision(layers); + const prepared = await prepareExecutionFiles(sandbox, h, [awsCredential("key=abc")]); + assert.ok( + prepared.plan.directory.startsWith("/workspace/"), + `credentials staged outside the workspace: ${prepared.plan.directory}`, + ); + const read = await sandbox.run(h, `cat ${prepared.env.HOME}/.aws/credentials`); + assert.equal(read.code, 0); + assert.equal(read.stdout.trim(), "key=abc"); + await prepared.finish(); +}); + +test("the stale credential sweep clears old credential directories from the scratch directory", async () => { + const h = await sandbox.provision(layers); + const stale = "/workspace/.qm-scratch/qm-credentials.stale"; + await sandbox.run(h, `mkdir -p ${stale} && touch -d '3 hours ago' ${stale}`); + const prepared = await prepareExecutionFiles(sandbox, h, [awsCredential("key=sweep")]); + const left = await sandbox.run(h, `test -e ${stale} && echo present || echo gone`); + assert.equal(left.stdout.trim(), "gone"); + await prepared.finish(); +}); + +test("exporting the computer works when transfer is confined to the workspace", async () => { + const h = await sandbox.provision(layers); + await sandbox.writeFile(h, "notes/export-me.txt", "exported"); + const entries = await sandbox.exportFiles!(h, { include: ["workspace"] }); + const found = entries.find((e) => e.path.endsWith("export-me.txt")); + assert.ok(found, "expected the written file to appear in the export"); + assert.equal(Buffer.from(found!.data).toString("utf8"), "exported"); +}); + test("process sessions stream output through the data plane exec path", async () => { assert.ok(supportsProcessSessions(sandbox)); if (!supportsProcessSessions(sandbox)) return; diff --git a/test/support/fake-managed-agents.ts b/test/support/fake-managed-agents.ts index 39754201da..807d8a0316 100644 --- a/test/support/fake-managed-agents.ts +++ b/test/support/fake-managed-agents.ts @@ -16,13 +16,22 @@ import { } from "../../src/sandbox/managed-agents-client.ts"; import { mkdtempSync } from "node:fs"; -const GUEST_HOME_DIR = "/workspace/home"; +const GUEST_ROOT = "/workspace"; +const GUEST_ROOT_PREFIX = new RegExp(`(? { - const homeRe = r.home.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); - const remapPath = new RegExp(`${homeRe}/tmp/|${homeRe}(?![A-Za-z0-9._-])|/tmp/`, "g"); + const rootRe = r.guestRoot.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + const remapPath = new RegExp(`${rootRe}/tmp/|${rootRe}(?![A-Za-z0-9._-])|/tmp/`, "g"); return ( `export HOME=${JSON.stringify(r.home)}; ` + script .replace(/\btimeout \d+ /g, "") - .replaceAll(GUEST_HOME_DIR, r.home) - .replace(remapPath, (mm) => (mm.startsWith(r.home) ? mm : `${r.home}/tmp/`)) + .replace(GUEST_ROOT_PREFIX, r.guestRoot) + .replace(remapPath, (mm) => (mm.startsWith(r.guestRoot) ? mm : `${r.guestRoot}/tmp/`)) ); }; + const unmap = (r: FakeRecord, out: string): string => + out.replaceAll(`${r.guestRoot}/tmp/`, "/tmp/").replaceAll(r.guestRoot, GUEST_ROOT); + + const hostPathFor = (r: FakeRecord, absPath: string): string => { + const clean = absPath.replace(/\/+$/, ""); + if (clean !== GUEST_ROOT && !clean.startsWith(`${GUEST_ROOT}/`)) throw new FakeOutsideWorkspaceError(absPath); + if (clean.split("/").includes("..")) throw new FakeOutsideWorkspaceError(absPath); + return r.guestRoot + clean.slice(GUEST_ROOT.length); + }; + const alive = (r: FakeRecord): void => { if (gone(r.state)) throw new ManagedAgentsSandboxGoneError(r.sessionId, `status ${r.state}`); if (r.state === "paused") r.state = "ready"; @@ -123,27 +142,27 @@ export function installFakeManagedAgents(): FakeManagedAgents { throw new ManagedAgentsHitlRejectedError(r.sessionId); } execScripts.push(command); - mkdirSync(join(r.home, "tmp"), { recursive: true }); + mkdirSync(join(r.guestRoot, "tmp"), { recursive: true }); const spawned = spawnSync("sh", ["-c", remap(r, command)], { encoding: "buffer", maxBuffer: 128 * 1024 * 1024, env: { ...process.env, COPYFILE_DISABLE: "1" }, }); return { - stdout: (spawned.stdout ?? Buffer.alloc(0)).toString("utf8"), - stderr: (spawned.stderr ?? Buffer.alloc(0)).toString("utf8"), + stdout: unmap(r, (spawned.stdout ?? Buffer.alloc(0)).toString("utf8")), + stderr: unmap(r, (spawned.stderr ?? Buffer.alloc(0)).toString("utf8")), exitCode: spawned.status ?? (spawned.signal ? 137 : -1), }; }, async readFileBytes(absPath): Promise { alive(r); - const hostPath = absPath.startsWith(GUEST_HOME_DIR) ? r.home + absPath.slice(GUEST_HOME_DIR.length) : absPath; + const hostPath = hostPathFor(r, absPath); if (!existsSync(hostPath)) return null; return new Uint8Array(readFileSync(hostPath)); }, async writeFileBytes(absPath, data): Promise { alive(r); - const hostPath = absPath.startsWith(GUEST_HOME_DIR) ? r.home + absPath.slice(GUEST_HOME_DIR.length) : absPath; + const hostPath = hostPathFor(r, absPath); mkdirSync(dirname(hostPath), { recursive: true }); writeFileSync(hostPath, Buffer.from(data)); }, @@ -182,7 +201,7 @@ export function installFakeManagedAgents(): FakeManagedAgents { async kill(): Promise { guestDisconnects.push(r.sessionId); r.state = "destroyed"; - rmSync(r.home, { recursive: true, force: true }); + rmSync(r.guestRoot, { recursive: true, force: true }); }, }); @@ -200,7 +219,8 @@ export function installFakeManagedAgents(): FakeManagedAgents { sandboxId: `sbx-${id}`, name: opts.name, state: "ready", - home: join(root, id), + guestRoot: join(root, id), + home: join(root, id, "home"), createdAt: ++clock, loseNextCommand: false, rejectNextCommand: false, @@ -242,7 +262,7 @@ export function installFakeManagedAgents(): FakeManagedAgents { const r = records.get(sessionId); if (!r) return; r.state = "destroyed"; - rmSync(r.home, { recursive: true, force: true }); + rmSync(r.guestRoot, { recursive: true, force: true }); }, }; @@ -260,7 +280,7 @@ export function installFakeManagedAgents(): FakeManagedAgents { destroy: (name) => { const r = need(name); r.state = "destroyed"; - rmSync(r.home, { recursive: true, force: true }); + rmSync(r.guestRoot, { recursive: true, force: true }); }, loseNextCommand: (name) => { need(name).loseNextCommand = true;