Files
PaperClipAI/server/src/__tests__/provider-trace-store.test.ts
T
DottaandDev Agent 25cf079ec5 feat(runner): add Codex-native application integration (#12591)
## 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>
2026-08-31 14:38:38 -05:00

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",
});
});
});