diff --git a/packages/paperclip-runner/src/drivers/native-mcp.ts b/packages/paperclip-runner/src/drivers/native-mcp.ts new file mode 100644 index 0000000000..41dfa96652 --- /dev/null +++ b/packages/paperclip-runner/src/drivers/native-mcp.ts @@ -0,0 +1,41 @@ +export interface NativeMcpLaunchBinding { + name: string; + url: string; + token: string; +} + +export function nativeMcpLaunchBinding( + environment: NodeJS.ProcessEnv = process.env, +): NativeMcpLaunchBinding | null { + const name = environment.PAPERCLIP_NATIVE_MCP_NAME?.trim(); + const url = environment.PAPERCLIP_NATIVE_MCP_URL?.trim(); + const token = environment.PAPERCLIP_NATIVE_MCP_TOKEN?.trim(); + if (!name && !url && !token) return null; + if ( + !name + || !url + || !token + || token.length < 32 + || token.length > 4096 + || url.length > 2048 + ) { + throw new Error("assigned native MCP launch binding is incomplete"); + } + if (!/^[a-zA-Z0-9_.-]{1,128}$/.test(name)) { + throw new Error("assigned native MCP name is invalid"); + } + const endpoint = new URL(url); + if (endpoint.username || endpoint.password || endpoint.hash) { + throw new Error("assigned native MCP endpoint contains forbidden URL data"); + } + if ( + endpoint.protocol !== "https:" + && !(endpoint.protocol === "http:" + && ["127.0.0.1", "localhost"].includes(endpoint.hostname)) + ) { + throw new Error( + "assigned native MCP endpoint requires HTTPS or loopback HTTP", + ); + } + return { name, url: endpoint.toString(), token }; +} diff --git a/packages/paperclip-runner/src/drivers/runtime-context-materializer.test.ts b/packages/paperclip-runner/src/drivers/runtime-context-materializer.test.ts new file mode 100644 index 0000000000..0c562665dd --- /dev/null +++ b/packages/paperclip-runner/src/drivers/runtime-context-materializer.test.ts @@ -0,0 +1,463 @@ +import { + chmod, + lstat, + mkdtemp, + mkdir, + readFile, + readdir, + rm, + stat, + symlink, + writeFile, +} from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { dirname, join } from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; + +import { + NATIVE_RUNTIME_ASSET_SCHEMA, + PAPERCLIP_EXECUTION_PROMPT, + PAPERCLIP_EXECUTION_PROMPT_REVISION, + canonicalNativeRuntimeContextDigest, + nativeRuntimePromptDigest, + type NativeRuntimeContextSnapshot, +} from "../contracts/runtime-context.js"; +import { nativeMcpLaunchBinding } from "./native-mcp.js"; +import { + materializeNativeRuntimeSkills, + prepareIsolatedCodexHome, +} from "./runtime-context-materializer.js"; + +const roots: string[] = []; + +async function makeWritable(root: string): Promise { + const info = await lstat(root).catch(() => null); + if (!info || info.isSymbolicLink()) return; + if (info.isDirectory()) { + await chmod(root, 0o700); + for (const entry of await readdir(root)) { + await makeWritable(join(root, entry)); + } + } else { + await chmod(root, 0o600); + } +} + +afterEach(async () => { + await Promise.all(roots.splice(0).map(async (root) => { + await makeWritable(root); + await rm(root, { recursive: true, force: true }); + })); +}); + +function context( + skillRoot: string, + instructionRoot: string, + runtimeName = "assigned", +): NativeRuntimeContextSnapshot { + const digest = "0".repeat(64); + const value = { + prompt: { + revision: PAPERCLIP_EXECUTION_PROMPT_REVISION, + text: PAPERCLIP_EXECUTION_PROMPT, + digest: nativeRuntimePromptDigest(), + }, + instructions: { + entryPath: "AGENTS.md", + bundle: { + schema: NATIVE_RUNTIME_ASSET_SCHEMA, + digest, + manifestDigest: digest, + rootPath: instructionRoot, + fileCount: 2, + totalBytes: 2, + }, + }, + skills: [{ + key: `company/${runtimeName}`, + runtimeName, + versionId: "version-1", + bundle: { + schema: NATIVE_RUNTIME_ASSET_SCHEMA, + digest, + manifestDigest: digest, + rootPath: skillRoot, + fileCount: 2, + totalBytes: 2, + }, + }], + mcp: { assignmentSetId: runtimeName, digest, bindingId: "binding" }, + } satisfies Omit; + return { ...value, aggregateDigest: canonicalNativeRuntimeContextDigest(value) }; +} + +describe("runtime context materialization", () => { + it("validates native MCP launch bindings before they reach Codex", () => { + expect(nativeMcpLaunchBinding({})).toBeNull(); + expect(() => nativeMcpLaunchBinding({ + PAPERCLIP_NATIVE_MCP_NAME: "paperclip", + PAPERCLIP_NATIVE_MCP_URL: "http://paperclip.example/mcp", + PAPERCLIP_NATIVE_MCP_TOKEN: "x".repeat(40), + })).toThrow("requires HTTPS or loopback HTTP"); + expect(() => nativeMcpLaunchBinding({ + PAPERCLIP_NATIVE_MCP_NAME: "paperclip", + PAPERCLIP_NATIVE_MCP_URL: "https://user:pass@paperclip.example/mcp#secret", + PAPERCLIP_NATIVE_MCP_TOKEN: "x".repeat(40), + })).toThrow("contains forbidden URL data"); + }); + + it("copies only assigned skills and writes an isolated MCP config", async () => { + const root = await mkdtemp(join(tmpdir(), "paperclip-runtime-context-")); + roots.push(root); + const assigned = join(root, "assigned-source"); + const instructions = join(root, "instructions-source"); + const hostSkills = join(root, "host-home", "skills", "unassigned"); + const codexHome = join(root, "codex-home"); + await Promise.all([ + mkdir(join(assigned, "references"), { recursive: true }), + mkdir(instructions), + mkdir(hostSkills, { recursive: true }), + ]); + await writeFile(join(assigned, "SKILL.md"), "# Assigned\n"); + await writeFile( + join(assigned, "references", "support.md"), + "support\n", + ); + await writeFile(join(instructions, "AGENTS.md"), "Instructions\n"); + await writeFile(join(hostSkills, "SKILL.md"), "# Must not leak\n"); + + await prepareIsolatedCodexHome({ + context: context(assigned, instructions), + codexHome, + sourceCodexHome: join(root, "host-home"), + nativeMcp: nativeMcpLaunchBinding({ + PAPERCLIP_NATIVE_MCP_NAME: "paperclip-assigned", + PAPERCLIP_NATIVE_MCP_URL: "https://paperclip.example/mcp", + PAPERCLIP_NATIVE_MCP_TOKEN: "x".repeat(40), + }), + }); + + await expect(readFile( + join(codexHome, "skills", "assigned", "references", "support.md"), + "utf8", + )).resolves.toBe("support\n"); + await expect(stat(join(codexHome, "skills", "unassigned"))).rejects.toThrow(); + expect( + (await stat(join(codexHome, "skills", "assigned", "SKILL.md"))).mode + & 0o222, + ).toBe(0); + const config = await readFile(join(codexHome, "config.toml"), "utf8"); + expect(config).toContain("shell_snapshot = false"); + expect(config).toContain("paperclip-assigned"); + expect(config).toContain("Bearer "); + expect(config).not.toContain("unassigned"); + }); + + it("rejects repeated assignments without changing the current assignment", async () => { + const root = await mkdtemp(join(tmpdir(), "paperclip-runtime-repeat-")); + roots.push(root); + const assigned = join(root, "assigned-source"); + const replacement = join(root, "replacement-source"); + const instructions = join(root, "instructions-source"); + const codexHome = join(root, "codex-home"); + await Promise.all([mkdir(assigned), mkdir(replacement), mkdir(instructions)]); + await writeFile(join(assigned, "SKILL.md"), "# Assigned\n"); + await writeFile(join(replacement, "SKILL.md"), "# Replacement\n"); + await writeFile(join(instructions, "AGENTS.md"), "Instructions\n"); + + await prepareIsolatedCodexHome({ + context: context(assigned, instructions), + codexHome, + }); + await expect(prepareIsolatedCodexHome({ + context: context(replacement, instructions, "replacement"), + codexHome, + })).rejects.toThrow("skills home must be a fresh destination"); + await expect(prepareIsolatedCodexHome({ + context: null, + codexHome, + })).rejects.toThrow("skills home must be a fresh destination"); + + await expect(readFile( + join(codexHome, "skills", "assigned", "SKILL.md"), + "utf8", + )).resolves.toBe("# Assigned\n"); + await expect(stat(join(codexHome, "skills", "replacement"))).rejects + .toThrow(); + }); + + it("rejects source symlinks without changing the current assignment", async () => { + const root = await mkdtemp(join(tmpdir(), "paperclip-runtime-symlink-")); + roots.push(root); + const assigned = join(root, "assigned-source"); + const unsafe = join(root, "unsafe-source"); + const instructions = join(root, "instructions-source"); + const external = join(root, "external.txt"); + const codexHome = join(root, "codex-home"); + await Promise.all([mkdir(assigned), mkdir(unsafe), mkdir(instructions)]); + await writeFile(join(assigned, "SKILL.md"), "# Assigned\n"); + await writeFile(join(instructions, "AGENTS.md"), "Instructions\n"); + await writeFile(external, "secret\n"); + await symlink(external, join(unsafe, "SKILL.md")); + + await prepareIsolatedCodexHome({ + context: context(assigned, instructions), + codexHome, + }); + await expect(prepareIsolatedCodexHome({ + context: context(unsafe, instructions, "unsafe"), + codexHome, + })).rejects.toThrow("runtime context asset contains a symlink"); + await expect(readFile( + join(codexHome, "skills", "assigned", "SKILL.md"), + "utf8", + )).resolves.toBe("# Assigned\n"); + }); + + it("rejects overlapping portable names without changing the current assignment", async () => { + const root = await mkdtemp(join(tmpdir(), "paperclip-runtime-atomic-")); + roots.push(root); + const assigned = join(root, "assigned-source"); + const replacement = join(root, "replacement-source"); + const instructions = join(root, "instructions-source"); + const codexHome = join(root, "codex-home"); + await Promise.all([mkdir(assigned), mkdir(replacement), mkdir(instructions)]); + await writeFile(join(assigned, "SKILL.md"), "# Assigned\n"); + await writeFile(join(replacement, "SKILL.md"), "# Replacement\n"); + await writeFile(join(instructions, "AGENTS.md"), "Instructions\n"); + + await prepareIsolatedCodexHome({ + context: context(assigned, instructions), + codexHome, + }); + const replacementContext = context(replacement, instructions, "group/child"); + for (const runtimeName of ["group", "GROUP", "group.", "group "]) { + const collidingSkill = { + ...replacementContext.skills[0]!, + key: `company/${runtimeName}`, + runtimeName, + }; + for (const skills of [ + [...replacementContext.skills, collidingSkill], + [collidingSkill, ...replacementContext.skills], + ]) { + const value = { ...replacementContext, skills }; + const collidingContext = { + ...value, + aggregateDigest: canonicalNativeRuntimeContextDigest(value), + }; + + await expect(prepareIsolatedCodexHome({ + context: collidingContext, + codexHome, + })).rejects.toThrow("skill names must not overlap"); + await expect(readFile( + join(codexHome, "skills", "assigned", "SKILL.md"), + "utf8", + )).resolves.toBe("# Assigned\n"); + await expect(stat(join(codexHome, "skills", "group"))).rejects.toThrow(); + } + } + + const win32AliasedChild = { + ...replacementContext.skills[0]!, + key: "company/group-dot-child", + runtimeName: "group./child", + }; + const value = { + ...replacementContext, + skills: [...replacementContext.skills, win32AliasedChild], + }; + await expect(prepareIsolatedCodexHome({ + context: { + ...value, + aggregateDigest: canonicalNativeRuntimeContextDigest(value), + }, + codexHome, + })).rejects.toThrow("skill names must not overlap"); + }); + + it("rejects an existing destination before filesystem mutation", async () => { + const root = await mkdtemp(join(tmpdir(), "paperclip-runtime-existing-")); + roots.push(root); + const assigned = join(root, "assigned-source"); + const replacement = join(root, "replacement-source"); + const instructions = join(root, "instructions-source"); + const skillsHome = join(root, "codex-home", "skills"); + await Promise.all([ + mkdir(assigned), + mkdir(replacement), + mkdir(instructions), + ]); + await writeFile(join(assigned, "SKILL.md"), "# Assigned\n"); + await writeFile(join(replacement, "SKILL.md"), "# Replacement\n"); + await writeFile(join(instructions, "AGENTS.md"), "Instructions\n"); + + await materializeNativeRuntimeSkills( + context(assigned, instructions), + skillsHome, + ); + const originalBytes = await readFile( + join(skillsHome, "assigned", "SKILL.md"), + ); + let dependencyCalls = 0; + await expect( + materializeNativeRuntimeSkills( + context(replacement, instructions, "replacement"), + skillsHome, + { + renameTree: async () => { + dependencyCalls += 1; + }, + removeTree: async () => { + dependencyCalls += 1; + }, + }, + ), + ).rejects.toThrow("skills home must be a fresh destination"); + + expect(dependencyCalls).toBe(0); + expect(await readFile(join(skillsHome, "assigned", "SKILL.md"))).toEqual( + originalBytes, + ); + await expect(stat(join(skillsHome, "replacement"))).rejects.toThrow(); + expect( + (await readdir(dirname(skillsHome))).filter((entry) => + entry.startsWith(".paperclip-skills-"), + ), + ).toEqual([]); + }); + + it("cleans private staging when fresh publication fails", async () => { + const root = await mkdtemp(join(tmpdir(), "paperclip-runtime-publish-")); + roots.push(root); + const assigned = join(root, "assigned-source"); + const instructions = join(root, "instructions-source"); + const skillsHome = join(root, "codex-home", "skills"); + await Promise.all([mkdir(assigned), mkdir(instructions)]); + await writeFile(join(assigned, "SKILL.md"), "# Assigned\n"); + await writeFile(join(instructions, "AGENTS.md"), "Instructions\n"); + + await expect(materializeNativeRuntimeSkills( + context(assigned, instructions), + skillsHome, + { + renameTree: async (source) => { + await expect( + readFile(join(source, "assigned", "SKILL.md"), "utf8"), + ).resolves.toBe("# Assigned\n"); + throw new Error("simulated publication failure"); + }, + }, + )).rejects.toThrow("simulated publication failure"); + + await expect(stat(skillsHome)).rejects.toThrow(); + expect( + (await readdir(dirname(skillsHome))).filter((entry) => + entry.startsWith(".paperclip-skills-staging-"), + ), + ).toEqual([]); + }); + + it("rejects skill names that can escape or alias the skills home", async () => { + const root = await mkdtemp(join(tmpdir(), "paperclip-runtime-name-")); + roots.push(root); + const assigned = join(root, "assigned-source"); + const instructions = join(root, "instructions-source"); + const codexHome = join(root, "codex-home"); + await Promise.all([mkdir(assigned), mkdir(instructions)]); + await writeFile(join(assigned, "SKILL.md"), "# Assigned\n"); + await writeFile(join(instructions, "AGENTS.md"), "Instructions\n"); + + await expect(prepareIsolatedCodexHome({ + context: context(assigned, instructions, "../outside"), + codexHome, + })).rejects.toThrow("skill name must be a safe relative path"); + for (const runtimeName of [ + "CON", + "con.txt", + "CON .txt", + "aux .json", + "COM1 .md", + "group/AUX", + "Lpt1.json", + "group:name", + "group?name", + "group*name", + 'group"name', + "groupname", + "group|name", + "group\u0001name", + "...", + ". .", + "group/...", + "group/. . ", + ]) { + await expect(prepareIsolatedCodexHome({ + context: context(assigned, instructions, runtimeName), + codexHome, + })).rejects.toThrow("skill name must be a safe relative path"); + } + await expect(stat(join(root, "outside"))).rejects.toThrow(); + }); + + it("writes API login state as an owner-only file and removes stale auth", async () => { + const root = await mkdtemp(join(tmpdir(), "paperclip-runtime-auth-")); + roots.push(root); + const codexHome = join(root, "codex-home"); + const emptyCodexHome = join(root, "empty-codex-home"); + await Promise.all([ + mkdir(codexHome, { recursive: true }), + mkdir(emptyCodexHome, { recursive: true }), + ]); + await Promise.all([ + writeFile(join(codexHome, "auth.json"), "stale", { mode: 0o600 }), + writeFile(join(emptyCodexHome, "auth.json"), "stale", { mode: 0o600 }), + ]); + + await prepareIsolatedCodexHome({ + context: null, + codexHome, + apiKey: "fresh-ephemeral-key", + }); + + await expect(readFile(join(codexHome, "auth.json"), "utf8")).resolves.toBe( + JSON.stringify({ OPENAI_API_KEY: "fresh-ephemeral-key" }), + ); + expect((await stat(join(codexHome, "auth.json"))).mode & 0o777).toBe(0o600); + + await prepareIsolatedCodexHome({ context: null, codexHome: emptyCodexHome }); + await expect(stat(join(emptyCodexHome, "auth.json"))).rejects.toThrow(); + }); + + it("copies regular host auth but refuses symlinked auth", async () => { + const root = await mkdtemp(join(tmpdir(), "paperclip-runtime-host-auth-")); + roots.push(root); + const codexHome = join(root, "codex-home"); + const symlinkCodexHome = join(root, "symlink-codex-home"); + const sourceHome = join(root, "source-home"); + const secret = join(root, "secret.json"); + await mkdir(sourceHome); + await writeFile(join(sourceHome, "auth.json"), "host-auth", { mode: 0o600 }); + + await prepareIsolatedCodexHome({ + context: null, + codexHome, + sourceCodexHome: sourceHome, + }); + await expect(readFile(join(codexHome, "auth.json"), "utf8")).resolves.toBe( + "host-auth", + ); + + await rm(join(sourceHome, "auth.json")); + await writeFile(secret, "must-not-copy"); + await symlink(secret, join(sourceHome, "auth.json")); + await prepareIsolatedCodexHome({ + context: null, + codexHome: symlinkCodexHome, + sourceCodexHome: sourceHome, + }); + await expect(stat(join(symlinkCodexHome, "auth.json"))).rejects.toThrow(); + }); +}); diff --git a/packages/paperclip-runner/src/drivers/runtime-context-materializer.ts b/packages/paperclip-runner/src/drivers/runtime-context-materializer.ts new file mode 100644 index 0000000000..8ae9e08ea6 --- /dev/null +++ b/packages/paperclip-runner/src/drivers/runtime-context-materializer.ts @@ -0,0 +1,293 @@ +import { randomUUID } from "node:crypto"; +import { constants } from "node:fs"; +import { + chmod, + cp, + lstat, + mkdir, + open, + readdir, + rename, + rm, + writeFile, +} from "node:fs/promises"; +import { dirname, isAbsolute, join, relative, resolve, sep } from "node:path"; + +import type { NativeRuntimeContextSnapshot } from "../contracts/runtime-context.js"; +import type { NativeMcpLaunchBinding } from "./native-mcp.js"; + +async function assertSafeTree(root: string, child = ""): Promise { + const directory = child ? join(root, child) : root; + const directoryStat = await lstat(directory); + if (directoryStat.isSymbolicLink()) { + throw new Error( + `runtime context asset contains a symlink: ${child || "."}`, + ); + } + if (!directoryStat.isDirectory()) { + throw new Error("runtime context asset root must be a directory"); + } + for (const entry of await readdir(directory, { withFileTypes: true })) { + const childRelative = child ? `${child}/${entry.name}` : entry.name; + const stat = await lstat(join(root, childRelative)); + if (stat.isSymbolicLink()) { + throw new Error( + `runtime context asset contains a symlink: ${childRelative}`, + ); + } + if (stat.isDirectory()) await assertSafeTree(root, childRelative); + else if (!stat.isFile()) { + throw new Error( + `runtime context asset contains an unsupported file: ${childRelative}`, + ); + } + } +} + +function safeMaterializationTarget(root: string, runtimeName: string): string { + const segments = runtimeName.split("/"); + if ( + runtimeName.startsWith("/") + || runtimeName.includes("\\") + || runtimeName.includes("\0") + || segments.some((segment) => /[<>:"|?*\u0000-\u001f\u007f]/u.test(segment)) + || segments.some((segment) => !segment || segment === "." || segment === "..") + || segments.some((segment) => !segment.replace(/[ .]+$/u, "")) + || segments.some(isWin32ReservedPathSegment) + ) { + throw new Error("runtime context skill name must be a safe relative path"); + } + const target = resolve(root, runtimeName); + const relation = relative(resolve(root), target); + if ( + relation === "" + || relation === ".." + || relation.startsWith(`..${sep}`) + || isAbsolute(relation) + ) { + throw new Error("runtime context skill name must stay inside the skills home"); + } + return target; +} + +function isWin32ReservedPathSegment(segment: string): boolean { + const basename = segment + .normalize("NFC") + .split(".", 1)[0]! + .replace(/[ .]+$/u, "") + .toUpperCase(); + return /^(?:CON|PRN|AUX|NUL|CONIN\$|CONOUT\$|COM[1-9¹²³]|LPT[1-9¹²³])$/u.test( + basename, + ); +} + +function portableRuntimeNameKey(runtimeName: string): string { + // Skill assignments must remain unambiguous when the same context is moved + // between the case-sensitive Linux runner and the case-insensitive default + // filesystems on macOS or Windows. NFC also catches composed/decomposed + // aliases, while per-segment trimming matches Win32's treatment of trailing + // dots and spaces before either spelling reaches the staging tree. + return runtimeName + .normalize("NFC") + .split("/") + .map((segment) => segment.replace(/[ .]+$/u, "")) + .join("/") + .toLowerCase(); +} + +async function protectStagedTree(root: string): Promise { + const rootHandle = await open( + root, + constants.O_RDONLY + | (constants.O_NOFOLLOW ?? 0) + | (constants.O_DIRECTORY ?? 0), + ); + try { + const opened = await rootHandle.stat(); + if (!opened.isDirectory()) { + throw new Error("staged runtime context root must be a directory"); + } + // Keep directories owner-writable so the private staging tree can be + // removed without a second path-based chmod traversal. + await rootHandle.chmod(0o700); + for (const entry of await readdir(root, { withFileTypes: true })) { + const child = join(root, entry.name); + if (entry.isDirectory()) { + await protectStagedTree(child); + continue; + } + const childHandle = await open( + child, + constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0), + ); + try { + const openedChild = await childHandle.stat(); + if (!openedChild.isFile()) { + throw new Error("staged runtime context asset must be a regular file"); + } + await childHandle.chmod(openedChild.mode & 0o555); + } finally { + await childHandle.close(); + } + } + } finally { + await rootHandle.close(); + } +} + +export async function materializeNativeRuntimeSkills( + context: NativeRuntimeContextSnapshot | null, + skillsHome: string, + dependencies: { + /** Internal test seam for failed staging cleanup coverage. */ + removeTree?: (path: string) => Promise; + /** Internal test seam for publication failure coverage. */ + renameTree?: (source: string, destination: string) => Promise; + } = {}, +): Promise { + const removeTree = + dependencies.removeTree ?? + ((path: string) => rm(path, { recursive: true, force: true })); + const renameTree = dependencies.renameTree ?? rename; + if (context) { + const runtimeNames = new Set(); + for (const skill of context.skills) { + safeMaterializationTarget(skillsHome, skill.runtimeName); + const runtimeName = portableRuntimeNameKey(skill.runtimeName); + if (runtimeNames.has(runtimeName)) { + throw new Error("runtime context skill names must not overlap"); + } + runtimeNames.add(runtimeName); + } + for (const runtimeName of runtimeNames) { + const segments = runtimeName.split("/"); + for (let length = 1; length < segments.length; length += 1) { + if (runtimeNames.has(segments.slice(0, length).join("/"))) { + throw new Error("runtime context skill names must not overlap"); + } + } + } + for (const skill of context.skills) { + await assertSafeTree(skill.bundle.rootPath); + } + } + + const skillsHomeExists = await lstat(skillsHome).then( + () => true, + (error: NodeJS.ErrnoException) => { + if (error.code === "ENOENT") return false; + throw error; + }, + ); + if (skillsHomeExists) { + throw new Error( + "runtime context skills home must be a fresh destination", + ); + } + + const parent = dirname(skillsHome); + const nonce = randomUUID(); + const stagingHome = join(parent, `.paperclip-skills-staging-${nonce}`); + await mkdir(parent, { recursive: true, mode: 0o700 }); + await chmod(parent, 0o700); + await mkdir(stagingHome, { mode: 0o700 }); + try { + for (const skill of context?.skills ?? []) { + const target = safeMaterializationTarget(stagingHome, skill.runtimeName); + await cp(skill.bundle.rootPath, target, { + recursive: true, + force: false, + errorOnExist: true, + dereference: false, + verbatimSymlinks: true, + }); + await assertSafeTree(target); + await protectStagedTree(target); + } + + // The caller supplies a new isolated home for each provider launch. Never + // move an existing assignment out of its canonical path: a failed bounded + // rollback cannot portably guarantee that name is restored on every + // filesystem. Publication therefore targets only a fresh destination. + await renameTree(stagingHome, skillsHome); + } catch (error) { + await removeTree(stagingHome).catch(() => undefined); + throw error; + } +} + +async function readSourceCodexAuth(sourceAuth: string): Promise { + const handle = await open( + sourceAuth, + constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0), + ).catch((error: NodeJS.ErrnoException) => { + if (error.code === "ENOENT" || error.code === "ELOOP") return null; + throw error; + }); + if (!handle) return null; + try { + const stat = await handle.stat(); + if (!stat.isFile()) return null; + if (stat.size > 1024 * 1024) { + throw new Error("source Codex auth file exceeds the 1 MiB limit"); + } + return await handle.readFile(); + } finally { + await handle.close(); + } +} + +export async function prepareIsolatedCodexHome(input: { + context: NativeRuntimeContextSnapshot | null; + codexHome: string; + sourceCodexHome?: string | null; + nativeMcp?: NativeMcpLaunchBinding | null; + apiKey?: string | null; +}): Promise { + await materializeNativeRuntimeSkills( + input.context, + join(input.codexHome, "skills"), + ); + + const configPath = join(input.codexHome, "config.toml"); + await rm(configPath, { force: true }); + await writeFile(configPath, [ + // Codex shell snapshots serialize the provider process environment. The + // native runner injects short-lived provider and MCP bindings, so a + // snapshot would turn ephemeral credentials into durable session state. + "[features]", + "shell_snapshot = false", + "", + ...(input.nativeMcp + ? [ + `[mcp_servers.${JSON.stringify(input.nativeMcp.name)}]`, + `url = ${JSON.stringify(input.nativeMcp.url)}`, + `http_headers = { Authorization = ${JSON.stringify(`Bearer ${input.nativeMcp.token}`)} }`, + "", + ] + : []), + ].join("\n"), { mode: 0o600 }); + + const targetAuth = join(input.codexHome, "auth.json"); + await rm(targetAuth, { force: true }); + const apiKey = input.apiKey?.trim(); + if (apiKey) { + // The pinned Codex app-server authenticates API-key automation through its + // login cache rather than the CLI-only CODEX_API_KEY path. Keep this file + // owner-only in the disposable session home. + await writeFile( + targetAuth, + JSON.stringify({ OPENAI_API_KEY: apiKey }), + { mode: 0o600 }, + ); + await chmod(targetAuth, 0o600); + return; + } + + const sourceHome = input.sourceCodexHome?.trim(); + if (!sourceHome) return; + const sourceAuth = await readSourceCodexAuth(join(sourceHome, "auth.json")); + if (!sourceAuth) return; + await writeFile(targetAuth, sourceAuth, { mode: 0o600 }); + await chmod(targetAuth, 0o600); +}