mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-29 12:59:13 +01:00
fix(native): deliver selected skills to remote Codex homes (#18080)
## Thinking Path > - Paperclip coordinates company agents and preserves their assigned instructions and approval rules. > - Native runs pin an immutable skill inventory for each execution. > - Built-in remote Codex launches prepare that inventory on the controller, but transfer only authentication, configuration, and the separate context assets. > - Codex needs the selected skills under its isolated home. An explicit skill path does not load a skill outside its registered discovery roots. > - This pull request transfers the sealed selected inventory to the path already used by explicit skill inputs. > - Each launch replaces stale assignments inside the authorized workspace while preserving provider history. ## Linked Issues or Issue Description Refs #18025. The corrected first-task instructions exist in the selected bundle, but remote Codex must receive those bytes before it can follow them. The legacy skill resolver work in #16646 and #9323 uses different paths and does not repair native remote launch staging. **What happened?** A fresh remote native Codex run could not read its assigned first-task skill. It produced an approval question instead of the required confirmation. The Product E2E journey correctly rejected that question before child creation. **Expected behavior** Codex receives the selected immutable skill at the same provider-local discovery path used by explicit skill input. Removed or unassigned skills do not remain available after a new launch. **Steps to reproduce** 1. Prepare an isolated Codex home with one assigned skill. 2. Run the built-in remote launch staging path against a fresh remote filesystem. 3. Resolve an explicit invocation of that skill. Before the repair, its provider-local file is missing. ## What Changed - Transfer only the prepared selected skill inventory into the isolated remote Codex home before other launch writes. - Reject writable files, symlinks, missing entries, and extra unassigned packages in the controller projection. - Validate each provider-home path component below the authorized workspace. Reject redirected retained paths before deleting stale skills. - Create the private discovery root before native sandbox transfers, which omit the source-root metadata and can ignore the requested directory mode. - Replace stale or empty inventories without changing provider conversation history, preconfigured homes, other providers, or plugin-provided launch preparation. - Exercise the real home materializer and turn resolver through the existing test-only Runner surface. Document the remote discovery contract. - Synchronize an existing Telegram attachment-failure test with the durable provider-effect commit, preserving its required final states. This CI repair is a separate test-only commit. ## Verification - The new production-staging regression failed before the repair because the resolved skill file was absent. - All 23 focused staging checks pass. They cover delivery, file modes, empty/revoked inventories, history retention, extra packages, writable files, source symlinks, target symlinks, redirected ancestors on sandbox/SSH, and an authorized workspace alias. The filesystem fixture packs and extracts actual tar bytes without applying the requested root mode; it preserves exact projected-file digests and executable bits. - The unchanged materializer and prepared-launch contracts passed 26 tests. Eight selected native caller checks passed; other cases in that filtered run were not selected. - Independent probes used official Codex 0.160.0, the real materializer, production staging, the real resolver, and a local model endpoint. The original staging supplied no skill message. The final staging supplied the complete selected skill body with its exact SHA-256. A final probe also ran production Daytona `performSyncIn`, backing only its SDK exec/upload/chmod edge with the local filesystem under umask `022`: the pre-review source reproduced root `0755`, and the final source retained root `0700`, package `0555`, file `0444`, and the exact selected digest. All model requests returned local 403; there were no upstream requests or credentials. - Full local `pnpm -r typecheck` and `pnpm build` passed on verification commit `e7d4e7a9`, based on `60b8089a`. The full local `pnpm test:run` on that frozen tree was not completed: later embedded PostgreSQL startup failed because the host could not allocate semaphore sets (`semget`, SEMMNI/SEMMNS), and the owned attempt was stopped with its log preserved. Its earlier four caller-fixture failures and one workspace-conflict failure are recorded separately below; the later resource error is not an explanation for them. Delivery commit `567d838d` replayed the initial six-file patch onto `ee67d2ec`; production was last changed in `23456f51`, followed by test-only commits `002fa4a7` and `6e8fa595`. All 24 staging checks, all 26 materializer/prepared-launch checks, and the complete native executor file (968 tests, zero skipped) pass locally. Server typecheck also passed after these test edits. The complete CI matrix passed on `002fa4a7`; current-head CI is tracked separately. - Initial-head CI exposed four caller fixtures that manually wrote auth/config without preparing the skills inventory. The same four failures reproduced locally. Those fixtures now use the real home preparer for their existing empty inventory and assert its transfer; all 34 retained/replacement scenarios and the complete 966-test caller file pass. A sandboxed full-file attempt could not bind local PostgreSQL (`EPERM`); it was stopped with its log preserved, then the identical command passed with local fixture access. Initial-head CI also recorded timeouts in workspace recovery, approval-queue backoff, and heartbeat attribution. Their three complete files pass unchanged locally (102 tests); the next CI head also passed those cases. The original slowdown remains unexplained. The consequent approval cleanup foreign-key failure is a timeout teardown race already recorded in the owning worker documentation. - The frozen full local run also observed an existing workspace-conflict test fail. The test and owning workspace code are unchanged between the verification and delivery trees, and the isolated delivery case passes. The original failure remains unresolved: stopping the later resource-blocked run prevented the final assertion detail from being emitted. That isolated pass does not replace the earlier result. - CI on `23456f51` caught a Telegram test reading `provider_effect=processing` after observing the provider mock call. Production commits `processed` later, so the test now waits for the unchanged complete durable-state assertion. All 18 selected Telegram attachment/media cases pass (the other 1,670 cases were not selected). The same CI attempt also recorded an E2E runner shutdown followed by cancellation, without a failed Playwright assertion; the necessary new test-only head reruns the matrix. Earlier failures remain preserved and are not reported as passes. - Review requested an executor-level preconfigured-home negative test. Retained and replacement workspace cases now drive the actual launcher, assert successful context staging, and exclude the isolated home's skills/auth/config transfers. A counterfactual that removes only the executor exclusion makes both tests fail; the exact production bytes were restored before the complete 968-test pass. Flat and nested selected package delivery is also covered. Optional arbitrary remote-stderr expansion is deferred; the repair keeps the existing closed error code and report surface. - Final head `6e8fa595` passes the complete CI matrix, including typecheck, build, Runner, browser, and aggregate gates. Two first-attempt jobs were interrupted by runner shutdown/lost communication; one unchanged-head rerun of those jobs and their aggregates passed. The underlying host interruption cause remains unknown. Both current-head automated reviews are 5/5 with the implemented finding resolved. - The original checked-in lockfile fails a fresh frozen install on the baseline. The repository's maintained resolution-then-frozen sequence passes. No lockfile change is included. - Independent source review found and then verified the retained-ancestor guard. This PR does not claim that the full live Cloud onboarding campaign has passed. It changes no journey assertion or approval gate. ## Risks Launch preparation adds an asynchronous inventory walk, one bounded remote cleanup command, and one transfer of the selected skill tree. Those costs grow with selected files and bytes and occur only at provider launch. A failed validation, cleanup, or transfer prevents launch. Remote Node is already required by the shared SSH/sandbox launcher. The selected packages remain read-only; the private discovery root retains Codex's normal writable mode. No database, API, credential-routing, or deployment change is included. ## Model Used OpenAI Codex based on GPT-6, with reasoning, source inspection, tool use, and code execution. The host does not expose the exact serving model ID or context-window size. ## 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 - [x] All Paperclip CI gates are green - [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge --------- Co-authored-by: Paperclip <noreply@paperclip.ing>
This commit is contained in:
1 parent
efd65c1125
commit
f8484a9f8d
8 files changed
+312
-24
No files matched your search
@@ -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
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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<void>;
|
||||
stageDirectory: (input: StageAsset) => Promise<void>;
|
||||
isolatedCodexSkills?: { workspaceRoot: string };
|
||||
trace?: NativeRunTrace;
|
||||
};
|
||||
|
||||
async function assertSelectedSkillsTree(root: string, names: string[]): Promise<void> {
|
||||
const found = new Set<string>();
|
||||
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<void> {
|
||||
const measure = async (name: string, operation: () => Promise<void>) =>
|
||||
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) {
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in new issue
Block a user