Skip to content
Merged
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
40 changes: 40 additions & 0 deletions infra/runtime/runtime-config.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,46 @@ const azureDeploy = read("../azure/deploy.sh");
const azureImageBuilder = read("../azure/image-builder.bicep");
const azureImageBuild = read("../azure/build-host-image.sh");

test("both guest images provision a searchable, host-owned agent profile root", () => {
assert.match(imageProvision, /codev-shell:x:2000:2000:CoDev shell/);
for (const source of [bootstrap, imageProvision]) {
const guestUnit = between(
source,
'cat >"${work_dir}/rootfs/etc/systemd/system/codev-guestd.service"',
"\nUNIT",
);
assert.match(
guestUnit,
/ExecStartPre=-\/bin\/chgrp -R codev-shell \/workspace/,
);
assert.match(guestUnit, /ExecStartPre=-\/bin\/chmod -R g\+w \/workspace/);
assert.match(
guestUnit,
/ExecStartPre=-\/usr\/bin\/find \/workspace -type d -exec \/bin\/chmod g\+s \{\} \+/,
);
assert.match(guestUnit, /UMask=0002/);
const unit = between(
source,
'cat >"${work_dir}/rootfs/etc/systemd/system/codev-superset-host.service"',
"\nUNIT",
);
assert.match(unit, /StateDirectory=codev-superset codev-agent-profiles/);
assert.match(unit, /StateDirectoryMode=0700/);
assert.match(unit, /After=workspace\.mount codev-guestd\.service/);
assert.match(unit, /UMask=0002/);
assert.match(
unit,
/ExecStartPre=\/bin\/chmod 0711 \/var\/lib\/codev-agent-profiles/,
);
assert.match(
unit,
/Environment=CODEV_AGENT_PROFILE_ROOT=\/var\/lib\/codev-agent-profiles/,
);
assert.match(unit, /ReadWritePaths=.*\/var\/lib\/codev-agent-profiles/);
assert.doesNotMatch(unit, /(?:^|\n)User=/);
}
});

// Firecracker needs /dev/kvm, and not every Azure size exposes it: a size
// without nested virtualization provisions perfectly and then cannot start a
// single microVM. The Dsv7 Intel series supports nested virtualization and
Expand Down
11 changes: 7 additions & 4 deletions infra/runtime/scripts/bootstrap-host.sh
Original file line number Diff line number Diff line change
Expand Up @@ -780,7 +780,7 @@ Type=simple
# other way round.
ExecStartPre=-/bin/chgrp -R codev-shell /workspace
ExecStartPre=-/bin/chmod -R g+w /workspace
ExecStartPre=-/bin/chmod g+s /workspace
ExecStartPre=-/usr/bin/find /workspace -type d -exec /bin/chmod g+s {} +
ExecStart=/usr/local/bin/codev-guestd
Environment=CODEV_WORKSPACE_ROOT=/workspace
EnvironmentFile=/etc/codev/superset-bridge.env
Expand All @@ -803,16 +803,18 @@ UNIT
cat >"${work_dir}/rootfs/etc/systemd/system/codev-superset-host.service" <<'UNIT'
[Unit]
Description=CoDev Superset host service
After=workspace.mount
After=workspace.mount codev-guestd.service
Requires=workspace.mount

[Service]
Type=simple
ExecStartPre=/bin/chmod 0711 /var/lib/codev-agent-profiles
ExecStart=/usr/local/bin/node /opt/codev/superset-host/host-service.js
Environment=HOME=/var/lib/codev-superset
Environment=CODEV_WORKSPACE_ROOT=/workspace
EnvironmentFile=/etc/codev/superset-bridge.env
Environment=SUPERSET_HOME_DIR=/var/lib/codev-superset
Environment=CODEV_AGENT_PROFILE_ROOT=/var/lib/codev-agent-profiles
Environment=HOST_DB_PATH=/var/lib/codev-superset/host.db
Environment=HOST_MIGRATIONS_FOLDER=/opt/codev/superset-host/host-migrations
Environment=SUPERSET_CHAT_V3_MIGRATIONS=/opt/codev/superset-host/chat-migrations
Expand All @@ -823,15 +825,16 @@ Environment=AUTH_TOKEN=codev-guest-local
Environment=SUPERSET_API_URL=http://127.0.0.1:9
Environment=PORT=4879
Environment=NODE_ENV=production
StateDirectory=codev-superset
StateDirectory=codev-superset codev-agent-profiles
StateDirectoryMode=0700
UMask=0002
Restart=on-failure
RestartSec=2
NoNewPrivileges=true
PrivateTmp=true
ProtectHome=true
ProtectSystem=strict
ReadWritePaths=/workspace /var/lib/codev-superset
ReadWritePaths=/workspace /var/lib/codev-superset /var/lib/codev-agent-profiles
TasksMax=256

