Files
PaperClipAI/server/src/services/provider-trace-store.ts
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

610 lines
18 KiB
TypeScript

import fs from "node:fs/promises";
import path from "node:path";
import { createHash, randomUUID } from "node:crypto";
import { and, eq, gt, inArray, isNull, lte, ne, or } from "drizzle-orm";
import type { Db } from "@paperclipai/db";
import { providerTraceRecords } from "@paperclipai/db";
import type {
ProviderTraceFrame,
ProviderTraceMetadata,
} from "@paperclipai/shared";
import { resolvePaperclipInstanceRoot } from "../home-paths.js";
import { logActivity } from "./activity-log.js";
export const PROVIDER_TRACE_MAX_BYTES = 64 * 1024 * 1024;
export const PROVIDER_TRACE_RETENTION_MS = 24 * 60 * 60 * 1_000;
const EXPIRED_PROVIDER_TRACE_REF = "00000000-0000-0000-0000-000000000000.ndjson";
export function providerTraceRequiredChannels(provider: string): string[] {
if (provider === "opencode") return ["typescript_opencode_native"];
if (provider === "acpx") return ["typescript_acpx_native"];
return ["rust_native", "typescript_runnerd_rehydration"];
}
type TraceRow = typeof providerTraceRecords.$inferSelect;
function traceRoot() {
return (
process.env.PROVIDER_TRACE_BASE_PATH ??
path.resolve(resolvePaperclipInstanceRoot(), "data", "provider-traces")
);
}
function tracePath(traceRef: string) {
if (
path.basename(traceRef) !== traceRef ||
!/^[a-f0-9-]+\.ndjson$/.test(traceRef)
) {
throw new Error("invalid_provider_trace_ref");
}
return path.resolve(traceRoot(), traceRef);
}
function rehydrationTracePath(traceRef: string) {
return `${tracePath(traceRef)}.rehydration`;
}
function asMetadata(row: TraceRow): ProviderTraceMetadata {
return {
schema: "paperclip.provider_trace_metadata.v1",
id: row.id,
runId: row.runId,
companyId: row.companyId,
status: row.status as ProviderTraceMetadata["status"],
provider: row.provider,
frameCount: row.frameCount,
byteCount: row.byteCount,
digest: row.digest as ProviderTraceMetadata["digest"],
reason: row.reason,
requestedBy: row.requestedBy,
createdAt: row.createdAt,
expiresAt: row.expiresAt,
deletedAt: row.deletedAt,
};
}
function record(value: unknown): Record<string, unknown> {
return value && typeof value === "object" && !Array.isArray(value)
? (value as Record<string, unknown>)
: {};
}
const SECRET_KEY =
/(authorization|cookie|credential|password|passwd|secret|token|api[_-]?key|private[_-]?key)/i;
const REASONING_KEY =
/(^|[_-])(analysis|reasoning|chain[_-]?of[_-]?thought)($|[_-])/i;
const SECRET_VALUE =
/(?:\bBearer\s+[A-Za-z0-9._~+/=-]{8,}|\bsk-[A-Za-z0-9_-]{12,}|-----BEGIN (?:RSA |EC |OPENSSH )?PRIVATE KEY-----)/i;
function redactParsed(
value: unknown,
pathParts: string[] = [],
withheld: string[] = [],
): unknown {
if (Array.isArray(value)) {
return value.map((item, index) =>
redactParsed(item, [...pathParts, String(index)], withheld),
);
}
if (typeof value === "string" && SECRET_VALUE.test(value)) {
withheld.push(pathParts.join(".") || "$raw");
return "[withheld]";
}
if (!value || typeof value !== "object") return value;
const source = value as Record<string, unknown>;
const reasoningObject =
source.type === "reasoning" || source.type === "analysis";
const output: Record<string, unknown> = {};
for (const [key, child] of Object.entries(source)) {
const childPath = [...pathParts, key];
if (
SECRET_KEY.test(key) ||
REASONING_KEY.test(key) ||
(reasoningObject && key !== "type" && key !== "id" && key !== "status")
) {
output[key] = "[withheld]";
withheld.push(childPath.join("."));
continue;
}
output[key] = redactParsed(child, childPath, withheld);
}
return output;
}
function redactedFrame(entry: Record<string, unknown>) {
const rawBase64 = typeof entry.rawBase64 === "string" ? entry.rawBase64 : "";
const withheldPaths: string[] = [];
let parsed: unknown = null;
try {
parsed = redactParsed(
JSON.parse(Buffer.from(rawBase64, "base64").toString("utf8")),
[],
withheldPaths,
);
} catch {
withheldPaths.push("$raw");
}
const { rawBase64: _raw, ...safe } = entry;
return {
...safe,
parsed,
withheldPaths,
exactAvailable: rawBase64.length > 0,
};
}
export function redactProviderTraceFrame(entry: Record<string, unknown>) {
return redactedFrame(entry);
}
async function readTraceBytes(traceRef: string) {
const nativeBytes = await fs.readFile(tracePath(traceRef));
const rehydrationBytes = await fs
.readFile(rehydrationTracePath(traceRef))
.catch(() => Buffer.alloc(0));
if (rehydrationBytes.byteLength === 0) return nativeBytes;
const separator =
nativeBytes.byteLength > 0 && nativeBytes.at(-1) !== 0x0a
? Buffer.from("\n")
: Buffer.alloc(0);
return Buffer.concat([nativeBytes, separator, rehydrationBytes]);
}
async function readEntries(traceRef: string) {
const content = (await readTraceBytes(traceRef)).toString("utf8");
return content.split("\n").flatMap((line) => {
if (!line.trim()) return [];
try {
return [record(JSON.parse(line))];
} catch {
return [
{
kind: "trace_status",
status: "incomplete",
reason: "invalid_sidecar_record",
},
];
}
});
}
export function assessProviderTraceEntries(
entries: Record<string, unknown>[],
options: {
invalidRecordCount?: number;
requiredChannels?: string[];
} = {},
): { status: "complete" | "incomplete" | "truncated"; reason: string | null } {
if ((options.invalidRecordCount ?? 0) > 0) {
return { status: "incomplete", reason: "invalid_sidecar_record" };
}
for (const entry of entries) {
if (entry.kind !== "frame") continue;
const encoded =
typeof entry.rawBase64 === "string" ? entry.rawBase64 : null;
const byteLength = Number(entry.byteLength);
const digest = typeof entry.digest === "string" ? entry.digest : null;
if (encoded === null || !Number.isSafeInteger(byteLength) || !digest) {
return { status: "incomplete", reason: "invalid_frame_metadata" };
}
const raw = Buffer.from(encoded, "base64");
if (
raw.toString("base64") !== encoded ||
raw.byteLength !== byteLength ||
`sha256:${createHash("sha256").update(raw).digest("hex")}` !== digest
) {
return { status: "incomplete", reason: "provider_frame_digest_mismatch" };
}
}
const channels = new Set(options.requiredChannels ?? []);
for (const entry of entries) {
if (typeof entry.debugChannel === "string")
channels.add(entry.debugChannel);
}
for (const channel of channels) {
const channelEntries = entries.filter(
(entry) => entry.debugChannel === channel,
);
if (channelEntries.length === 0) {
return {
status: "incomplete",
reason: `trace_channel_missing:${channel}`,
};
}
for (let index = 0; index < channelEntries.length; index += 1) {
if (Number(channelEntries[index]?.debugSequence) !== index + 1) {
return {
status: "incomplete",
reason: `trace_debug_sequence_gap:${channel}`,
};
}
}
const terminal = channelEntries.at(-1)!;
if (terminal.kind !== "trace_status") {
return {
status: "incomplete",
reason: `trace_channel_ack_missing:${channel}`,
};
}
if (
Number(terminal.acknowledgedDebugSequence) !==
Number(terminal.debugSequence) - 1
) {
return {
status: "incomplete",
reason: `trace_channel_ack_invalid:${channel}`,
};
}
}
const statuses = entries.filter((entry) => entry.kind === "trace_status");
const incomplete = statuses.find((entry) => entry.status === "incomplete");
if (incomplete) {
return {
status: "incomplete",
reason:
typeof incomplete.reason === "string"
? incomplete.reason
: "trace_channel_incomplete",
};
}
const truncated = statuses.find((entry) => entry.status === "truncated");
if (truncated) {
return {
status: "truncated",
reason:
typeof truncated.reason === "string"
? truncated.reason
: "provider_trace_max_bytes_exceeded",
};
}
if (statuses.length === 0) {
return { status: "incomplete", reason: "trace_terminal_ack_missing" };
}
return { status: "complete", reason: null };
}
export function providerTraceStore(db: Db) {
async function removeTraceFiles(traceRef: string) {
for (const filePath of [tracePath(traceRef), rehydrationTracePath(traceRef)]) {
try {
await fs.unlink(filePath);
} catch (error) {
if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
}
}
}
async function expireRow(row: TraceRow, now: Date) {
let expired = row;
let newlyExpired = false;
if (!row.deletedAt) {
if (row.expiresAt.getTime() > now.getTime()) return null;
const updated = await db
.update(providerTraceRecords)
.set({
status: "expired",
deletedAt: now,
updatedAt: now,
})
.where(and(
eq(providerTraceRecords.id, row.id),
isNull(providerTraceRecords.deletedAt),
lte(providerTraceRecords.expiresAt, now),
))
.returning()
.then((rows) => rows[0] ?? null);
if (!updated) return null;
expired = updated;
newlyExpired = true;
} else if (row.expiresAt.getTime() > now.getTime()) {
return null;
}
// The database tombstone is authoritative before filesystem cleanup, so
// concurrent readers fail closed. traceRef becomes a durable completion
// marker only after both raw sidecars are absent; failures are retried by
// later reads and cleanup passes.
if (expired.traceRef !== EXPIRED_PROVIDER_TRACE_REF) {
await removeTraceFiles(expired.traceRef);
await db
.update(providerTraceRecords)
.set({
status: "expired",
traceRef: EXPIRED_PROVIDER_TRACE_REF,
updatedAt: now,
})
.where(and(
eq(providerTraceRecords.id, expired.id),
lte(providerTraceRecords.expiresAt, now),
eq(providerTraceRecords.traceRef, expired.traceRef),
));
expired = { ...expired, traceRef: EXPIRED_PROVIDER_TRACE_REF };
}
if (newlyExpired) {
await logActivity(db, {
companyId: row.companyId,
actorType: "system",
actorId: "provider-trace-expiry",
runId: row.runId,
action: "provider_trace.expired",
entityType: "heartbeat_run",
entityId: row.runId,
details: {
traceId: row.id,
expiredAt: now.toISOString(),
payloadLogged: false,
},
});
}
return expired;
}
async function cleanupExpired(now = new Date()) {
const expired = await db
.select()
.from(providerTraceRecords)
.where(
and(
lte(providerTraceRecords.expiresAt, now),
or(
isNull(providerTraceRecords.deletedAt),
ne(providerTraceRecords.traceRef, EXPIRED_PROVIDER_TRACE_REF),
),
),
);
const removed: TraceRow[] = [];
for (const row of expired) {
const removedRow = await expireRow(row, now);
if (removedRow) removed.push(removedRow);
}
return removed;
}
async function prepare(input: {
runId: string;
companyId: string;
provider: string;
requestedBy: string;
}) {
await cleanupExpired().catch(() => []);
const existing = await db
.select()
.from(providerTraceRecords)
.where(eq(providerTraceRecords.runId, input.runId))
.limit(1)
.then((rows) => rows[0] ?? null);
if (existing) {
const now = new Date();
const expired = existing.expiresAt.getTime() <= now.getTime();
if (expired) await expireRow(existing, now);
if (existing.deletedAt || expired) {
throw new Error("provider_trace_unavailable");
}
return {
metadata: asMetadata(existing),
path: tracePath(existing.traceRef),
};
}
await fs.mkdir(traceRoot(), { recursive: true, mode: 0o700 });
const traceRef = `${randomUUID()}.ndjson`;
const filePath = tracePath(traceRef);
const handle = await fs.open(filePath, "wx", 0o600);
await handle.close();
await fs.chmod(filePath, 0o600);
const expiresAt = new Date(Date.now() + PROVIDER_TRACE_RETENTION_MS);
const row = await db
.insert(providerTraceRecords)
.values({
runId: input.runId,
companyId: input.companyId,
status: "capturing",
provider: input.provider,
traceRef,
requestedBy: input.requestedBy,
expiresAt,
})
.returning()
.then((rows) => rows[0]);
if (!row) throw new Error("provider_trace_metadata_create_failed");
return { metadata: asMetadata(row), path: filePath };
}
async function getByRun(runId: string, companyId?: string) {
const where = companyId
? and(
eq(providerTraceRecords.runId, runId),
eq(providerTraceRecords.companyId, companyId),
)
: eq(providerTraceRecords.runId, runId);
return db
.select()
.from(providerTraceRecords)
.where(where)
.limit(1)
.then((rows) => rows[0] ?? null);
}
async function getReadableByRun(runId: string, companyId: string) {
const row = await getByRun(runId, companyId);
if (!row) return null;
const now = new Date();
if (row.deletedAt) {
if (
row.expiresAt.getTime() <= now.getTime()
&& row.traceRef !== EXPIRED_PROVIDER_TRACE_REF
) {
await expireRow(row, now);
}
return null;
}
if (row.expiresAt.getTime() <= now.getTime()) {
await expireRow(row, now);
return null;
}
return row;
}
async function listMetadataForRuns(companyId: string, runIds: string[]) {
if (runIds.length === 0) return [];
const rows = await db
.select()
.from(providerTraceRecords)
.where(
and(
eq(providerTraceRecords.companyId, companyId),
inArray(providerTraceRecords.runId, runIds.slice(0, 100)),
),
);
return rows.map(asMetadata);
}
async function finalize(
runId: string,
companyId: string,
failureReason?: string | null,
) {
const row = await getReadableByRun(runId, companyId);
if (!row) return null;
let bytes: Buffer;
try {
bytes = await readTraceBytes(row.traceRef);
} catch {
const updatedAt = new Date();
const finalized = await db
.update(providerTraceRecords)
.set({
status: "incomplete",
reason: failureReason ?? "trace_sidecar_missing",
updatedAt,
})
.where(and(
eq(providerTraceRecords.id, row.id),
isNull(providerTraceRecords.deletedAt),
gt(providerTraceRecords.expiresAt, updatedAt),
))
.returning()
.then((rows) => rows[0] ?? null);
if (!finalized) await getReadableByRun(runId, companyId);
return finalized;
}
let invalidRecordCount = 0;
const entries = bytes
.toString("utf8")
.split("\n")
.flatMap((line) => {
try {
return line.trim() ? [record(JSON.parse(line))] : [];
} catch {
invalidRecordCount += 1;
return [];
}
});
const frames = entries.filter((entry) => entry.kind === "frame");
const requiredChannels = providerTraceRequiredChannels(row.provider);
const integrity = assessProviderTraceEntries(entries, {
invalidRecordCount,
requiredChannels,
});
const terminalStatus = failureReason ? "incomplete" : integrity.status;
const updatedAt = new Date();
const finalized = await db
.update(providerTraceRecords)
.set({
status: terminalStatus,
frameCount: frames.length,
byteCount: frames.reduce(
(total, frame) => total + (Number(frame.byteLength) || 0),
0,
),
digest: `sha256:${createHash("sha256").update(bytes).digest("hex")}`,
reason: failureReason ?? integrity.reason,
updatedAt,
})
.where(and(
eq(providerTraceRecords.id, row.id),
isNull(providerTraceRecords.deletedAt),
gt(providerTraceRecords.expiresAt, updatedAt),
))
.returning()
.then((rows) => rows[0] ?? null);
if (!finalized) await getReadableByRun(runId, companyId);
return finalized;
}
async function inspect(runId: string, companyId: string) {
const row = await getReadableByRun(runId, companyId);
if (!row) return { trace: null, entries: [] };
const entries = await readEntries(row.traceRef).catch(() => []);
return {
trace: asMetadata(row),
entries: entries.map((entry) =>
entry.kind === "frame" ? redactedFrame(entry) : entry,
),
};
}
async function readExactEntries(runId: string, companyId: string) {
const row = await getReadableByRun(runId, companyId);
if (!row) return null;
return readEntries(row.traceRef);
}
async function revealFrame(
runId: string,
companyId: string,
frameId: number,
) {
const row = await getReadableByRun(runId, companyId);
if (!row) return null;
const entry = (await readEntries(row.traceRef)).find(
(candidate) =>
candidate.kind === "frame" && Number(candidate.frameId) === frameId,
);
return entry
? (entry as unknown as ProviderTraceFrame & { kind: "frame" })
: null;
}
async function download(runId: string, companyId: string) {
const row = await getReadableByRun(runId, companyId);
if (!row) return null;
return { row, bytes: await readTraceBytes(row.traceRef) };
}
async function remove(runId: string, companyId: string) {
const row = await getByRun(runId, companyId);
if (!row || row.deletedAt) return null;
// Manual deletion is filesystem-first and strict: a reported success must
// never leave raw sidecars orphaned behind an inaccessible database row.
await removeTraceFiles(row.traceRef);
const now = new Date();
return db
.update(providerTraceRecords)
.set({
status: "deleted",
deletedAt: now,
updatedAt: now,
})
.where(eq(providerTraceRecords.id, row.id))
.returning()
.then((rows) => rows[0] ?? null);
}
return {
prepare,
finalize,
inspect,
readExactEntries,
revealFrame,
download,
remove,
getByRun,
listMetadataForRuns,
cleanupExpired,
};
}