diff --git a/doc/execution-semantics.md b/doc/execution-semantics.md index fb4754c95e4..0b5bba36e66 100644 --- a/doc/execution-semantics.md +++ b/doc/execution-semantics.md @@ -1624,6 +1624,15 @@ reads lightweight thread state, and fetches paginated turn metadata or specific turn items only when execution reconciliation needs them. Unsupported or incomplete history is an explicit error, not evidence of idle execution. +Built-in remote Codex launches install the current sealed skill inventory into +the isolated provider home's `skills` directory before starting Codex. Explicit +skill inputs use that same discovery path; a separate immutable context copy does +not make a skill discoverable to Codex. Launch preparation replaces prior skills, +including an empty or revoked inventory, while preserving provider conversation +history. It rejects source or destination symlinks and does not copy ambient host +skills. Preconfigured homes and plugin-provided launch preparation keep their +existing contracts. + The root-thread usage snapshot sent during resume belongs to its reported completed turn. Retain a bounded local diagnostic and use cumulative totals as a baseline; do not emit a warning or charge its historical `last` usage to the diff --git a/packages/paperclip-runner/src/testing.ts b/packages/paperclip-runner/src/testing.ts index 5260f4f5114..84dfc42386a 100644 --- a/packages/paperclip-runner/src/testing.ts +++ b/packages/paperclip-runner/src/testing.ts @@ -10,6 +10,8 @@ export * from "./conformance/control-plane-port.js"; export * from "./conformance/capability-semantic-conformance.js"; export * from "./conformance/harness-driver.js"; export { startOpenCodeMcpBridge } from "./drivers/opencode/mcp-bridge.js"; +export { prepareIsolatedCodexHome, releaseMaterializedNativeRuntimeSkills } from "./drivers/runtime-context-materializer.js"; +export { resolveRunnerdCodexSkillInputs } from "./live/runnerd-codex-transport.js"; export * from "./conformance/semantic-conformance.js"; export * from "./mock-core/deterministic-harness-driver.js"; export * from "./mock-core/mock-control-plane-adapter.js"; diff --git a/server/src/__tests__/chat-channels.integration.test.ts b/server/src/__tests__/chat-channels.integration.test.ts index 7ec86d1574b..d5dd8041ffb 100644 --- a/server/src/__tests__/chat-channels.integration.test.ts +++ b/server/src/__tests__/chat-channels.integration.test.ts @@ -72348,17 +72348,19 @@ describeEmbeddedPostgres("chat channel control-plane integration", () => { await vi.waitFor(() => expect(dm.post).toHaveBeenCalledWith(visibleFailure), ); - await expect( - db - .select({ kind: chatActions.kind, status: chatActions.status }) - .from(chatActions) - .where(eq(chatActions.deliveryId, delivery.id)), - ).resolves.toEqual( - expect.arrayContaining([ - { kind: "inbound_wakeup", status: "failed" }, - { kind: "provider_effect", status: "processed" }, - ]), - ); + await vi.waitFor(async () => { + await expect( + db + .select({ kind: chatActions.kind, status: chatActions.status }) + .from(chatActions) + .where(eq(chatActions.deliveryId, delivery.id)), + ).resolves.toEqual( + expect.arrayContaining([ + { kind: "inbound_wakeup", status: "failed" }, + { kind: "provider_effect", status: "processed" }, + ]), + ); + }); } finally { await retirePublicationFixture(service, endpoint.id); } diff --git a/server/src/services/native-runtime/native-session-executor.test.ts b/server/src/services/native-runtime/native-session-executor.test.ts index b3af82ecd05..3960d600faf 100644 --- a/server/src/services/native-runtime/native-session-executor.test.ts +++ b/server/src/services/native-runtime/native-session-executor.test.ts @@ -84,6 +84,7 @@ import { verifyNativeHarnessBackupStamp, } from "./native-harness-backup-stamp.js"; import { nativeRuntimeContextFixture } from "./runtime-context.test-fixture.js"; +import { prepareIsolatedCodexHome } from "../../vendor/paperclip-runner/testing.js"; import { nativeToolContractFingerprintForTarget } from "./native-session-resume.js"; import * as nativeSessionResume from "./native-session-resume.js"; import * as nativeLeaseRenewal from "./native-execution-lease.js"; @@ -14801,10 +14802,13 @@ describe("runnerd provider runtime wiring", () => { } }); - it.each(["resumed", "replacement"].flatMap((outcome) => + it.each([ + ...["resumed", "replacement"].flatMap((outcome) => ["fresh", "external_checkpoint", "external_pending_turn", "external_wrong_identity", "external_unregistered", "existing_state", "symlink_parent", "wrong_identity", "wrong_runner", "wrong_lease", "missing_control_plane", "connected", "committed_event", "committed_command", "pending_turn", "remote_probe_failed", "backup_present"] - .map((scenario) => ({ outcome, scenario }))))( - "bootstraps only an untouched provider session in a $outcome workspace lease: $scenario", async ({ outcome, scenario }) => { + .map((scenario) => ({ outcome, scenario, homeMode: "isolated" }))), + ...["resumed", "replacement"].map(outcome => ({ outcome, scenario: "fresh", homeMode: "preconfigured" })), + ])( + "bootstraps only an untouched provider session in a $outcome workspace lease: $scenario ($homeMode)", async ({ outcome, scenario, homeMode }) => { const remoteCwd = join(isolatedStateDirectory, "remote"); const runtimeRoot = join(remoteCwd, ".paperclip-runtime", "paperclip-runner"); await mkdir(runtimeRoot, { recursive: true }); @@ -14831,6 +14835,9 @@ describe("runnerd provider runtime wiring", () => { if (command.args?.[0] === "--version") return { exitCode: 0, timedOut: false, stdout: "codex-cli 0.160.0", stderr: "", }; + if (command.args?.[1]?.includes("getent passwd")) return { + exitCode: 0, timedOut: false, stdout: "/home/executor\n", stderr: "", + }; if (command.args?.[2] === "paperclip-runner-launch") { throw new Error("fixture_stop_after_launch_staging"); } @@ -14840,12 +14847,16 @@ describe("runnerd provider runtime wiring", () => { return { exitCode: 0, timedOut: false, stdout: "", stderr: "" }; }); const runtimeContext = nativeRuntimeContextFixture(); - const executionWithContext = { ...execution, runtimeContext }; + const executionWithContext = homeMode === "preconfigured" + ? { ...execution, schema: "paperclip.native-execution-input.v7" as const, + homeMode: "preconfigured" as const, executionMode: "default" as const, planningContext: null, + provider: { kind: "codex" as const, model: null, approvalPolicy: "never" as const }, runtimeContext } + : { ...execution, runtimeContext }; const onTraceEvent = vi.fn(async (_event: AdapterRuntimeEvent) => undefined); const controllerArtifact = join(isolatedStateDirectory, "bootstrap-runnerd"); if (scenario.startsWith("external_")) await writeFile(controllerArtifact, "fixture bootstrap artifact"); const backend = await createRunnerdBackend({ - db: leaseDb(execution), execution: executionWithContext, runnerInstanceId: "runner-new-in-retained-workspace", + db: leaseDb(executionWithContext), execution: executionWithContext, runnerInstanceId: "runner-new-in-retained-workspace", ...(scenario.startsWith("external_") ? { runnerRemoteBinaryPath: controllerArtifact, runnerRemoteCodexPath: controllerArtifact } : {}), trace: createNativeRunTrace({ runId: execution.binding.runId, onEvent: onTraceEvent }), runnerIngressAuthorized: true, @@ -14934,9 +14945,11 @@ describe("runnerd provider runtime wiring", () => { // the current invocation's launch files. The actual launch must stage // those files without trying to claim/restore the session a second time. const home = join(options.stateDirectory!, "codex-home"); - await mkdir(home, { recursive: true }); - await writeFile(join(home, "auth.json"), "fixture-current-credential"); - await writeFile(join(home, "config.toml"), "fixture-current-config"); + if (homeMode === "isolated") { + await prepareIsolatedCodexHome({ codexHome: home, context: runtimeContext }); + await writeFile(join(home, "auth.json"), "fixture-current-credential"); + await writeFile(join(home, "config.toml"), "fixture-current-config"); + } await writeFile(join(options.stateDirectory!, "runtime-context.json"), JSON.stringify(runtimeContext)); syncIn.mockClear(); await expect(options.prepareExternalRunnerState()).resolves.toBeUndefined(); @@ -14945,10 +14958,20 @@ describe("runnerd provider runtime wiring", () => { command: "/controller/paperclip-runnerd", args: [], cwd: "/controller", environment: {}, }); await expect(launched.completion).rejects.toThrow("fixture_stop_after_launch_staging"); - for (const name of ["auth.json", "config.toml"]) { + if (homeMode === "isolated") { expect(syncIn).toHaveBeenCalledWith([expect.objectContaining({ - files: [expect.objectContaining({ sourcePath: join(home, name), kind: "file", mode: 0o600 })], + files: [expect.objectContaining({ sourcePath: join(home, "skills"), kind: "directory", mode: 0o700 })], })]); + for (const name of ["auth.json", "config.toml"]) { + expect(syncIn).toHaveBeenCalledWith([expect.objectContaining({ + files: [expect.objectContaining({ sourcePath: join(home, name), kind: "file", mode: 0o600 })], + })]); + } + } else { + const files = syncIn.mock.calls.flat(2).flatMap((entry: { files: Array<{ sourcePath: string }> }) => entry.files); + for (const name of ["skills", "auth.json", "config.toml"]) { + expect(files.some(file => file.sourcePath === join(home, name))).toBe(false); + } } expect(syncIn.mock.calls.flat(2).flatMap((entry: { files: Array<{ sourcePath: string }> }) => entry.files) .filter((file: { sourcePath: string }) => file.sourcePath === runtimeContext.instructions.bundle.rootPath)).toHaveLength(1); diff --git a/server/src/services/native-runtime/native-session-executor.ts b/server/src/services/native-runtime/native-session-executor.ts index 7b1ae6608a6..8b43bdf8d91 100644 --- a/server/src/services/native-runtime/native-session-executor.ts +++ b/server/src/services/native-runtime/native-session-executor.ts @@ -13186,6 +13186,9 @@ async function createRunnerdBackendWithinSessionClaim( caBundleMapping: remoteCaBundleMapping, stageFile: asset => stageRemoteRunnerFile({ target: remoteTarget, runner: remoteCommandRunner, ...asset }), stageDirectory: asset => stageRemoteRunnerDirectory({ target: remoteTarget, runner: remoteCommandRunner, ...asset }), + isolatedCodexSkills: input.execution.provider.kind === "codex" + && !("homeMode" in input.execution && input.execution.homeMode === "preconfigured") + ? { workspaceRoot: remoteTarget.remoteCwd } : undefined, trace: input.trace, }); } diff --git a/server/src/services/native-runtime/remote-launch-assets.test.ts b/server/src/services/native-runtime/remote-launch-assets.test.ts index a322126e34d..46a46d877a2 100644 --- a/server/src/services/native-runtime/remote-launch-assets.test.ts +++ b/server/src/services/native-runtime/remote-launch-assets.test.ts @@ -1,9 +1,13 @@ -import { mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { chmodSync, cpSync, existsSync, lstatSync, mkdtempSync, mkdirSync, readFileSync, readdirSync, rmSync, statSync, symlinkSync, writeFileSync } from "node:fs"; +import { execFileSync } from "node:child_process"; +import { createHash } from "node:crypto"; import { tmpdir } from "node:os"; -import { join } from "node:path"; +import { dirname, join } from "node:path"; import { afterEach, describe, expect, it, vi } from "vitest"; +import { createTarballFromDirectory } from "@paperclipai/adapter-utils/sandbox-managed-runtime"; import { nativeRuntimeContextFixture } from "./runtime-context.test-fixture.js"; import { stageRemoteLaunchAssets } from "./remote-launch-assets.js"; +import { prepareIsolatedCodexHome, releaseMaterializedNativeRuntimeSkills, resolveRunnerdCodexSkillInputs } from "../../vendor/paperclip-runner/testing.js"; function deferred() { let resolve!: () => void; @@ -13,7 +17,15 @@ function deferred() { const roots: string[] = []; afterEach(() => { - for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true }); + function writable(path: string) { + const stat = lstatSync(path); + if (stat.isSymbolicLink()) return; + if (stat.isDirectory()) { + chmodSync(path, 0o700); + for (const entry of readdirSync(path)) writable(join(path, entry)); + } + } + for (const root of roots.splice(0)) { writable(root); rmSync(root, { recursive: true, force: true }); } }); function fixture(options: { transport?: "sandbox" | "ssh"; concurrent?: boolean } = {}) { @@ -52,7 +64,156 @@ function fixture(options: { transport?: "sandbox" | "ssh"; concurrent?: boolean return { input, root, calls, stageFile, stageDirectory, execute }; } +async function filesystemFixture() { + const f = fixture(); + const source = f.input.runtimeContext!.source; + f.input.isolatedCodexSkills = { workspaceRoot: f.root }; + mkdirSync(source.instructions.bundle.rootPath); + writeFileSync(join(source.instructions.bundle.rootPath, "AGENTS.md"), "Agent instructions\n"); + mkdirSync(source.skills[0]!.bundle.rootPath); + writeFileSync(join(source.skills[0]!.bundle.rootPath, "SKILL.md"), "---\nname: test\ndescription: Selected skill\n---\nUse a native confirmation.\n"); + mkdirSync(join(source.skills[0]!.bundle.rootPath, "scripts")); + writeFileSync(join(source.skills[0]!.bundle.rootPath, "scripts", "selected.sh"), "#!/bin/sh\nprintf selected\\n\n", { mode: 0o755 }); + await prepareIsolatedCodexHome({ context: source, codexHome: join(f.root, "codex-home") }); + const remote = join(f.root, "remote", "codex-home"); + f.input.persistenceDirectories = [{ name: "codex-home", location: "filesystem", targetPath: remote }]; + f.input.stageFile = async ({ sourcePath, targetPath }) => { + if (!targetPath.startsWith(f.root)) return; + mkdirSync(dirname(targetPath), { recursive: true }); + cpSync(sourcePath, targetPath); + }; + f.input.stageDirectory = vi.fn(async ({ sourcePath, targetPath }) => { + if (!targetPath.startsWith(f.root)) return; + // Native sandbox transfer archives omit the source root and mkdir the + // destination without applying the mapping mode. Exercise those actual + // tar/filesystem semantics rather than forcing the requested mode here. + const archivePath = join(f.root, "native-transfer.tar"); + await createTarballFromDirectory({ localDir: sourcePath, archivePath }); + execFileSync("sh", ["-c", 'umask 022; mkdir -p "$1" && tar -xf "$2" -C "$1"', "native-directory-transfer", targetPath, archivePath]); + }); + f.input.runner.execute = async ({ command, args }) => { + try { + const stdout = execFileSync(command, args, { encoding: "utf8", stdio: "pipe" }); + return { exitCode: 0, signal: null, pid: null, startedAt: null, timedOut: false, stdout: stdout.toString(), stderr: "" }; + } catch { + return { exitCode: 1, signal: null, pid: null, startedAt: null, timedOut: false, stdout: "", stderr: "fixture command rejected" }; + } + }; + return { ...f, source, remote }; +} + describe("remote launch asset staging", () => { + it("delivers the selected Codex skills to the same discoverable path used by explicit turn input", async () => { + const f = await filesystemFixture(); + await stageRemoteLaunchAssets(f.input); + const [skill] = resolveRunnerdCodexSkillInputs([ + { type: "skill", name: "test", path: join(f.source.skills[0]!.bundle.rootPath, "SKILL.md") }, + ], f.source, f.remote); + expect(existsSync(skill!.path)).toBe(true); + expect(readFileSync(skill!.path, "utf8")).toContain("Use a native confirmation."); + const digest = (path: string) => createHash("sha256").update(readFileSync(path)).digest("hex"); + for (const relative of ["SKILL.md", "scripts/selected.sh"]) { + expect(digest(join(f.remote, "skills", "test", relative))).toBe(digest(join(f.root, "codex-home", "skills", "test", relative))); + } + expect(statSync(skill!.path).mode & 0o777).toBe(0o444); + expect(statSync(join(f.remote, "skills", "test")).mode & 0o222).toBe(0); + expect(statSync(join(f.remote, "skills", "test", "scripts", "selected.sh")).mode & 0o777).toBe(0o555); + expect(statSync(join(f.remote, "skills")).mode & 0o777).toBe(0o700); + }); + + it("replaces stale and revoked skills on resume while retaining provider history", async () => { + const f = await filesystemFixture(); + await stageRemoteLaunchAssets(f.input); + writeFileSync(join(f.remote, "conversation.json"), "saved provider conversation"); + mkdirSync(join(f.remote, "skills", "ambient")); + writeFileSync(join(f.remote, "skills", "ambient", "SKILL.md"), "unassigned"); + await releaseMaterializedNativeRuntimeSkills(join(f.root, "codex-home", "skills")); + f.source.skills = []; + f.input.runtimeContext!.target.skills = []; + await prepareIsolatedCodexHome({ context: f.source, codexHome: join(f.root, "codex-home") }); + await stageRemoteLaunchAssets(f.input); + expect(readdirSync(join(f.remote, "skills"))).toEqual([]); + expect(readFileSync(join(f.remote, "conversation.json"), "utf8")).toBe("saved provider conversation"); + }); + + it("delivers flat and nested selected packages without flattening their paths", async () => { + const f = await filesystemFixture(); + const nestedRoot = join(f.root, "nested-skill"); + mkdirSync(nestedRoot); + const body = "---\nname: nested\ndescription: Nested selected skill\n---\nKeep the nested package.\n"; + writeFileSync(join(nestedRoot, "SKILL.md"), body); + f.source.skills.push({ ...f.source.skills[0]!, key: "nested", runtimeName: "group/nested", + bundle: { ...f.source.skills[0]!.bundle, rootPath: nestedRoot } }); + f.input.runtimeContext!.target.skills = f.source.skills.map((skill, index) => ({ ...skill, + bundle: { ...skill.bundle, rootPath: `/session/filesystem/context/skills/${index}-digest` } })); + await releaseMaterializedNativeRuntimeSkills(join(f.root, "codex-home", "skills")); + await prepareIsolatedCodexHome({ context: f.source, codexHome: join(f.root, "codex-home") }); + await stageRemoteLaunchAssets(f.input); + expect(readFileSync(join(f.remote, "skills", "test", "SKILL.md"), "utf8")).toContain("Use a native confirmation."); + expect(readFileSync(join(f.remote, "skills", "group", "nested", "SKILL.md"), "utf8")).toBe(body); + expect(statSync(join(f.remote, "skills", "group", "nested", "SKILL.md")).mode & 0o222).toBe(0); + }); + + it.each(["symlink", "ambient", "writable"] as const)("rejects an unsafe selected source projection before clearing retained skills: %s", async kind => { + const f = await filesystemFixture(); + mkdirSync(join(f.remote, "skills"), { recursive: true }); + writeFileSync(join(f.remote, "skills", "retained"), "not touched before valid admission"); + const home = join(f.root, "codex-home", "skills"); + if (kind === "ambient") { + mkdirSync(join(home, "unassigned")); + writeFileSync(join(home, "unassigned", "SKILL.md"), "unassigned", { mode: 0o444 }); + } else if (kind === "writable") chmodSync(join(home, "test", "SKILL.md"), 0o644); + else { + chmodSync(join(home, "test"), 0o700); + symlinkSync(join(f.root, "outside"), join(home, "test", "link")); + } + await expect(stageRemoteLaunchAssets(f.input)).rejects.toThrow("runner_remote_codex_skills_invalid"); + expect(readFileSync(join(f.remote, "skills", "retained"), "utf8")).toBe("not touched before valid admission"); + }); + + it.each([false, true])("rejects a retained skills-root symlink without touching its target (dangling: %s)", async dangling => { + const f = await filesystemFixture(); + mkdirSync(f.remote, { recursive: true }); + const outside = join(f.root, "outside"); + if (!dangling) { mkdirSync(outside); writeFileSync(join(outside, "keep"), "untouched"); } + symlinkSync(outside, join(f.remote, "skills")); + await expect(stageRemoteLaunchAssets(f.input)).rejects.toThrow("runner_remote_codex_skills_reset_failed"); + expect(lstatSync(join(f.remote, "skills")).isSymbolicLink()).toBe(true); + if (!dangling) expect(readFileSync(join(outside, "keep"), "utf8")).toBe("untouched"); + }); + + it.each(["sandbox", "ssh"] as const)("rejects a redirected retained ancestor before writing launch assets: %s", async transport => { + const f = await filesystemFixture(); + f.input.transport = transport; + const outside = join(f.root, "outside"); + mkdirSync(join(outside, "codex-home", "skills"), { recursive: true }); + writeFileSync(join(outside, "codex-home", "skills", "keep"), "untouched"); + symlinkSync(outside, join(f.root, "retained-filesystem")); + f.input.persistenceDirectories = [{ name: "codex-home", location: "filesystem", targetPath: join(f.root, "retained-filesystem", "codex-home") }]; + await expect(stageRemoteLaunchAssets(f.input)).rejects.toThrow("runner_remote_codex_skills_reset_failed"); + expect(readFileSync(join(outside, "codex-home", "skills", "keep"), "utf8")).toBe("untouched"); + expect(existsSync(join(outside, "codex-home", "config.toml"))).toBe(false); + }); + + it("allows an explicitly authorized workspace alias while checking its owned descendants", async () => { + const f = await filesystemFixture(); + const actual = join(f.root, "authorized-workspace"); + mkdirSync(actual); + const alias = join(f.root, "authorized-alias"); + symlinkSync(actual, alias); + f.input.isolatedCodexSkills = { workspaceRoot: alias }; + f.input.persistenceDirectories = [{ name: "codex-home", location: "filesystem", targetPath: join(alias, "codex-home") }]; + await stageRemoteLaunchAssets(f.input); + expect(readFileSync(join(actual, "codex-home", "skills", "test", "SKILL.md"), "utf8")).toContain("Use a native confirmation."); + }); + + it("does not project skills for a preconfigured home or another provider", async () => { + const f = await filesystemFixture(); + f.input.isolatedCodexSkills = undefined; + await stageRemoteLaunchAssets(f.input); + expect(existsSync(join(f.remote, "skills"))).toBe(false); + }); + it("retains serial paths, private launch modes and immutable context modes without an opt-in", async () => { const f = fixture(); await stageRemoteLaunchAssets(f.input); diff --git a/server/src/services/native-runtime/remote-launch-assets.ts b/server/src/services/native-runtime/remote-launch-assets.ts index fed76ce82a1..00788572b4c 100644 --- a/server/src/services/native-runtime/remote-launch-assets.ts +++ b/server/src/services/native-runtime/remote-launch-assets.ts @@ -1,4 +1,5 @@ import { existsSync } from "node:fs"; +import { lstat, readdir } from "node:fs/promises"; import { posix, resolve } from "node:path"; import type { CommandManagedRuntimeRunner } from "@paperclipai/adapter-utils/command-managed-runtime"; import type { NativeRuntimeContextSnapshot } from "../../vendor/paperclip-runner/index.js"; @@ -27,9 +28,67 @@ type LaunchAssetsInput = { caBundleMapping: { sourcePath: string; targetPath: string } | null; stageFile: (input: StageAsset) => Promise; stageDirectory: (input: StageAsset) => Promise; + isolatedCodexSkills?: { workspaceRoot: string }; trace?: NativeRunTrace; }; +async function assertSelectedSkillsTree(root: string, names: string[]): Promise { + const found = new Set(); + const reject = () => { throw new Error("runner_remote_codex_skills_invalid"); }; + async function visit(relativePath: string) { + const path = relativePath ? resolve(root, relativePath) : root; + const stat = await lstat(path); + if (stat.isSymbolicLink()) reject(); + if (stat.isDirectory()) { + if (relativePath && !names.some(name => relativePath === name || relativePath.startsWith(`${name}/`) || name.startsWith(`${relativePath}/`))) reject(); + for (const entry of await readdir(path)) await visit(relativePath ? `${relativePath}/${entry}` : entry); + } else { + if (!stat.isFile() || (stat.mode & 0o222) !== 0 || !names.some(name => relativePath.startsWith(`${name}/`))) reject(); + for (const name of names) if (relativePath === `${name}/SKILL.md`) found.add(name); + } + } + await visit(""); + if (found.size !== names.length) reject(); +} + +// Launch preparation owns this isolated directory and runs only after the old +// provider has stopped. Never follow a retained symlink while removing stale +// assignments; auth, config and provider conversation history are not touched. +const clearRemoteCodexSkills = ` +const fs = require("node:fs"), path = require("node:path"); +const home = path.resolve(process.argv[1]), workspace = path.resolve(process.argv[2]); +const relative = path.relative(workspace, home); +if (!relative || relative === ".." || relative.startsWith(".." + path.sep) || path.isAbsolute(relative)) throw Error("unsafe_codex_home"); +// The execution target authorizes the workspace anchor. Resolve only that +// anchor (which can have an OS alias), then refuse links below it. +let current = fs.realpathSync(workspace); +if (!fs.lstatSync(current).isDirectory()) throw Error("unsafe_workspace_root"); +for (const segment of relative.split(path.sep)) { + current = path.join(current, segment); + if (!fs.lstatSync(current, { throwIfNoEntry: false })) fs.mkdirSync(current, { mode: 0o700 }); + const stat = fs.lstatSync(current); + if (!stat.isDirectory() || stat.isSymbolicLink()) throw Error("unsafe_codex_home"); +} +const skills = path.join(current, "skills"); +function unseal(directory) { + const stat = fs.lstatSync(directory); + if (stat.isSymbolicLink() || !stat.isDirectory()) throw Error("unsafe_skills_root"); + fs.chmodSync(directory, 0o700); + for (const name of fs.readdirSync(directory)) { + const child = path.join(directory, name), stat = fs.lstatSync(child); + if (stat.isDirectory() && !stat.isSymbolicLink()) unseal(child); + } +} +if (fs.lstatSync(skills, { throwIfNoEntry: false })) { + unseal(skills); + fs.rmSync(skills, { recursive: true }); +} +// Native directory transfers omit the source root and may ignore its mapping +// mode. Create the private discovery root before their mkdir/extract step. +fs.mkdirSync(skills, { mode: 0o700 }); +fs.chmodSync(skills, 0o700); +`; + export async function stageRemoteLaunchAssets(input: LaunchAssetsInput): Promise { const measure = async (name: string, operation: () => Promise) => input.trace ? input.trace.measure(name, operation) : operation(); @@ -80,6 +139,28 @@ export async function stageRemoteLaunchAssets(input: LaunchAssetsInput): Promise }); }); }; + const stageCodexSkills = async () => { + if (!input.isolatedCodexSkills) return; + const directory = input.persistenceDirectories.find(entry => entry.name === "codex-home" && entry.location === "filesystem"); + if (!directory?.targetPath) throw new Error("runner_harness_state_mismatch: bootstrap_target_unavailable"); + const sourcePath = resolve(input.root, "codex-home", "skills"); + await assertSelectedSkillsTree(sourcePath, input.runtimeContext?.source.skills.map(skill => skill.runtimeName) ?? []); + await measure("stage.asset.codex_skills", async () => { + const cleared = await input.runner.execute({ + command: "node", + args: ["-e", clearRemoteCodexSkills, directory.targetPath!, input.isolatedCodexSkills!.workspaceRoot], + bypassSession: true, + timeoutMs: 10_000, + }); + if (cleared.exitCode !== 0 || cleared.timedOut) throw new Error("runner_remote_codex_skills_reset_failed"); + // Match prepareIsolatedCodexHome: the discovery root is private/writable + // for Codex's own built-ins; each selected package remains sealed. + await input.stageDirectory({ sourcePath, targetPath: posix.join(directory.targetPath!, "skills"), mode: 0o700 }); + }); + }; + // Resolve the owned home and remove obsolete assignments before any launch + // file can be written through a retained, redirected path. + await stageCodexSkills(); if (input.transport === "sandbox" && input.runner.allowConcurrentSyncOperations === true) { const outcomes = await Promise.allSettled([materializeLaunchState(), stageRuntimeContext()]); for (const outcome of outcomes) { diff --git a/server/src/vendor/paperclip-runner/testing.ts b/server/src/vendor/paperclip-runner/testing.ts index 06a85b6f36d..895546253a9 100644 --- a/server/src/vendor/paperclip-runner/testing.ts +++ b/server/src/vendor/paperclip-runner/testing.ts @@ -26,6 +26,13 @@ const sourceUrl = new URL( ); const runnerTesting = await import(sourceUrl.href) as RunnerTestingModule; +export const prepareIsolatedCodexHome: RunnerTestingModule["prepareIsolatedCodexHome"] = + runnerTesting.prepareIsolatedCodexHome; +export const releaseMaterializedNativeRuntimeSkills: RunnerTestingModule["releaseMaterializedNativeRuntimeSkills"] = + runnerTesting.releaseMaterializedNativeRuntimeSkills; +export const resolveRunnerdCodexSkillInputs: RunnerTestingModule["resolveRunnerdCodexSkillInputs"] = + runnerTesting.resolveRunnerdCodexSkillInputs; + export const startOpenCodeMcpBridge: RunnerTestingModule["startOpenCodeMcpBridge"] = runnerTesting.startOpenCodeMcpBridge;