diff --git a/infra/runtime/runtime-config.test.mjs b/infra/runtime/runtime-config.test.mjs index f05762ac9..00be2fc2f 100644 --- a/infra/runtime/runtime-config.test.mjs +++ b/infra/runtime/runtime-config.test.mjs @@ -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 diff --git a/infra/runtime/scripts/bootstrap-host.sh b/infra/runtime/scripts/bootstrap-host.sh index c78e194d2..3876406ad 100755 --- a/infra/runtime/scripts/bootstrap-host.sh +++ b/infra/runtime/scripts/bootstrap-host.sh @@ -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 @@ -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 @@ -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] diff --git a/infra/runtime/scripts/provision-host-image.sh b/infra/runtime/scripts/provision-host-image.sh index f055a8864..29dba79d3 100755 --- a/infra/runtime/scripts/provision-host-image.sh +++ b/infra/runtime/scripts/provision-host-image.sh @@ -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. @@ -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 @@ -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 @@ -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] diff --git a/vendor/superset/packages/host-service/package.json b/vendor/superset/packages/host-service/package.json index 70ff30bdc..c0c4ca09f 100644 --- a/vendor/superset/packages/host-service/package.json +++ b/vendor/superset/packages/host-service/package.json @@ -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", diff --git a/vendor/superset/packages/host-service/src/codev/agent-isolation.node-test.ts b/vendor/superset/packages/host-service/src/codev/agent-isolation.node-test.ts new file mode 100644 index 000000000..b7b895027 --- /dev/null +++ b/vendor/superset/packages/host-service/src/codev/agent-isolation.node-test.ts @@ -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> | undefined; + let second: Awaited> | 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 }); + } + }, +); diff --git a/vendor/superset/packages/host-service/src/codev/agent-isolation.ts b/vendor/superset/packages/host-service/src/codev/agent-isolation.ts new file mode 100644 index 000000000..b6c35a94f --- /dev/null +++ b/vendor/superset/packages/host-service/src/codev/agent-isolation.ts @@ -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(); + +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 { + 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 { + 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 { + if (!launch) return; + await rm(launch.directory, { recursive: true, force: true }); + reservedUids.delete(launch.uid); +} diff --git a/vendor/superset/packages/host-service/src/codev/agents.ts b/vendor/superset/packages/host-service/src/codev/agents.ts index 106487adb..812a98553 100644 --- a/vendor/superset/packages/host-service/src/codev/agents.ts +++ b/vendor/superset/packages/host-service/src/codev/agents.ts @@ -1,6 +1,4 @@ -import { mkdir, rm, writeFile } from "node:fs/promises"; -import { randomUUID, timingSafeEqual } from "node:crypto"; -import { join } from "node:path"; +import { timingSafeEqual } from "node:crypto"; import { eq } from "drizzle-orm"; import type { Hono } from "hono"; import { z } from "zod"; @@ -13,6 +11,7 @@ import { snapshotSession, writeFramedInputToSession, } from "../terminal/terminal"; +import { prepareAgentLaunch, removeAgentLaunch, type AgentLaunch } from "./agent-isolation"; import { resolveCoDevWorktreeRoot } from "./files"; const worktreeIdSchema = z @@ -48,8 +47,8 @@ export type CoDevAgentBridgeOptions = { type AgentPollState = { sequence: number; text: string }; const agentPollStates = new Map(); -/** The private credential profile directory, if one was materialized, keyed by agent ID. */ -const agentProfileDirs = new Map(); +/** Private process identity and launch directory, keyed by agent ID. */ +const agentLaunches = new Map(); /** idempotencyKey -> agentId, so a retried start reattaches instead of relaunching. */ const agentIdempotency = new Map(); let agentSequence = 0; @@ -109,23 +108,6 @@ function terminalError(error: unknown) { : "Superset agent operation failed."; } -/** - * Quotes one argument for a POSIX shell command line: wrap in single quotes, - * and turn each embedded `'` into `'\''` (close the quote, an escaped - * literal quote, reopen the quote). Required because `initialCommand` is - * typed into a live shell -- an agent's command carries a member's prompt - * text verbatim (see apps/web's buildGen2CodexCommand), so an unescaped - * `$()`, backtick, or quote in that text would otherwise run as shell code. - */ -function posixShellQuote(value: string): string { - return `'${value.replaceAll("'", `'\\''`)}'`; -} - -async function removeProfileDir(profileDir: string | undefined) { - if (!profileDir) return; - await rm(profileDir, { recursive: true, force: true }).catch(() => undefined); -} - /** * Fixed, bridge-secret-protected operations that launch and drive a * terminal-agent session for CoDev's Superset Agent Session Plan Phase 3. @@ -184,30 +166,18 @@ export function registerCoDevAgentBridge({ agentIdempotency.delete(idempotencyKey); } - let profileDir: string | undefined; + let launch: AgentLaunch | undefined; try { const workspace = await ensureAgentWorkspace({ db, git, workspaceRoot, worktreeId }); - let launchCommand = command.map(posixShellQuote).join(" "); - if (codexAuthCacheJson) { - const homeRoot = process.env.SUPERSET_HOME_DIR; - if (!homeRoot) { - return context.json( - { error: "Superset host has no private home directory configured." }, - 503, - ); - } - profileDir = join(homeRoot, "codev-agent-profiles", randomUUID()); - await mkdir(profileDir, { recursive: true, mode: 0o700 }); - await writeFile(join(profileDir, "auth.json"), codexAuthCacheJson, { - mode: 0o600, - }); - launchCommand = `CODEX_HOME=${posixShellQuote(profileDir)} ${launchCommand}`; + const profileRoot = process.env.CODEV_AGENT_PROFILE_ROOT; + if (!profileRoot) { + return context.json({ error: "Isolated agent profiles are not configured." }, 503); } - // A one-shot exec must not leave an idle shell behind once the - // provider process finishes -- exiting lets the terminal - // subsystem's own PTY-exit handling mark endedAt, which is what - // /codev/agents/:id/poll and /recovery read. - launchCommand = `${launchCommand}; exit $?`; + launch = await prepareAgentLaunch({ + root: profileRoot, + command, + authCacheJson: codexAuthCacheJson, + }); const agentId = `agent-${Date.now()}-${++agentSequence}`; const created = await createTerminalSessionInternal({ @@ -218,25 +188,25 @@ export function registerCoDevAgentBridge({ rows: 1_000, cols: 4_096, includeDefaultAccountEnv: false, - // Matches /codev/terminal: the image reserves uid/gid 2000 for - // codev-shell, and setpriv drops directly to it with no PAM - // session. + homeDirectory: launch.directory, + // Each agent has its own uid; gid 2000 keeps the selected + // worktree writable alongside ordinary codev-shell terminals. shell: "/usr/bin/setpriv", shellArgs: [ - "--reuid=2000", + `--reuid=${launch.uid}`, "--regid=2000", "--clear-groups", "--", "/bin/sh", "-l", ], - initialCommand: launchCommand, + initialCommand: launch.command, }); if ("error" in created) { - await removeProfileDir(profileDir); + await removeAgentLaunch(launch); return context.json({ error: terminalError(created) }, 400); } - if (profileDir) agentProfileDirs.set(agentId, profileDir); + agentLaunches.set(agentId, launch); agentIdempotency.set(idempotencyKey, agentId); agentPollStates.set(agentId, { sequence: 0, text: "" }); return context.json( @@ -248,7 +218,7 @@ export function registerCoDevAgentBridge({ 201, ); } catch (error) { - await removeProfileDir(profileDir); + await removeAgentLaunch(launch); return context.json( { error: error instanceof Error ? error.message : "Could not start Superset agent." }, 400, @@ -334,8 +304,8 @@ export function registerCoDevAgentBridge({ try { await disposeSessionAndWait(agentId, db); agentPollStates.delete(agentId); - await removeProfileDir(agentProfileDirs.get(agentId)); - agentProfileDirs.delete(agentId); + await removeAgentLaunch(agentLaunches.get(agentId)); + agentLaunches.delete(agentId); return context.json({ ok: true }); } catch (error) { return context.json( diff --git a/vendor/superset/packages/host-service/src/terminal/terminal.ts b/vendor/superset/packages/host-service/src/terminal/terminal.ts index 14d1a8b65..d675dce33 100644 --- a/vendor/superset/packages/host-service/src/terminal/terminal.ts +++ b/vendor/superset/packages/host-service/src/terminal/terminal.ts @@ -2879,6 +2879,8 @@ interface CreateTerminalSessionOptions { /** Trusted host callers may replace the login shell for an unprivileged launcher. */ shell?: string; shellArgs?: string[]; + /** Trusted host callers may set a private home before the shell starts. */ + homeDirectory?: string; /** Only recover an already-live daemon session; never spawn a new PTY. */ adoptOnly?: boolean; /** @@ -2968,6 +2970,7 @@ async function createTerminalSessionUnlocked({ includeDefaultAccountEnv = true, shell: shellOverride, shellArgs: shellArgsOverride, + homeDirectory, adoptOnly = false, restoredNotice = false, }: CreateTerminalSessionOptions): Promise< @@ -3080,6 +3083,7 @@ async function createTerminalSessionUnlocked({ // this terminal run on the selected login. Baked at spawn as the fast // path; the agent wrappers re-resolve later switches at launch time. ...(includeDefaultAccountEnv ? resolveDefaultAccountTerminalEnv(db) : {}), + ...(homeDirectory ? { HOME: homeDirectory } : {}), SUPERSET_ACCOUNT_ATTRIBUTION_TOKEN: issueAttributionToken(terminalId), };