mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-11 05:31:46 +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>
160 lines
5.1 KiB
TypeScript
160 lines
5.1 KiB
TypeScript
import { and, desc, eq, inArray, sql } from "drizzle-orm";
|
|
|
|
import type { Db } from "@paperclipai/db";
|
|
import { heartbeatRunEvents } from "@paperclipai/db";
|
|
import type { HarnessRuntimeRequestKind } from "../../vendor/paperclip-runner/index.js";
|
|
|
|
const TERMINAL_RUNTIME_REQUEST_EVENTS = [
|
|
"runtime_request.resolved",
|
|
"runtime_request.cancelled",
|
|
"runtime_request.expired",
|
|
] as const;
|
|
const RUNTIME_REQUEST_EVENTS = [
|
|
"runtime_request.created",
|
|
...TERMINAL_RUNTIME_REQUEST_EVENTS,
|
|
] as const;
|
|
const APPROVAL_REQUEST_KINDS = new Set<NativeRuntimeRequestKind>([
|
|
"command_approval",
|
|
"file_approval",
|
|
"permission_approval",
|
|
]);
|
|
const REQUEST_ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,159}$/;
|
|
|
|
export type NativeRuntimeRequestKind = HarnessRuntimeRequestKind | "runtime";
|
|
|
|
export interface PendingNativeRuntimeRequest {
|
|
readonly companyId: string;
|
|
readonly runId: string;
|
|
readonly requestId: string;
|
|
readonly requestKind: NativeRuntimeRequestKind;
|
|
readonly turnId: string;
|
|
/** Server-owned policy derived from the canonical request kind. */
|
|
readonly resolverPolicy: "human_only" | "instance_admin";
|
|
}
|
|
|
|
export interface NativeRuntimeRequestResolver {
|
|
readonly type: "user";
|
|
readonly userId: string;
|
|
readonly isInstanceAdmin: boolean;
|
|
}
|
|
|
|
export class NativeRuntimeRequestResolutionAuthorizationError extends Error {
|
|
constructor(readonly code: "native_runtime_request_resolver_denied") {
|
|
super(code);
|
|
this.name = "NativeRuntimeRequestResolutionAuthorizationError";
|
|
}
|
|
}
|
|
|
|
function record(value: unknown): Record<string, unknown> | null {
|
|
return typeof value === "object" && value !== null && !Array.isArray(value)
|
|
? value as Record<string, unknown>
|
|
: null;
|
|
}
|
|
|
|
function canonicalPendingRequest(input: {
|
|
companyId: string;
|
|
runId: string;
|
|
requestId: string;
|
|
payload: unknown;
|
|
}): PendingNativeRuntimeRequest | null {
|
|
const event = record(record(input.payload)?.prpEvent);
|
|
const eventPayload = record(event?.payload);
|
|
const request = record(eventPayload?.request);
|
|
if (
|
|
!event
|
|
|| event.schema !== "paperclip.prp.event.v1"
|
|
|| event.eventType !== "runtime_request.created"
|
|
|| event.sourceKind !== "runner"
|
|
|| event.runId !== input.runId
|
|
|| !request
|
|
|| request.status !== "pending"
|
|
) return null;
|
|
|
|
const requestId = typeof request.requestId === "string" ? request.requestId : "";
|
|
const requestKind = typeof request.requestKind === "string" ? request.requestKind : "";
|
|
const turnId = typeof request.turnId === "string"
|
|
? request.turnId
|
|
: typeof event.turnId === "string"
|
|
? event.turnId
|
|
: "";
|
|
const supportedKinds: readonly NativeRuntimeRequestKind[] = [
|
|
"command_approval",
|
|
"file_approval",
|
|
"permission_approval",
|
|
"user_input",
|
|
"elicitation",
|
|
"runtime",
|
|
];
|
|
if (
|
|
requestId !== input.requestId
|
|
|| !REQUEST_ID_PATTERN.test(requestId)
|
|
|| !supportedKinds.includes(requestKind as NativeRuntimeRequestKind)
|
|
|| !REQUEST_ID_PATTERN.test(turnId)
|
|
) {
|
|
return null;
|
|
}
|
|
const kind = requestKind as NativeRuntimeRequestKind;
|
|
return {
|
|
companyId: input.companyId,
|
|
runId: input.runId,
|
|
requestId,
|
|
requestKind: kind,
|
|
turnId,
|
|
resolverPolicy: APPROVAL_REQUEST_KINDS.has(kind)
|
|
? "instance_admin"
|
|
: "human_only",
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Load the latest durable lifecycle event for one request. A request is
|
|
* actionable only when that exact latest event is its canonical creation.
|
|
* Client-supplied kind/turn fields never participate in this lookup.
|
|
*/
|
|
export async function readPendingNativeRuntimeRequest(
|
|
db: Db,
|
|
input: {
|
|
readonly companyId: string;
|
|
readonly runId: string;
|
|
readonly requestId: string;
|
|
},
|
|
): Promise<PendingNativeRuntimeRequest | null> {
|
|
if (!REQUEST_ID_PATTERN.test(input.requestId)) return null;
|
|
const [latest] = await db
|
|
.select({
|
|
eventType: heartbeatRunEvents.eventType,
|
|
payload: heartbeatRunEvents.payload,
|
|
})
|
|
.from(heartbeatRunEvents)
|
|
.where(and(
|
|
eq(heartbeatRunEvents.companyId, input.companyId),
|
|
eq(heartbeatRunEvents.runId, input.runId),
|
|
inArray(heartbeatRunEvents.eventType, [...RUNTIME_REQUEST_EVENTS]),
|
|
sql`coalesce(
|
|
${heartbeatRunEvents.payload} #>> '{prpEvent,payload,request,requestId}',
|
|
${heartbeatRunEvents.payload} #>> '{prpEvent,payload,requestId}'
|
|
) = ${input.requestId}`,
|
|
))
|
|
.orderBy(desc(heartbeatRunEvents.seq))
|
|
.limit(1);
|
|
if (!latest || latest.eventType !== "runtime_request.created") return null;
|
|
return canonicalPendingRequest({ ...input, payload: latest.payload });
|
|
}
|
|
|
|
/** Revalidate the server-owned resolver policy at the command-consumption edge. */
|
|
export function assertNativeRuntimeRequestResolverAuthorized(
|
|
request: PendingNativeRuntimeRequest,
|
|
actor: NativeRuntimeRequestResolver,
|
|
): void {
|
|
if (!actor.userId.trim()) {
|
|
throw new NativeRuntimeRequestResolutionAuthorizationError(
|
|
"native_runtime_request_resolver_denied",
|
|
);
|
|
}
|
|
if (request.resolverPolicy === "instance_admin" && !actor.isInstanceAdmin) {
|
|
throw new NativeRuntimeRequestResolutionAuthorizationError(
|
|
"native_runtime_request_resolver_denied",
|
|
);
|
|
}
|
|
}
|