Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 5 additions & 3 deletions src/credentials/execute-files.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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();
Expand Down
9 changes: 8 additions & 1 deletion src/sandbox/exec-file-ops.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -158,6 +159,7 @@ export interface ExecExportDeps {
exec: ExecFileOpsDeps["exec"];
readAbsBytes(id: string, absPath: string): Promise<Uint8Array | null>;
defaultHomeDir: string;
scratchDir?: string;
ephemeralCredentialPrefixes: readonly string[];
}

Expand All @@ -170,6 +172,7 @@ export function createExecExport({
exec,
readAbsBytes,
defaultHomeDir,
scratchDir = DEFAULT_SCRATCH_DIR,
ephemeralCredentialPrefixes,
}: ExecExportDeps): ExecExport {
const rootForArea = (handle: SandboxHandle, area: AgentComputerExportArea): string =>
Expand Down Expand Up @@ -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 {
Expand Down
22 changes: 18 additions & 4 deletions src/sandbox/managed-agents-sandbox.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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";
Expand All @@ -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;
Expand Down Expand Up @@ -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"],
Expand All @@ -325,9 +338,9 @@ export function createManagedAgentsSandbox(workspace: WorkspaceStore, opts: Mana
const procSessions = createExecProcessSessions(procIo);

const writeAbsBytes = (name: string, absPath: string, data: Uint8Array): Promise<void> =>
withSession(name, (session) => session.writeFileBytes(absPath, data));
withSession(name, (session) => session.writeFileBytes(transferPath(absPath), data));
const readAbsBytes = (name: string, absPath: string): Promise<Uint8Array | null> =>
withSession(name, (session) => session.readFileBytes(absPath));
withSession(name, (session) => session.readFileBytes(transferPath(absPath)));
const installLayerTools = opts.layerToolFiles ? createLayerToolInstaller(opts.layerToolFiles) : null;

const execFileOps = createExecFileOps({
Expand All @@ -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),
});

Expand Down
6 changes: 6 additions & 0 deletions src/sandbox/sandbox.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<AgentComputerProfile, "scratchDir"> | undefined): string =>
profile?.scratchDir ?? DEFAULT_SCRATCH_DIR;

export function effectiveEgressEnforcement(
profile: AgentComputerProfile,
controlPlane: { signingSecret?: string; apiBaseUrl?: string },
Expand Down
57 changes: 57 additions & 0 deletions test/managed-agents-sandbox.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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";

Expand Down Expand Up @@ -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<void> => {},
});

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;
Expand Down
48 changes: 34 additions & 14 deletions test/support/fake-managed-agents.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(`(?<![A-Za-z0-9._-])${GUEST_ROOT}(?![A-Za-z0-9._-])`, "g");

export class FakeOutsideWorkspaceError extends Error {
constructor(absPath: string) {
super(`path outside workspace: ${JSON.stringify(absPath)} (workspace ${JSON.stringify(GUEST_ROOT)})`);
this.name = "FakeOutsideWorkspaceError";
}
}

interface FakeRecord {
sessionId: string;
sandboxId: string;
name: string;
state: ManagedAgentsSessionState;
guestRoot: string;
home: string;
createdAt: number;
loseNextCommand: boolean;
Expand Down Expand Up @@ -84,17 +93,27 @@ export function installFakeManagedAgents(): FakeManagedAgents {
};

const remap = (r: FakeRecord, script: string): string => {
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";
Expand Down Expand Up @@ -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<Uint8Array | null> {
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<void> {
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));
},
Expand Down Expand Up @@ -182,7 +201,7 @@ export function installFakeManagedAgents(): FakeManagedAgents {
async kill(): Promise<void> {
guestDisconnects.push(r.sessionId);
r.state = "destroyed";
rmSync(r.home, { recursive: true, force: true });
rmSync(r.guestRoot, { recursive: true, force: true });
},
});

Expand All @@ -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,
Expand Down Expand Up @@ -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 });
},
};

Expand All @@ -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;
Expand Down
Loading