mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-21 08:22:20 +02:00
## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work. > - The runner package is useful only when the application can start, observe, and recover a native Codex run safely. > - Existing direct adapters must keep their current execution and finalization paths. > - The application boundary therefore needs additive persistence, authorization, coordination, and recovery behind an explicit experimental adapter. > - This pull request adds that Codex-only boundary without activating generalized providers, remote environments, or the later task/SDK surfaces. ## Linked Issues or Issue Description **Subsystem affected** Shared contracts, database persistence, adapter utilities, server native-runtime services, and the experimental Paperclip Runner adapter. **Problem or motivation** The already-landed runner package has a qualified Codex path, but the application needs durable native-run state, guarded runtime selection, authenticated coordination, tool security, finalization, and recovery before the experimental adapter can be exercised safely. **Proposed solution** Add a Codex-only `paperclip_runner` application path behind the existing default-off native-runner setting. Bind native state and coordination to company/run identity, preserve persisted-run recovery, and leave every direct adapter on its existing legacy execution path. **Alternatives considered** The earlier stack boundary introduced a generalized executor and remote-environment lifecycle here. That made this PR depend on implementations in higher PRs and changed reusable sandbox behavior globally. Those pieces are now deferred together to #12592. **Roadmap alignment** ROADMAP.md does not list a conflicting native-runner integration project. This change adds the application boundary for the existing Runner architecture. ## What Changed - Added native run/result/finalization/provider-trace persistence, shared validators, and idempotent migration/replay coverage. - Added guarded Codex-only runtime selection, authenticated PRP coordination, recovery, finalization, and interaction services. - Added run/company-bound tool-gateway authorization, credential redaction, SSRF protections, and replay-safe behavior. - Added the explicit `paperclip_runner` adapter behind the default-off rollout setting. - Preserved legacy answered-question wake projection and direct-adapter execution/finalization paths. - Hardened cancellation so only owned in-memory child processes are signaled; persisted recycled PIDs/process groups are never trusted. - Retained the narrow Claude ACPX isolated-context security follow-up discovered after #12590. - Deferred the generalized executor, provider ingress, remote lifecycle, SDK/lab/eval work, release-process changes, and lockfile. ## Verification - Changed-file delta against `master`: 133 files. - GitHub Actions is the authoritative verification environment for this PR. - Full CI, security, and Greptile review will run on this lowest unmerged stack PR. - Local tests/build/typecheck were not run because this checkout is resource constrained. - Static diff/reference checks pass, and `pnpm-lock.yaml` is unchanged. ## Risks - This touches central heartbeat and agent-route code, so legacy compatibility is the primary risk. - Runtime selection remains Codex-only and explicit; direct Codex, Claude, OpenCode, process, HTTP, and plugin adapters remain on their existing paths. - Fresh native starts fail closed while the rollout flag is off; persisted native records remain readable and recoverable. - Cancellation, company/run binding, tool calls, status decisions, and completion writes are guarded or replay-safe. > 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. ## Model Used OpenAI Codex, GPT-5.6, with repository tools, code execution, and parallel agent review. ## 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 existing issues or described the issue in-PR following the relevant issue template - [x] I have not referenced internal or instance-local Paperclip issues or links - [x] My branch name describes the change and contains no internal Paperclip ticket id - [ ] I have run tests locally and they pass — GitHub Actions is authoritative for this resource-constrained checkout - [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 risks above - [ ] All Paperclip CI and security gates are green - [ ] Greptile is 5/5 with no open actionable findings - [x] I will address all Greptile and reviewer comments before merge ## Stack - Position: 3 of 5 overall; lowest of 3 currently unmerged - Base: `master` - Previous: [#12590](https://github.com/paperclipai/paperclip/pull/12590), qualified Claude ACPX runtime — merged - Next: [#12592](https://github.com/paperclipai/paperclip/pull/12592), generalized Codex executor, task experience, and developer SDKs --------- Co-authored-by: Dev Agent <dev@paperclip.ing>
329 lines
10 KiB
TypeScript
329 lines
10 KiB
TypeScript
import { describe, expect, it, vi } from "vitest";
|
|
import { createHash } from "node:crypto";
|
|
import {
|
|
assessProviderTraceEntries,
|
|
providerTraceStore,
|
|
providerTraceRequiredChannels,
|
|
redactProviderTraceFrame,
|
|
} from "../services/provider-trace-store.js";
|
|
|
|
const mockUnlink = vi.hoisted(() => vi.fn(async () => undefined));
|
|
vi.mock("node:fs/promises", () => ({
|
|
default: { unlink: mockUnlink },
|
|
}));
|
|
vi.mock("../services/activity-log.js", () => ({
|
|
logActivity: vi.fn(async () => undefined),
|
|
}));
|
|
|
|
function frame(payload: unknown) {
|
|
return {
|
|
kind: "frame",
|
|
schema: "paperclip.provider_trace_frame.v1",
|
|
frameId: 1,
|
|
rawBase64: Buffer.from(JSON.stringify(payload)).toString("base64"),
|
|
};
|
|
}
|
|
|
|
describe("provider trace redaction", () => {
|
|
it("removes exact bytes and masks secret-shaped fields and values", () => {
|
|
const redacted = redactProviderTraceFrame(
|
|
frame({
|
|
authorization: "Bearer exact-token-value",
|
|
message: "sk-abcdefghijklmnopqrstuvwxyz",
|
|
nested: { apiKey: "also-secret", safe: "visible" },
|
|
}),
|
|
);
|
|
|
|
expect(redacted).not.toHaveProperty("rawBase64");
|
|
expect(redacted.parsed).toEqual({
|
|
authorization: "[withheld]",
|
|
message: "[withheld]",
|
|
nested: { apiKey: "[withheld]", safe: "visible" },
|
|
});
|
|
expect(redacted.withheldPaths).toEqual([
|
|
"authorization",
|
|
"message",
|
|
"nested.apiKey",
|
|
]);
|
|
});
|
|
|
|
it("withholds reasoning item content while retaining routing metadata", () => {
|
|
const redacted = redactProviderTraceFrame(
|
|
frame({
|
|
item: {
|
|
id: "reason-1",
|
|
type: "reasoning",
|
|
status: "completed",
|
|
summary: ["private chain"],
|
|
encrypted_content: "ciphertext",
|
|
},
|
|
}),
|
|
);
|
|
|
|
expect(redacted.parsed).toEqual({
|
|
item: {
|
|
id: "reason-1",
|
|
type: "reasoning",
|
|
status: "completed",
|
|
summary: "[withheld]",
|
|
encrypted_content: "[withheld]",
|
|
},
|
|
});
|
|
expect(redacted.withheldPaths).toContain("item.summary");
|
|
});
|
|
});
|
|
|
|
describe("provider trace channel integrity", () => {
|
|
it("requires each provider's actual native transport topology", () => {
|
|
expect(providerTraceRequiredChannels("codex")).toEqual([
|
|
"rust_native",
|
|
"typescript_runnerd_rehydration",
|
|
]);
|
|
expect(providerTraceRequiredChannels("opencode")).toEqual(["typescript_opencode_native"]);
|
|
expect(providerTraceRequiredChannels("acpx")).toEqual(["typescript_acpx_native"]);
|
|
});
|
|
|
|
function completeChannel(channel: string, raw = Buffer.from("{}")) {
|
|
return [
|
|
{
|
|
kind: "frame",
|
|
debugChannel: channel,
|
|
debugSequence: 1,
|
|
frameId: 1,
|
|
rawBase64: raw.toString("base64"),
|
|
byteLength: raw.byteLength,
|
|
digest: `sha256:${createHash("sha256").update(raw).digest("hex")}`,
|
|
},
|
|
{
|
|
kind: "trace_status",
|
|
debugChannel: channel,
|
|
debugSequence: 2,
|
|
acknowledgedDebugSequence: 1,
|
|
status: "complete",
|
|
reason: null,
|
|
},
|
|
];
|
|
}
|
|
|
|
it("accepts independently sequenced and acknowledged debug channels", () => {
|
|
expect(
|
|
assessProviderTraceEntries([
|
|
...completeChannel("rust_native"),
|
|
...completeChannel("typescript_runnerd_rehydration"),
|
|
]),
|
|
).toEqual({ status: "complete", reason: null });
|
|
});
|
|
|
|
it("marks gaps, missing acknowledgements, and digest changes incomplete", () => {
|
|
const gap = completeChannel("rust_native");
|
|
gap[1]!.debugSequence = 3;
|
|
expect(assessProviderTraceEntries(gap).reason).toBe(
|
|
"trace_debug_sequence_gap:rust_native",
|
|
);
|
|
|
|
const noAck = completeChannel("rust_native").slice(0, 1);
|
|
expect(assessProviderTraceEntries(noAck).reason).toBe(
|
|
"trace_channel_ack_missing:rust_native",
|
|
);
|
|
|
|
const changed = completeChannel("rust_native");
|
|
changed[0]!.rawBase64 = Buffer.from("changed").toString("base64");
|
|
expect(assessProviderTraceEntries(changed).reason).toBe(
|
|
"provider_frame_digest_mismatch",
|
|
);
|
|
});
|
|
});
|
|
|
|
describe("provider trace retention", () => {
|
|
function expiredTraceRow() {
|
|
return {
|
|
id: "trace-1",
|
|
runId: "run-1",
|
|
companyId: "company-1",
|
|
status: "complete",
|
|
provider: "codex",
|
|
traceRef: "11111111-1111-4111-8111-111111111111.ndjson",
|
|
frameCount: 1,
|
|
byteCount: 2,
|
|
digest: "sha256:abc",
|
|
reason: null,
|
|
requestedBy: "user-1",
|
|
createdAt: new Date("1999-12-30T12:00:00.000Z"),
|
|
updatedAt: new Date("1999-12-30T12:00:00.000Z"),
|
|
expiresAt: new Date("1999-12-31T12:00:00.000Z"),
|
|
deletedAt: null as Date | null,
|
|
};
|
|
}
|
|
|
|
function expiredTraceDb() {
|
|
const row = expiredTraceRow();
|
|
const returning = vi.fn(async () => [{
|
|
...row,
|
|
status: "expired",
|
|
deletedAt: new Date("2026-08-31T12:00:00.000Z"),
|
|
}]);
|
|
const db = {
|
|
select: vi.fn(() => ({
|
|
from: vi.fn(() => ({
|
|
where: vi.fn(() => ({
|
|
limit: vi.fn(async () => [row]),
|
|
})),
|
|
})),
|
|
})),
|
|
update: vi.fn(() => ({
|
|
set: vi.fn(() => ({
|
|
where: vi.fn(() => ({ returning })),
|
|
})),
|
|
})),
|
|
};
|
|
return { db, returning };
|
|
}
|
|
|
|
function statefulExpiredTraceDb() {
|
|
let row = expiredTraceRow();
|
|
const selectRows = () => [row];
|
|
const db = {
|
|
select: vi.fn(() => ({
|
|
from: vi.fn(() => ({
|
|
where: vi.fn(() => {
|
|
const result = Promise.resolve(selectRows()) as Promise<Array<typeof row>> & {
|
|
limit: (count: number) => Promise<Array<typeof row>>;
|
|
};
|
|
result.limit = vi.fn(async () => selectRows());
|
|
return result;
|
|
}),
|
|
})),
|
|
})),
|
|
update: vi.fn(() => ({
|
|
set: vi.fn((values: Partial<typeof row>) => ({
|
|
where: vi.fn(() => {
|
|
const execute = async () => {
|
|
row = { ...row, ...values };
|
|
return [row];
|
|
};
|
|
return {
|
|
returning: execute,
|
|
then: <TResult1 = Array<typeof row>, TResult2 = never>(
|
|
onfulfilled?: ((value: Array<typeof row>) => TResult1 | PromiseLike<TResult1>) | null,
|
|
onrejected?: ((reason: unknown) => TResult2 | PromiseLike<TResult2>) | null,
|
|
) => execute().then(onfulfilled, onrejected),
|
|
};
|
|
}),
|
|
})),
|
|
})),
|
|
};
|
|
return { db, row: () => row };
|
|
}
|
|
|
|
function finalizeExpiryRaceDb() {
|
|
let row = {
|
|
...expiredTraceRow(),
|
|
expiresAt: new Date("2999-12-31T12:00:00.000Z"),
|
|
};
|
|
const selectRows = () => [row];
|
|
const db = {
|
|
select: vi.fn(() => ({
|
|
from: vi.fn(() => ({
|
|
where: vi.fn(() => ({
|
|
limit: vi.fn(async () => selectRows()),
|
|
})),
|
|
})),
|
|
})),
|
|
update: vi.fn(() => ({
|
|
set: vi.fn((values: Partial<typeof row>) => ({
|
|
where: vi.fn(() => {
|
|
const execute = async () => {
|
|
if (values.status === "incomplete") {
|
|
row = {
|
|
...row,
|
|
status: "expired",
|
|
deletedAt: new Date("2000-01-01T00:00:00.000Z"),
|
|
expiresAt: new Date("1999-12-31T12:00:00.000Z"),
|
|
};
|
|
return [];
|
|
}
|
|
row = { ...row, ...values };
|
|
return [row];
|
|
};
|
|
return {
|
|
returning: execute,
|
|
then: <TResult1 = Array<typeof row>, TResult2 = never>(
|
|
onfulfilled?: ((value: Array<typeof row>) => TResult1 | PromiseLike<TResult1>) | null,
|
|
onrejected?: ((reason: unknown) => TResult2 | PromiseLike<TResult2>) | null,
|
|
) => execute().then(onfulfilled, onrejected),
|
|
};
|
|
}),
|
|
})),
|
|
})),
|
|
};
|
|
return { db, row: () => row };
|
|
}
|
|
|
|
const rawReaders: Array<{
|
|
name: string;
|
|
read: (store: ReturnType<typeof providerTraceStore>) => Promise<unknown>;
|
|
}> = [
|
|
{ name: "inspect", read: (store) => store.inspect("run-1", "company-1") },
|
|
{ name: "readExactEntries", read: (store) => store.readExactEntries("run-1", "company-1") },
|
|
{ name: "revealFrame", read: (store) => store.revealFrame("run-1", "company-1", 1) },
|
|
{ name: "download", read: (store) => store.download("run-1", "company-1") },
|
|
];
|
|
|
|
it.each(rawReaders)("denies and expires raw trace access through $name", async ({ name, read }) => {
|
|
mockUnlink.mockReset();
|
|
mockUnlink.mockResolvedValue(undefined);
|
|
const { db, returning } = expiredTraceDb();
|
|
const store = providerTraceStore(db as never);
|
|
|
|
const result = await read(store);
|
|
|
|
expect(result).toEqual(name === "inspect" ? { trace: null, entries: [] } : null);
|
|
expect(returning).toHaveBeenCalledOnce();
|
|
});
|
|
|
|
it("keeps an expired row denied and retries failed raw-file cleanup", async () => {
|
|
const unlinkError = Object.assign(new Error("permission denied"), { code: "EACCES" });
|
|
mockUnlink.mockReset();
|
|
mockUnlink.mockRejectedValueOnce(unlinkError).mockResolvedValue(undefined);
|
|
const { db, row } = statefulExpiredTraceDb();
|
|
const store = providerTraceStore(db as never);
|
|
|
|
await expect(store.inspect("run-1", "company-1")).rejects.toThrow("permission denied");
|
|
expect(row()).toMatchObject({ status: "expired", deletedAt: expect.any(Date) });
|
|
|
|
await expect(store.inspect("run-1", "company-1")).resolves.toEqual({
|
|
trace: null,
|
|
entries: [],
|
|
});
|
|
expect(row().traceRef).toBe("00000000-0000-0000-0000-000000000000.ndjson");
|
|
});
|
|
|
|
it("never returns a writable capture path for an expired existing trace", async () => {
|
|
mockUnlink.mockReset();
|
|
mockUnlink.mockResolvedValue(undefined);
|
|
const { db } = statefulExpiredTraceDb();
|
|
const store = providerTraceStore(db as never);
|
|
|
|
await expect(store.prepare({
|
|
runId: "run-1",
|
|
companyId: "company-1",
|
|
provider: "codex",
|
|
requestedBy: "user-1",
|
|
})).rejects.toThrow("provider_trace_unavailable");
|
|
});
|
|
|
|
it("does not let concurrent finalization overwrite expiry or strand raw files", async () => {
|
|
mockUnlink.mockReset();
|
|
mockUnlink.mockResolvedValue(undefined);
|
|
const { db, row } = finalizeExpiryRaceDb();
|
|
const store = providerTraceStore(db as never);
|
|
|
|
await expect(store.finalize("run-1", "company-1")).resolves.toBeNull();
|
|
expect(row()).toMatchObject({
|
|
status: "expired",
|
|
deletedAt: expect.any(Date),
|
|
traceRef: "00000000-0000-0000-0000-000000000000.ndjson",
|
|
});
|
|
});
|
|
});
|