[Install]
Expand Down
26 changes: 23 additions & 3 deletions infra/runtime/scripts/provision-host-image.sh
Original file line number Diff line number Diff line change
Expand Up @@ -246,6 +246,19 @@ cp -a "/usr/lib/${guest_lib_dir}/." \
install -d -m 0755 "${work_dir}/rootfs/workspace"
install -d -m 0755 "${work_dir}/rootfs/etc/systemd/system/multi-user.target.wants"

# The guest's interactive shell and the isolated agents share this workspace
# group, but the agents use separate numeric uids for private credentials.
if ! grep -q '^codev-shell:' "${work_dir}/rootfs/etc/group"; then
echo 'codev-shell:x:2000:' >>"${work_dir}/rootfs/etc/group"
fi
if ! grep -q '^codev-shell:' "${work_dir}/rootfs/etc/passwd"; then
echo 'codev-shell:x:2000:2000:CoDev shell:/workspace:/bin/sh' \
>>"${work_dir}/rootfs/etc/passwd"
fi
if ! grep -q '^codev-shell:' "${work_dir}/rootfs/etc/shadow"; then
echo 'codev-shell:!:20000::::::' >>"${work_dir}/rootfs/etc/shadow"
fi

# The interactive shell is unprivileged while CoDev assembles the checkout as
# root. Trust this checkout and its managed worktrees without making every
# path trusted for a terminal user.
Expand Down Expand Up @@ -287,9 +300,13 @@ Requires=workspace.mount

[Service]
Type=simple
ExecStartPre=-/bin/chgrp -R codev-shell /workspace
ExecStartPre=-/bin/chmod -R g+w /workspace
ExecStartPre=-/usr/bin/find /workspace -type d -exec /bin/chmod g+s {} +
ExecStart=/usr/local/bin/codev-guestd
Environment=CODEV_WORKSPACE_ROOT=/workspace
EnvironmentFile=/etc/codev/superset-bridge.env
UMask=0002
Restart=on-failure
RestartSec=1
NoNewPrivileges=true
Expand All @@ -308,16 +325,18 @@ UNIT
cat >"${work_dir}/rootfs/etc/systemd/system/codev-superset-host.service" <<'UNIT'
[Unit]
Description=CoDev Superset host service
After=workspace.mount
After=workspace.mount codev-guestd.service
Requires=workspace.mount

[Service]
Type=simple
ExecStartPre=/bin/chmod 0711 /var/lib/codev-agent-profiles
ExecStart=/usr/local/bin/node /opt/codev/superset-host/host-service.js
Environment=HOME=/var/lib/codev-superset
Environment=CODEV_WORKSPACE_ROOT=/workspace
EnvironmentFile=/etc/codev/superset-bridge.env
Environment=SUPERSET_HOME_DIR=/var/lib/codev-superset
Environment=CODEV_AGENT_PROFILE_ROOT=/var/lib/codev-agent-profiles
Environment=HOST_DB_PATH=/var/lib/codev-superset/host.db
Environment=HOST_MIGRATIONS_FOLDER=/opt/codev/superset-host/host-migrations
Environment=SUPERSET_CHAT_V3_MIGRATIONS=/opt/codev/superset-host/chat-migrations
Expand All @@ -328,15 +347,16 @@ Environment=AUTH_TOKEN=codev-guest-local
Environment=SUPERSET_API_URL=http://127.0.0.1:9
Environment=PORT=4879
Environment=NODE_ENV=production
StateDirectory=codev-superset
StateDirectory=codev-superset codev-agent-profiles
StateDirectoryMode=0700
UMask=0002
Restart=on-failure
RestartSec=2
NoNewPrivileges=true
PrivateTmp=true
ProtectHome=true
ProtectSystem=strict
ReadWritePaths=/workspace /var/lib/codev-superset
ReadWritePaths=/workspace /var/lib/codev-superset /var/lib/codev-agent-profiles
TasksMax=256

