mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-14 17:22:00 +01:00
## Thinking Path > - Paperclip manages AI agents that perform work. > - Paperclip Runner connects durable task runs to local provider processes. > - The full-stack paid matrix exposed failures after the runner integrity repair. > - Verified JavaScript entrypoints lost their relative module graph when Linux executed them through descriptor paths. > - Returned provider startup errors also remained pending and became indeterminate after recovery. > - Sparse Codex tool lifecycle events lost the `write_document` identity before task transcript projection. > - This pull request repairs those three boundaries and makes the structured-question fixture deterministic. > - The benefit is repeatable provider startup, exact failure replay, and correct inline Plan placement. ## Linked Issues or Issue Description Refs #12721 and #12700. **What happened?** The paid runner matrix failed ACPX and OpenCode startup before provider session creation. The runner journal then replaced the original startup error with an indeterminate recovery result. Native Codex saved a Plan but rendered it only as a fallback card. A legacy Claude waiting reply could also echo the reserved terminal marker before the answer arrived. **Expected behavior** Verified JavaScript providers must start from immutable descriptor-backed artifacts. Returned startup failures must persist as terminal failed command results. Native tool lifecycle updates must preserve the `write_document` boundary. Pre-answer fixture output must not contain the reserved terminal marker. **Steps to reproduce** 1. Run the local provider cells in the Runner Full-Stack E2E workflow. 2. Observe ACPX and OpenCode fail during `session.open` before provider execution. 3. Observe recovery report `execution_indeterminate` instead of the original startup error. 4. Run the native Codex Plan cell and observe the fallback Plan card after the tool activity row. 5. Run the legacy Claude structured-question resume cell and observe an early marker echo in waiting prose. **Paperclip version or commit** `0f9452101740835ce0b1488a204bf48acd5bafc3` **Deployment mode** Local development with the paid GitHub Actions acceptance workflow. ## What Changed - Bundle the ACPX sidecar and OpenCode proxy as self-contained Node ESM entrypoints before hashing and verified descriptor launch. - Anchor ACPX dynamic provider package resolution at a controller-derived provider-pack root and keep that root out of the provider child environment. - Persist executor-returned startup errors as redacted durable failed command results while retaining indeterminate recovery for true process death. - Coalesce sparse native tool items by stable ID so a late `write_document` name, input, and result reach the transcript boundary once. - Forbid the structured-question fixture from spelling or announcing its reserved terminal marker before the user answers. ## Verification - Rust and TypeScript regression tests cover durable failed replay, true crash ambiguity, bundle closure, package-root derivation, environment filtering, exact Codex tool lifecycle coalescing, and prompt determinism. - Local execution is intentionally limited to formatters and static diff checks. GitHub Actions will run tests, type checks, builds, and security checks. - After ordinary CI is green, scoped paid cells will validate one ACPX launch, one OpenCode launch, native Codex Plan projection, and legacy Claude structured resume before a complete matrix rerun. - Prior failing matrix: https://github.com/paperclipai/paperclip/actions/runs/33682434315 ## Risks - Bundling changes the bytes covered by provider launch hashes. Provider-pack generation already hashes the final built files. - ACPX still loads qualified provider packages dynamically. The controller supplies a normalized package root, while existing version, digest, path, and descriptor checks remain active. - Durable `failed` is terminal. Replays return the same redacted result and do not execute the provider effect twice. > For core feature work, check [`ROADMAP.md`](ROADMAP.md) first and discuss it in `#dev` before opening the PR. Feature PRs that overlap with planned core work may need to be redirected — check the roadmap first. See `CONTRIBUTING.md`. ## Model Used OpenAI Codex based on GPT-5 with agentic reasoning, repository inspection, code editing, Git, parallel subagents, and GitHub Actions coordination. The exact deployed snapshot and context-window size are not exposed to this task. ## 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 linked related public work or described the bug in this PR - [x] I have not referenced internal or instance-local Paperclip issues or links - [x] My branch name describes the change and contains no internal ticket id - [ ] I have run tests locally and they pass (intentionally deferred to GitHub Actions) - [x] I have added or updated tests where applicable - [x] No documentation change is required for this runtime repair - [x] I have considered and documented the 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
179 lines
5.2 KiB
TypeScript
179 lines
5.2 KiB
TypeScript
import { spawnSync } from "node:child_process";
|
|
import { closeSync, openSync } from "node:fs";
|
|
|
|
import { describe, expect, it } from "vitest";
|
|
|
|
import {
|
|
VERIFIED_RUNTIME_EXECUTABLE_ENV,
|
|
verifiedRuntimeExecutable,
|
|
verifiedRuntimeExecutableHandoff,
|
|
} from "./verified-runtime-executable.js";
|
|
|
|
describe("verified runtime executable", () => {
|
|
it("preserves an inherited Linux descriptor for an explicit child handoff", () => {
|
|
expect(
|
|
verifiedRuntimeExecutable(
|
|
{ [VERIFIED_RUNTIME_EXECUTABLE_ENV]: "/proc/self/fd/17" },
|
|
"linux",
|
|
4321,
|
|
"/usr/bin/node",
|
|
),
|
|
).toBe("/proc/self/fd/17");
|
|
});
|
|
|
|
it("rejects a deleted live-process alias as a verified descendant runtime", () => {
|
|
expect(() =>
|
|
verifiedRuntimeExecutable(
|
|
{ [VERIFIED_RUNTIME_EXECUTABLE_ENV]: "/proc/self/exe" },
|
|
"linux",
|
|
8765,
|
|
"/usr/bin/node",
|
|
),
|
|
).toThrow("descriptor is invalid");
|
|
});
|
|
|
|
it("rejects ancestor descriptor paths at the verified boundary", () => {
|
|
expect(() =>
|
|
verifiedRuntimeExecutable(
|
|
{ [VERIFIED_RUNTIME_EXECUTABLE_ENV]: "/proc/4321/fd/17" },
|
|
"linux",
|
|
8765,
|
|
"/usr/bin/node",
|
|
),
|
|
).toThrow("descriptor is invalid");
|
|
});
|
|
|
|
it("rejects mutable Linux paths at the verified boundary", () => {
|
|
expect(() =>
|
|
verifiedRuntimeExecutable(
|
|
{ [VERIFIED_RUNTIME_EXECUTABLE_ENV]: "/usr/bin/node" },
|
|
"linux",
|
|
4321,
|
|
"/usr/bin/node",
|
|
),
|
|
).toThrow("descriptor is invalid");
|
|
});
|
|
|
|
it("uses process identity only when no verified runtime was supplied", () => {
|
|
expect(verifiedRuntimeExecutable({}, "linux", 4321, "/usr/bin/node")).toBe(
|
|
"/usr/bin/node",
|
|
);
|
|
});
|
|
|
|
it("remaps the authenticated Linux descriptor into the child stdio table", () => {
|
|
expect(
|
|
verifiedRuntimeExecutableHandoff(
|
|
29,
|
|
{ [VERIFIED_RUNTIME_EXECUTABLE_ENV]: "/proc/self/fd/17" },
|
|
"linux",
|
|
4321,
|
|
"/usr/bin/node",
|
|
),
|
|
).toEqual({
|
|
executable: "/proc/self/fd/29",
|
|
environmentValue: "/proc/self/fd/29",
|
|
sourceFd: 17,
|
|
});
|
|
});
|
|
|
|
it("does not invent a descriptor handoff for an ambient runtime", () => {
|
|
expect(
|
|
verifiedRuntimeExecutableHandoff(29, {}, "linux", 4321, "/usr/bin/node"),
|
|
).toEqual({
|
|
executable: "/usr/bin/node",
|
|
environmentValue: undefined,
|
|
sourceFd: null,
|
|
});
|
|
});
|
|
|
|
it("rejects standard and invalid child descriptor targets", () => {
|
|
for (const targetFd of [-1, 0, 2, 3.5, Number.MAX_SAFE_INTEGER + 1]) {
|
|
expect(() =>
|
|
verifiedRuntimeExecutableHandoff(
|
|
targetFd,
|
|
{ [VERIFIED_RUNTIME_EXECUTABLE_ENV]: "/proc/self/fd/17" },
|
|
"linux",
|
|
4321,
|
|
"/usr/bin/node",
|
|
),
|
|
).toThrow("target descriptor is invalid");
|
|
}
|
|
});
|
|
|
|
it.runIf(process.platform === "linux")(
|
|
"keeps the authenticated runtime executable across two child generations",
|
|
() => {
|
|
const sourceFd = openSync(process.execPath, "r");
|
|
try {
|
|
const targetFd = 10;
|
|
const handoff = verifiedRuntimeExecutableHandoff(
|
|
targetFd,
|
|
{
|
|
[VERIFIED_RUNTIME_EXECUTABLE_ENV]: `/proc/self/fd/${sourceFd}`,
|
|
},
|
|
"linux",
|
|
);
|
|
const stdio: Array<"ignore" | "pipe" | number> = [
|
|
"ignore",
|
|
"pipe",
|
|
"pipe",
|
|
];
|
|
while (stdio.length < targetFd) stdio.push("ignore");
|
|
stdio.push(handoff.sourceFd!);
|
|
const child = spawnSync(
|
|
handoff.executable,
|
|
[
|
|
"--eval",
|
|
`const { spawnSync } = require("node:child_process");
|
|
const fd = ${targetFd};
|
|
const stdio = ["ignore", "pipe", "pipe"];
|
|
while (stdio.length < fd) stdio.push("ignore");
|
|
stdio.push(fd);
|
|
const nested = spawnSync("/proc/self/fd/" + fd, ["--eval", "process.stdout.write('nested-ok')"], { stdio });
|
|
if (nested.status !== 0) throw nested.error || new Error(nested.stderr.toString());
|
|
process.stdout.write(nested.stdout);`,
|
|
],
|
|
{
|
|
env: {
|
|
[VERIFIED_RUNTIME_EXECUTABLE_ENV]: handoff.environmentValue!,
|
|
},
|
|
stdio,
|
|
encoding: "utf8",
|
|
},
|
|
);
|
|
expect(child.error).toBeUndefined();
|
|
expect(child.status).toBe(0);
|
|
expect(child.stderr).toBe("");
|
|
expect(child.stdout).toBe("nested-ok");
|
|
} finally {
|
|
closeSync(sourceFd);
|
|
}
|
|
},
|
|
);
|
|
|
|
it("accepts only the authenticated live process image on macOS", () => {
|
|
expect(
|
|
verifiedRuntimeExecutable(
|
|
{
|
|
[VERIFIED_RUNTIME_EXECUTABLE_ENV]:
|
|
"/private/tmp/.paperclip-verified-executable/launch",
|
|
},
|
|
"darwin",
|
|
4321,
|
|
"/private/tmp/.paperclip-verified-executable/launch",
|
|
),
|
|
).toBe("/private/tmp/.paperclip-verified-executable/launch");
|
|
});
|
|
|
|
it("rejects a different environment-supplied executable on macOS", () => {
|
|
expect(() =>
|
|
verifiedRuntimeExecutable(
|
|
{ [VERIFIED_RUNTIME_EXECUTABLE_ENV]: "/tmp/attacker/node" },
|
|
"darwin",
|
|
4321,
|
|
"/private/tmp/.paperclip-verified-executable/launch",
|
|
),
|
|
).toThrow("path is invalid");
|
|
});
|
|
});
|