From fd7cb77d8e952a28e448d0be6159eb2febec734f Mon Sep 17 00:00:00 2001 From: Dotta <34892728+cryppadotta@users.noreply.github.com> Date: Sun, 30 Aug 2026 03:18:12 -0500 Subject: [PATCH] feat(runner): isolate Codex runtime context (#12376) ## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work > - The runner package now has a Codex-only native backend > - A native Codex process needs an isolated runtime context before it can start safely > - Assigned skills, local authentication, and MCP bindings cross separate trust boundaries > - Runtime materialization must reject symlink escapes and unsafe remote bindings > - This pull request adds the package-local Codex runtime context boundary > - It does not start runnerd or enable the Paperclip Runner adapter ## Linked Issues or Issue Description **Subsystem affected** `packages/paperclip-runner` Codex runtime context materialization. **Problem or motivation** The runner needs a private Codex home for each native session. It must stage only assigned skills, copy local Codex authentication safely, and validate native MCP bindings before it exposes them to the child process. **Proposed solution** Create an isolated runtime directory. Validate the skill tree before and after copying it. Make staged skill files read-only. Read authentication through a no-follow file descriptor with a size bound. Accept only HTTPS or loopback MCP endpoints and bounded tokens. **Alternatives considered** Using the operator Codex home directly would expose unrelated state and skills. Following symlinks while copying skills or authentication could escape the assigned source. Accepting arbitrary MCP URLs could send a bearer token to an untrusted endpoint. **Roadmap alignment** This adds a package-local safety boundary for the reviewed Codex runner path. It does not enable a new adapter or change an existing direct adapter path. ## What Changed - Added the native MCP binding contract and strict validation. - Added isolated Codex home materialization with shell snapshots disabled. - Added assigned-skill staging with lexical containment and two-pass symlink checks. - Added read-only permissions for staged skill trees. - Added owner-only authentication staging with no-follow reads and a size bound. - Added cleanup for complete and partially materialized runtime directories. ## Verification - The focused runtime context suite has 7 passing cases. - `pnpm --filter @paperclipai/paperclip-runner test:typescript` (36 files, 351 tests) - `pnpm -r typecheck` - `pnpm build` ## Risks The main risks are filesystem escape, secret exposure, and token delivery to an unsafe endpoint. The materializer rejects symlinks before and after skill copying, resolves existing source paths, reads authentication with `O_NOFOLLOW`, applies private permissions, and restricts MCP URLs to HTTPS or loopback hosts. Existing direct adapters do not use this package-local runtime context. ## Model Used OpenAI Codex with GPT-5 and repository tool use. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have searched GitHub for duplicate or related PRs and linked them above - [x] I have either (a) linked existing issues with `Fixes: #` / `Closes #` / `Refs #` OR (b) described the issue in-PR following the relevant issue template - [x] I have not referenced internal/instance-local Paperclip issues or links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip` URLs) - [x] My branch name describes the change (e.g. `docs/...`, `fix/...`) and contains no internal Paperclip ticket id or instance-derived details - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [ ] All Paperclip CI gates are green - [ ] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge --- .../src/drivers/native-mcp.ts | 41 ++ .../runtime-context-materializer.test.ts | 463 ++++++++++++++++++ .../drivers/runtime-context-materializer.ts | 293 +++++++++++ 3 files changed, 797 insertions(+) create mode 100644 packages/paperclip-runner/src/drivers/native-mcp.ts create mode 100644 packages/paperclip-runner/src/drivers/runtime-context-materializer.test.ts create mode 100644 packages/paperclip-runner/src/drivers/runtime-context-materializer.ts 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); +}