[Install]
Expand Down
2 changes: 1 addition & 1 deletion vendor/superset/packages/host-service/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,7 @@
"test": "bun test --pass-with-no-tests",
"test:integration": "bun test --pass-with-no-tests test/integration",
"test:integration:daemon": "node --experimental-strip-types --test src/terminal/DaemonClient/DaemonClient.node-test.ts src/daemon/DaemonSupervisor.node-test.ts",
"test:integration:terminal": "node --experimental-strip-types --test src/terminal/terminal.initial-command.node-test.ts src/terminal/terminal.ungated-launch.node-test.ts src/terminal/terminal.shell-ready-learning.node-test.ts src/terminal/terminal.fish-prompt-transport.node-test.ts",
"test:integration:terminal": "node --experimental-strip-types --test src/terminal/terminal.initial-command.node-test.ts src/terminal/terminal.ungated-launch.node-test.ts src/terminal/terminal.shell-ready-learning.node-test.ts src/terminal/terminal.fish-prompt-transport.node-test.ts src/codev/agent-isolation.node-test.ts",
"test:integration:ports": "node --experimental-strip-types --test src/ports/forward-mux-route.node-test.ts",
"test:integration:workers": "node --experimental-strip-types --test src/workers/worker-termination.node-test.ts",
"test:e2e": "bun run scripts/test-e2e.ts",
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
import { strict as assert } from "node:assert";
import { spawnSync } from "node:child_process";
import { existsSync } from "node:fs";
import { chmod, mkdtemp, rm, stat } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { test } from "node:test";
import { agentLaunchScript, prepareAgentLaunch, removeAgentLaunch } from "./agent-isolation.ts";

test("launch script quotes every argument and keeps the credential out of the command", () => {
const script = agentLaunchScript("/private/agent's-dir", ["codex", "exec", "a'$(id)`b"], "{}");
assert.match(script, /export CODEX_HOME='\/private\/agent'\\''s-dir'/);
assert.match(script, /'a'\\''\$\(id\)`b'/);
assert.ok(!script.includes("{}"));
assert.match(script, /umask 0002/);
});

const canExerciseLinuxPermissions =
process.platform === "linux" && process.getuid?.() === 0 && existsSync("/usr/bin/setpriv");

test(
"one agent can read its long launch while the shell and a second agent cannot",
{
skip: !canExerciseLinuxPermissions,
},
async () => {
const root = await mkdtemp(join(tmpdir(), "codev-agent-isolation-"));
const secret = '{"tokens":{"access_token":"test-only"}}';
const longArgument = `${"x".repeat(2_000)}'$(printf injected)`;
let first: Awaited<ReturnType<typeof prepareAgentLaunch>> | undefined;
let second: Awaited<ReturnType<typeof prepareAgentLaunch>> | undefined;
try {
await assert.rejects(
prepareAgentLaunch({ root, command: ["/usr/bin/true"] }),
/not provisioned safely/,
);
await chmod(root, 0o711);
first = await prepareAgentLaunch({
root,
command: ["/usr/bin/printf", "%s", longArgument],
authCacheJson: secret,
});
second = await prepareAgentLaunch({ root, command: ["/usr/bin/true"] });
assert.notEqual(first.uid, second.uid);
assert.ok(first.command.length < 512);
assert.equal((await stat(first.directory)).mode & 0o777, 0o700);
assert.equal((await stat(join(first.directory, "auth.json"))).mode & 0o777, 0o600);

const runAs = (uid: number, command: string) =>
spawnSync(
"/usr/bin/setpriv",
[`--reuid=${uid}`, "--regid=2000", "--clear-groups", "--", "/bin/sh", "-c", command],
{ encoding: "utf8" },
);
assert.equal(runAs(2000, `test ! -r '${first.directory}/auth.json'`).status, 0);
assert.equal(runAs(2000, `test ! -r '${first.directory}/launch.sh'`).status, 0);
assert.equal(runAs(second.uid, `test ! -r '${first.directory}/auth.json'`).status, 0);
assert.equal(runAs(second.uid, `test ! -r '${first.directory}/launch.sh'`).status, 0);
assert.equal(runAs(first.uid, `test -r '${first.directory}/auth.json'`).status, 0);
const launched = runAs(first.uid, first.command);
assert.equal(launched.status, 0, launched.stderr);
assert.equal(launched.stdout, longArgument);
} finally {
await removeAgentLaunch(first);
await removeAgentLaunch(second);
await rm(root, { recursive: true, force: true });
}
},
);
97 changes: 97 additions & 0 deletions vendor/superset/packages/host-service/src/codev/agent-isolation.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
import { randomInt } from "node:crypto";
import { chown, chmod, lstat, mkdtemp, readdir, rm, writeFile } from "node:fs/promises";
import { join } from "node:path";

// Reserved for CoDev agent processes. Ordinary terminals always use uid 2000.
const MIN_AGENT_UID = 100_000;
const MAX_AGENT_UID = 2_147_483_647;
const WORKSPACE_GID = 2000;
const reservedUids = new Set<number>();

export type AgentLaunch = {
directory: string;
uid: number;
command: string;
};

function quote(value: string): string {
return `'${value.replaceAll("'", `'\\''`)}'`;
}

export function agentLaunchScript(
directory: string,
command: string[],
authCacheJson?: string,
): string {
const lines = ["#!/bin/sh", "umask 0002"];
if (authCacheJson) lines.push(`export CODEX_HOME=${quote(directory)}`);
lines.push(command.map(quote).join(" "), "exit $?");
return `${lines.join("\n")}\n`;
}

async function allocateUid(root: string): Promise<number> {
const used = new Set(reservedUids);
for (const entry of await readdir(root, { withFileTypes: true })) {
if (!entry.isDirectory() || !entry.name.startsWith("agent-")) continue;
used.add((await lstat(join(root, entry.name))).uid);
}
for (let attempt = 0; attempt < 100; attempt += 1) {
const uid = randomInt(MIN_AGENT_UID, MAX_AGENT_UID);
if (used.has(uid) || reservedUids.has(uid)) continue;
reservedUids.add(uid);
return uid;
}
throw new Error("Could not allocate an isolated agent identity.");
}

/**
* The root is provisioned by systemd: root-owned and searchable (0711), but
* not listable or writable by a terminal. Each child is owned by one distinct
* agent uid (0700), so uid 2000 and other agents cannot read its contents.
*/
export async function prepareAgentLaunch(input: {
root: string;
command: string[];
authCacheJson?: string;
}): Promise<AgentLaunch> {
const root = await lstat(input.root);
if (
!root.isDirectory() ||
root.isSymbolicLink() ||
root.uid !== 0 ||
(root.mode & 0o777) !== 0o711
) {
throw new Error("The isolated agent profile root is not provisioned safely.");
}
const uid = await allocateUid(input.root);
let directory: string | undefined;
try {
directory = await mkdtemp(join(input.root, "agent-"));
await chmod(directory, 0o700);
if (input.authCacheJson) {
const authPath = join(directory, "auth.json");
await writeFile(authPath, input.authCacheJson, { mode: 0o600, flag: "wx" });
await chown(authPath, uid, WORKSPACE_GID);
}
const scriptPath = join(directory, "launch.sh");
await writeFile(scriptPath, agentLaunchScript(directory, input.command, input.authCacheJson), {
mode: 0o600,
flag: "wx",
});
await chown(scriptPath, uid, WORKSPACE_GID);
await chown(directory, uid, WORKSPACE_GID);
// This short source line avoids Superset's root-owned /tmp staging for
// initialCommand strings longer than 512 bytes.
return { directory, uid, command: `. ${quote(scriptPath)}` };
} catch (error) {
if (directory) await rm(directory, { recursive: true, force: true });
reservedUids.delete(uid);
throw error;
}
}

export async function removeAgentLaunch(launch: AgentLaunch | undefined): Promise<void> {
if (!launch) return;
await rm(launch.directory, { recursive: true, force: true });
reservedUids.delete(launch.uid);
}
Loading
Loading