Reconcile Claude platform packages with merged Codex defaults

Co-Authored-By: Paperclip <noreply@paperclip.ing>

* codex/claude-runner-default: (85 commits)
  feat: default new Codex agents to Paperclip Runner on Linux and macOS (#15683)
  docs(skills): add slow-query diagnosis and fix skill (#16303)
  feat(sentry): add opt-in run failure reporting with saved run references (#16300)
  refactor(server): isolate provider message admission and runtime policies (#16289)
  refactor(server): isolate provider webhook lifecycle decisions (#16283)
  fix(workspaces): keep cleanup Git scans fresh and dedupe page reads (#16302)
  fix(sentry): keep provider-rejected Grok wakes out of run failure reports (#16297)
  fix(runner): report Runner disconnect age when a Dot bridge exits (#16296)
  refactor(server): isolate chat conversation and control policies (#16276)
  perf(recovery): use indexed execution identity lookups (#16291)
  perf(runs): bound history and reduce repeated context reads (#16286)
  fix(native-runtime): report which close barrier failed (#16298)
  feat(skills): select and validate tracked GitHub branches (#16288)
  fix(observability): sample native runs independently of triggers (#16285)
  fix(github): retry and recover review publication (#16284)
  perf(workspaces): wake terminal cleanup from committed changes (#16282)
  fix(mcp): deduplicate discovered tool descriptors (#16281)
  perf: remove history scans from task and chat queries (#16278)
  docs(skills): add maintenance bot setup workflow (#16279)
  fix(observability): time execution configuration steps (#16236)
  ...

# Conflicts:
#	scripts/verify-grok-npm-install.mjs
This commit is contained in:
Dotta committed 2026-10-14 12:12:58 -05:00
commit b030683ca2
1176 files changed
+129461 -261897

No files matched your search

@@ -18,6 +18,52 @@ describe("assigned MCP runner tools", () => {
const searchName = "paperclip_search_assigned_tools";
const callName = "paperclip_call_assigned_tool";
it("deduplicates repeated app tools in direct and on-demand catalogs while retaining revocation", async () => {
const read = descriptor("memory.search");
const write = descriptor("memory.store", "write");
const f = fixture([read, structuredClone(read), write, structuredClone(write)]);
const assigned = await createAssignedMcpTools(f);
const direct = assigned.definitions();
expect(direct).toHaveLength(2);
expect(assigned.definitions(tools => tools.length <= 2)).toEqual(direct);
expect(assigned.definitions(tools => tools[0]?.name === searchName).map(tool => tool.name)).toEqual([searchName, callName]);
await expect(assigned.execute({ tool: searchName, arguments: { query: "" } }, "planning"))
.resolves.toEqual({ tools: [direct[0]], nextOffset: null });
await assigned.execute({ tool: callName, arguments: { name: direct[0]!.name, arguments: { query: "fixture" } } });
expect(f.executeTool).toHaveBeenCalledExactlyOnceWith(expect.objectContaining({ tool: read.name }));
f.listToolsForNamedGateway.mockResolvedValue([write, structuredClone(write)]);
await expect(assigned.execute({ tool: searchName, arguments: { query: "" } }))
.resolves.toEqual({ tools: [direct[1]], nextOffset: null });
});
it.each([
{ risk: "write" as const },
{ connectionId: "another-connection" },
{ parametersSchema: { type: "object", properties: { secret: { type: "string" } } } },
])("rejects conflicting definitions with the same gateway name: %j", async change => {
const tool = descriptor("memory.search");
const f = fixture([tool, { ...tool, ...change }]);
await expect(createAssignedMcpTools(f)).rejects.toMatchObject({ reasonCode: "tool_name_collision" });
expect(f.executeTool).not.toHaveBeenCalled();
});
it("identifies the conflicting tool and both connection bindings without exporting descriptor metadata", async () => {
const tool = { ...descriptor("calendar.search"), connectionId: "first-connection", providerMetadata: { token: "private-token" } };
const f = fixture([tool, { ...tool, connectionId: "second-connection" }]);
await expect(createAssignedMcpTools(f)).rejects.toMatchObject({ reasonCode: "tool_name_collision",
message: 'Conflicting tool definitions share the name "calendar.search" (connections ["first-connection","second-connection"]).' });
expect(f.executeTool).not.toHaveBeenCalled();
});
it("keeps same-action tools from distinct connections separately callable", async () => {
const f = fixture([descriptor("mcp.memory-first:search"), descriptor("mcp.memory-second:search")]);
const assigned = await createAssignedMcpTools(f);
const direct = assigned.definitions();
expect(new Set(direct.map(tool => tool.name)).size).toBe(2);
for (const tool of direct) await assigned.execute({ tool: tool.name as string, arguments: {} });
expect(f.executeTool.mock.calls.map(([call]) => call.tool)).toEqual(["mcp.memory-first:search", "mcp.memory-second:search"]);
});
it("pages through an oversized catalog and calls every tool through the original gateway", async () => {
const f = fixture(Array.from({ length: 224 }, (_, i) => descriptor(`app.action_${i}`)));
const assigned = await createAssignedMcpTools(f);
@@ -1,6 +1,6 @@
import { createHash } from "node:crypto";
import type { Db } from "@paperclipai/db";
import { ToolGatewayHttpError, type ToolGatewayDescriptor, type ToolGatewayService } from "../tool-gateway.js";
import { deduplicateToolDescriptors, ToolGatewayHttpError, type ToolGatewayDescriptor, type ToolGatewayService } from "../tool-gateway.js";
type WorkMode = "standard" | "planning" | "ask";
type ToolDefinition = Record<string, unknown>;
@@ -14,7 +14,7 @@ const SCHEMA_CHUNK_CHARACTERS = 64 * 1024;
const ON_DEMAND_TOOLS: ToolDefinition[] = [
{
name: SEARCH_TOOL,
description: "Search your assigned app tools by name or description and read their input schemas. Use an empty query to browse. Pass nextOffset to fetch the next page. A large definition returns inputSchemaRef: set schemaTool to that name with an empty query, then pass nextSchemaOffset until null. Concatenate schemaJson chunks and parse JSON. Use paperclip_call_assigned_tool with a returned name and arguments matching its inputSchema.",
description: "Search your assigned app tools by name or description and read their input schemas. Pass {\"query\":\"search terms\"}, or an empty query to browse. Omit unused optional fields instead of sending null. To fetch the next page, pass the returned nextOffset as offset. A large definition returns inputSchemaRef: set schemaTool to that name with an empty query, then pass the returned nextSchemaOffset as schemaOffset until null. Concatenate schemaJson chunks and parse JSON. Use paperclip_call_assigned_tool with a returned name and arguments matching its inputSchema.",
inputSchema: {
type: "object", additionalProperties: false,
properties: {
@@ -92,9 +92,14 @@ export async function createAssignedMcpTools(input: {
bearerToken: input.bearerToken,
});
const tools = new Map<string, ToolGatewayDescriptor>();
for (const descriptor of listed) {
for (const descriptor of deduplicateToolDescriptors(listed)) {
const name = assignedToolName(descriptor.name);
if (tools.has(name)) throw new Error("assigned_mcp_tool_name_collision");
const previous = tools.get(name);
if (previous) {
throw new ToolGatewayHttpError(409,
`Assigned MCP tool name collision for ${JSON.stringify([previous.name, descriptor.name])}.`,
"assigned_mcp_tool_name_collision");
}
tools.set(name, descriptor);
}
const permits = (tool: ToolGatewayDescriptor, mode: WorkMode = input.workMode ?? "standard") => mode === "standard" || tool.risk === "read";
@@ -114,7 +119,7 @@ export async function createAssignedMcpTools(input: {
}
// Intersect fresh grants with this session's pinned catalog. Revocations
// take effect immediately; newly assigned tools require a new session.
const current = new Map((await input.gateway.listToolsForNamedGateway({
const current = new Map(deduplicateToolDescriptors(await input.gateway.listToolsForNamedGateway({
gatewayPublicId: input.gatewayPublicId, bearerToken: input.bearerToken,
})).map(tool => [tool.name, tool]));
const terms = args.query.toLowerCase().trim().split(/\s+/).filter(Boolean);
@@ -6,7 +6,6 @@ import { issueComments, issueThreadInteractions, issues } from "@paperclipai/db"
import { createLocalAgentJwt } from "../../agent-auth-jwt.js";
import { startRunnerApiTestServer } from "../../__tests__/helpers/runner-api-server.js";
import { issueThreadInteractionService } from "../issue-thread-interactions.js";
import { runnerApiMutationRestriction } from "./runner-api-policy.js";
describe("confirmation reply through the native API tool", () => {
let server: Awaited<ReturnType<typeof startRunnerApiTestServer>>;
@@ -52,10 +51,11 @@ describe("confirmation reply through the native API tool", () => {
const token = createLocalAgentJwt(f.agentId, f.companyId, "paperclip_runner", f.runId, f.userId);
expect((await fetch(url, { method: "POST", headers: { "Content-Type": "application/json", Authorization: `Bearer ${token}` }, body: JSON.stringify({ ...call.arguments.body, actorUserId: f.userId }) })).status).toBe(400);
});
it("does not open the ordinary interaction or governance mutation routes", () => {
for (const operation of ["accept", "reject", "respond", "verdicts", "withdraw"]) {
expect(runnerApiMutationRestriction(`/api/issues/{id}/interactions/{interactionId}/${operation}`)).not.toBeNull();
}
expect(runnerApiMutationRestriction("/api/issues/{id}/interactions/{interactionId}/resolve-from-comment")).toBeNull();
it("preserves formal approval authority in the HTTP route", async () => {
const { f } = await seed();
expect(await f.authority.execute({ tool: "call_api", callId: randomUUID(), arguments: {
operationId: "POST /api/approvals/{id}/approve", pathParams: { id: f.approvalId }, body: {},
} })).toMatchObject({ ok: false, status: 403 });
expect((await f.snapshot()).approvals.find(row => row.id === f.approvalId)?.status).toBe("pending");
});
});
@@ -72,7 +72,7 @@ export interface BuildNativeExecutionInput {
branchName: string | null;
};
normalizedSessionId: string | null;
provider?: "codex" | "opencode" | "claude_managed" | "aws_agentcore" | "acpx" | "openai_dot";
provider?: "codex" | "opencode" | "claude_managed" | "aws_agentcore" | "acpx" | "openai_dot" | "grok_bot";
dotBinding?: DotBindingSnapshot;
acpxAgent?: NativeAcpxAgent;
codexApprovalPolicy?: NativeCodexApprovalPolicy;
@@ -110,8 +110,8 @@ export interface BuildNativeExecutionInput {
};
runtimeContext: NativeRuntimeContextSnapshot;
}
export function buildNativeExecutionInput(input: BuildNativeExecutionInput & { provider: "openai_dot"; dotBinding: DotBindingSnapshot }): NativeExecutionInputV6;
export function buildNativeExecutionInput(input: BuildNativeExecutionInput & { provider?: Exclude<BuildNativeExecutionInput["provider"], "openai_dot"> }): NativeExecutionInputV5 | NativeExecutionInputV7;
export function buildNativeExecutionInput(input: BuildNativeExecutionInput & { provider: "openai_dot" | "grok_bot"; dotBinding: DotBindingSnapshot }): NativeExecutionInputV6;
export function buildNativeExecutionInput(input: BuildNativeExecutionInput & { provider?: Exclude<BuildNativeExecutionInput["provider"], "openai_dot" | "grok_bot"> }): NativeExecutionInputV5 | NativeExecutionInputV7;
export function buildNativeExecutionInput(input: BuildNativeExecutionInput): NativeExecutionInputV5 | NativeExecutionInputV6 | NativeExecutionInputV7;
export function buildNativeExecutionInput(input: BuildNativeExecutionInput): NativeExecutionInputV5 | NativeExecutionInputV6 | NativeExecutionInputV7 {
if (input.issue.workMode !== "standard" && input.issue.workMode !== "planning" && input.issue.workMode !== "ask") {
@@ -333,10 +333,10 @@ export function buildNativeExecutionInput(input: BuildNativeExecutionInput): Nat
credentialBindings: [],
runtimeContext: input.runtimeContext,
};
return parseNativeExecutionInput(input.provider === "openai_dot" ? { ...prepared,
schema: "paperclip.native-execution-input.v6", provider: { kind: "openai_dot", model: null, binding: input.dotBinding },
return parseNativeExecutionInput(["openai_dot", "grok_bot"].includes(input.provider ?? "") ? { ...prepared,
schema: "paperclip.native-execution-input.v6", provider: { kind: input.provider, model: null, binding: input.dotBinding },
workspace: { access: "none", cwd: null, repoUrl: null, repoRef: null, branchName: null },
session: { ...prepared.session, driverKind: "openai_dot_mcp", lifecyclePolicy: { mode: "per_turn", idleTimeoutMs: null } }
session: { ...prepared.session, driverKind: input.provider === "grok_bot" ? "grok_bot_mcp" : "openai_dot_mcp", lifecyclePolicy: { mode: "per_turn", idleTimeoutMs: null } }
} : prepared) as NativeExecutionInputV5 | NativeExecutionInputV6 | NativeExecutionInputV7;
}
@@ -1,3 +1,4 @@
import { enableRunFailureReporting } from "../../__tests__/helpers/run-failure-reporting.js";
import { randomUUID } from "node:crypto";
import { eq } from "drizzle-orm";
import { afterAll, beforeAll, describe, expect, it, vi } from "vitest";
@@ -917,3 +918,6 @@ describeEmbeddedPostgres("native run finalizer / status decision committer — a
expect(agentTaskRunCalls(callsBefore)).toHaveLength(1);
});
});
enableRunFailureReporting();
@@ -0,0 +1,75 @@
import { context, trace, TraceFlags } from "@opentelemetry/api";
import { AsyncLocalStorageContextManager } from "@opentelemetry/context-async-hooks";
import { AlwaysOffSampler, ParentBasedSampler, TracerProvider, InMemorySpanExporter, SimpleSpanProcessor } from "@opentelemetry/sdk-trace";
import { expect, it, vi } from "vitest";
import { createNativeRunTrace } from "./native-run-trace.js";
it.each([TraceFlags.NONE, TraceFlags.SAMPLED])("exports a complete native run independently of trigger flags %i", async traceFlags => {
const manager = new AsyncLocalStorageContextManager().enable();
const exporter = new InMemorySpanExporter();
const provider = new TracerProvider({ spanProcessors: [new SimpleSpanProcessor({ exporter })] });
expect(context.setGlobalContextManager(manager)).toBe(true);
expect(trace.setGlobalTracerProvider(provider)).toBe(true);
const trigger = {
traceId: "11111111111111111111111111111111", spanId: "2222222222222222", traceFlags, isRemote: true,
};
try {
await context.with(trace.setSpanContext(context.active(), trigger), async () => {
const native = createNativeRunTrace({ runId: "diagnostic-native-run" });
await native.measure("native.session.execute", async () => undefined);
await native.finish("ok");
expect(trace.getActiveSpan()?.spanContext()).toEqual(trigger);
});
await provider.forceFlush();
const spans = exporter.getFinishedSpans();
expect(spans.map(span => span.name).sort()).toEqual(["native.session.execute", "task.provider_session"]);
const root = spans.find(span => span.name === "task.provider_session")!;
const child = spans.find(span => span.name === "native.session.execute")!;
expect(root.parentSpanContext).toBeUndefined();
expect(root.links.map(link => link.context)).toEqual([trigger]);
expect(root.spanContext().traceId).not.toBe(trigger.traceId);
expect(child.parentSpanContext?.spanId).toBe(root.spanContext().spanId);
expect(child.spanContext().traceId).toBe(root.spanContext().traceId);
} finally {
trace.disable(); context.disable(); manager.disable(); await provider.shutdown();
}
});
it("honors an operator who disables root sampling despite a sampled trigger", async () => {
const manager = new AsyncLocalStorageContextManager().enable();
const exporter = new InMemorySpanExporter();
const provider = new TracerProvider({
sampler: new ParentBasedSampler({ root: new AlwaysOffSampler() }),
spanProcessors: [new SimpleSpanProcessor({ exporter })],
});
expect(context.setGlobalContextManager(manager)).toBe(true);
expect(trace.setGlobalTracerProvider(provider)).toBe(true);
try {
await context.with(trace.setSpanContext(context.active(), {
traceId: "11111111111111111111111111111111", spanId: "2222222222222222", traceFlags: TraceFlags.SAMPLED, isRemote: true,
}), async () => {
const native = createNativeRunTrace({ runId: "disabled-native-run" });
await native.measure("native.session.execute", async () => undefined);
await native.finish("ok");
});
await provider.forceFlush();
expect(exporter.getFinishedSpans()).toEqual([]);
} finally {
trace.disable(); context.disable(); manager.disable(); await provider.shutdown();
}
});
it("stays silent without a tracer provider", async () => {
trace.disable(); context.disable();
const methods = ["log", "info", "warn", "error", "debug"] as const;
const spies = methods.map(method => vi.spyOn(console, method));
try {
const native = createNativeRunTrace({ runId: "unconfigured-native-run" });
const value = {};
await expect(native.measure("native.session.execute", async () => value)).resolves.toBe(value);
const error = new Error("original failure");
await expect(native.measure("native.session.execute", async () => { throw error; })).rejects.toBe(error);
await native.finish("ok");
for (const spy of spies) expect(spy).not.toHaveBeenCalled();
} finally { for (const spy of spies) spy.mockRestore(); }
});
@@ -1,5 +1,6 @@
import { AsyncLocalStorage } from "node:async_hooks";
import { createHash } from "node:crypto";
import { isSpanContextValid, ROOT_CONTEXT, trace } from "@opentelemetry/api";
import { runWithRuntimeParent } from "@paperclipai/adapter-utils/acpx-engine/startup-timing";
import type { AdapterRuntimeEvent } from "../../adapters/index.js";
@@ -210,6 +211,7 @@ export function createNativeRunTrace(input: {
const runIdHash = hashedId(input.runId);
let rootSpan: SpanHandle;
try {
const trigger = trace.getActiveSpan()?.spanContext();
rootSpan = tracing.tracer.startSpan("task.provider_session", {
startTime: traceStartedAtMs,
attributes: {
@@ -218,7 +220,8 @@ export function createNativeRunTrace(input: {
"paperclip.task.run.trace_schema_version":
NATIVE_RUN_TRACE_SCHEMA_VERSION,
},
});
...(trigger && isSpanContextValid(trigger) ? { links: [{ context: trigger }] } : {}),
}, ROOT_CONTEXT);
} catch {
rootSpan = NOOP_SPAN;
}
@@ -1,3 +1,4 @@
import { enableRunFailureReporting } from "../../__tests__/helpers/run-failure-reporting.js";
import { hasNativeLocalProcessStop } from "../native-local-process-stop.js";
import { randomUUID } from "node:crypto";
import { spawn } from "node:child_process";
@@ -1151,3 +1152,6 @@ describeEmbeddedPostgres("native runner restart recovery with real processes", (
).toBe(true);
}, 30_000);
});
enableRunFailureReporting();
@@ -1681,7 +1681,8 @@ export function nativeSessionWorkspaceScope(execution: { binding: Pick<NativeExe
function nativeProviderSessionScope(execution: NativeExecutionInput) {
switch (execution.provider.kind) {
case "openai_dot": return { kind: "openai_dot", bindingId: execution.provider.binding.bindingId, generation: execution.provider.binding.bindingGeneration };
case "grok_bot":
case "openai_dot": return { kind: execution.provider.kind, bindingId: execution.provider.binding.bindingId, generation: execution.provider.binding.bindingGeneration };
case "claude_managed":
return {
kind: execution.provider.kind,
@@ -5549,6 +5550,7 @@ function canonicalJson(value: unknown): string {
function runnerProviderStateFilename(execution: NativeExecutionInput): string {
switch (execution.provider.kind) {
case "grok_bot":
case "openai_dot":
return "dot-provider-state.json";
case "codex":
@@ -5583,6 +5585,7 @@ export function providerSessionIdentityFromDurableProviderState(input: {
providerSessionIdentity: null,
});
switch (input.execution.provider.kind) {
case "grok_bot":
case "openai_dot":
// Dot checkpoints recover the bridge only; there is no native provider thread.
return emptyIdentity();
@@ -7540,7 +7543,7 @@ export async function executePaperclipNativeSession(input: {
await retireSupersededWarmNativeSessions(
ownerScope, nativeSessionScopeKey(input.execution),
);
if (input.execution.provider.kind === "openai_dot" && isCloudManagedInstance()
if (input.execution.schema === "paperclip.native-execution-input.v6" && isCloudManagedInstance()
&& (!input.useRunnerd || input.runnerExecutionTarget?.kind !== "remote"
|| input.runnerExecutionTarget.transport !== "sandbox")) {
throw new Error("dot_cloud_requires_managed_runner");
@@ -7565,7 +7568,7 @@ export async function executePaperclipNativeSession(input: {
// a later sweep from closing an owner this turn is about to acquire.
await closingWarmNativeSessions.get(sessionScopeId);
if (input.execution.provider.kind === "openai_dot") {
if (input.execution.schema === "paperclip.native-execution-input.v6") {
return await executePaperclipNativeSessionWithinScope(input);
}
const workspaceRoot = input.execution.workspace.cwd;
@@ -7671,7 +7674,7 @@ async function executePaperclipNativeSessionWithinScope(
input.execution.provider.kind !== "claude_managed" &&
input.execution.provider.kind !== "aws_agentcore" &&
input.execution.provider.kind !== "acpx" &&
input.execution.provider.kind !== "openai_dot"
input.execution.schema !== "paperclip.native-execution-input.v6"
) {
throw new Error("paperclip_runner_provider_unsupported");
}
@@ -8618,7 +8621,7 @@ async function executePaperclipNativeSessionWithinScope(
controller,
});
try {
if (input.managedGitHub && input.execution.provider.kind !== "openai_dot") {
if (input.managedGitHub && input.execution.schema !== "paperclip.native-execution-input.v6") {
if (!input.execution.workspace.cwd) throw new Error("native_workspace_required");
githubAccess ??= await createNativeGitHubAccess({
scope: input.execution.binding,
@@ -9424,7 +9427,9 @@ async function executePaperclipNativeSessionWithinScope(
recoveryProjection.supersedeOnIdentityChange,
});
});
if (terminalRunToReport) void reportRunFailure(input.db, terminalRunToReport);
// Pass the raw error only for closed settlement labels: its text bypasses
// the identity redactor applied to the persisted message above.
if (terminalRunToReport) void reportRunFailure(input.db, terminalRunToReport, { nativeCloseFailure: error });
await boundedExecutionCleanup(async () => {
await stoppedLeaseRenewal;
await attemptFailureStep(() =>
@@ -9603,7 +9608,7 @@ async function executePaperclipNativeSessionWithinScope(
resultJson: {
nativeResult: native.result as unknown as Record<string, unknown>,
nativeTerminal: native.terminal as unknown as Record<string, unknown>,
...(input.execution.provider.kind === "openai_dot" ? { providerAccounting: { usage: null, cost: null, externallyBilled: true }, externalStopConfirmed: false } : {}),
...(input.execution.schema === "paperclip.native-execution-input.v6" ? { providerAccounting: { usage: null, cost: null, externallyBilled: true }, externalStopConfirmed: false } : {}),
...(providerFailure ? { nativeProviderFailure: providerFailure.diagnostic } : {}),
...(native.goalRolloverRequired ? { goalRolloverRequired: true } : {}),
planSynchronizations,
@@ -9614,7 +9619,7 @@ async function executePaperclipNativeSessionWithinScope(
...resolveNativeBilling(input.execution.provider, input.runnerEnvironment, input.billingIdentity),
model: input.execution.provider.model,
usage: normalizeNativeUsage(native.usage, { inputIncludesCacheReads: input.execution.provider.kind === "codex" }),
costUsd: input.execution.provider.kind !== "openai_dot" && normalizeNativeUsage(native.usage, { inputIncludesCacheReads: input.execution.provider.kind === "codex" })
costUsd: input.execution.schema !== "paperclip.native-execution-input.v6" && normalizeNativeUsage(native.usage, { inputIncludesCacheReads: input.execution.provider.kind === "codex" })
? nativeUsageCostUsd(native.usage, input.execution.provider) ?? null : null,
costUsdExact: typeof native.usage?.accountingCostUsdExact === "string" ? native.usage.accountingCostUsdExact : undefined,
costStatus: nativeAccountingComplete && native.usage?.accountingCostIncomplete !== true
@@ -9784,6 +9789,7 @@ export function nativeUsageCostUsd(
/** Billing identity follows the selected runtime and resolved credentials.
* Unknown authentication remains unknown; a provider name does not prove billing mode. */
export function resolveNativeBilling(provider: NativeExecutionInput["provider"], env: NodeJS.ProcessEnv = {}, identity?: Pick<AdapterExecutionResult, "provider" | "biller" | "billingType">): Pick<AdapterExecutionResult, "provider" | "biller" | "billingType"> {
if (provider.kind === "grok_bot") return { provider: "grok_bot", biller: "unknown", billingType: "unknown" };
if (identity) return identity.provider === "openai" && identity.billingType === "metered_api"
? { ...identity, biller: inferOpenAiCompatibleBiller(env, identity.biller ?? "unknown") }
: identity;
@@ -10152,7 +10158,7 @@ function readRemoteProviderPackIdentity(packRoot: string, verifyControllerFiles:
export function assertRemoteRunnerBuildMetadata(
value: unknown,
requiredMode: "dial_wss" | "listen_ws",
requiredExternalProvider?: "openai_dot_mcp",
requiredExternalProvider?: "openai_dot_mcp" | "grok_bot_mcp",
): void {
if (!value || typeof value !== "object" || Array.isArray(value)) {
throw new Error("runner_remote_artifact_metadata_invalid");
@@ -11253,7 +11259,7 @@ async function prepareRemoteDotRunner({ input, target, runner, root, identity }:
}
const metadata = await runner.execute({ command: remoteBinary, args: ["--build-metadata"], bypassSession: true, timeoutMs: 30000 });
if (metadata.exitCode !== 0 || metadata.timedOut) throw new Error("runner_remote_artifact_verification_failed");
assertRemoteRunnerBuildMetadata(JSON.parse(metadata.stdout), requiredMode, "openai_dot_mcp");
assertRemoteRunnerBuildMetadata(JSON.parse(metadata.stdout), requiredMode, input.execution.provider.kind === "grok_bot" ? "grok_bot_mcp" : "openai_dot_mcp");
const checksum = await runner.execute({ command: "sh", args: ["-c",
'if command -v sha256sum >/dev/null 2>&1; then sha256sum "$1"; else shasum -a 256 "$1"; fi', "paperclip-dot-artifact", remoteBinary],
bypassSession: true, timeoutMs: 10000 });
@@ -11386,7 +11392,7 @@ async function createRunnerdBackendWithinSessionClaim(
// Remote Codex already sends dynamic tool calls over authenticated PRP. Keep
// the assigned gateway on the control plane instead of asking the sandbox to
// reach the host's HTTP origin (which may be private or loopback-only).
const relayAssignedMcp = (remoteTarget !== null && input.execution.provider.kind === "codex") || input.execution.provider.kind === "openai_dot";
const relayAssignedMcp = (remoteTarget !== null && input.execution.provider.kind === "codex") || input.execution.schema === "paperclip.native-execution-input.v6";
const assignedMcpUrl = input.runnerEnvironment?.PAPERCLIP_NATIVE_MCP_URL;
const assignedMcpToken = input.runnerEnvironment?.PAPERCLIP_NATIVE_MCP_TOKEN;
const assignedMcpName = input.runnerEnvironment?.PAPERCLIP_NATIVE_MCP_NAME;
@@ -11408,7 +11414,7 @@ async function createRunnerdBackendWithinSessionClaim(
workMode: input.execution.task.workMode,
})
: undefined;
const dotAttachmentActor = input.execution.provider.kind === "openai_dot"
const dotAttachmentActor = input.execution.schema === "paperclip.native-execution-input.v6"
? await input.db.select({ config: agents.adapterConfig }).from(agents).where(and(
eq(agents.id, input.execution.binding.agentId), eq(agents.companyId, input.execution.binding.companyId),
)).limit(1).then(rows => rows[0]) : undefined;
@@ -11427,10 +11433,11 @@ async function createRunnerdBackendWithinSessionClaim(
: undefined,
workMode: input.execution.task.workMode,
runtimeContext: "runtimeContext" in input.execution ? input.execution.runtimeContext : undefined,
workspaceBridge: input.execution.provider.kind === "openai_dot" && !!input.dotWorkspaceRoot,
workspaceBridge: input.execution.schema === "paperclip.native-execution-input.v6" && !!input.dotWorkspaceRoot,
taskAttachmentRead: dotAttachmentActor?.config.dotAttachmentAccess === true,
dotRuntime: input.execution.provider.kind === "openai_dot",
assertBridgeAuthority: input.execution.provider.kind === "openai_dot" ? () => dotRunnerBroker(input.db).assertRunAuthority(input.execution as import("../../vendor/paperclip-runner/index.js").NativeExecutionInputV6) : undefined,
dotRuntime: input.execution.schema === "paperclip.native-execution-input.v6",
firstPartyOnly: input.execution.provider.kind === "grok_bot",
assertBridgeAuthority: input.execution.schema === "paperclip.native-execution-input.v6" ? () => dotRunnerBroker(input.db).assertRunAuthority(input.execution as import("../../vendor/paperclip-runner/index.js").NativeExecutionInputV6) : undefined,
workspaceRoot: input.dotWorkspaceRoot ?? remoteTarget?.remoteCwd ?? input.execution.workspace.cwd ?? undefined,
executionTargetKind: target.kind,
readRemoteWorkspaceFile: remoteTarget && remoteCommandRunner
@@ -1,15 +1,19 @@
import { logActivity } from "../activity-log.js";
import { issueRecoveryActionService } from "../issue-recovery-actions.js";
import { nativeSha256 } from "./canonical.js";
import { NATIVE_WORKSPACE_OWNER_UNVERIFIED } from "./native-workspace-finalization-recovery-state.js";
import { randomUUID } from "node:crypto";
import os from "node:os";
import { and, eq, sql } from "drizzle-orm";
import { heartbeatRuns, workspaceOperations, withDedicatedDbConnection, type Db } from "@paperclipai/db";
import { and, eq, inArray, sql } from "drizzle-orm";
import { heartbeatRuns, issues, issueRecoveryActions, nativeRunFinalizations, workspaceOperations, withDedicatedDbConnection, type Db } from "@paperclipai/db";
import { currentNativeWorkspaceRecoveryResult } from "./native-workspace-recovery-binding.js";
import {
currentNativeControllerIdentity,
evaluateNativeControllerTakeover,
} from "./native-restart-recovery.js";
import { currentWorkspaceWorker, requestWorkspaceWorkerStopProof, type WorkspaceWorker, type WorkspaceWorkerStopProof } from "./native-workspace-platform-proof.js";
const OWNER_KEY = "nativeWorkspaceFinalizationOwner";
const activeOwnerTokens = new Set<string>();
// Positive same-boot evidence, retained only when durable cleanup could not be
@@ -21,6 +25,7 @@ type Owner = {
hostname: string;
pid: number;
processStartedAt: string;
worker?: WorkspaceWorker;
};
export type NativeWorkspaceFinalizationOwnership = {
token: string;
@@ -58,7 +63,7 @@ export async function withNativeWorkspaceFinalizationOwnership<T>(
const identity = await currentNativeControllerIdentity();
const owner: Owner = {
token: randomUUID(), controllerBootId: identity.bootId, hostname: os.hostname(), pid: identity.pid,
processStartedAt: identity.processStartedAt.toISOString(),
processStartedAt: identity.processStartedAt.toISOString(), worker: currentWorkspaceWorker(),
};
const scope = and(eq(heartbeatRuns.id, input.runId), eq(heartbeatRuns.companyId, input.companyId), eq(heartbeatRuns.runtimeMode, "native"));
const owned = and(scope, sql`${heartbeatRuns.runnerProfileJson}->${OWNER_KEY}->>'token' = ${owner.token}`);
@@ -73,13 +78,42 @@ export async function withNativeWorkspaceFinalizationOwnership<T>(
if (!run) throw new Error("native_workspace_finalization_binding_missing");
const rawPrior = run.profile?.[OWNER_KEY];
const prior = readOwner(rawPrior);
let automaticResultId: string | undefined;
const currentRecoveryResult = async (db: Db) => {
if (!run.issueId) return null;
// Use the arbiter's lock order and release row locks before copyback I/O.
const [coordinator] = await db.select().from(nativeRunFinalizations).where(and(
eq(nativeRunFinalizations.companyId, input.companyId), eq(nativeRunFinalizations.runId, input.runId),
eq(nativeRunFinalizations.issueId, run.issueId),
)).for("update");
const [issue] = await db.select().from(issues).where(and(
eq(issues.companyId, input.companyId), eq(issues.id, run.issueId),
)).for("update");
const [currentRun] = await db.select().from(heartbeatRuns).where(scope).for("update");
return coordinator && issue && currentRun ? currentNativeWorkspaceRecoveryResult(db, {
companyId: input.companyId, coordinator, issue, run: currentRun,
}) : null;
};
let platformProof: WorkspaceWorkerStopProof | null = null;
if (rawPrior != null) {
const requireStopVerification = async () => {
const fingerprint = nativeSha256({ runId: input.runId, owner: rawPrior });
const nextAction = "Verify the previous controller and its workspace-copyback processes have stopped, then release only this exact workspace owner receipt using doc/native-workspace-finalization-recovery.md. Resume workspace finalization without another provider turn.";
const recovery = issueRecoveryActionService(input.db);
const existing = run.issueId ? await recovery.getActiveForIssue(input.companyId, run.issueId) : null;
// Rechecking the same physical owner is not a new recovery attempt.
if (existing?.cause === NATIVE_WORKSPACE_OWNER_UNVERIFIED && existing.fingerprint === fingerprint) {
if (!existing.evidence?.workspaceOwnerStop) {
await input.db.update(issueRecoveryActions).set({ nextAction, updatedAt: new Date(),
evidence: sql`jsonb_set(coalesce(${issueRecoveryActions.evidence}, '{}'::jsonb), '{workspaceOwnerRecovery}', '{"state":"operator_required"}'::jsonb)`,
}).where(and(
eq(issueRecoveryActions.companyId, input.companyId), eq(issueRecoveryActions.id, existing.id),
eq(issueRecoveryActions.fingerprint, fingerprint), eq(issueRecoveryActions.cause, NATIVE_WORKSPACE_OWNER_UNVERIFIED),
inArray(issueRecoveryActions.status, ["active", "escalated"]),
sql`${issueRecoveryActions.evidence}->'workspaceOwnerStop' is null`,
sql`(${issueRecoveryActions.nextAction} is distinct from ${nextAction} or ${issueRecoveryActions.evidence}#>>'{workspaceOwnerRecovery,state}' is distinct from 'operator_required')`,
));
}
return { acquired: false } as const;
}
if (run.issueId) await recovery.upsertSourceScoped({
@@ -87,18 +121,21 @@ export async function withNativeWorkspaceFinalizationOwnership<T>(
kind: "active_run_watchdog", ownerType: "board", returnOwnerAgentId: run.agentId,
cause: NATIVE_WORKSPACE_OWNER_UNVERIFIED,
fingerprint,
evidence: { runId: input.runId, owner: rawPrior },
nextAction: "Verify the previous controller and its workspace-copyback processes have stopped, then release only this exact workspace owner receipt using doc/native-workspace-finalization-recovery.md. Resume workspace finalization without another provider turn.",
evidence: { runId: input.runId, owner: rawPrior, workspaceOwnerRecovery: { state: "operator_required" } },
nextAction,
wakePolicy: null, maxAttempts: 1, supersedeOnIdentityChange: true,
});
return { acquired: false } as const;
};
// A foreign host or malformed receipt cannot prove the old physical owner stopped.
if (!prior || prior.hostname !== owner.hostname) return requireStopVerification();
// The harness checks the exact owned deployment, including orphaned children.
// A live deployment, unsigned answer or older receipt stays fenced.
if (prior?.worker) platformProof = await requestWorkspaceWorkerStopProof({ runId: input.runId, ownerToken: prior.token, worker: prior.worker });
if (!prior) return requireStopVerification();
if (!platformProof && prior.hostname !== owner.hostname) return requireStopVerification();
const knownJoinedHere = prior.controllerBootId === identity.bootId
&& prior.pid === identity.pid && joinedOwnerTokens.has(prior.token)
&& !activeOwnerTokens.has(prior.token);
if (!knownJoinedHere) {
if (!platformProof && !knownJoinedHere) {
const takeover = await evaluateNativeControllerTakeover({
owner: { leaseOwner: prior.token, leaseExpiresAt: new Date(0), controllerPid: prior.pid,
controllerProcessStartedAt: new Date(prior.processStartedAt) }, now: new Date(),
@@ -119,15 +156,32 @@ export async function withNativeWorkspaceFinalizationOwnership<T>(
}
// Claim before the potentially ambiguous write so finally can remove
// only our token even if the server committed but its response was lost.
activeOwnerTokens.add(owner.token);
claimed = true;
const [claimedRun] = await input.db.update(heartbeatRuns).set({
runnerProfileJson: sql`jsonb_set(coalesce(${heartbeatRuns.runnerProfileJson}, '{}'::jsonb), array[${OWNER_KEY}], ${JSON.stringify(owner)}::jsonb)`,
}).where(and(scope, prior
? sql`${heartbeatRuns.runnerProfileJson}->${OWNER_KEY}->>'token' = ${prior.token}`
: sql`${heartbeatRuns.runnerProfileJson}->>${OWNER_KEY} is null`
)).returning({ id: heartbeatRuns.id });
const claimOwner = async (db: Db) => {
activeOwnerTokens.add(owner.token);
claimed = true;
const [claimedRun] = await db.update(heartbeatRuns).set({
runnerProfileJson: sql`jsonb_set(coalesce(${heartbeatRuns.runnerProfileJson}, '{}'::jsonb), array[${OWNER_KEY}], ${JSON.stringify(owner)}::jsonb) || ${JSON.stringify(platformProof ? { nativeWorkspaceOwnerStopProof: platformProof } : {})}::jsonb`,
}).where(and(scope, prior
? sql`${heartbeatRuns.runnerProfileJson}->${OWNER_KEY} = ${JSON.stringify(rawPrior)}::jsonb`
: sql`${heartbeatRuns.runnerProfileJson}->>${OWNER_KEY} is null`
)).returning({ id: heartbeatRuns.id });
return claimedRun;
};
// A stop receipt proves the old worker exited, not that its task is still
// current. Revalidate after the provider read and atomically with the CAS.
const claimedRun = platformProof ? await input.db.transaction(async tx => {
const result = await currentRecoveryResult(tx as unknown as Db);
if (!result) return null;
const claimedRun = await claimOwner(tx as unknown as Db);
if (claimedRun) automaticResultId = result.id;
return claimedRun;
}) : await claimOwner(input.db);
if (!claimedRun) return { acquired: false } as const;
if (platformProof && run.issueId) await logActivity(input.db, {
companyId: input.companyId, actorType: "system", actorId: "workspace-recovery",
action: "issue.workspace_owner_stop_verified", entityType: "issue", entityId: run.issueId,
runId: input.runId, details: { proof: platformProof },
});
if (prior) joinedOwnerTokens.delete(prior.token);
const ownership: NativeWorkspaceFinalizationOwnership = {
token: owner.token,
@@ -137,12 +191,25 @@ export async function withNativeWorkspaceFinalizationOwnership<T>(
await lock.execute(sql`select 1`);
const [current] = await input.db.select({ id: heartbeatRuns.id }).from(heartbeatRuns).where(owned).limit(1);
if (!current) throw new Error("owner_replaced");
if (automaticResultId) {
const result = await input.db.transaction(tx => currentRecoveryResult(tx as unknown as Db));
if (result?.id !== automaticResultId) throw new Error("task_replaced");
}
} catch (cause) {
throw new NativeWorkspaceFinalizationOwnershipLostError({ cause });
}
},
};
await ownership.assertHeld();
if (platformProof && run.issueId) await input.db.update(issueRecoveryActions).set({
evidence: sql`jsonb_set(coalesce(${issueRecoveryActions.evidence}, '{}'::jsonb), '{workspaceOwnerRecovery}', '{"state":"resuming"}'::jsonb)`,
updatedAt: new Date(),
}).where(and(
eq(issueRecoveryActions.companyId, input.companyId), eq(issueRecoveryActions.sourceIssueId, run.issueId),
eq(issueRecoveryActions.cause, NATIVE_WORKSPACE_OWNER_UNVERIFIED),
eq(issueRecoveryActions.fingerprint, nativeSha256({ runId: input.runId, owner: rawPrior })),
inArray(issueRecoveryActions.status, ["active", "escalated"]),
));
actionPromise = action(ownership);
const value = await actionPromise;
await ownership.assertHeld();
@@ -1,3 +1,8 @@
import * as platformProof from "./native-workspace-platform-proof.js";
import { currentNativeControllerIdentity } from "./native-restart-recovery.js";
import { nativeSha256 } from "./canonical.js";
import { resumeNativeWorkspaceAfterOwnerStop } from "./native-workspace-owner-recovery.js";
import { issueRecoveryActionService } from "../issue-recovery-actions.js";
import { spawn } from "node:child_process";
import { once } from "node:events";
import { readProcessStartedAt } from "../hot-restart.js";
@@ -9,6 +14,8 @@ import path from "node:path";
import { afterAll, beforeAll, describe, expect, it, vi } from "vitest";
import { and, desc, eq, sql } from "drizzle-orm";
import {
activityLog,
agentWakeupRequests,
agents,
companies,
completionContracts,
@@ -19,11 +26,15 @@ import {
nativeRunFinalizations,
nativeRunResults,
issueRecoveryActions,
issueThreadInteractions,
projects,
workspaceOperations,
workAssessments,
statusDecisions,
} from "@paperclipai/db";
import { startEmbeddedPostgresTestDatabase } from "../../__tests__/helpers/embedded-postgres.js";
import { resumeNativeWorkspaceFinalization } from "./native-workspace-finalizer.js";
import { reconcileNativeFinalizations } from "./native-finalization-reconciler.js";
describe("native workspace finalization recovery", () => {
let temporary: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>>;
@@ -159,6 +170,225 @@ describe("native workspace finalization recovery", () => {
}
});
async function seedAbandonedCopyback() {
const seeded = await seedRun({ executionWorkspaceId: randomUUID(), title: "Accepted yield, abandoned copyback" });
const owner = { token: randomUUID(), hostname: "replaced-controller", pid: 2,
processStartedAt: "2026-10-10T00:02:55.560Z", controllerBootId: randomUUID() };
await db.update(heartbeatRuns).set({ status: "running", nativePhase: "workspace_finalizing",
runnerProfileJson: { nativeWorkspaceFinalizationOwner: owner, preserved: "workspace descriptor" },
}).where(eq(heartbeatRuns.id, seeded.runId));
await db.update(issues).set({ executionRunId: seeded.runId }).where(eq(issues.id, seeded.issueId));
await db.update(nativeRunFinalizations).set({ phase: "workspace_finalizing" }).where(eq(nativeRunFinalizations.runId, seeded.runId));
const recovery = issueRecoveryActionService(db);
const hold = { companyId, sourceIssueId: seeded.issueId, kind: "active_run_watchdog" as const,
ownerType: "board" as const, cause: "native_workspace_finalization_owner_unverified",
fingerprint: nativeSha256({ runId: seeded.runId, owner }), evidence: { runId: seeded.runId, owner }, maxAttempts: 1,
nextAction: "Verify the previous controller and its workspace-copyback processes have stopped. Resume workspace finalization without another provider turn." };
const action = await recovery.upsertSourceScoped(hold);
await recovery.upsertSourceScoped(hold);
return { ...seeded, owner, actionId: action.id, companyId, db, ownerToken: owner.token,
actorId: "operator", controllerAndCopybackStopped: true as const,
stopEvidence: "Deployment platform confirms the exact old container and all its processes exited." };
}
it("retains copyback-specific instructions after repeated recovery sweeps", async () => {
const seed = await seedAbandonedCopyback();
const [action] = await db.select().from(issueRecoveryActions).where(eq(issueRecoveryActions.id, seed.actionId));
expect(action.status).toBe("escalated");
expect(action.nextAction).toContain("workspace-copyback processes have stopped");
expect(action.nextAction).not.toContain("replacement run");
await db.update(issueRecoveryActions).set({ nextAction: "Start a replacement run" }).where(eq(issueRecoveryActions.id, seed.actionId));
await withNativeWorkspaceFinalizationOwnership(seed, async () => "unexpected");
const [repaired] = await db.select().from(issueRecoveryActions).where(eq(issueRecoveryActions.id, seed.actionId));
expect(repaired.nextAction).toContain("workspace-copyback processes have stopped");
expect(repaired.attemptCount).toBe(action.attemptCount);
expect(repaired.evidence?.recoveryBudget).toEqual(action.evidence?.recoveryBudget);
});
it("releases only the verified owner and resumes the saved result without changing its disposition", async () => {
const seed = await seedAbandonedCopyback();
const [before] = await db.select().from(nativeRunFinalizations).where(eq(nativeRunFinalizations.runId, seed.runId));
expect(await withNativeWorkspaceFinalizationOwnership(seed, async () => "unexpected")).toEqual({ acquired: false });
const receipt = await resumeNativeWorkspaceAfterOwnerStop(seed);
expect(receipt).toEqual({ runId: seed.runId, resultId: before.resultId, status: "queued" });
const [after] = await db.select().from(nativeRunFinalizations).where(eq(nativeRunFinalizations.runId, seed.runId));
expect(after).toEqual(before);
const [run] = await db.select().from(heartbeatRuns).where(eq(heartbeatRuns.id, seed.runId));
expect(run.runnerProfileJson).toEqual({ preserved: "workspace descriptor" });
expect(run.status).toBe("running");
const [action] = await db.select().from(issueRecoveryActions).where(eq(issueRecoveryActions.id, seed.actionId));
expect(action.evidence?.workspaceOwnerStop).toMatchObject({ owner: seed.owner, actorId: "operator", resultId: before.resultId, stopEvidence: seed.stopEvidence });
expect(action.status).toBe("escalated"); // Only commitment resolves the hold.
expect(action.wakePolicy).toBeNull();
expect(await db.select().from(activityLog).where(eq(activityLog.runId, seed.runId))).toHaveLength(1);
const file = path.join(workspaceRoot, seed.runId);
expect(await withNativeWorkspaceFinalizationOwnership(seed, async () => {
await fs.writeFile(file, "accepted work");
return "resumed";
})).toEqual({ acquired: true, value: "resumed" });
expect(await fs.readFile(file, "utf8")).toBe("accepted work");
// A duplicate confirmation neither clears a newer owner nor writes another audit.
await expect(resumeNativeWorkspaceAfterOwnerStop(seed)).resolves.toEqual(receipt);
expect(await db.select().from(activityLog).where(eq(activityLog.runId, seed.runId))).toHaveLength(1);
});
it.each(["expired", "live", "unknown"] as const)("checks the coordinator lease before verified recovery: %s", async state => {
const seed = await seedAbandonedCopyback();
const leaseExpiresAt = state === "unknown" ? null : new Date(Date.now() + (state === "expired" ? -60_000 : 60_000));
await db.update(nativeRunFinalizations).set({ leaseOwner: "previous-reconciler", leaseExpiresAt }).where(eq(nativeRunFinalizations.runId, seed.runId));
if (state === "expired") {
await expect(resumeNativeWorkspaceAfterOwnerStop(seed)).resolves.toMatchObject({ status: "queued" });
expect((await db.select().from(nativeRunFinalizations).where(eq(nativeRunFinalizations.runId, seed.runId)))[0].leaseOwner).toBe("previous-reconciler");
} else {
await expect(resumeNativeWorkspaceAfterOwnerStop(seed)).rejects.toMatchObject({ status: 409 });
expect((await db.select().from(heartbeatRuns).where(eq(heartbeatRuns.id, seed.runId)))[0].runnerProfileJson?.nativeWorkspaceFinalizationOwner).toEqual(seed.owner);
}
});
it.each(["pending", "resolved", "superseded", "unassigned"] as const)("requires authoritative reviewer recovery: %s", async state => {
const seed = await seedAbandonedCopyback();
const reviewerId = randomUUID(), sourceRunId = randomUUID(), sourceResultId = randomUUID(), assessmentId = randomUUID();
const decisionId = randomUUID(), interactionId = randomUUID();
await db.insert(agents).values({ id: reviewerId, companyId, name: "Review agent", adapterType: "paperclip_runner", status: "running" });
const [actingRun] = await db.select().from(heartbeatRuns).where(eq(heartbeatRuns.id, seed.runId));
await db.insert(heartbeatRuns).values({ id: sourceRunId, companyId, agentId, nativeIssueId: seed.issueId, runtimeMode: "native", status: "succeeded", completionContractId: actingRun.completionContractId, createdAt: new Date(actingRun.createdAt.getTime() - 60_000) });
await db.insert(nativeRunResults).values({ id: sourceResultId, companyId, issueId: seed.issueId, runId: sourceRunId, completionContractId: actingRun.completionContractId!, serverFingerprint: sourceResultId, schemaStatus: "accepted", resultJson: {}, canonicalSha256: sourceResultId });
await db.insert(workAssessments).values({ id: assessmentId, companyId, issueId: seed.issueId, runId: sourceRunId, contractId: actingRun.completionContractId!, resultId: sourceResultId, triggerKind: "native_result", triggerActorCompanyId: companyId, priorIssueStatus: "in_progress", priorStatusVersion: 0, policyVersion: "fixture", assessmentJson: {}, inputDigest: assessmentId });
const [issue] = await db.update(issues).set({ status: "in_review" }).where(eq(issues.id, seed.issueId)).returning();
await db.insert(statusDecisions).values({ id: decisionId, companyId, issueId: seed.issueId, runId: sourceRunId, assessmentId, decisionVersion: 1, policyVersion: "fixture", fromStatus: "in_progress", toStatus: "in_review", reasonCode: "explicit_review", decisionJson: { projectedStatusVersion: issue.statusVersion }, decisionDigest: decisionId, applicationState: "applied" });
await db.update(issues).set({ lastStatusDecisionId: state === "superseded" ? null : decisionId }).where(eq(issues.id, seed.issueId));
await db.insert(issueThreadInteractions).values({ id: interactionId, companyId, issueId: seed.issueId, kind: "request_confirmation", sourceRunId, addresseeAgentId: state === "unassigned" ? agentId : reviewerId, createdByAgentId: agentId, continuationPolicy: "wake_assignee", status: state === "resolved" ? "accepted" : "pending", ...(state === "resolved" ? { resolvedByRunId: seed.runId, resolvedByAgentId: reviewerId } : {}), payload: { version: 1, prompt: "Review the completed work.", target: { type: "custom", key: "native_completion_review", revisionId: decisionId } } });
await db.update(heartbeatRuns).set({ agentId: reviewerId, contextSnapshot: { issueId: seed.issueId, nativeReviewInteractionId: interactionId, nativeReviewDecisionId: decisionId } }).where(eq(heartbeatRuns.id, seed.runId));
if (state === "pending" || state === "resolved") {
await expect(resumeNativeWorkspaceAfterOwnerStop(seed)).resolves.toMatchObject({ status: "queued" });
expect((await db.select().from(issues).where(eq(issues.id, seed.issueId)))[0].assigneeAgentId).toBe(agentId);
} else {
await expect(resumeNativeWorkspaceAfterOwnerStop(seed)).rejects.toMatchObject({ status: 409 });
}
});
it.each(([
["workspace_finalizing", "monitor"], ["workspace_finalizing", "approval"],
["retryable_failure", "monitor"], ["retryable_failure", "approval"],
["result_accepted", "monitor"], ["result_accepted", "approval"],
] as const).flatMap(([phase, wait]) => (["operator", "automatic"] as const).map(mode => [phase, wait, mode] as const)))("commits the saved yield from %s preserving its %s wait via %s recovery", async (phase, wait, mode) => {
const seed = await seedAbandonedCopyback();
await db.update(nativeRunFinalizations).set({ phase,
...(phase === "retryable_failure" ? { attempt: 1, nextAttemptAt: new Date(Date.now() - 60_000),
failureCode: "native_workspace_sync_out_failed" } : {}),
}).where(eq(nativeRunFinalizations.runId, seed.runId));
await db.update(heartbeatRuns).set({ nativePhase: phase }).where(eq(heartbeatRuns.id, seed.runId));
const workspaceId = randomUUID();
const cwd = path.join(workspaceRoot, seed.runId);
await fs.mkdir(cwd);
await fs.writeFile(path.join(cwd, "saved.txt"), "accepted work");
await db.insert(executionWorkspaces).values({ id: workspaceId, companyId, projectId,
mode: "local", strategyType: "local_directory", name: "Retained workspace", cwd });
const nextCheckAt = new Date(Date.now() - 60_000); // Already due, like the staging runs.
const policy = { monitor: { nextCheckAt: nextCheckAt.toISOString(), notes: "Check the existing PR." } };
await db.update(issues).set({ executionWorkspaceId: workspaceId,
...(wait === "monitor" ? { executionPolicy: policy, monitorNextCheckAt: nextCheckAt } : {}),
}).where(eq(issues.id, seed.issueId));
const interactionId = randomUUID();
if (wait === "approval") await db.insert(issueThreadInteractions).values({
id: interactionId, companyId, issueId: seed.issueId, sourceRunId: seed.runId,
createdByAgentId: agentId, kind: "request_confirmation", status: "pending",
payload: { version: 1, prompt: "Approve the plan?", target: { type: "issue_document",
issueId: seed.issueId, key: "plan", revisionId: randomUUID() } },
});
const resultJson = {
result: { schema: "paperclip.run_result.v1", reportedWorkDisposition: "yielded",
summary: "Wait for the existing continuation.",
completionClaim: { contractRevision: "test", objectiveSatisfied: false, criteria: [],
remainingWork: [{ description: "Wait for the check or plan confirmation.", blocksCompletion: true }] },
continuation: { kind: wait === "monitor" ? "monitor" : "response_wake",
summary: "Wait for the existing continuation.", idempotencyKey: "saved-wait" },
evidence: [], verification: [], attentionRequests: [], artifacts: [] },
terminal: { schema: "paperclip.prp.terminal.v1", turnTerminalState: "completed",
runTerminalState: "succeeded", reportedWorkDisposition: "yielded" },
};
await db.update(nativeRunResults).set({ resultJson }).where(eq(nativeRunResults.runId, seed.runId));
const [accepted] = await db.select().from(nativeRunResults).where(eq(nativeRunResults.runId, seed.runId));
// The real reconciler must remain fenced before operator confirmation.
await reconcileNativeFinalizations(db, [seed.runId]);
expect((await db.select().from(nativeRunFinalizations).where(eq(nativeRunFinalizations.runId, seed.runId)))[0].phase).toBe(phase);
const [beforeConfirmation] = await db.select().from(nativeRunFinalizations).where(eq(nativeRunFinalizations.runId, seed.runId));
if (mode === "operator") {
await resumeNativeWorkspaceAfterOwnerStop(seed);
expect((await db.select().from(nativeRunFinalizations).where(eq(nativeRunFinalizations.runId, seed.runId)))[0]).toEqual(beforeConfirmation);
await reconcileNativeFinalizations(db, [seed.runId]);
} else {
const worker = { provider: "railway" as const, projectId: "p", environmentId: "e", serviceId: "s", deploymentId: "d" };
const [run] = await db.select().from(heartbeatRuns).where(eq(heartbeatRuns.id, seed.runId));
await db.update(heartbeatRuns).set({ runnerProfileJson: { ...run.runnerProfileJson, nativeWorkspaceFinalizationOwner: { ...seed.owner, worker } } }).where(eq(heartbeatRuns.id, seed.runId));
const probe = vi.spyOn(platformProof, "requestWorkspaceWorkerStopProof").mockResolvedValue({ v: 1, iss: "paperclip-cloud", aud: "paperclip-workspace-worker-stop/v1", sub: "stack", runId: seed.runId, ownerToken: seed.owner.token, worker, stopped: true, iat: 1, exp: 61 });
try { await reconcileNativeFinalizations(db, [seed.runId]); } finally { probe.mockRestore(); }
}
expect((await db.select().from(nativeRunFinalizations).where(eq(nativeRunFinalizations.runId, seed.runId)))[0]).toMatchObject({ phase: "committed", resultId: accepted.id });
expect((await db.select().from(heartbeatRuns).where(eq(heartbeatRuns.id, seed.runId)))[0]).toMatchObject({ status: "succeeded", nativePhase: "committed" });
expect(await db.select().from(nativeRunResults).where(eq(nativeRunResults.runId, seed.runId))).toEqual([accepted]);
expect((await db.select().from(issueRecoveryActions).where(eq(issueRecoveryActions.id, seed.actionId)))[0].status).toBe("resolved");
const [issue] = await db.select().from(issues).where(eq(issues.id, seed.issueId));
expect(issue.status).toBe(wait === "monitor" ? "in_progress" : "in_review");
if (wait === "monitor") {
expect(issue.monitorNextCheckAt).toEqual(nextCheckAt);
expect(issue.executionPolicy).toEqual(policy);
} else {
expect((await db.select().from(issueThreadInteractions).where(eq(issueThreadInteractions.id, interactionId)))[0].status).toBe("pending");
}
expect(await db.select().from(heartbeatRuns).where(eq(heartbeatRuns.nativeIssueId, seed.issueId))).toHaveLength(1);
expect(await db.select().from(agentWakeupRequests).where(sql`${agentWakeupRequests.payload}->>'issueId' = ${seed.issueId}`)).toHaveLength(0);
expect(await db.select().from(workspaceOperations).where(and(eq(workspaceOperations.heartbeatRunId, seed.runId),
eq(workspaceOperations.phase, "workspace_finalize"), eq(workspaceOperations.status, "succeeded")))).toHaveLength(1);
expect(await fs.readFile(path.join(cwd, "saved.txt"), "utf8")).toBe("accepted work");
});
it.each(["token", "company", "result", "newer owner", "missing confirmation", "malformed owner"])("rejects stale or unverified copyback recovery: %s", async changed => {
const seed = await seedAbandonedCopyback();
if (changed === "malformed owner") {
const owner = { token: seed.ownerToken };
await db.update(heartbeatRuns).set({ runnerProfileJson: { nativeWorkspaceFinalizationOwner: owner } }).where(eq(heartbeatRuns.id, seed.runId));
await db.update(issueRecoveryActions).set({ evidence: { runId: seed.runId, owner } }).where(eq(issueRecoveryActions.id, seed.actionId));
}
if (changed === "token") seed.ownerToken = randomUUID();
if (changed === "company") seed.companyId = foreignCompanyId;
if (changed === "result") await db.update(nativeRunFinalizations).set({ resultId: null }).where(eq(nativeRunFinalizations.runId, seed.runId));
if (changed === "newer owner") await db.update(heartbeatRuns).set({ runnerProfileJson: { nativeWorkspaceFinalizationOwner: { ...seed.owner, token: randomUUID() } } }).where(eq(heartbeatRuns.id, seed.runId));
if (changed === "missing confirmation") Object.assign(seed, { controllerAndCopybackStopped: false });
await expect(resumeNativeWorkspaceAfterOwnerStop(seed)).rejects.toThrow();
const [run] = await db.select().from(heartbeatRuns).where(eq(heartbeatRuns.id, seed.runId));
expect(run.runnerProfileJson?.nativeWorkspaceFinalizationOwner).toBeDefined();
expect(await db.select().from(activityLog).where(eq(activityLog.runId, seed.runId))).toHaveLength(0);
});
it("rejects a locally live owner even after its advisory connection is gone", async () => {
const seed = await seedAbandonedCopyback();
const identity = await currentNativeControllerIdentity();
const owner = { ...seed.owner, hostname: os.hostname(), pid: identity.pid,
processStartedAt: identity.processStartedAt.toISOString(), controllerBootId: identity.bootId };
await db.update(heartbeatRuns).set({ runnerProfileJson: { nativeWorkspaceFinalizationOwner: owner } }).where(eq(heartbeatRuns.id, seed.runId));
await db.update(issueRecoveryActions).set({ evidence: { runId: seed.runId, owner } }).where(eq(issueRecoveryActions.id, seed.actionId));
await expect(resumeNativeWorkspaceAfterOwnerStop(seed)).rejects.toThrow("still active");
const [run] = await db.select().from(heartbeatRuns).where(eq(heartbeatRuns.id, seed.runId));
expect(run.runnerProfileJson?.nativeWorkspaceFinalizationOwner).toEqual(owner);
});
it("does not reopen an old result after a newer task execution exists", async () => {
const seed = await seedAbandonedCopyback();
await db.insert(heartbeatRuns).values({ companyId, agentId, status: "queued", nativeIssueId: seed.issueId });
await expect(resumeNativeWorkspaceAfterOwnerStop(seed)).rejects.toThrow("no longer current");
});
it("rejects stop confirmation while another controller holds the physical lock", async () => {
const seed = await seedAbandonedCopyback();
await db.transaction(async tx => {
await tx.execute(sql`select pg_advisory_xact_lock(hashtextextended(${`native-workspace-finalization:${companyId}:${seed.runId}`}, 0))`);
await expect(resumeNativeWorkspaceAfterOwnerStop(seed)).rejects.toThrow("still active");
});
});
it("reuses a successful export even if an old controller wrote a later failed barrier", async () => {
const seeded = await seedRun({ executionWorkspaceId: randomUUID(), title: "Late failed barrier" });
const [success] = await db.insert(workspaceOperations).values({ companyId, heartbeatRunId: seeded.runId,
@@ -311,6 +541,137 @@ describe("native workspace finalization recovery", () => {
.toEqual({ acquired: true, value: "recovered" });
});
it("automatically adopts a proven stopped deployment without another agent turn", async () => {
const seed = await seedAbandonedCopyback();
const worker = { provider: "railway" as const, projectId: "fixture-project", environmentId: "fixture-env", serviceId: "fixture-service", deploymentId: "fixture-old" };
const owner = { ...seed.owner, worker };
await db.update(heartbeatRuns).set({ runnerProfileJson: { nativeWorkspaceFinalizationOwner: owner, preserved: "workspace descriptor" } }).where(eq(heartbeatRuns.id, seed.runId));
await db.update(issueRecoveryActions).set({ fingerprint: nativeSha256({ runId: seed.runId, owner }), evidence: { runId: seed.runId, owner, preserved: "recovery evidence" } }).where(eq(issueRecoveryActions.id, seed.actionId));
const [before] = await db.select().from(nativeRunFinalizations).where(eq(nativeRunFinalizations.runId, seed.runId));
const proof = { v: 1 as const, iss: "paperclip-cloud" as const, aud: "paperclip-workspace-worker-stop/v1" as const, sub: "stack", runId: seed.runId, ownerToken: owner.token, worker, stopped: true as const, iat: 1, exp: 61 };
const probe = vi.spyOn(platformProof, "requestWorkspaceWorkerStopProof").mockResolvedValue(proof);
try {
const work = vi.fn(async () => {
const [recovery] = await db.select().from(issueRecoveryActions).where(eq(issueRecoveryActions.id, seed.actionId));
expect(recovery.evidence?.workspaceOwnerRecovery).toEqual({ state: "resuming" });
expect(recovery.evidence?.preserved).toBe("recovery evidence");
return "saved workspace copied";
});
expect(await withNativeWorkspaceFinalizationOwnership(seed, work)).toEqual({ acquired: true, value: "saved workspace copied" });
expect(probe).toHaveBeenCalledWith({ runId: seed.runId, ownerToken: owner.token, worker });
expect(work).toHaveBeenCalledOnce();
const [run] = await db.select().from(heartbeatRuns).where(eq(heartbeatRuns.id, seed.runId));
expect(run.runnerProfileJson?.nativeWorkspaceFinalizationOwner).toBeUndefined();
expect(run.runnerProfileJson?.nativeWorkspaceOwnerStopProof).toEqual(proof);
expect(run.runnerProfileJson?.preserved).toBe("workspace descriptor");
const [after] = await db.select().from(nativeRunFinalizations).where(eq(nativeRunFinalizations.runId, seed.runId));
expect(after).toEqual(before);
expect(await db.select().from(agentWakeupRequests).where(eq(agentWakeupRequests.companyId, companyId))).toHaveLength(0);
} finally { probe.mockRestore(); }
});
it("retains the fence for unknown platform evidence and owner changes during verification", async () => {
const seed = await seedAbandonedCopyback();
const worker = { provider: "railway" as const, projectId: "p", environmentId: "e", serviceId: "s", deploymentId: "d" };
const owner = { ...seed.owner, worker };
await db.update(heartbeatRuns).set({ runnerProfileJson: { nativeWorkspaceFinalizationOwner: owner } }).where(eq(heartbeatRuns.id, seed.runId));
const probe = vi.spyOn(platformProof, "requestWorkspaceWorkerStopProof").mockResolvedValue(null);
const work = vi.fn();
try {
expect(await withNativeWorkspaceFinalizationOwnership(seed, work)).toEqual({ acquired: false });
const [recovery] = await db.select().from(issueRecoveryActions).where(and(eq(issueRecoveryActions.sourceIssueId, seed.issueId), eq(issueRecoveryActions.fingerprint, nativeSha256({ runId: seed.runId, owner }))));
expect(recovery.evidence?.workspaceOwnerRecovery).toEqual({ state: "operator_required" });
const replacement = { ...owner, token: randomUUID() };
probe.mockImplementation(async () => {
await db.update(heartbeatRuns).set({ runnerProfileJson: { nativeWorkspaceFinalizationOwner: replacement } }).where(eq(heartbeatRuns.id, seed.runId));
return { v: 1, iss: "paperclip-cloud", aud: "paperclip-workspace-worker-stop/v1", sub: "stack", runId: seed.runId, ownerToken: owner.token, worker, stopped: true, iat: 1, exp: 61 };
});
expect(await withNativeWorkspaceFinalizationOwnership(seed, work)).toEqual({ acquired: false });
expect(work).not.toHaveBeenCalled();
const [run] = await db.select().from(heartbeatRuns).where(eq(heartbeatRuns.id, seed.runId));
expect(run.runnerProfileJson?.nativeWorkspaceFinalizationOwner).toEqual(replacement);
} finally { probe.mockRestore(); }
});
it.each(["done", "cancelled", "execution replaced", "checkout replaced", "newer run", "queued legacy run", "reassigned", "result unbound", "result rejected", "run stopped"])("does not copy saved files after the task moves on during platform verification: %s", async changed => {
const seed = await seedAbandonedCopyback();
const worker = { provider: "railway" as const, projectId: "p", environmentId: "e", serviceId: "s", deploymentId: "d" };
const owner = { ...seed.owner, worker };
await db.update(heartbeatRuns).set({ runnerProfileJson: { nativeWorkspaceFinalizationOwner: owner } }).where(eq(heartbeatRuns.id, seed.runId));
const file = path.join(workspaceRoot, `${seed.runId}.txt`);
await fs.writeFile(file, "new task work");
const probe = vi.spyOn(platformProof, "requestWorkspaceWorkerStopProof").mockImplementation(async () => {
if (changed === "done" || changed === "cancelled") await db.update(issues).set({ status: changed }).where(eq(issues.id, seed.issueId));
if (changed === "execution replaced") await db.update(issues).set({ executionRunId: null }).where(eq(issues.id, seed.issueId));
if (changed === "checkout replaced") {
const [other] = await db.insert(heartbeatRuns).values({ companyId, agentId, status: "succeeded" }).returning();
await db.update(issues).set({ checkoutRunId: other.id }).where(eq(issues.id, seed.issueId));
}
if (changed === "reassigned") await db.update(issues).set({ assigneeAgentId: null }).where(eq(issues.id, seed.issueId));
if (changed === "result unbound") await db.update(nativeRunFinalizations).set({ resultId: null }).where(eq(nativeRunFinalizations.runId, seed.runId));
if (changed === "result rejected") await db.update(nativeRunResults).set({ schemaStatus: "rejected" }).where(eq(nativeRunResults.runId, seed.runId));
if (changed === "run stopped") await db.update(heartbeatRuns).set({ status: "failed" }).where(eq(heartbeatRuns.id, seed.runId));
if (changed === "newer run" || changed === "queued legacy run") {
const [run] = await db.select().from(heartbeatRuns).where(eq(heartbeatRuns.id, seed.runId));
await db.insert(heartbeatRuns).values({ companyId, agentId,
runtimeMode: changed === "newer run" ? "native" : "legacy",
nativeIssueId: changed === "newer run" ? seed.issueId : null,
contextSnapshot: { issueId: seed.issueId }, status: changed === "newer run" ? "succeeded" : "queued",
createdAt: new Date(run.createdAt.getTime() + (changed === "newer run" ? 60_000 : -60_000)),
});
}
return { v: 1, iss: "paperclip-cloud", aud: "paperclip-workspace-worker-stop/v1", sub: "stack", runId: seed.runId, ownerToken: owner.token, worker, stopped: true, iat: 1, exp: 61 };
});
const copyback = vi.fn(async () => fs.writeFile(file, "old sandbox work"));
try {
expect(await withNativeWorkspaceFinalizationOwnership(seed, copyback)).toEqual({ acquired: false });
expect(copyback).not.toHaveBeenCalled();
expect(await fs.readFile(file, "utf8")).toBe("new task work");
const [run] = await db.select().from(heartbeatRuns).where(eq(heartbeatRuns.id, seed.runId));
expect(run.runnerProfileJson?.nativeWorkspaceFinalizationOwner).toEqual(owner);
expect(run.runnerProfileJson?.nativeWorkspaceOwnerStopProof).toBeUndefined();
} finally { probe.mockRestore(); }
});
it("rechecks automatic task ownership at the copyback publication boundary", async () => {
const seed = await seedAbandonedCopyback();
const worker = { provider: "railway" as const, projectId: "p", environmentId: "e", serviceId: "s", deploymentId: "d" };
const owner = { ...seed.owner, worker };
await db.update(heartbeatRuns).set({ runnerProfileJson: { nativeWorkspaceFinalizationOwner: owner } }).where(eq(heartbeatRuns.id, seed.runId));
const probe = vi.spyOn(platformProof, "requestWorkspaceWorkerStopProof").mockResolvedValue({ v: 1, iss: "paperclip-cloud", aud: "paperclip-workspace-worker-stop/v1", sub: "stack", runId: seed.runId, ownerToken: owner.token, worker, stopped: true, iat: 1, exp: 61 });
const write = vi.fn();
try {
await expect(withNativeWorkspaceFinalizationOwnership(seed, async ownership => {
await db.update(issues).set({ status: "cancelled" }).where(eq(issues.id, seed.issueId));
await ownership.assertHeld();
write();
})).rejects.toThrow("native_workspace_finalization_ownership_lost");
expect(write).not.toHaveBeenCalled();
} finally { probe.mockRestore(); }
});
it.each(["closed", "superseded"])("does not start a workspace operation when the real reconciler selects a %s task", async changed => {
const seed = await seedAbandonedCopyback();
const worker = { provider: "railway" as const, projectId: "p", environmentId: "e", serviceId: "s", deploymentId: "d" };
const owner = { ...seed.owner, worker };
const workspaceId = randomUUID(), cwd = path.join(workspaceRoot, seed.runId);
await fs.mkdir(cwd);
await fs.writeFile(path.join(cwd, "current.txt"), "new task work");
await db.insert(executionWorkspaces).values({ id: workspaceId, companyId, projectId, mode: "local", strategyType: "local_directory", name: "Current workspace", cwd });
await db.update(issues).set({ executionWorkspaceId: workspaceId,
...(changed === "closed" ? { status: "done" } : { executionRunId: null }),
}).where(eq(issues.id, seed.issueId));
await db.update(heartbeatRuns).set({ runnerProfileJson: { nativeWorkspaceFinalizationOwner: owner } }).where(eq(heartbeatRuns.id, seed.runId));
const probe = vi.spyOn(platformProof, "requestWorkspaceWorkerStopProof").mockResolvedValue({ v: 1, iss: "paperclip-cloud", aud: "paperclip-workspace-worker-stop/v1", sub: "stack", runId: seed.runId, ownerToken: owner.token, worker, stopped: true, iat: 1, exp: 61 });
try {
await reconcileNativeFinalizations(db, [seed.runId]);
expect(probe).toHaveBeenCalledOnce();
expect(await db.select().from(workspaceOperations).where(eq(workspaceOperations.heartbeatRunId, seed.runId))).toHaveLength(0);
expect((await db.select().from(nativeRunFinalizations).where(eq(nativeRunFinalizations.runId, seed.runId)))[0].phase).toBe("workspace_finalizing");
expect(await fs.readFile(path.join(cwd, "current.txt"), "utf8")).toBe("new task work");
} finally { probe.mockRestore(); }
});
it("surfaces an unverifiable foreign controller as board recovery without physical writes", async () => {
const seeded = await seedRun({ executionWorkspaceId: randomUUID(), title: "Foreign copyback controller" });
const owner = { token: randomUUID(), hostname: "other-controller.invalid", pid: 42, processStartedAt: new Date().toISOString() };
@@ -0,0 +1,87 @@
import os from "node:os";
import { and, eq, sql } from "drizzle-orm";
import { heartbeatRuns, issues, issueRecoveryActions, nativeRunFinalizations, type Db } from "@paperclipai/db";
import { resumeWorkspaceFinalizationSchema } from "@paperclipai/shared";
import { conflict } from "../../errors.js";
import { persistActivity, publishActivity } from "../activity-log.js";
import { currentNativeWorkspaceRecoveryResult } from "./native-workspace-recovery-binding.js";
import { evaluateNativeControllerTakeover } from "./native-restart-recovery.js";
const OWNER_KEY = "nativeWorkspaceFinalizationOwner";
const record = (value: unknown): Record<string, unknown> => value && typeof value === "object" && !Array.isArray(value) ? value as Record<string, unknown> : {};
const changed = () => conflict("The recorded workspace owner is no longer current. Refresh the task and inspect its run.", { code: "workspace_owner_recovery_stale" });
/** Board-only admission after deployment-platform verification. A missing DB
* lock or an expired lease alone never proves physical copyback has stopped. */
export async function resumeNativeWorkspaceAfterOwnerStop(input: {
db: Db; companyId: string; issueId: string; actionId: string; runId: string;
ownerToken: string; controllerAndCopybackStopped: true; stopEvidence: string; actorId: string;
}) {
const confirmation = resumeWorkspaceFinalizationSchema.parse({
actionId: input.actionId, runId: input.runId, ownerToken: input.ownerToken,
controllerAndCopybackStopped: input.controllerAndCopybackStopped, stopEvidence: input.stopEvidence,
});
const admitted = await input.db.transaction(async tx => {
// Share the physical copyback lock. Do not wait behind an active exporter
// and then apply a confirmation that described an earlier owner.
const locks = await tx.execute(sql`select pg_try_advisory_xact_lock(hashtextextended(${`native-workspace-finalization:${input.companyId}:${input.runId}`}, 0)) as acquired`);
if (locks[0]?.acquired !== true) throw conflict("Workspace copyback is still active. Wait for it to stop before confirming recovery.");
// Match the status arbiter's coordinator -> issue -> run lock order.
const [coordinator] = await tx.select().from(nativeRunFinalizations).where(and(eq(nativeRunFinalizations.companyId, input.companyId), eq(nativeRunFinalizations.issueId, input.issueId), eq(nativeRunFinalizations.runId, input.runId))).for("update");
const [issue] = await tx.select().from(issues).where(and(eq(issues.companyId, input.companyId), eq(issues.id, input.issueId))).for("update");
const [run] = await tx.select().from(heartbeatRuns).where(and(eq(heartbeatRuns.companyId, input.companyId), eq(heartbeatRuns.nativeIssueId, input.issueId), eq(heartbeatRuns.id, input.runId))).for("update");
const [action] = await tx.select().from(issueRecoveryActions).where(and(eq(issueRecoveryActions.companyId, input.companyId), eq(issueRecoveryActions.sourceIssueId, input.issueId), eq(issueRecoveryActions.id, input.actionId))).for("update");
if (!issue || !run || !coordinator || !action || action.evidence?.runId !== run.id
|| action.cause !== "native_workspace_finalization_owner_unverified" || action.ownerType !== "board"
|| action.kind !== "active_run_watchdog" || !["active", "escalated"].includes(action.status)
|| (coordinator.leaseOwner && (!coordinator.leaseExpiresAt || coordinator.leaseExpiresAt.getTime() > Date.now()))) throw changed();
const result = await currentNativeWorkspaceRecoveryResult(tx as unknown as Db, {
companyId: input.companyId, issue, run, coordinator,
});
if (!result) throw changed();
const prior = record(run.runnerProfileJson?.[OWNER_KEY]);
const recordedOwner = record(action.evidence?.owner);
const stop = record(action.evidence?.workspaceOwnerStop);
const receipt = { runId: run.id, resultId: result.id, status: "queued" as const };
if (run.runnerProfileJson?.[OWNER_KEY] == null && record(stop.owner).token === input.ownerToken
&& stop.resultId === result.id) return { receipt, publication: null };
if (typeof prior.hostname !== "string" || !prior.hostname
|| !Number.isInteger(prior.pid) || Number(prior.pid) <= 0
|| typeof prior.processStartedAt !== "string" || !Number.isFinite(Date.parse(prior.processStartedAt))
|| prior.token !== input.ownerToken || recordedOwner.token !== input.ownerToken
|| prior.hostname !== recordedOwner.hostname || prior.pid !== recordedOwner.pid
|| prior.processStartedAt !== recordedOwner.processStartedAt
|| prior.controllerBootId !== recordedOwner.controllerBootId) throw changed();
// Reject a confirmation contradicted by a locally observable live owner,
// even if that owner's advisory connection has already disconnected.
if (prior.hostname === os.hostname()) {
const takeover = await evaluateNativeControllerTakeover({ owner: {
leaseOwner: input.ownerToken, leaseExpiresAt: new Date(0),
controllerPid: typeof prior.pid === "number" ? prior.pid : null,
controllerProcessStartedAt: typeof prior.processStartedAt === "string" ? new Date(prior.processStartedAt) : null,
}, now: new Date() });
if (!takeover.allowed) throw conflict("The previous workspace controller is still active or its stop cannot be verified.");
}
const now = new Date();
const proof = { owner: prior, resultId: result.id, actorId: input.actorId,
stopEvidence: confirmation.stopEvidence, confirmedAt: now.toISOString() };
await tx.update(heartbeatRuns).set({
runnerProfileJson: sql`${heartbeatRuns.runnerProfileJson} - ${OWNER_KEY}`, updatedAt: now,
}).where(and(eq(heartbeatRuns.companyId, input.companyId), eq(heartbeatRuns.id, run.id),
sql`${heartbeatRuns.runnerProfileJson}->${OWNER_KEY}->>'token' = ${input.ownerToken}`));
await tx.update(issueRecoveryActions).set({ evidence: { ...action.evidence, workspaceOwnerStop: proof },
nextAction: "Workspace finalization is queued for the saved result. The agent will not repeat its work.",
wakePolicy: null, monitorPolicy: null, updatedAt: now,
}).where(eq(issueRecoveryActions.id, action.id));
const publication = await persistActivity(tx as unknown as Db, { companyId: input.companyId,
actorType: "user", actorId: input.actorId, action: "issue.workspace_owner_stop_confirmed",
entityType: "issue", entityId: input.issueId, runId: input.runId,
details: { recoveryActionId: input.actionId, ...proof },
});
// Leave the result, coordinator, source lease, task status and wait paths
// untouched. The normal reconciler resumes copyback and arbitrates it.
return { receipt, publication };
});
if (admitted.publication) publishActivity(admitted.publication.publication);
return admitted.receipt;
}
@@ -0,0 +1,34 @@
import { generateKeyPairSync, sign } from "node:crypto";
import { describe, expect, it, vi } from "vitest";
import { verifyWorkspaceWorkerStopReceipt, requestWorkspaceWorkerStopProof, currentWorkspaceWorker } from "./native-workspace-platform-proof.js";
const { privateKey, publicKey } = generateKeyPairSync("ed25519");
const env = { PAPERCLIP_CLOUD_STACK_ID: "stack", PAPERCLIP_CLOUD_API_ORIGIN: "https://cloud.example", PAPERCLIP_CLOUD_TENANT_SERVER_TOKEN: "fixture-token", PAPERCLIP_CLOUD_RUNTIME_IDENTITY_JWKS: JSON.stringify({ keys: [{ ...publicKey.export({ format: "jwk" }), kid: "test", alg: "EdDSA", use: "sig" }] }) };
const worker = { provider: "railway" as const, projectId: "project", environmentId: "environment", serviceId: "service", deploymentId: "old-deployment" };
const binding = { runId: "run", ownerToken: "owner", worker };
const now = new Date("2099-01-01T00:00:00Z");
function receipt(overrides = {}) {
const header = Buffer.from(JSON.stringify({ alg: "EdDSA", kid: "test", typ: "paperclip-workspace-worker-stop+jwt" })).toString("base64url");
const payload = Buffer.from(JSON.stringify({ v: 1, iss: "paperclip-cloud", aud: "paperclip-workspace-worker-stop/v1", sub: "stack", ...binding, stopped: true, iat: now.getTime()/1000, exp: now.getTime()/1000+60, ...overrides })).toString("base64url");
return `${header}.${payload}.${sign(null, Buffer.from(`${header}.${payload}`), privateKey).toString("base64url")}`;
}
describe("workspace platform stop proof", () => {
it("accepts only signed proof for the exact stack, owner, run and full stopped deployment", () => {
expect(verifyWorkspaceWorkerStopReceipt(receipt(), binding, env, now)).toMatchObject({ stopped: true, worker });
for (const invalid of [{ sub: "other-stack" }, { runId: "other-run" }, { ownerToken: "other-owner" }, { stopped: false }, { exp: now.getTime()/1000-1 }, { worker: { ...worker, deploymentId: "other" } }, { aud: "paperclip-cloud-control/v1" }]) {
expect(verifyWorkspaceWorkerStopReceipt(receipt(invalid), binding, env, now)).toBeNull();
}
expect(verifyWorkspaceWorkerStopReceipt(receipt().slice(0,-5)+"wrong", binding, env, now)).toBeNull();
});
it("fails closed on live, unknown, unreachable and unsigned platform responses", async () => {
for (const response of [{ stopped: false }, { stopped: true }, { receipt: "forged" }]) {
const fetchImpl = vi.fn().mockResolvedValue(Response.json(response));
expect(await requestWorkspaceWorkerStopProof(binding, { env, now, fetchImpl })).toBeNull();
}
expect(await requestWorkspaceWorkerStopProof(binding, { env, now, fetchImpl: vi.fn().mockRejectedValue(new Error("offline")) })).toBeNull();
expect(await requestWorkspaceWorkerStopProof(binding, { env, now, fetchImpl: vi.fn().mockResolvedValue(Response.json({ receipt: receipt() })) })).toMatchObject({ stopped: true });
});
it("records a complete platform identity, and does not guess legacy ownership", () => {
expect(currentWorkspaceWorker({ RAILWAY_PROJECT_ID: "project", RAILWAY_ENVIRONMENT_ID: "environment", RAILWAY_SERVICE_ID: "service", RAILWAY_DEPLOYMENT_ID: "old-deployment" })).toEqual(worker);
expect(currentWorkspaceWorker({ RAILWAY_DEPLOYMENT_ID: "old-deployment" })).toBeUndefined();
});
});
@@ -0,0 +1,65 @@
import { verify } from "node:crypto";
import { cloudRuntimePublicKeyForKid } from "../cloud-runtime-identity.js";
import { getCloudStackContext } from "../cloud-instance.js";
/** Deployment identity covers the controller and every orphaned tar/Git child. */
export type WorkspaceWorker = {
provider: "railway"; projectId: string; environmentId: string; serviceId: string; deploymentId: string;
};
export type WorkspaceStopBinding = { runId: string; ownerToken: string; worker: WorkspaceWorker };
export type WorkspaceWorkerStopProof = WorkspaceStopBinding & {
v: 1; iss: "paperclip-cloud"; aud: "paperclip-workspace-worker-stop/v1"; sub: string;
stopped: true; iat: number; exp: number;
};
function validWorker(worker: unknown): worker is WorkspaceWorker {
if (!worker || typeof worker !== "object" || Array.isArray(worker)) return false;
const value = worker as Record<string, unknown>;
return value.provider === "railway" && ["projectId", "environmentId", "serviceId", "deploymentId"].every(key =>
typeof value[key] === "string" && value[key].length > 0 && value[key].length <= 200);
}
export function currentWorkspaceWorker(env: NodeJS.ProcessEnv = process.env): WorkspaceWorker | undefined {
const ids = [env.RAILWAY_PROJECT_ID, env.RAILWAY_ENVIRONMENT_ID, env.RAILWAY_SERVICE_ID, env.RAILWAY_DEPLOYMENT_ID];
if (ids.some(id => !id?.trim())) return undefined;
return { provider: "railway", projectId: ids[0]!, environmentId: ids[1]!, serviceId: ids[2]!, deploymentId: ids[3]! };
}
export function verifyWorkspaceWorkerStopReceipt(
receipt: string, binding: WorkspaceStopBinding, env: NodeJS.ProcessEnv = process.env, now = new Date(),
): WorkspaceWorkerStopProof | null {
try {
if (receipt.length > 16_000 || !validWorker(binding.worker)) return null;
const parts = receipt.split(".");
if (parts.length !== 3 || parts.some(part => !/^[A-Za-z0-9_-]+$/.test(part))) return null;
const header = JSON.parse(Buffer.from(parts[0], "base64url").toString("utf8"));
if (header.alg !== "EdDSA" || header.typ !== "paperclip-workspace-worker-stop+jwt" || typeof header.kid !== "string") return null;
if (!verify(null, Buffer.from(`${parts[0]}.${parts[1]}`, "ascii"), cloudRuntimePublicKeyForKid(env, header.kid), Buffer.from(parts[2], "base64url"))) return null;
const proof = JSON.parse(Buffer.from(parts[1], "base64url").toString("utf8")) as WorkspaceWorkerStopProof;
const seconds = Math.floor(now.getTime()/1000);
if (proof.v !== 1 || proof.iss !== "paperclip-cloud" || proof.aud !== "paperclip-workspace-worker-stop/v1"
|| !env.PAPERCLIP_CLOUD_STACK_ID || proof.sub !== env.PAPERCLIP_CLOUD_STACK_ID || proof.stopped !== true
|| proof.runId !== binding.runId || proof.ownerToken !== binding.ownerToken
|| !Number.isInteger(proof.iat) || !Number.isInteger(proof.exp) || proof.iat > seconds+30
|| proof.exp <= seconds || proof.exp <= proof.iat || proof.exp-proof.iat > 60
|| !validWorker(proof.worker) || Object.keys(binding.worker).some(key => proof.worker[key as keyof WorkspaceWorker] !== binding.worker[key as keyof WorkspaceWorker])) return null;
return proof;
} catch { return null; }
}
/** Unsupported platforms, legacy receipts and unavailable evidence never authorize takeover. */
export async function requestWorkspaceWorkerStopProof(binding: WorkspaceStopBinding, options: {
env?: NodeJS.ProcessEnv; fetchImpl?: typeof fetch; now?: Date;
} = {}): Promise<WorkspaceWorkerStopProof | null> {
const env = options.env ?? process.env;
const context = getCloudStackContext(env);
const token = env.PAPERCLIP_CLOUD_TENANT_SERVER_TOKEN?.trim();
if (!context?.stackId || !context.cloudOrigin || !token || !validWorker(binding.worker)) return null;
try {
const origin = new URL(context.cloudOrigin);
if (origin.protocol !== "https:" || origin.username || origin.password) return null;
const response = await (options.fetchImpl ?? fetch)(`${origin.origin}/v1/tenant/workspace-worker-stop`, {
method: "POST", redirect: "error", headers: { authorization: `Bearer ${token}`, "x-paperclip-cloud-stack-id": context.stackId, "content-type": "application/json" },
body: JSON.stringify(binding), signal: AbortSignal.timeout(10_000),
});
if (!response.ok) return null;
const payload = await response.json() as { receipt?: unknown };
return typeof payload.receipt === "string" ? verifyWorkspaceWorkerStopReceipt(payload.receipt, binding, env, options.now) : null;
} catch { return null; }
}
@@ -0,0 +1,36 @@
import { and, eq, gt, inArray, ne, or, sql } from "drizzle-orm";
import { heartbeatRuns, issues, nativeRunFinalizations, nativeRunResults, type Db } from "@paperclipai/db";
import { getNativeReviewAssignment } from "./native-review-participant.js";
/** Both operator and platform recovery must still own the task's accepted work.
* Call with coordinator -> issue -> run rows locked in a short transaction. */
export async function currentNativeWorkspaceRecoveryResult(db: Db, input: {
companyId: string;
issue: typeof issues.$inferSelect;
run: typeof heartbeatRuns.$inferSelect;
coordinator: typeof nativeRunFinalizations.$inferSelect;
}) {
const { companyId, issue, run, coordinator } = input;
if (issue.companyId !== companyId || run.companyId !== companyId || coordinator.companyId !== companyId
|| run.nativeIssueId !== issue.id || coordinator.issueId !== issue.id || coordinator.runId !== run.id
|| run.runtimeMode !== "native" || run.status !== "running"
|| !["result_accepted", "workspace_finalizing", "retryable_failure"].includes(coordinator.phase)
|| !coordinator.resultId || ["done", "cancelled"].includes(issue.status)
|| issue.executionRunId !== run.id || (issue.checkoutRunId && issue.checkoutRunId !== run.id)) return null;
if (issue.assigneeAgentId !== run.agentId && !await getNativeReviewAssignment(db, {
companyId, issueId: issue.id, agentId: run.agentId,
contextSnapshot: run.contextSnapshot, actingRunId: run.id, issueExecutionRunId: issue.executionRunId,
allowResolvedByRunId: run.id,
})) return null;
const [result] = await db.select().from(nativeRunResults).where(and(
eq(nativeRunResults.id, coordinator.resultId), eq(nativeRunResults.companyId, companyId),
eq(nativeRunResults.issueId, issue.id), eq(nativeRunResults.runId, run.id), eq(nativeRunResults.schemaStatus, "accepted"),
));
if (!result || result.completionContractId !== run.completionContractId) return null;
const [newer] = await db.select({ id: heartbeatRuns.id }).from(heartbeatRuns).where(and(
eq(heartbeatRuns.companyId, companyId), ne(heartbeatRuns.id, run.id),
or(eq(heartbeatRuns.nativeIssueId, issue.id), sql`${heartbeatRuns.contextSnapshot}->>'issueId' = ${issue.id}`),
or(gt(heartbeatRuns.createdAt, run.createdAt), inArray(heartbeatRuns.status, ["queued", "running"])),
)).limit(1);
return newer ? null : result;
}
@@ -361,6 +361,53 @@ describe("PaperclipControlPlanePort conformance", () => {
terminal: CONTROL_PLANE_CONFORMANCE_TERMINAL })).resolves.toBeUndefined();
});
it("omits API credentials from persisted tool events and checkpoints without changing wire results", async () => {
const identity = { ...CONTROL_PLANE_CONFORMANCE_OPEN.identity, runId: randomUUID(), sessionId: randomUUID() };
const runnerId = randomUUID();
await db.insert(heartbeatRuns).values({
id: identity.runId, companyId: identity.companyId, agentId: identity.agentId,
status: "running", runtimeMode: "native", nativeIssueId: identity.issueId,
nativeSessionId: identity.sessionId, runnerInstanceId: runnerId,
completionContractId: contractId, completionContractSha256: contractSha,
contextSnapshot: { issueId: identity.issueId },
});
const observed: PrpEvent[] = [];
const port = new PaperclipControlPlanePort(db, {
...identity, completionContractId: contractId, completionContractSha256: contractSha,
sourceInstanceId: runnerId, controlPlaneSourceInstanceId: `api-${identity.runId}`,
}, { onCommittedEvent: async event => { observed.push(event); } });
await port.openRun({ identity, backendKind: "mock", sourceInstanceId: runnerId });
const result = { operationId: "call_api", apiOperationId: "POST /api/companies/{companyId}/exports", callId: "private-export",
status: 200, ok: true, data: { credentials: "private-history-fixture-value" } };
const wireResult = { success: true, contentItems: [{ type: "inputText", text: JSON.stringify(result) }] };
const event: PrpEvent = {
schema: "paperclip.prp.event.v1", sourceEventId: `${runnerId}:1`, sourceSeq: 1,
sourceInstanceId: runnerId, sourceKind: "runner", runId: identity.runId,
normalizedSessionId: identity.sessionId, eventType: "item.completed", schemaVersion: 1,
priority: 0, emittedAt: new Date().toISOString(),
payload: { kind: "dynamicToolCall", item: { type: "tool_result", id: "private-export", tool_use_id: "private-export", result: wireResult } },
};
await port.appendEvent(event);
expect((await port.appendEvent(event)).disposition).toBe("duplicate");
expect(port.normalizeReplayPayload(event.payload)).toEqual(observed[0]?.payload);
expect(port.normalizeReplayPayload(observed[0]!.payload)).toEqual(observed[0]?.payload);
await expect(port.appendEvent({ ...event, payload: { ...event.payload, item: {
...(event.payload.item as object), result: { success: true, contentItems: [{ type: "inputText",
text: JSON.stringify({ ...result, data: { credentials: "different-history-fixture-value" } }),
}] },
} } })).rejects.toThrow();
await port.checkpointSession({ backendKind: "mock", sessionId: identity.sessionId, identity,
semanticResult: { ...CONTROL_PLANE_CONFORMANCE_RESULT, summary: JSON.stringify(result) },
});
const persisted = await db.select().from(heartbeatRunEvents).where(eq(heartbeatRunEvents.runId, identity.runId));
const [run] = await db.select().from(heartbeatRuns).where(eq(heartbeatRuns.id, identity.runId));
for (const saved of [persisted, observed, run.runnerProfileJson]) {
expect(JSON.stringify(saved)).not.toContain("private-history-fixture-value");
expect(JSON.stringify(saved)).toContain("apiPayloadRedacted");
}
expect(result.data.credentials).toBe("private-history-fixture-value");
});
it("redacts identity private material before persisting native events and checkpoints", async () => {
const identity = { ...CONTROL_PLANE_CONFORMANCE_OPEN.identity, runId: randomUUID(), sessionId: randomUUID() };
const runnerId = randomUUID();
@@ -27,6 +27,7 @@ import {
import { appendHeartbeatRunEvent } from "../heartbeat-run-events.js";
import { publishChatPublicationCommitSignal } from "../chat-publication-reconciliation.js";
import { nativeSha256 } from "./canonical.js";
import { redactRunnerApiPayloads } from "./runner-api-log-redaction.js";
export interface PaperclipControlPlaneBinding {
companyId: string;
@@ -172,9 +173,13 @@ export class PaperclipControlPlanePort implements ControlPlanePort {
return structuredClone(snapshot as PersistedNativeSession);
}
normalizeReplayPayload(payload: PrpEvent["payload"]): PrpEvent["payload"] {
return redactRunnerApiPayloads(this.#identityRedactor.redact(payload));
}
async checkpointSession(snapshot: PersistedNativeSession, options?: { signal: AbortSignal }): Promise<void> {
this.#assertActive(options);
snapshot = this.#identityRedactor.redact(snapshot);
snapshot = redactRunnerApiPayloads(this.#identityRedactor.redact(snapshot));
const identity = snapshot.identity;
if (
identity.companyId !== this.#binding.companyId
@@ -251,6 +256,7 @@ export class PaperclipControlPlanePort implements ControlPlanePort {
}
}
}
event = redactRunnerApiPayloads(event);
const persisted = await this.#db.transaction(async tx => {
this.#assertActive(options);
const receipt = await appendHeartbeatRunEvent(tx as unknown as Db, {
@@ -331,7 +337,7 @@ export class PaperclipControlPlanePort implements ControlPlanePort {
async completeRun(value: NativeRunResult | CompleteControlPlaneRunInput, options?: { signal: AbortSignal }): Promise<void> {
this.#assertActive(options);
value = this.#identityRedactor.redact(value);
value = redactRunnerApiPayloads(this.#identityRedactor.redact(value));
if (!isCompleteInput(value)) throw new Error("native_structured_result_required");
// Capture compatibility diagnostics before canonical validation removes
// legacy/non-actionable attention payloads. They are operator evidence,
@@ -1,3 +1,4 @@
import { skillAccessService } from "../skill-access.js";
import { callTaskWorkspaceTool } from "../task-workspace-tools.js";
import { TaskAttachmentReadCache, TASK_ATTACHMENT_DEFINITIONS, TASK_ATTACHMENT_SCHEMAS, listTaskAttachments, readTaskAttachment } from "./runner-task-attachments.js";
import { RUNNER_BRIDGE_SCHEMAS, runnerBridgeDefinitions, readAssignedSkill, executeWorkspaceTool, readWorkspaceUploadFile, settleRunnerBridgeRead, assertRunnerWorkspacePathAllowed } from "./runner-bridge-tools.js";
@@ -42,6 +43,9 @@ import { createLocalAgentJwt } from "../../agent-auth-jwt.js";
import { getStorageService } from "../../storage/index.js";
import type { StorageService } from "../../storage/types.js";
import { assetService } from "../assets.js";
import { canActorReadAssetContent } from "../asset-content-access.js";
import { getSecretProvider } from "../../secrets/provider-registry.js";
import { runnerApiSensitiveResponse } from "./runner-api-sensitive-response.js";
import { workspaceFileResourceService } from "../workspace-file-resources.js";
import { badRequest, forbidden, notFound, HttpError } from "../../errors.js";
import { runnerApiCatalog, searchRunnerApi } from "./runner-api-catalog.js";
@@ -52,6 +56,7 @@ import type { Db } from "@paperclipai/db";
import {
authUsers,
companyMemberships,
companySkills,
activityLog,
assets,
runnerApiResponseReservations,
@@ -105,6 +110,7 @@ import {
type NativeChatAttachmentReadScope,
} from "./chat-attachment-read.js";
const EXTERNAL_WORKSPACE_OPERATIONS = new Set(["get_workspace", "list_workspaces", "select_workspace", "prepare_repository", "register_deliverable"]);
const IMPLEMENTED_OPERATIONS = new Set([
"submit_complaint", "submit_suggestion",
"read_agent_instructions", "update_agent_instructions", "get_agent_instruction_history", "restore_agent_instructions",
@@ -141,6 +147,8 @@ type Binding = {
workspaceBridge?: boolean;
/** Pinned provider identity; configuration edits cannot remove API file guards. */
dotRuntime?: boolean;
/** Grok retains its provider apps; this release exposes first-party Paperclip tools only. */
firstPartyOnly?: boolean;
/** Operator-approved Dot attachment access, pinned when the turn opens. */
taskAttachmentRead?: boolean;
runtimeContext?: NativeRuntimeContextSnapshot;
@@ -236,6 +244,7 @@ export class PaperclipRunnerToolAuthority {
CAPABILITY_SEMANTIC_TOOL_CATALOG.filter(
(descriptor) =>
IMPLEMENTED_OPERATIONS.has(descriptor.operationId) &&
(!this.binding.firstPartyOnly || !EXTERNAL_WORKSPACE_OPERATIONS.has(descriptor.operationId)) &&
(runnerApiToolsEnabled(
this.binding.companyId,
this.binding.apiToolsEnabled,
@@ -284,10 +293,10 @@ export class PaperclipRunnerToolAuthority {
definitions.push(REUSE_CHAT_ATTACHMENT_TOOL_DEFINITION);
if (this.binding.chatAttachmentReadScope) definitions.push(READ_CHAT_ATTACHMENT_TOOL_DEFINITION);
if (this.binding.taskAttachmentRead) definitions.push(...TASK_ATTACHMENT_DEFINITIONS);
definitions.push(...runnerBridgeDefinitions({ workspace: !!this.binding.workspaceRoot && this.binding.workspaceBridge === true, skills: !!this.binding.runtimeContext, api: runnerApiToolsEnabled(this.binding.companyId, this.binding.apiToolsEnabled), mode: workMode }));
const connectionTools = [...RUNTIME_CONNECTION_TOOL_DEFINITIONS,
definitions.push(...runnerBridgeDefinitions({ workspace: !this.binding.firstPartyOnly && !!this.binding.workspaceRoot && this.binding.workspaceBridge === true, skills: !!this.binding.runtimeContext, api: runnerApiToolsEnabled(this.binding.companyId, this.binding.apiToolsEnabled), mode: workMode }));
const connectionTools = this.binding.firstPartyOnly ? [] : [...RUNTIME_CONNECTION_TOOL_DEFINITIONS,
...(this.binding.connectorAssignments ?? []).flatMap((assignment) => assignment.tools)];
const assignedTools = this.binding.assignedMcpTools?.definitions((tools) =>
const assignedTools = this.binding.firstPartyOnly ? [] : this.binding.assignedMcpTools?.definitions((tools) =>
runnerCodexDynamicToolsFit([...connectionTools, ...tools, ...definitions])) ?? [];
return [...connectionTools, ...assignedTools, ...definitions];
}
@@ -297,6 +306,7 @@ export class PaperclipRunnerToolAuthority {
callId: string;
arguments: unknown;
}): Promise<unknown> {
if (this.binding.firstPartyOnly && (EXTERNAL_WORKSPACE_OPERATIONS.has(call.tool) || call.tool.startsWith("workspace_"))) throw forbidden("Paperclip workspace access is unavailable to this Grok Bot session.");
if (this.binding.nativeReview) {
if (call.tool === "resolve_review") return this.#resolveReview(call.arguments);
if (!NATIVE_REVIEW_READ_TOOLS.has(call.tool)) {
@@ -347,8 +357,19 @@ export class PaperclipRunnerToolAuthority {
.innerJoin(authUsers, eq(authUsers.id, companyMemberships.principalId))
.where(and(eq(companyMemberships.companyId, this.binding.companyId), eq(companyMemberships.principalType, "user"), eq(companyMemberships.status, "active"), input.after ? sql`${authUsers.id} > ${input.after}` : undefined))
.orderBy(authUsers.id).limit(input.limit);
if (call.tool === "list_assigned_skills") return this.binding.runtimeContext!.skills.map(skill => ({ key: skill.key, name: skill.runtimeName, versionId: skill.versionId, digest: skill.bundle.digest }));
if (call.tool === "read_assigned_skill") return settleRunnerBridgeRead(() => readAssignedSkill(this.binding.runtimeContext!, input));
if (call.tool === "list_assigned_skills" || call.tool === "read_assigned_skill") {
const current = await this.#boundContext();
const visibility = await skillAccessService(this.db, this.#privacyActor(current.run)).condition(this.binding.companyId);
const known = await this.db.select({ key: companySkills.key }).from(companySkills).where(eq(companySkills.companyId, this.binding.companyId));
const permitted = await this.db.select({ key: companySkills.key }).from(companySkills).where(and(eq(companySkills.companyId, this.binding.companyId), visibility));
const hidden = new Set(known.filter(skill => !permitted.some(allowed => allowed.key === skill.key)).map(skill => skill.key));
const savedVersions = current.run.contextSnapshot?.skillVersions;
const permittedKeys = new Set(permitted.map(skill => skill.key));
const runtimeContext = { ...this.binding.runtimeContext!, skills: this.binding.runtimeContext!.skills.filter(skill =>
!hidden.has(skill.key) && !(savedVersions && Object.prototype.hasOwnProperty.call(savedVersions, skill.key) && !permittedKeys.has(skill.key))) };
if (call.tool === "list_assigned_skills") return runtimeContext.skills.map(skill => ({ key: skill.key, name: skill.runtimeName, versionId: skill.versionId, digest: skill.bundle.digest }));
return settleRunnerBridgeRead(() => readAssignedSkill(runtimeContext, input));
}
const paths: Record<string, [string, string]> = { get_task: ["GET", "/api/issues/{id}"], comment_on_task: ["POST", "/api/issues/{id}/comments"],
list_task_documents: ["GET", "/api/issues/{id}/documents"], read_task_document: ["GET", "/api/issues/{id}/documents/{key}"], write_task_document: ["PUT", "/api/issues/{id}/documents/{key}"] };
const [method, path] = paths[call.tool]!;
@@ -358,6 +379,8 @@ export class PaperclipRunnerToolAuthority {
const { operationId, ...response } = record(await this.#callApi(call.callId, { operationId: operation.operationId, pathParams: { id: input.taskId, ...(input.key ? { key: input.key } : {}) }, ...(body ? { body } : {}) }));
return { ...response, apiOperationId: operationId };
}
if (this.binding.firstPartyOnly && (this.binding.assignedMcpTools?.has(call.tool) || isConnectorTool(call.tool)
|| RUNTIME_CONNECTION_TOOL_DEFINITIONS.some(tool => tool.name === call.tool))) throw forbidden("This Grok Bot connection exposes first-party Paperclip operations. Use your own provider apps under their existing permissions.");
if (this.binding.assignedMcpTools?.has(call.tool)) {
const current = await this.#boundContext();
return this.binding.assignedMcpTools.execute(call, current.issue.workMode as "standard" | "planning" | "ask");
@@ -498,13 +521,13 @@ export class PaperclipRunnerToolAuthority {
}
case "create_skill": {
const apiUrl = this.binding.apiUrl ?? process.env.PAPERCLIP_API_URL;
const token = createLocalAgentJwt(this.binding.agentId, this.binding.companyId, context.actor.adapterType, this.binding.runId, context.run.responsibleUserId);
const token = this.#apiToken(context);
if (!apiUrl || !token) throw new Error("Skill tool authentication is unavailable");
return callCreateSkillTool({ arguments: input, apiUrl, token, companyId: this.binding.companyId });
}
case "update_skill": {
const apiUrl = this.binding.apiUrl ?? process.env.PAPERCLIP_API_URL;
const token = createLocalAgentJwt(this.binding.agentId, this.binding.companyId, context.actor.adapterType, this.binding.runId, context.run.responsibleUserId);
const token = this.#apiToken(context);
if (!apiUrl || !token) throw new Error("Skill tool authentication is unavailable");
return callUpdateSkillTool({ arguments: input, apiUrl, token, companyId: this.binding.companyId });
}
@@ -521,7 +544,7 @@ export class PaperclipRunnerToolAuthority {
case "list_project_repositories":
case "list_projects": {
const apiUrl = this.binding.apiUrl ?? process.env.PAPERCLIP_API_URL;
const token = createLocalAgentJwt(this.binding.agentId, this.binding.companyId, context.actor.adapterType, this.binding.runId, context.run.responsibleUserId);
const token = this.#apiToken(context);
if (!apiUrl || !token) throw new Error("Project tool authentication is unavailable");
return callProjectTool({ name: call.tool, arguments: input, apiUrl, token,
companyId: this.binding.companyId, issueId: this.binding.issueId, agentId: this.binding.agentId,
@@ -703,14 +726,36 @@ export class PaperclipRunnerToolAuthority {
return result;
}
#apiScope(context: {
issue: { workMode: string };
run: { contextSnapshot: unknown };
}) {
const snapshot = record(context.run.contextSnapshot);
const admittedKind = record(snapshot.paperclipAgentJwtScope).kind;
const skillTest = admittedKind === "skill_test" || (admittedKind !== "standard"
&& (Boolean(snapshot.paperclipSkillTest) || context.issue.workMode === "skill_test"));
return skillTest ? { kind: "skill_test" as const, issueId: this.binding.issueId } : { kind: "standard" as const };
}
#apiToken(context: {
actor: { adapterType: string };
issue: { workMode: string };
run: { responsibleUserId: string | null; contextSnapshot: unknown };
}) {
return createLocalAgentJwt(this.binding.agentId, this.binding.companyId, context.actor.adapterType,
this.binding.runId, context.run.responsibleUserId, this.#apiScope(context));
}
async #callApi(callId: string, value: unknown): Promise<unknown> {
const bound = await this.#boundContext();
const context = { ...this.binding, issueIdentifier: bound.issue.identifier, workMode: bound.issue.workMode };
const { input, operation } = validateRunnerApiCall(value, context);
if (this.binding.firstPartyOnly && /\/(?:workspaces|workspace-runtime|repositories|tool-gateway|tool-connections|tool-profiles|tools|connection-intents|connections|plugins)(?:\/|$)/.test(operation.path)) throw forbidden("Third-party tools through Paperclip are unavailable for this Grok Bot connection.");
const sensitiveResponse = runnerApiSensitiveResponse(operation.method, operation.path);
await this.#assertDotApiFileAccess(operation.path, input);
const apiUrl = this.binding.apiUrl ?? process.env.PAPERCLIP_API_URL;
if (!apiUrl) throw new Error("Paperclip API origin is unavailable");
const token = createLocalAgentJwt(this.binding.agentId, this.binding.companyId, bound.actor.adapterType, this.binding.runId, bound.run.responsibleUserId);
const token = this.#apiToken(bound);
if (!token) throw new Error("Paperclip run authentication is unavailable");
const execute = async () => {
let reservationId: string | undefined;
@@ -764,6 +809,8 @@ export class PaperclipRunnerToolAuthority {
};
if (["GET", "HEAD", "OPTIONS"].includes(operation.method)) return execute();
if (!callId || callId.length > 500) throw badRequest("A bounded runner call id is required");
const provider = sensitiveResponse ? getSecretProvider("local_encrypted") : null;
if (provider) await provider.createSecret({ value: "runner-api-receipt-key-check" });
// Reserve durably before HTTP dispatch, without holding a DB lock over an
// HTTP route that itself needs DB locks. A crash leaves an explicit unknown
// outcome rather than replaying a possibly committed external mutation.
@@ -776,6 +823,10 @@ export class PaperclipRunnerToolAuthority {
const existing = record(receipts[key]);
if (receipts[key]) {
if (existing.digest !== digest) throw badRequest("API call id was reused with different arguments");
if (existing.encryptedResult) {
const text = await getSecretProvider("local_encrypted").resolveVersion({ material: record(existing.encryptedResult), externalRef: null });
return { result: JSON.parse(text) as unknown };
}
return { result: existing.result ?? { ok: false, status: null, error: "api_outcome_unknown", outcome: "unknown", guidance: "Inspect state before issuing another mutation." } };
}
if (Object.keys(receipts).length >= 512) throw badRequest("Run API mutation limit reached");
@@ -785,6 +836,9 @@ export class PaperclipRunnerToolAuthority {
});
if (prior) return prior.result;
const result = await execute();
const storedResult = provider
? { encryptedResult: (await provider.createSecret({ value: JSON.stringify(result) })).material }
: { result };
// Some older routes attribute the agent but omit runId. Retain their domain
// event and add the run-bound HTTP receipt, without logging request bodies.
const apiActivity = await persistActivity(this.db, {
@@ -798,7 +852,7 @@ export class PaperclipRunnerToolAuthority {
const [run] = await tx.select().from(heartbeatRuns).where(eq(heartbeatRuns.id, this.binding.runId)).for("update");
const resultJson = record(run?.resultJson);
const receipts = record(resultJson.apiToolReceipts);
receipts[key] = { digest, operationId: operation.operationId, state: "completed", result };
receipts[key] = { digest, operationId: operation.operationId, state: "completed", ...storedResult };
await tx.update(heartbeatRuns).set({ resultJson: { ...resultJson, apiToolReceipts: receipts } }).where(eq(heartbeatRuns.id, this.binding.runId));
});
return result;
@@ -882,6 +936,9 @@ export class PaperclipRunnerToolAuthority {
}
async #assertDotApiFileAccess(path: string, input: { pathParams?: Record<string, string>; files?: RunnerApiFile[] }) {
if (this.binding.firstPartyOnly && (input.files?.length || /\/(?:attachments|assets)\/[^/]+\/content$/.test(path) || /\/file-resources\/content$/.test(path))) {
throw forbidden("Use this Grok work session's bounded upload or download grant; direct file API access is unavailable.");
}
if (!this.binding.dotRuntime) return;
const current = await this.#boundContext();
await this.binding.assertBridgeAuthority?.();
@@ -914,6 +971,12 @@ export class PaperclipRunnerToolAuthority {
if (file.artifactId) {
const asset = await assetService(this.db).getById(file.artifactId);
if (!asset || asset.companyId !== this.binding.companyId) throw forbidden("Artifact is not available in this company");
const assertArtifactAccess = async () => {
const current = await this.#boundContext();
if (!(await canActorReadAssetContent(this.db, { ...this.#privacyActor(current.run), keyScope: this.#apiScope(current) }, asset)))
throw forbidden("Artifact is not available to this actor");
};
await assertArtifactAccess();
if (asset.byteSize > RUNNER_API_MAX_BYTES) throw badRequest("Artifact exceeds API transfer limit");
const object = await (this.binding.storage ?? getStorageService()).getObject(this.binding.companyId, asset.objectKey);
const chunks: Buffer[] = [];
@@ -926,6 +989,7 @@ export class PaperclipRunnerToolAuthority {
chunks.push(bytes);
}
} finally { object.stream.destroy(); }
await assertArtifactAccess();
return { bytes: Buffer.concat(chunks), filename: asset.originalFilename ?? "file", contentType: asset.contentType };
}
const resolved = await workspaceFileResourceService(this.db).prepareDownload(this.binding.issueId, { path: file.path!, workspace: "auto" });
@@ -1027,8 +1091,7 @@ export class PaperclipRunnerToolAuthority {
return { interactionId: review.interaction.id, status: expectedStatus, deduplicated: true };
}
const apiUrl = this.binding.apiUrl ?? process.env.PAPERCLIP_API_URL;
const token = createLocalAgentJwt(this.binding.agentId, this.binding.companyId,
context.actor.adapterType, this.binding.runId, context.run.responsibleUserId);
const token = this.#apiToken(context);
if (!apiUrl || !token) throw new Error("Review tool authentication is unavailable");
// Use the existing resolution route so authorization, activity, dependency
// wakes and request-changes continuation have one implementation.
@@ -39,6 +39,7 @@ export type QualifiedPaperclipRunnerAcpxAgent =
type AdmittedPaperclipRunnerAcpxAgent = QualifiedPaperclipRunnerAcpxAgent | AcpxQualificationCandidate;
export type PaperclipRunnerProviderProfile =
| { provider: "grok_bot"; backend: "grok_bot_mcp"; model: null; externalBindingId: string }
| { provider: "openai_dot"; backend: "openai_dot_mcp"; model: null; dotBindingId: string }
| {
provider: "codex";
@@ -72,6 +73,7 @@ export type PaperclipRunnerProviderProfile =
};
export type PaperclipRunnerNativeProviderInput =
| { provider: "grok_bot"; model: null; dotBinding: import("../../vendor/paperclip-runner/index.js").DotBindingSnapshot }
| { provider: "openai_dot"; model: null; dotBinding: import("../../vendor/paperclip-runner/index.js").DotBindingSnapshot }
| {
provider: "codex";
@@ -167,6 +169,18 @@ export function validatePaperclipRunnerDotConfig(config: Record<string, unknown>
return bindingId;
}
export function validatePaperclipRunnerGrokConfig(config: Record<string, unknown>, requireBinding = true): string | null {
const bindingId = optionalString(config.externalBindingId);
if ((requireBinding && !bindingId) || (bindingId && !/^[0-9a-f-]{36}$/i.test(bindingId))
|| config.allowUnmeteredProvider !== true || (config.lifecycleMode && config.lifecycleMode !== "per_turn")
|| optionalString(config.model) || config.dotWorkspaceAccess === true || config.dotAttachmentAccess === true
|| optionalString(config.cwd) || optionalString(config.command)) {
throw new PaperclipRunnerProviderProfileError("paperclip_runner_grok_config_invalid", "Grok Bot requires a bound per-turn work session and an explicit acknowledgement of unreported provider spending. Paperclip workspace, shell and third-party app tools are unavailable.");
}
return bindingId;
}
/** A task may change a local Runner model (and Codex effort), but not provider identity. */
export function projectPaperclipRunnerTaskConfig(
backend: "codex_app_server" | "opencode_server" | "acpx_runtime",
agentConfig: unknown,
@@ -385,6 +399,9 @@ export function resolvePaperclipRunnerProviderProfile(
);
}
if (candidate === "grok_bot") {
return { provider: "grok_bot", backend: "grok_bot_mcp", model: null, externalBindingId: validatePaperclipRunnerGrokConfig(config)! };
}
if (candidate === "openai_dot") {
const bindingId = validatePaperclipRunnerDotConfig(config)!;
return { provider: "openai_dot", backend: "openai_dot_mcp", model: null, dotBindingId: bindingId };
@@ -577,6 +594,10 @@ export function resolvePaperclipRunnerNativeProviderInput(input: {
"Paperclip Runner provider changed after this run selected its native backend.",
);
}
if (profile.provider === "grok_bot") {
if (!input.dotBinding || input.dotBinding.bindingId !== profile.externalBindingId) throw new PaperclipRunnerProviderProfileError("paperclip_runner_grok_binding_unavailable", "The Grok binding must be resolved by the server after normal admission.");
return { provider: "grok_bot", model: null, dotBinding: input.dotBinding };
}
if (profile.provider === "openai_dot") {
if (!input.dotBinding || input.dotBinding.bindingId !== profile.dotBindingId) throw new PaperclipRunnerProviderProfileError("paperclip_runner_dot_binding_unavailable", "Dot binding must be resolved by the server after normal admission.");
return { provider: "openai_dot", model: null, dotBinding: input.dotBinding };
@@ -3,7 +3,6 @@ import { z } from "zod";
import { buildOpenApiDocument } from "../../routes/openapi.js";
import { badRequest, notFound } from "../../errors.js";
import { runnerApiReference } from "./runner-api-reference.js";
import { runnerApiRestriction } from "./runner-api-policy.js";
import { CAPABILITY_SEMANTIC_TOOL_CATALOG } from "../../vendor/paperclip-runner/index.js";
type Json = Record<string, any>;
@@ -24,7 +23,7 @@ export interface RunnerApiOperation {
dedicatedToolGuidance: string;
dedicatedToolCapabilities?: { name: string; description: string; supportedParameters: string[] }[];
runnerRestrictions?: string[];
callPolicy: "rest" | "restricted" | "protocol";
callPolicy: "rest" | "protocol";
}
const METHODS = new Set(["get", "post", "put", "patch", "delete", "head", "options"]);
@@ -82,7 +81,6 @@ export function buildRunnerApiCatalog(document: Json = buildOpenApiDocument()):
for (const [verb, operation] of Object.entries<Json>(item)) {
if (!METHODS.has(verb)) continue;
const method = verb.toUpperCase();
const restriction = runnerApiRestriction(method, path);
const conversationalConfirmation = method === "POST"
&& /^\/api\/issues\/\{[^}]+\}\/interactions\/\{[^}]+\}\/resolve-from-comment$/.test(path);
const taskTitleUpdate = method === "PUT" && /^\/api\/issues\/\{[^}]+\}\/title$/.test(path);
@@ -99,19 +97,19 @@ export function buildRunnerApiCatalog(document: Json = buildOpenApiDocument()):
responses: dereference(operation.responses ?? {}),
authorization: operation["x-paperclip-authorization"] ?? { actor: "board_or_agent" },
transport: protocol ? "protocol" : "rest",
callPolicy: protocol ? "protocol" : restriction ? "restricted" : "rest",
callPolicy: protocol ? "protocol" : "rest",
dedicatedTools: dedicatedTools(method, path),
dedicatedToolCapabilities: dedicatedTools(method, path).flatMap(name => {
const descriptor = CAPABILITY_SEMANTIC_TOOL_CATALOG.find(tool => tool.operationId === name);
return descriptor ? [{ name, description: descriptor.description, supportedParameters: Object.keys((descriptor.inputSchema as Json).properties ?? {}) }] : [];
}),
runnerRestrictions: [...(restriction ? [restriction] : []), "Active run and assignment must remain authorized.",
runnerRestrictions: ["Active run and assignment must remain authorized.",
conversationalConfirmation
? "This records an ordinary conversational confirmation under existing resolver permissions. It cannot decide governed approvals or impersonate the user."
: "Cannot replace checkout, completion, task status/ownership changes, governed approval decisions, or runner execution control. Dedicated tools retain their existing permissions."],
: "The HTTP route enforces the same endpoint, payload, company, resource and approval permissions as agent REST access."],
dedicatedToolGuidance: dedicatedTools(method, path).includes("hire_agent")
? "Use hire_agent for native teammate identity and persona fields. It fixes the reportsTo and source task context and inherits the caller's native runtime; never use call_api to supply adapter, environment, or credential configuration."
: "Use an available dedicated tool for its supported fields. Inspect that tool's advertised schema; call_api may be used for additional API fields, subject to lifecycle restrictions.",
? "Prefer hire_agent for native teammate identity and persona fields. It fixes the reportsTo and source task context and inherits the caller's native runtime. Additional API fields use the route's existing agent permissions."
: "Prefer an available dedicated tool for its supported fields. call_api supports the route's other operations and fields under the same agent permissions.",
allowedModes: method === "GET" || method === "HEAD" || taskTitleUpdate ? ["standard", "ask", "planning", "skill_test"]
: conversationalConfirmation ? ["standard", "planning", "skill_test"] : ["standard", "skill_test"],
...(skillReference ? { skillReference } : {}),
@@ -1,8 +1,8 @@
import { z } from "zod";
import { badRequest, forbidden, unprocessable } from "../../errors.js";
import { runnerApiSensitiveResponse } from "./runner-api-sensitive-response.js";
import { runnerApiOperation, type RunnerApiOperation } from "./runner-api-catalog.js";
import { captureRunnerApiResponse, type RunnerApiResponseBody } from "./runner-api-response.js";
import { runnerApiRestriction } from "./runner-api-policy.js";
import { RunnerApiResponseLimitError, RunnerApiResponseCleanupError, RUNNER_API_RESPONSE_MAX_BYTES } from "./runner-api-response-limits.js";
/** Request/upload bound only. Response capture has a separate streamed limit. */
@@ -54,37 +54,8 @@ export function validateRunnerApiCall(value: unknown, context: RunnerApiContext)
const operation = runnerApiOperation(input.operationId);
if (operation.transport !== "rest") throw unprocessable("This endpoint requires its existing protocol client; call_api supports REST only");
if (input.responseText && operation.method !== "GET") throw badRequest("responseText requires a GET operation; read the saved artifact instead of repeating a mutation");
const restriction = runnerApiRestriction(operation.method, operation.path);
if (restriction) throw forbidden(restriction);
if (!operation.allowedModes.includes(context.workMode)) throw forbidden("call_api permits only reads in Ask and Plan modes; use the permitted dedicated tools");
if (input.pathParams?.companyId && input.pathParams.companyId !== context.companyId) throw forbidden("API call belongs to another company");
const mutation = !["GET", "HEAD", "OPTIONS"].includes(operation.method);
if (mutation) {
const issueRoute = /^\/api\/issues\/\{[^}]+\}/.test(operation.path);
const body = input.body && typeof input.body === "object" && !Array.isArray(input.body) ? input.body as Record<string, unknown> : {};
// Raw uploads must not hide lifecycle fields from the structured policy
// check and subsequently become JSON inside Express's body parser.
const guardedBody = /^\/api\/(issues|agents)\/\{[^}]+\}$/.test(operation.path)
|| (issueRoute && /\/comments$/.test(operation.path));
if (guardedBody && (input.files?.length || (input.body !== undefined && (!input.body || typeof input.body !== "object" || Array.isArray(input.body))))) {
throw forbidden("Lifecycle-sensitive API updates require an inline JSON object");
}
if (/^\/api\/agents\/\{[^}]+\}$/.test(operation.path) && ["status", "runtimeState"].some(key => Object.hasOwn(body, key))) {
throw forbidden("API agent updates cannot bypass runner execution-control authority");
}
if (issueRoute && /\/comments$/.test(operation.path) && ["reopen", "resume", "interrupt"].some(key => body[key] === true)) {
throw forbidden("API comments cannot change runner execution state");
}
if (issueRoute && /\/issues\/\{[^}]+\}$/.test(operation.path)) {
const lifecycle = ["status", "workMode", "executionRunId", "checkoutRunId", "executionState", "executionPolicy", "executionAgentNameKey", "executionLockedAt", "completedAt", "cancelledAt", "startedAt", "assigneeAgentId", "assigneeUserId", "blockedByIssueIds", "reopen", "resume", "interrupt"];
if (lifecycle.some((key) => Object.hasOwn(body, key))) throw forbidden("Use dedicated completion, review, delegation or dependency tools for task lifecycle changes");
// The real issue router normalizes identifiers and PostgreSQL accepts
// uppercase UUIDs. Apply the same identity comparison before dispatch.
const target = Object.values(input.pathParams ?? {}).map(id => id.trim().toLowerCase());
const activeIds = [context.issueId, context.issueIdentifier].filter((id): id is string => Boolean(id)).map(id => id.toLowerCase());
if (operation.method === "DELETE" && target.some(id => activeIds.includes(id))) throw forbidden("The active runner task cannot delete itself");
}
}
const contentType = (input.contentType ?? "application/json").split(";", 1)[0].trim().toLowerCase();
if (input.body !== undefined && !input.files?.length) {
const requestSchema = operation.requestBody?.content?.[contentType]?.schema as { type?: string } | undefined;
@@ -151,6 +122,7 @@ export async function readBoundedResponse(response: Response, maxBytes = RUNNER_
export async function executeRunnerApi(input: RunnerApiCall, context: RunnerApiContext, io: RunnerApiIo) {
const { operation, input: validated } = validateRunnerApiCall(input, context);
input = validated;
const sensitiveResponse = runnerApiSensitiveResponse(operation.method, operation.path);
const url = runnerApiUrl(operation, input, context, io.apiUrl);
if (!io.token) throw new Error("Paperclip run authentication is unavailable");
const headers = new Headers({ Authorization: `Bearer ${io.token}`, "X-Paperclip-Run-Id": context.runId });
@@ -215,7 +187,11 @@ export async function executeRunnerApi(input: RunnerApiCall, context: RunnerApiC
try {
response = await (io.fetch ?? fetch)(url, { method: operation.method, headers, body, redirect: "manual", signal: controller.signal });
clearTimeout(timeout);
if (assetTextRange && response.ok) {
if (sensitiveResponse) {
timeout = setTimeout(() => controller.abort(), RUNNER_API_TIMEOUT_MS);
bytes = await readBoundedResponse(response);
await io.beforeDispatch?.();
} else if (assetTextRange && response.ok) {
timeout = setTimeout(() => controller.abort(), RUNNER_API_TIMEOUT_MS);
bytes = await readBoundedResponse(response, textLimit + 2);
} else {
@@ -294,10 +270,11 @@ export async function executeRunnerApi(input: RunnerApiCall, context: RunnerApiC
guidance: "Read this asset with responseText: { offsetBytes: 0 }. Continue with responseText.nextOffsetBytes until null." };
}
if (!totalBytes) return { ...base, data: null };
if (totalBytes > RUNNER_API_INLINE_BYTES || !/json|^text\//i.test(type)) {
if (!sensitiveResponse && (totalBytes > RUNNER_API_INLINE_BYTES || !/json|^text\//i.test(type))) {
return { ...base, artifact: await saveResponse(captured?.body ?? bytes, type), byteSize: totalBytes, preview: /json|^text\//i.test(type) ? bytes.subarray(0, 2000).toString("utf8") : null,
...(/json|^text\//i.test(type) ? { guidance: "Read the saved artifact with GET /api/assets/{assetId}/content and responseText: { offsetBytes: 0 }. Continue with responseText.nextOffsetBytes until null. Do not repeat a mutation to read its response." } : {}) };
}
if (sensitiveResponse && !/json|^text\//i.test(type)) return { ...base, ok: false, error: "credential_response_requires_text_content" };
const text = bytes.toString("utf8");
if (/json/i.test(type)) {
try { return { ...base, data: JSON.parse(text) as unknown }; }
@@ -0,0 +1,35 @@
import { describe, expect, it } from "vitest";
import { redactRunnerApiPayloads } from "./runner-api-log-redaction.js";
describe("API payload persistence redaction", () => {
it.each(["POST /api/agents/me/secrets/{key}/value", "POST /api/companies/{companyId}/exports", "GET /api/projects/{id}"])("preserves private HTTP results on the wire, but omits %s from history", operationId => {
const result = { operationId: "call_api", apiOperationId: operationId, status: 200, ok: true, data: { value: "private-fixture-value" } };
const event = { item: { type: "tool_result", result } };
const saved = redactRunnerApiPayloads(event);
expect(saved.item.result).toMatchObject({ operationId: "call_api", apiOperationId: operationId, status: 200, apiPayloadRedacted: true });
expect(JSON.stringify(saved)).not.toContain("private-fixture-value");
expect(event.item.result.data.value).toBe("private-fixture-value");
expect(redactRunnerApiPayloads(event)).toEqual(saved);
expect(redactRunnerApiPayloads(saved)).toEqual(saved);
expect((redactRunnerApiPayloads({ ...result, data: { value: "another-fixture-value" } }) as any).apiPayloadSha256)
.not.toBe((saved.item.result as any).apiPayloadSha256);
});
it("redacts serialized legacy receipts and API request arguments", () => {
const value = { text: JSON.stringify({ result: { operationId: "POST /api/agents/me/secrets/{key}/value", data: { value: "private-fixture-value" } } }),
input: { operationId: "POST /api/agents/me/secret-proposals", body: { value: "proposal-fixture-value" } } };
const saved = redactRunnerApiPayloads(value);
expect(JSON.stringify(saved)).not.toContain("private-fixture-value");
expect(JSON.stringify(saved)).not.toContain("proposal-fixture-value");
expect(JSON.parse(saved.text).result.apiPayloadRedacted).toBe(true);
});
it("preserves unrelated tool output and JSON text formatting", () => {
const value = { result: { operationId: "get_task_context", data: { title: "Work" } }, text: '{ "ordinary": "output" }' };
expect(redactRunnerApiPayloads(value)).toEqual(value);
});
it("omits malformed API arguments and semantic result envelopes", () => {
const value = { item: { type: "tool_use", name: "call_api", input: { operationId: "invalid", body: { value: "private-fixture-value" } } },
result: { operationId: "call_api", result: { data: "private-fixture-value" } } };
expect(JSON.stringify(redactRunnerApiPayloads(value))).not.toContain("private-fixture-value");
expect(redactRunnerApiPayloads(redactRunnerApiPayloads(value))).toEqual(redactRunnerApiPayloads(value));
});
});
@@ -0,0 +1,40 @@
import { REDACTED_EVENT_VALUE } from "../../redaction.js";
import { nativeSha256 } from "./canonical.js";
const RECEIPT_FIELDS = new Set(["operationId", "apiOperationId", "callId", "ok", "status", "contentType"]);
const REDACTED_FIELDS = new Set([...RECEIPT_FIELDS, "data", "apiPayloadRedacted", "apiPayloadSha256"]);
function redactedPayload(value: unknown) {
const entry = value && typeof value === "object" ? value as Record<string, unknown> : {};
const wasRedacted = entry.data === REDACTED_EVENT_VALUE && entry.apiPayloadRedacted === true
&& typeof entry.apiPayloadSha256 === "string" && /^[a-f0-9]{64}$/.test(entry.apiPayloadSha256)
&& Object.keys(entry).every(key => REDACTED_FIELDS.has(key));
return {
...Object.fromEntries(Object.entries(entry).filter(([key, item]) => RECEIPT_FIELDS.has(key)
&& ["string", "number", "boolean"].includes(typeof item))),
data: REDACTED_EVENT_VALUE,
apiPayloadRedacted: true,
apiPayloadSha256: wasRedacted ? entry.apiPayloadSha256 : nativeSha256(value),
};
}
export function redactRunnerApiPayloads<T>(value: T): T {
if (Array.isArray(value)) return value.map(redactRunnerApiPayloads) as T;
if (typeof value === "string" && /^[\s]*[\[{]/.test(value)) {
try {
const parsed = JSON.parse(value);
const redacted = redactRunnerApiPayloads(parsed);
return (JSON.stringify(parsed) === JSON.stringify(redacted) ? value : JSON.stringify(redacted)) as T;
} catch { return value; }
}
if (!value || typeof value !== "object") return value;
const entry = value as Record<string, unknown>;
const operation = entry.apiOperationId ?? entry.operationId;
if (operation === "call_api" || (typeof operation === "string" && /^(GET|HEAD|OPTIONS|POST|PUT|PATCH|DELETE) \/api\//.test(operation))) {
return redactedPayload(value) as T;
}
if (entry.type === "tool_use" && entry.name === "call_api") {
return { ...entry, input: redactedPayload(entry.input) } as T;
}
return Object.fromEntries(Object.entries(entry).map(([key, item]) => [key, redactRunnerApiPayloads(item)])) as T;
}
@@ -1,66 +0,0 @@
/** Ordinary API results and replay receipts are not a secret-value channel. */
export function runnerApiRestriction(
method: string,
path: string,
): string | null {
const metadataRead =
method === "GET" &&
[
"/api/agents/me/secrets",
"/api/companies/{companyId}/secrets/catalog",
].includes(path);
if (
(/^\/api\/tool-gateway\/sessions(\/|$)/.test(path) && method !== "GET") ||
/^\/api\/chat-identity-links(\/|$)/.test(path) ||
/\/chat-endpoints\/\{[^}]+\}\/principals\/\{[^}]+\}\/link(?:-intent)?$/.test(
path,
) ||
(!metadataRead &&
(/\/(secrets|setup-secret|secret-proposals|secret-provider-configs|user-secrets|user-secret-definitions|keys|board-api-keys|credentials|setup-token-login-sessions|board-claim|invites|join-requests|gateway-tokens|tokens|token|rotate-secret|terminal-session-token|claim-api-key)(\/|$)/.test(
path,
) ||
/^\/api\/companies\/\{companyId\}\/exports?(\/|$)/.test(path)))
) {
return "Use the existing credential broker or secure management client: call_api cannot return secret values, manage credentials, or export company credential configuration.";
}
return ["GET", "HEAD", "OPTIONS"].includes(method)
? null
: runnerApiMutationRestriction(path);
}
/** Runner-owned transitions cannot be reached by the generic HTTP escape hatch. */
export function runnerApiMutationRestriction(path: string): string | null {
// This endpoint only records an eligible responding agent's interpretation
// of a same-task user message. It checks the live run, resolver policy and
// session under lock, and cannot execute governed approvals or enqueue work.
if (/^\/api\/issues\/\{[^}]+\}\/interactions\/\{[^}]+\}\/resolve-from-comment$/.test(path)) return null;
const issueRoute = /^\/api\/issues\/\{[^}]+\}/.test(path);
const routineAnnotation =
/^\/api\/routines\/\{[^}]+\}\/description\/annotations(?:\/\{[^}]+\}(?:\/comments)?)?$/.test(
path,
);
if (
(!routineAnnotation && /\/(routines|routine-triggers)(\/|$)/.test(path)) ||
/\/(runtime-commands|runtime-services)\/\{action\}$/.test(path) ||
/\/(?:tool-gateway|tools)\/runtime-slots\/\{[^}]+\}\/(restart|stop)$/.test(
path,
) ||
/^\/api\/cases\/\{[^}]+\}\/(automation|automations)\//.test(path) ||
/\/skills\/\{[^}]+\}\/test-runs(\/|$)/.test(path) ||
/^\/api\/heartbeat-runs\//.test(path) ||
/^\/api\/agents\/\{[^}]+\}\/(heartbeat|wakeup|pause|resume|terminate|approve|clear-error|runtime-state)(\/|$)/.test(
path,
) ||
/^\/api\/(approvals|decisions)\/\{[^}]+\}\/(approve|reject|decide|cancel|dismiss|request-revision|resubmit)$/.test(
path,
) ||
(issueRoute && /\/queued-comments\/\{[^}]+\}\/steer$/.test(path)) ||
(issueRoute &&
/\/(interactions|accepted-plan-decompositions|stalled-review-decision|tree-holds|watchdog|recovery-actions|scheduled-retry|monitor|admin|checkout|release|cancel|resume|wake|run|retry|recover|tree-control)(\/|$)/.test(
path,
))
) {
return "Use the dedicated tools and existing clients: call_api cannot bypass runner lifecycle, execution-control or approval authority";
}
return null;
}
@@ -0,0 +1,8 @@
export function runnerApiSensitiveResponse(method: string, path: string): boolean {
if (/\/secrets\/\{[^}]+\}\/value$/.test(path)) return true;
return /^\/api\/tool-gateway\/sessions(?:\/|$)/.test(path)
|| /^\/api\/companies\/\{[^}]+\}\/exports?(?:\/|$)/.test(path)
|| (method === "POST" && /^\/api\/routines\/\{[^}]+\}\/triggers$/.test(path))
|| (method === "POST" && /^\/api\/routines\/\{[^}]+\}\/revisions\/\{[^}]+\}\/restore$/.test(path))
|| /\/(keys|board-api-keys|credentials|invites|join-requests|gateway-tokens|tokens|token|rotate-secret|terminal-session-token|claim-api-key)(\/|$)/.test(path);
}
@@ -3,8 +3,10 @@ import { randomUUID } from "node:crypto";
import { chmod, writeFile, symlink, mkdir, open } from "node:fs/promises";
import { join, dirname } from "node:path";
import { eq } from "drizzle-orm";
import { assets, documents, heartbeatRuns, issues, projects, routineDocuments, routines, runnerApiResponseReservations } from "@paperclipai/db";
import { agents, assets, documents, heartbeatRuns, issues, projects, routineDocuments, routines, runnerApiResponseReservations, companySkills, companyMemberships, principalPermissionGrants } from "@paperclipai/db";
import { beforeAll, afterAll, beforeEach, afterEach, describe, expect, it, vi } from "vitest";
import { secretService } from "../secrets.js";
import { authorizationService } from "../authorization.js";
import { startRunnerApiTestServer } from "../../__tests__/helpers/runner-api-server.js";
import { createRunnerdCodexTransport, defaultCapabilityRunnerdBinary } from "../../vendor/paperclip-runner/index.js";
import { runnerApiCatalog } from "./runner-api-catalog.js";
@@ -12,10 +14,12 @@ import { registerRunnerPrpAuthority } from "../../realtime/runner-prp-ws.js";
import { createLocalAgentJwt } from "../../agent-auth-jwt.js";
import { RUNNER_API_RESPONSE_MAX_BYTES, RUNNER_API_RESPONSE_RUN_MAX_BYTES } from "./runner-api-response-limits.js";
import { PaperclipRunnerToolAuthority } from "./paperclip-runner-tool-authority.js";
import { prepareHeartbeatTaskContext } from "../heartbeat/task-context.js";
describe("runner API against real HTTP routes", () => {
let server: Awaited<ReturnType<typeof startRunnerApiTestServer>>;
const oldSecret = process.env.PAPERCLIP_AGENT_JWT_SECRET;
const oldMasterKeyFile = process.env.PAPERCLIP_SECRETS_MASTER_KEY_FILE;
beforeEach(() => {
vi.stubEnv("PAPERCLIP_RUNNER_API_TOOLS_ENABLED", undefined);
vi.stubEnv("PAPERCLIP_RUNNER_API_TOOLS_COMPANY_IDS", undefined);
@@ -24,9 +28,12 @@ describe("runner API against real HTTP routes", () => {
beforeAll(async () => {
process.env.PAPERCLIP_AGENT_JWT_SECRET = randomUUID();
server = await startRunnerApiTestServer();
process.env.PAPERCLIP_SECRETS_MASTER_KEY_FILE = join(server.root, "master.key");
}, 60_000);
afterAll(async () => {
await server?.close();
if (oldMasterKeyFile === undefined) delete process.env.PAPERCLIP_SECRETS_MASTER_KEY_FILE;
else process.env.PAPERCLIP_SECRETS_MASTER_KEY_FILE = oldMasterKeyFile;
if (oldSecret === undefined) delete process.env.PAPERCLIP_AGENT_JWT_SECRET;
else process.env.PAPERCLIP_AGENT_JWT_SECRET = oldSecret;
});
@@ -108,24 +115,302 @@ else if(m.id!==undefined) send({id:m.id,result:{}});
} finally { delete process.env.PAPERCLIP_RUNNER_API_TOOLS_ENABLED; }
});
it("rejects credential calls before any durable receipt or secret result exists", async () => {
const fixture = await server.fixture();
for (const operationId of ["POST /api/agents/me/secrets/{key}/value", "POST /api/agents/{id}/keys"]) {
await expect(fixture.authority.execute({ tool: "call_api", callId: operationId, arguments: { operationId, pathParams: operationId.includes("{key}") ? { key: "EXAMPLE_SECRET" } : { id: fixture.agentId } } })).rejects.toThrow("credential broker");
}
const [run] = await server.db.select().from(heartbeatRuns).where(eq(heartbeatRuns.id, fixture.runId));
expect((run.resultJson as Record<string, unknown> | null)?.apiToolReceipts).toBeUndefined();
expect((await fixture.snapshot()).activity.filter(row => row.action === "runner.api_called")).toEqual([]);
it("uses route permissions for lifecycle payloads instead of fallback-only denials", async () => {
const fixture = await server.fixture({ disableWakeOnDemand: true });
const result = await fixture.authority.execute({ tool: "call_api", callId: "block-via-rest", arguments: {
operationId: "PATCH /api/issues/{id}", pathParams: { id: fixture.blockerId },
body: { status: "blocked", billingCode: "REST-PARITY", blockedByIssueIds: [fixture.issueId] },
} });
expect(result).toMatchObject({ ok: true, status: 200, data: { status: "blocked", billingCode: "REST-PARITY" } });
expect((await fixture.snapshot()).issues.find(row => row.id === fixture.blockerId))
.toMatchObject({ status: "blocked", billingCode: "REST-PARITY" });
});
it("rejects issue lifecycle intents before dispatch or receipt creation", async () => {
it("manages owned routines and triggers with durable replay, attribution and owner denials", async () => {
const fixture = await server.fixture({ disableWakeOnDemand: true, conversation: true });
await server.db.update(issues).set({ responsibleUserId: fixture.responsibleUserId }).where(eq(issues.id, fixture.issueId));
const args = { operationId: "POST /api/companies/{companyId}/routines", body: {
title: "REST parity routine", assigneeAgentId: fixture.agentId, projectId: fixture.projectId, parentIssueId: fixture.issueId, status: "paused",
} };
const call = (callId: string, arguments_: unknown) => fixture.authority.execute({ tool: "call_api", callId, arguments: arguments_ }) as Promise<any>;
const created = await call("routine-create", args);
expect(created).toMatchObject({ ok: true, status: 201, data: { title: "REST parity routine", assigneeAgentId: fixture.agentId } });
expect(await call("routine-create", args)).toEqual(created);
const routineId = created.data.id;
const trigger = await call("routine-trigger", { operationId: "POST /api/routines/{id}/triggers", pathParams: { id: routineId }, body: { kind: "schedule", cronExpression: "0 9 * * *", timezone: "UTC", enabled: false } });
expect(trigger).toMatchObject({ ok: true, status: 201, data: { trigger: { routineId, enabled: false } } });
expect(await call("routine-update", { operationId: "PATCH /api/routines/{id}", pathParams: { id: routineId }, body: { title: "Updated through fallback" } }))
.toMatchObject({ ok: true, status: 200, data: { title: "Updated through fallback" } });
const [other] = await server.db.insert(agents).values({ companyId: fixture.companyId, name: "Other routine owner", status: "active" }).returning();
expect(await call("routine-other-owner", { ...args, body: { ...args.body, assigneeAgentId: other.id } }))
.toMatchObject({ ok: false, status: 403 });
const snapshot = await fixture.snapshot();
expect(snapshot.activity).toEqual(expect.arrayContaining([
expect.objectContaining({ action: "routine.created", agentId: fixture.agentId, runId: fixture.runId }),
expect.objectContaining({ action: "runner.api_called", agentId: fixture.agentId, runId: fixture.runId, details: expect.objectContaining({ operationId: args.operationId, status: 201 }) }),
]));
expect(await server.db.select().from(routines).where(eq(routines.companyId, fixture.companyId))).toHaveLength(1);
});
it("starts a permitted skill test through the fallback using the existing assignment grants", async () => {
const fixture = await server.fixture({ conversation: true, disableWakeOnDemand: true });
await server.db.insert(companyMemberships).values({ companyId: fixture.companyId, principalType: "agent", principalId: fixture.agentId, status: "active", membershipRole: "member" });
await server.db.insert(principalPermissionGrants).values([
{ companyId: fixture.companyId, principalType: "agent", principalId: fixture.agentId, permissionKey: "tasks:assign" },
{ companyId: fixture.companyId, principalType: "user", principalId: fixture.responsibleUserId!, permissionKey: "tasks:assign" },
]);
const skillDir = join(server.root, `parity-skill-${randomUUID()}`);
await mkdir(skillDir);
await writeFile(join(skillDir, "SKILL.md"), "# Parity skill\nReturn the supplied fixture input.\n");
const [skill] = await server.db.insert(companySkills).values({
companyId: fixture.companyId, key: `company/${fixture.companyId}/parity`, slug: "parity", name: "Parity skill",
markdown: "# Parity skill\n", sourceType: "local_path", sourceLocator: skillDir,
trustLevel: "markdown_only", compatibility: "compatible", fileInventory: [{ path: "SKILL.md", kind: "skill" }], metadata: { sourceKind: "managed_local" },
}).returning();
const call = { tool: "call_api", callId: "skill-test-create", arguments: {
operationId: "POST /api/companies/{companyId}/skills/{skillId}/test-runs", pathParams: { skillId: skill.id },
body: { agentId: fixture.agentId, content: "Fixture input" },
} };
const result = await fixture.authority.execute(call) as any;
expect(result).toMatchObject({ ok: true, status: 201, data: { skillId: skill.id, agentId: fixture.agentId } });
expect(await fixture.authority.execute(call)).toEqual(result);
expect((await fixture.snapshot()).issues.some(issue => issue.id === result.data.issueId)).toBe(true);
});
it("keeps saved API responses within the issuing actor's scope", async () => {
const fixture = await server.fixture();
for (const field of ["reopen", "resume", "interrupt"]) {
await expect(fixture.authority.execute({ tool: "call_api", callId: field, arguments: { operationId: "PATCH /api/issues/{id}", pathParams: { id: fixture.blockerId }, body: { [field]: true } } })).rejects.toThrow("lifecycle changes");
const body = "Private API response\n".repeat(2000);
const saved = await fixture.authority.execute({ tool: "call_api", callId: "private-snapshot", arguments: {
operationId: "PUT /api/issues/{id}/documents/{key}", pathParams: { id: fixture.issueId, key: "private-api-result" },
body: { title: "Private API result", body, format: "markdown", baseRevisionId: null },
} }) as any;
expect(saved).toMatchObject({ ok: true, status: 201, artifact: { artifactId: expect.any(String) } });
const url = `${fixture.apiUrl}/api/assets/${saved.artifact.artifactId}/content`;
const owner = createLocalAgentJwt(fixture.agentId, fixture.companyId, "paperclip_runner", fixture.runId);
expect((await fetch(url, { headers: { Authorization: `Bearer ${owner}` } })).status).toBe(200);
const [peer] = await server.db.insert(agents).values({ companyId: fixture.companyId, name: "Peer snapshot reader", status: "active", role: "ceo", adapterType: "paperclip_runner" }).returning();
const [peerRun] = await server.db.insert(heartbeatRuns).values({ companyId: fixture.companyId, agentId: peer.id, status: "running", runtimeMode: "native", nativeIssueId: fixture.blockerId, contextSnapshot: { issueId: fixture.blockerId } }).returning();
await server.db.update(issues).set({ assigneeAgentId: peer.id, status: "in_progress", executionRunId: peerRun.id,
description: `![private](/api/assets/${saved.artifact.artifactId}/content)`,
}).where(eq(issues.id, fixture.blockerId));
const token = createLocalAgentJwt(peer.id, fixture.companyId, "codex_local", peerRun.id);
expect((await fetch(url, { headers: { Authorization: `Bearer ${token}` } })).status).toBe(404);
const peerAuthority = new PaperclipRunnerToolAuthority(server.db, {
...fixture.authority.binding, agentId: peer.id, runId: peerRun.id, issueId: fixture.blockerId,
});
await expect(peerAuthority.execute({ tool: "call_api", callId: "peer-snapshot-upload", arguments: {
operationId: "POST /api/companies/{companyId}/issues/{issueId}/attachments", pathParams: { issueId: fixture.blockerId },
files: [{ artifactId: saved.artifact.artifactId }],
} })).rejects.toThrow("Artifact is not available to this actor");
const exported = await peerAuthority.execute({ tool: "call_api", callId: "peer-snapshot-export", arguments: {
operationId: "POST /api/companies/{companyId}/exports", body: { include: { issues: true }, issues: [fixture.blockerId] },
} }) as any;
expect(exported).toMatchObject({ ok: true, status: 200 });
for (const [path, file] of Object.entries(exported.data.files)) {
if (!path.startsWith("blobs/")) continue;
expect(Buffer.from((file as { data: string }).data, "base64").toString("utf8")).not.toContain("Private API response");
}
expect(await peerAuthority.execute({ tool: "call_api", callId: "peer-snapshot-logo", arguments: {
operationId: "PATCH /api/companies/{companyId}/branding", body: { logoAssetId: saved.artifact.artifactId },
} })).toMatchObject({ ok: false, status: 422 });
const ownUpload = await fixture.authority.execute({ tool: "call_api", callId: "own-snapshot-upload", arguments: {
operationId: "POST /api/companies/{companyId}/issues/{issueId}/attachments", pathParams: { issueId: fixture.issueId },
files: [{ artifactId: saved.artifact.artifactId }],
} });
expect(ownUpload).toMatchObject({ ok: true, status: 201 });
});
it.each(["skill_test", "low_trust_review"])("allows task image reads without company browsing (%s)", async scope => {
const fixture = await server.fixture();
if (scope === "skill_test") {
await server.db.update(heartbeatRuns).set({ contextSnapshot: { issueId: fixture.issueId,
paperclipAgentJwtScope: { kind: "skill_test", issueId: fixture.issueId },
} }).where(eq(heartbeatRuns.id, fixture.runId));
} else {
await server.db.update(agents).set({ permissions: { trustPreset: "low_trust_review", authorizationPolicy: {
trustBoundary: { mode: "low_trust_review", companyId: fixture.companyId, issueIds: [fixture.issueId], allowedAgentIds: [fixture.agentId] },
} } }).where(eq(agents.id, fixture.agentId));
}
const keyScope = scope === "skill_test" ? { kind: "skill_test" as const, issueId: fixture.issueId } : { kind: "standard" as const };
const actor = { type: "agent" as const, agentId: fixture.agentId, companyId: fixture.companyId, runId: fixture.runId, keyScope };
expect((await authorizationService(server.db).decide({ actor, action: "company_scope:read",
resource: { type: "company", companyId: fixture.companyId },
})).allowed).toBe(false);
const imageIds: string[] = [];
for (const issueId of [fixture.issueId, fixture.blockerId]) {
const stored = await server.storage.putFile({ companyId: fixture.companyId, namespace: `issues/${issueId}`,
originalFilename: "image.png", contentType: "image/png", body: Buffer.from("task-image-fixture"),
});
const [image] = await server.db.insert(assets).values({ companyId: fixture.companyId, ...stored, createdByAgentId: fixture.agentId }).returning();
imageIds.push(image.id);
}
const token = createLocalAgentJwt(fixture.agentId, fixture.companyId, "paperclip_runner", fixture.runId, null, keyScope);
expect((await fetch(`${fixture.apiUrl}/api/assets/${imageIds[0]}/content`, { headers: { Authorization: `Bearer ${token}` } })).status).toBe(200);
expect((await fetch(`${fixture.apiUrl}/api/assets/${imageIds[1]}/content`, { headers: { Authorization: `Bearer ${token}` } })).status).toBe(404);
expect(await fixture.authority.execute({ tool: "call_api", callId: "scoped-image", arguments: {
operationId: "GET /api/assets/{assetId}/content", pathParams: { assetId: imageIds[0] },
} })).toMatchObject({ ok: true, status: 206 });
await expect(fixture.authority.execute({ tool: "call_api", callId: "scoped-image-upload", arguments: {
operationId: "POST /api/companies/{companyId}/issues/{issueId}/attachments", pathParams: { issueId: fixture.issueId },
files: [{ artifactId: imageIds[1] }],
} })).rejects.toThrow("Artifact is not available to this actor");
});
it("keeps restored webhook secrets inline and encrypts their replay receipts", async () => {
const fixture = await server.fixture({ disableWakeOnDemand: true, conversation: true });
vi.stubEnv("PAPERCLIP_API_URL", fixture.apiUrl);
await server.db.update(issues).set({ responsibleUserId: fixture.responsibleUserId }).where(eq(issues.id, fixture.issueId));
const call = (callId: string, args: unknown) => fixture.authority.execute({ tool: "call_api", callId, arguments: args }) as Promise<any>;
const created = await call("webhook-routine", { operationId: "POST /api/companies/{companyId}/routines", body: {
title: "Restored webhook", assigneeAgentId: fixture.agentId, projectId: fixture.projectId, parentIssueId: fixture.issueId, status: "paused",
} });
expect(created).toMatchObject({ status: 201 });
const routineId = created.data.id;
await server.db.update(routines).set({ description: "Restored routine description\n".repeat(2000) }).where(eq(routines.id, routineId));
const trigger = await call("webhook-trigger", { operationId: "POST /api/routines/{id}/triggers", pathParams: { id: routineId },
body: { kind: "webhook", signingMode: "bearer", replayWindowSec: 300 },
});
expect(trigger).toMatchObject({ status: 201 });
expect(await call("delete-webhook", { operationId: "DELETE /api/routine-triggers/{id}", pathParams: { id: trigger.data.trigger.id } }))
.toMatchObject({ status: 204 });
const args = { operationId: "POST /api/routines/{id}/revisions/{revisionId}/restore",
pathParams: { id: routineId, revisionId: trigger.data.revision.id },
};
const restored = await call("restore-webhook", args);
expect(restored).toMatchObject({ ok: true, status: 200, data: { secretMaterials: [expect.objectContaining({ webhookSecret: expect.any(String) })] } });
expect(restored).not.toHaveProperty("artifact");
expect(await call("restore-webhook", args)).toEqual(restored);
const value = restored.data.secretMaterials[0].webhookSecret;
const [run] = await server.db.select().from(heartbeatRuns).where(eq(heartbeatRuns.id, fixture.runId));
expect((run.resultJson as Record<string, unknown> | null)?.apiToolReceipts).toBeUndefined();
expect((await fixture.snapshot()).issues.find(row => row.id === fixture.blockerId)?.status).toBe("todo");
expect(JSON.stringify(run.resultJson)).not.toContain(value);
expect(Object.values((run.resultJson as any).apiToolReceipts).find((receipt: any) => receipt.operationId === args.operationId))
.toHaveProperty("encryptedResult");
expect((await fixture.snapshot()).assets.filter(asset => asset.objectKey.includes("/runner-api/"))).toEqual([]);
});
it("returns and replays scoped secret values without plaintext receipts or response assets", async () => {
const fixture = await server.fixture();
const value = "disposable-private-credential-".repeat(1500);
const secrets = secretService(server.db);
const secret = await secrets.create(fixture.companyId, { name: "parity/private-value", key: "PARITY_TOKEN", provider: "local_encrypted", value });
await secrets.createBinding({ companyId: fixture.companyId, secretId: secret.id, targetType: "agent", targetId: fixture.agentId, configPath: "env.PARITY_TOKEN" });
const call = { tool: "call_api", callId: "secret-value", arguments: { operationId: "POST /api/agents/me/secrets/{key}/value", pathParams: { key: secret.key } } };
const result = await fixture.authority.execute(call);
expect(result).toMatchObject({ ok: true, status: 200, data: { key: secret.key, value } });
expect(await fixture.authority.execute(call)).toEqual(result);
const snapshot = await fixture.snapshot();
expect(snapshot.assets.filter(asset => asset.objectKey.includes("/runner-api/"))).toEqual([]);
const [run] = await server.db.select().from(heartbeatRuns).where(eq(heartbeatRuns.id, fixture.runId));
expect(JSON.stringify(run.resultJson)).not.toContain(value);
expect(Object.values((run.resultJson as any).apiToolReceipts)[0]).toHaveProperty("encryptedResult");
expect(snapshot.activity.filter(row => row.action === "runner.api_called")).toHaveLength(1);
const otherAgentId = randomUUID();
await server.db.insert(agents).values({ id: otherAgentId, companyId: fixture.companyId, name: "Unbound peer", status: "active" });
const [peerRun] = await server.db.insert(heartbeatRuns).values({ companyId: fixture.companyId, agentId: otherAgentId, status: "running", runtimeMode: "native", nativeIssueId: fixture.blockerId, contextSnapshot: { issueId: fixture.blockerId } }).returning();
await server.db.update(issues).set({ assigneeAgentId: otherAgentId, status: "in_progress", executionRunId: peerRun.id }).where(eq(issues.id, fixture.blockerId));
const token = createLocalAgentJwt(otherAgentId, fixture.companyId, "paperclip_runner", peerRun.id);
expect((await fetch(`${fixture.apiUrl}/api/agents/me/secrets/${secret.key}/value`, { method: "POST", headers: { Authorization: `Bearer ${token}`, "X-Paperclip-Run-Id": peerRun.id } })).status).toBe(403);
});
it.each([
["POST /api/companies/{companyId}/exports", 1],
["POST /api/companies/{companyId}/export", 2500],
])("keeps authorized company export credentials private and replayable for %s", async (operationId, repeats) => {
const fixture = await server.fixture();
await server.db.update(agents).set({ role: "ceo" }).where(eq(agents.id, fixture.agentId));
const credential = "disposable-export-credential-".repeat(repeats);
const [httpAgent] = await server.db.insert(agents).values({
companyId: fixture.companyId, name: "Exported HTTP fixture", adapterType: "http",
adapterConfig: { url: "https://fixture.invalid", headers: { Authorization: credential } },
}).returning();
const call = { tool: "call_api", callId: "private-export", arguments: {
operationId, body: { include: { agents: true }, agents: [httpAgent.id] },
} };
const result = await fixture.authority.execute(call) as any;
expect(result).toMatchObject({ ok: true, status: 200 });
expect(JSON.stringify(result.data)).toContain(credential);
expect(result).not.toHaveProperty("artifact");
expect(await fixture.authority.execute(call)).toEqual(result);
const snapshot = await fixture.snapshot();
expect(snapshot.assets.filter(asset => asset.objectKey.includes("/runner-api/"))).toEqual([]);
expect(snapshot.activity.filter(row => row.action === "runner.api_called")).toHaveLength(1);
const [run] = await server.db.select().from(heartbeatRuns).where(eq(heartbeatRuns.id, fixture.runId));
expect(JSON.stringify(run.resultJson)).not.toContain(credential);
expect(Object.values((run.resultJson as any).apiToolReceipts)[0]).toHaveProperty("encryptedResult");
});
async function reprepareRun(fixture: { runId: string; issueId: string; agentId: string }) {
const [run] = await server.db.select().from(heartbeatRuns).where(eq(heartbeatRuns.id, fixture.runId));
const [issue] = await server.db.select().from(issues).where(eq(issues.id, fixture.issueId));
const [agent] = await server.db.select().from(agents).where(eq(agents.id, fixture.agentId));
const context = structuredClone(run.contextSnapshot ?? {});
await prepareHeartbeatTaskContext(server.db, { run, agent,
task: { issueRef: issue, issueContext: null, wakeCommentContext: null, context, taskSession: null },
policy: { recovering: true, trustPreset: { kind: "standard" }, experimentalInstanceSettings: {} },
services: { issuesSvc: { getAncestors: async () => [] }, getPinnedSkillTestContext: async () => null },
});
await server.db.update(heartbeatRuns).set({ contextSnapshot: context }).where(eq(heartbeatRuns.id, fixture.runId));
}
it("preserves the REST skill-test credential scope after a task mode edit and restart preparation", async () => {
const fixture = await server.fixture();
await server.db.update(heartbeatRuns).set({ contextSnapshot: { issueId: fixture.issueId,
paperclipAgentJwtScope: { kind: "skill_test", issueId: fixture.issueId },
} }).where(eq(heartbeatRuns.id, fixture.runId));
await server.db.update(issues).set({ workMode: "skill_test" }).where(eq(issues.id, fixture.issueId));
await server.db.update(issues).set({ workMode: "standard" }).where(eq(issues.id, fixture.issueId));
await reprepareRun(fixture);
const body = { kind: "secret", name: "parity/skill-test", value: "disposable-skill-test-value", justification: "Must remain denied" };
const token = createLocalAgentJwt(fixture.agentId, fixture.companyId, "paperclip_runner", fixture.runId,
fixture.responsibleUserId, { kind: "skill_test", issueId: fixture.issueId });
const direct = await fetch(`${fixture.apiUrl}/api/agents/me/secret-proposals`, {
method: "POST", headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json", "X-Paperclip-Run-Id": fixture.runId }, body: JSON.stringify(body),
});
expect(direct.status).toBe(403);
const result = await fixture.authority.execute({ tool: "call_api", callId: "skill-test-secret-denied", arguments: {
operationId: "POST /api/agents/me/secret-proposals", body,
} });
expect(result).toMatchObject({ ok: false, status: direct.status, data: await direct.json() });
});
it("retains standard REST credential scope after a task becomes a skill test and restart preparation", async () => {
const fixture = await server.fixture();
await server.db.update(heartbeatRuns).set({ contextSnapshot: { issueId: fixture.issueId,
paperclipAgentJwtScope: { kind: "standard" },
} }).where(eq(heartbeatRuns.id, fixture.runId));
await server.db.update(issues).set({ workMode: "skill_test" }).where(eq(issues.id, fixture.issueId));
await reprepareRun(fixture);
const token = createLocalAgentJwt(fixture.agentId, fixture.companyId, "paperclip_runner", fixture.runId, null, { kind: "standard" });
const direct = await fetch(`${fixture.apiUrl}/api/companies/${fixture.companyId}/projects`, { headers: { Authorization: `Bearer ${token}` } });
expect(direct.status).toBe(200);
expect(await fixture.authority.execute({ tool: "call_api", callId: "standard-scope", arguments: {
operationId: "GET /api/companies/{companyId}/projects",
} })).toMatchObject({ ok: true, status: 200, data: await direct.json() });
});
it("creates and reads secret proposals while preserving redaction and board review", async () => {
const fixture = await server.fixture();
const keyFile = process.env.PAPERCLIP_SECRETS_MASTER_KEY_FILE;
process.env.PAPERCLIP_SECRETS_MASTER_KEY_FILE = join(server.root, "proposal-master.key");
try {
const args = { operationId: "POST /api/agents/me/secret-proposals", body: {
kind: "secret", name: "parity/example", value: "disposable-parity-secret", justification: "Test existing agent REST permission",
} };
const call = (callId: string, arguments_: unknown) => fixture.authority.execute({ tool: "call_api", callId, arguments: arguments_ }) as Promise<any>;
const created = await call("proposal-create", args);
expect(created).toMatchObject({ ok: true, status: 201, data: { status: "pending", kind: "secret" } });
expect(await call("proposal-create", args)).toEqual(created);
const listed = await call("proposal-list", { operationId: "GET /api/agents/me/secret-proposals" });
expect(listed).toMatchObject({ ok: true, status: 200, data: { proposals: [expect.objectContaining({ id: created.data.id })] } });
expect(JSON.stringify([created, listed])).not.toContain(args.body.value);
expect(await call("proposal-approve-denied", { operationId: "POST /api/companies/{companyId}/secret-proposals/{id}/approve", pathParams: { id: created.data.id } }))
.toMatchObject({ ok: false, status: 403 });
const [run] = await server.db.select().from(heartbeatRuns).where(eq(heartbeatRuns.id, fixture.runId));
expect(JSON.stringify(run.resultJson)).not.toContain(args.body.value);
} finally {
if (keyFile === undefined) delete process.env.PAPERCLIP_SECRETS_MASTER_KEY_FILE;
else process.env.PAPERCLIP_SECRETS_MASTER_KEY_FILE = keyFile;
}
});
it("supports routine annotation collaboration without changing scheduling", async () => {
@@ -147,15 +432,31 @@ else if(m.id!==undefined) send({id:m.id,result:{}});
expect((await fixture.snapshot()).activity).toEqual(expect.arrayContaining([expect.objectContaining({ action: "routine.document_annotation_thread_created", agentId: fixture.agentId, runId: fixture.runId })]));
});
it("rejects every restricted REST mutation before dispatch or durable receipt", async () => {
const fixture = await server.fixture();
const operations = runnerApiCatalog().filter(operation => operation.transport === "rest" && !["GET", "HEAD", "OPTIONS"].includes(operation.method) && operation.callPolicy === "restricted");
expect(operations.length).toBeGreaterThan(5);
for (const operation of operations) {
await expect(fixture.authority.execute({ tool: "call_api", callId: operation.operationId, arguments: { operationId: operation.operationId } })).rejects.toThrow(/cannot bypass|credential broker/);
it("matches direct agent REST authorization for formerly restricted routes", async () => {
const fixture = await server.fixture({ disableWakeOnDemand: true });
const token = createLocalAgentJwt(fixture.agentId, fixture.companyId, "paperclip_runner", fixture.runId);
for (const [operationId, path, body] of [
["POST /api/agents/{id}/pause", `/api/agents/${fixture.agentId}/pause`, {}],
["POST /api/agents/{id}/resume", `/api/agents/${fixture.agentId}/resume`, {}],
["POST /api/companies/{companyId}/exports", `/api/companies/${fixture.companyId}/exports`, {}],
["POST /api/companies/{companyId}/invites", `/api/companies/${fixture.companyId}/invites`, {}],
["POST /api/approvals/{id}/approve", `/api/approvals/${fixture.approvalId}/approve`, {}],
["POST /api/issues/{id}/recovery-actions/resolve", `/api/issues/${fixture.blockerId}/recovery-actions/resolve`, {}],
["POST /api/execution-workspaces/{id}/runtime-services/{action}", `/api/execution-workspaces/${randomUUID()}/runtime-services/stop`, {}],
["POST /api/companies/{companyId}/skills/{skillId}/test-runs", `/api/companies/${fixture.companyId}/skills/${randomUUID()}/test-runs`, { agentId: fixture.agentId, content: "Test input" }],
] as const) {
const direct = await fetch(`${fixture.apiUrl}${path}`, { method: "POST", headers: { Authorization: `Bearer ${token}`, "X-Paperclip-Run-Id": fixture.runId, "Content-Type": "application/json" }, body: JSON.stringify(body) });
const names = [...operationId.matchAll(/\{([^}]+)\}/g)].map(([, name]) => name);
const template = operationId.slice(5).split("/");
const actual = path.split("/");
const values = template.flatMap((segment, index) => segment.startsWith("{") ? [actual[index]] : []);
const result = await fixture.authority.execute({ tool: "call_api", callId: randomUUID(), arguments: {
operationId, pathParams: Object.fromEntries(names.map((name, index) => [name, values[index]])), body,
} }) as any;
expect(result.status, operationId).toBe(direct.status);
expect(result.ok, operationId).toBe(direct.ok);
if (!direct.ok) expect(result.data, operationId).toEqual(await direct.json());
}
const [run] = await server.db.select().from(heartbeatRuns).where(eq(heartbeatRuns.id, fixture.runId));
expect((run.resultJson as Record<string, unknown> | null)?.apiToolReceipts).toBeUndefined();
});
it("preserves route validation, authorization and audit; replays mutations once", async () => {
@@ -187,20 +488,6 @@ else if(m.id!==undefined) send({id:m.id,result:{}});
}
});
it("allows API-only options while protecting task completion", async () => {
const fixture = await server.fixture();
const call = (body: unknown) => fixture.authority.execute({ tool: "call_api", callId: randomUUID(), arguments: { operationId: "PATCH /api/issues/{id}", pathParams: { id: fixture.issueId }, body } });
await expect(call({ billingCode: "API-EXTRA" })).resolves.toMatchObject({ status: 200 });
await expect(call({ status: "done" })).rejects.toThrow("lifecycle");
const [issue] = await server.db.select().from(issues).where(eq(issues.id, fixture.issueId));
expect(issue.billingCode).toBe("API-EXTRA");
expect(issue.status).toBe("in_progress");
for (const id of [fixture.issueId.toUpperCase(), issue.identifier!.toLowerCase(), ` ${issue.identifier!.toLowerCase()} `]) {
await expect(fixture.authority.execute({ tool: "call_api", callId: randomUUID(), arguments: { operationId: "DELETE /api/issues/{id}", pathParams: { id } } })).rejects.toThrow("cannot delete itself");
}
expect((await fixture.snapshot()).issues.some(row => row.id === fixture.issueId)).toBe(true);
});
it("revokes advertised API tools without disabling dedicated operations", async () => {
const fixture = await server.fixture();
expect(fixture.authority.definitions().some(tool => tool.name === "call_api")).toBe(true);
@@ -75,7 +75,7 @@ describe("runner API catalog", () => {
expect(operation.allowedModes).toContain("planning");
expect(operation.allowedModes).not.toContain("ask");
expect(operation.runnerRestrictions?.join(" ")).toContain("conversational confirmation");
expect(runnerApiOperation("POST /api/issues/{id}/interactions/{interactionId}/accept").callPolicy).toBe("restricted");
expect(runnerApiOperation("POST /api/issues/{id}/interactions/{interactionId}/accept").callPolicy).toBe("rest");
expect(runnerApiOperation(createProject).allowedModes).not.toContain("planning");
});
it("accounts for unique operations with resolved request contracts", () => {
@@ -138,21 +138,25 @@ describe("runner API catalog", () => {
});
describe("runner API request boundary", () => {
it.each([
"POST /api/execution-workspaces/{id}/runtime-commands/{action}",
"POST /api/projects/{id}/workspaces/{workspaceId}/runtime-services/{action}",
"POST /api/tool-gateway/runtime-slots/{slotId}/restart",
"POST /api/cases/{caseId}/automation/current-stage/rerun",
"POST /api/companies/{companyId}/skills/{skillId}/test-runs",
"POST /api/tool-gateway/sessions",
])(
"keeps execution and gateway control %s out of generic dispatch",
async (operationId) => {
const request = vi.fn<typeof fetch>();
await expect(
executeRunnerApi({ operationId }, context, io(request)),
).rejects.toThrow(/cannot bypass|credential broker/);
expect(request).not.toHaveBeenCalled();
it.each(runnerApiCatalog().filter(operation => operation.transport === "rest"))(
"dispatches $operationId with agent identity and preserves route authorization",
async operation => {
const request = vi.fn<typeof fetch>(async () =>
Response.json({ error: "Route permission required" }, { status: 403 }),
);
const pathParams = Object.fromEntries(
[...operation.path.matchAll(/\{([^}]+)\}/g)].map(([, name]) =>
[name, name === "companyId" ? context.companyId : "fixture"]),
);
const result = await executeRunnerApi(
{ operationId: operation.operationId, pathParams }, context, io(request),
);
expect(operation.callPolicy).toBe("rest");
expect(result).toMatchObject({ ok: false, status: 403, data: { error: "Route permission required" } });
expect(request).toHaveBeenCalledOnce();
const headers = new Headers(request.mock.calls[0][1]?.headers);
expect(headers.get("Authorization")).toBe("Bearer private-agent-token");
expect(headers.get("X-Paperclip-Run-Id")).toBe(context.runId);
},
);
it.each([
@@ -171,28 +175,6 @@ describe("runner API request boundary", () => {
expect(request).not.toHaveBeenCalled();
},
);
it.each(
runnerApiCatalog().filter(
(operation) =>
!["GET", "HEAD", "OPTIONS"].includes(operation.method) &&
/\/(routines|routine-triggers)(\/|$)/.test(operation.path) &&
!operation.path.includes("/description/annotations"),
),
)(
"keeps scheduled execution $operationId behind its existing client",
async (operation) => {
const request = vi.fn<typeof fetch>();
await expect(
executeRunnerApi(
{ operationId: operation.operationId },
context,
io(request),
),
).rejects.toThrow(/cannot bypass|credential broker/);
expect(request).not.toHaveBeenCalled();
expect(operation.callPolicy).toBe("restricted");
},
);
it.each(
runnerApiCatalog().filter(
(operation) =>
@@ -226,51 +208,16 @@ describe("runner API request boundary", () => {
).operation.callPolicy,
).toBe("rest");
});
it.each(["reopen", "resume", "interrupt"])(
"cannot hide lifecycle intent %s in an ordinary issue patch",
async (field) => {
const request = vi.fn<typeof fetch>();
await expect(
executeRunnerApi(
{
operationId: "PATCH /api/issues/{id}",
pathParams: { id: "other-issue" },
body: { [field]: true, billingCode: "safe-extra-field" },
},
context,
io(request),
),
).rejects.toThrow("lifecycle changes");
expect(request).not.toHaveBeenCalled();
},
);
it.each([
"POST /api/agents/me/secrets/{key}/value",
"POST /api/agents/{id}/keys",
"DELETE /api/agents/{id}/keys/{keyId}",
"POST /api/companies/{companyId}/secret-proposals/{id}/approve",
"POST /api/agents/me/secret-proposals",
"PATCH /api/secrets/{id}",
"POST /api/companies/{companyId}/exports",
"GET /api/secret-provider-configs/{id}",
"POST /api/chat-endpoints/{endpointId}/setup-secret",
"POST /api/chat-endpoints/{endpointId}/principals/{principalId}/link-intent",
"DELETE /api/chat-endpoints/{endpointId}/principals/{principalId}/link",
"POST /api/chat-identity-links/confirm",
"GET /api/chat-identity-links/preview",
])(
"keeps sensitive operation %s out of model results and receipts",
async (operationId) => {
const request = vi.fn<typeof fetch>();
await expect(
executeRunnerApi({ operationId }, context, io(request)),
).rejects.toThrow("credential broker");
expect(request).not.toHaveBeenCalled();
expect(searchRunnerApi({ query: operationId }).results[0]).toMatchObject({
callPolicy: "restricted",
});
},
);
["PATCH /api/issues/{id}", { status: "blocked", assigneeAgentId: "teammate", blockedByIssueIds: ["dependency"], reopen: true }],
["PATCH /api/agents/{id}", { status: "idle" }],
["POST /api/issues/{id}/comments", { body: "Continue", resume: true }],
])("passes REST payload fields to route authorization for %s", async (operationId, body) => {
const request = vi.fn<typeof fetch>(async () => Response.json({ accepted: true }));
expect(await executeRunnerApi({ operationId: operationId as string, pathParams: { id: "fixture" }, body }, context, io(request)))
.toMatchObject({ ok: true, status: 200 });
expect(JSON.parse(request.mock.calls[0][1]?.body as string)).toEqual(body);
});
it("retains safe secret metadata discovery", () => {
for (const operationId of [
"GET /api/agents/me/secrets",
@@ -341,39 +288,34 @@ describe("runner API request boundary", () => {
).toBe("GET");
}
});
it("retains API-only issue options while guarding lifecycle fields", () => {
const input = {
operationId: "PATCH /api/issues/{id}",
pathParams: { id: context.issueId },
body: { billingCode: "cost-center" },
};
expect(validateRunnerApiCall(input, context).operation.method).toBe(
"PATCH",
);
expect(() =>
validateRunnerApiCall({ ...input, body: { status: "done" } }, context),
).toThrow("dedicated");
expect(() =>
validateRunnerApiCall(
{
operationId: "POST /api/issues/{id}/checkout",
pathParams: { id: context.issueId },
},
context,
),
).toThrow("lifecycle");
it.each([
["POST /api/agents/me/secrets/{key}/value", { key: "FIXTURE_TOKEN" }],
["POST /api/companies/{companyId}/exports", {}],
["POST /api/companies/{companyId}/export", {}],
])("returns credential responses for %s above the inline limit without plaintext artifacts", async (operationId, pathParams) => {
const value = "fixture-credential-".repeat(3000);
const transport = io(async () => Response.json({ value }));
const save = vi.fn(transport.saveResponse);
const reserve = vi.fn(async () => vi.fn(async () => {}));
const result = await executeRunnerApi({ operationId, pathParams }, context, { ...transport, saveResponse: save, reserveResponseCapture: reserve });
expect(result).toMatchObject({ ok: true, status: 200, data: { value } });
expect(result).not.toHaveProperty("artifact");
expect(save).not.toHaveBeenCalled();
expect(reserve).not.toHaveBeenCalled();
});
it("withholds a credential response when the run is revoked during HTTP", async () => {
let authorized = true;
const request = vi.fn<typeof fetch>(async () => {
authorized = false;
return Response.json({ value: "private-fixture-value" });
});
const result = await executeRunnerApi({ operationId: "POST /api/agents/me/secrets/{key}/value", pathParams: { key: "fixture-key" } }, context, {
...io(request), beforeDispatch: async () => { if (!authorized) throw new Error("Run revoked"); },
});
expect(result).toMatchObject({ ok: false, error: "api_transport_failure", outcome: "unknown" });
expect(JSON.stringify(result)).not.toContain("private-fixture-value");
expect(request).toHaveBeenCalledOnce();
});
it.each(["issue-a", "ISSUE-A", "API-1", "api-1", " api-1 "])(
"cannot delete its active task using the route identity alias %s",
(id) => {
expect(() =>
validateRunnerApiCall(
{ operationId: "DELETE /api/issues/{id}", pathParams: { id } },
context,
),
).toThrow("cannot delete itself");
},
);
it("forwards only server-owned authentication and preserves API denials", async () => {
const request = vi.fn<typeof fetch>(async () =>
Response.json({ error: "Board access required" }, { status: 403 }),
@@ -559,29 +501,11 @@ describe("runner API request boundary", () => {
),
).rejects.toThrow();
});
it("classifies protocols and prevents execution control through alternate routes", () => {
const protocols = runnerApiCatalog().filter(
(operation) => operation.transport === "protocol",
);
expect(
protocols.some((operation) => operation.path.endsWith("/events/ws")),
).toBe(true);
for (const operation of protocols)
expect(() =>
validateRunnerApiCall({ operationId: operation.operationId }, context),
).toThrow("protocol client");
for (const operationId of [
"POST /api/issues/{id}/tree-holds",
"POST /api/issues/{id}/stalled-review-decision",
"POST /api/agents/{id}/runtime-state/reset-session",
"POST /api/approvals/{id}/resubmit",
]) {
expect(() =>
validateRunnerApiCall(
{ operationId, pathParams: { id: "fixture" } },
context,
),
).toThrow("cannot bypass");
it("keeps protocol clients separate from REST dispatch", () => {
const protocols = runnerApiCatalog().filter(operation => operation.transport === "protocol");
expect(protocols.some(operation => operation.path.endsWith("/events/ws"))).toBe(true);
for (const operation of protocols) {
expect(() => validateRunnerApiCall({ operationId: operation.operationId }, context)).toThrow("protocol client");
}
});
it("keeps skill-test mode consistent with the advertised tool contract", () => {
@@ -592,24 +516,13 @@ describe("runner API request boundary", () => {
).operation.method,
).toBe("POST");
});
it("cannot hide lifecycle mutations in a raw uploaded JSON body", () => {
for (const operationId of [
"PATCH /api/issues/{id}",
"PATCH /api/agents/{id}",
"POST /api/issues/{id}/comments",
]) {
expect(() =>
validateRunnerApiCall(
{
operationId,
pathParams: { id: "fixture" },
files: [{ path: "hidden-status.json" }],
contentType: "application/json",
},
context,
),
).toThrow("inline JSON object");
}
it("passes uploaded JSON bodies through the same route validation as REST", async () => {
const request = vi.fn<typeof fetch>(async () => Response.json({ error: "Board access required" }, { status: 403 }));
const transport = io(request);
transport.readFile = async () => ({ bytes: Buffer.from('{"status":"idle"}'), filename: "update.json", contentType: "application/json" });
expect(await executeRunnerApi({ operationId: "PATCH /api/agents/{id}", pathParams: { id: "fixture" }, files: [{ path: "update.json" }], contentType: "application/json" }, context, transport))
.toMatchObject({ ok: false, status: 403 });
expect(Buffer.from(request.mock.calls[0][1]?.body as Uint8Array).toString()).toBe('{"status":"idle"}');
});
it("reads text windows from a live response and supplies stable snapshots", async () => {
const text = "prefix\n" + "🧭é\n".repeat(6000) + "END-OF-EVIDENCE";
@@ -50,9 +50,33 @@ describe("resolveNativeRuntimeMode", () => {
})).toMatchObject({ kind: "native", profile: { backend: "codex_app_server" } });
});
it("admits native Claude without enabling other ACPX providers", () => {
expect(resolveNativeRuntimeMode({ ...eligible, enabled: false, adapterConfig: { provider: "acpx", acpxAgent: "claude" } })).toMatchObject({ kind: "native", profile: { backend: "acpx_runtime" } });
expect(() => resolveNativeRuntimeMode({ ...eligible, enabled: false, adapterConfig: { provider: "acpx", acpxAgent: "grok" } })).toThrow(expect.objectContaining({ code: "paperclip_runner_rollout_disabled" }));
it.each([
[false, false],
[false, true],
[true, false],
])("retains the Grok Bot gate with runner enabled=%s and Grok enabled=%s", (enabled, grokEnabled) => {
expect(() => resolveNativeRuntimeMode({
...eligible,
enabled,
grokEnabled,
adapterConfig: {
provider: "grok_bot",
externalBindingId: "00000000-0000-4000-8000-000000000001",
allowUnmeteredProvider: true,
},
})).toThrow(expect.objectContaining({ code: "paperclip_runner_grok_disabled" }));
});
it("admits a bound Grok Bot when its existing experimental gates are enabled", () => {
expect(resolveNativeRuntimeMode({
...eligible,
grokEnabled: true,
adapterConfig: {
provider: "grok_bot",
externalBindingId: "00000000-0000-4000-8000-000000000001",
allowUnmeteredProvider: true,
},
})).toMatchObject({ kind: "native", profile: { backend: "grok_bot_mcp" } });
});
it("still rejects experimental providers while the rollout flag is disabled", () => {
@@ -477,4 +501,8 @@ describe("resolveHeartbeatRuntimeMode compatibility", () => {
adapterConfig: { provider: "opencode", model: "opencode/gpt-5" },
})).toThrow(NativeRunnerSelectionError);
});
it("admits native Claude without enabling other ACPX providers", () => {
expect(resolveNativeRuntimeMode({ ...eligible, enabled: false, adapterConfig: { provider: "acpx", acpxAgent: "claude" } })).toMatchObject({ kind: "native", profile: { backend: "acpx_runtime" } });
expect(() => resolveNativeRuntimeMode({ ...eligible, enabled: false, adapterConfig: { provider: "acpx", acpxAgent: "grok" } })).toThrow(expect.objectContaining({ code: "paperclip_runner_rollout_disabled" }));
});
});
@@ -51,7 +51,7 @@ export type NativeRuntimeResolution =
| "claude_managed_agents_api"
| "aws_agentcore_harness_api"
| "acpx_runtime"
| "openai_dot_mcp";
| "openai_dot_mcp" | "grok_bot_mcp";
protocolVersion: 1;
};
authorityDecision: NativeStatusDecision;
@@ -98,6 +98,7 @@ function ineligible(
export function resolveNativeRuntimeMode(input: {
enabled: boolean;
dotEnabled?: boolean;
grokEnabled?: boolean;
runtimeConfig: unknown;
adapterConfig?: unknown;
agent: { id?: string; status: string; adapterType: string | null };
@@ -126,10 +127,10 @@ export function resolveNativeRuntimeMode(input: {
}
throw error;
}
// Native Codex is a supported default. Other providers retain their rollout;
// enabling Dot does not opt in any of those providers.
if (runnerProfile.provider === "openai_dot") {
if (input.dotEnabled !== true) throw ineligible("paperclip_runner_dot_disabled", "Enable OpenAI Dot and Assistant connections (MCP) in experimental settings before assigning new work.");
} else if (runnerProfile.provider === "grok_bot" && (input.grokEnabled !== true || !input.enabled)) {
throw ineligible("paperclip_runner_grok_disabled", "Enable Grok Bot, Paperclip Runner, and Assistant connections (MCP) before assigning new work.");
} else if (runnerProfile.provider !== "codex" && !(runnerProfile.provider === "acpx" && runnerProfile.acpxAgent === "claude") && !input.enabled) {
throw ineligible(
"paperclip_runner_rollout_disabled",
@@ -188,6 +189,7 @@ export function resolveHeartbeatRuntimeMode(input: {
};
enabled: boolean;
dotEnabled?: boolean;
grokEnabled?: boolean;
adapterType: string | null;
adapterConfig: unknown;
agentStatus: string;
@@ -215,6 +217,7 @@ export function resolveHeartbeatRuntimeMode(input: {
resolution = resolveNativeRuntimeMode({
enabled: input.enabled,
dotEnabled: input.dotEnabled,
grokEnabled: input.grokEnabled,
runtimeConfig: {},
adapterConfig: input.adapterConfig,
agent: {
@@ -250,6 +253,7 @@ export function resolveHeartbeatRuntimeMode(input: {
? "claude_managed"
: resolution.profile.backend === "aws_agentcore_harness_api"
? "aws_agentcore"
: resolution.profile.backend === "grok_bot_mcp" ? "grok_bot"
: resolution.profile.backend === "openai_dot_mcp" ? "openai_dot"
: resolution.profile.backend === "acpx_runtime"
? "acpx"
@@ -271,6 +275,7 @@ export function resolveHeartbeatNativeRuntimeMode(input: {
};
enabled: boolean;
dotEnabled?: boolean;
grokEnabled?: boolean;
runtimeConfig: unknown;
adapterConfig?: unknown;
agent: { id?: string; status: string; adapterType: string | null };
@@ -302,6 +307,7 @@ export function resolveHeartbeatNativeRuntimeMode(input: {
? "claude_managed_agents_api"
: driverKind === "aws_agentcore_harness_api"
? "aws_agentcore_harness_api"
: driverKind === "grok_bot_mcp" ? "grok_bot_mcp"
: driverKind === "openai_dot_mcp" ? "openai_dot_mcp"
: driverKind === "acpx_runtime"
? "acpx_runtime"
@@ -1,9 +1,21 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { mkdir, mkdtemp, readFile, realpath, rm, stat, symlink, writeFile } from "node:fs/promises";
import { chmod, lstat, mkdir, mkdtemp, readFile, readlink, readdir, realpath, rm, stat, symlink, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join, posix } from "node:path";
import { ADAPTER_AUTH_MISSING_CHECK_CODE } from "@paperclipai/shared";
import * as localCredentialFile from "../local-ai-credential-file.js";
import * as codexCredentials from "@paperclipai/adapter-codex-local/server";
const { prepareIsolatedCodexHome } = await import(new URL("../../../../packages/paperclip-runner/src/drivers/runtime-context-materializer.ts", import.meta.url).href);
const { execute, nativeProbe, selectedRunner, selectedRemoteRunner, sshExecute, sshRunner, nativeArtifacts, remoteLauncher, registerPrp, ingressConnect } = vi.hoisted(() => ({ execute: vi.fn(), nativeProbe: vi.fn(), selectedRunner: vi.fn(), selectedRemoteRunner: vi.fn(), sshExecute: vi.fn(), sshRunner: vi.fn(), nativeArtifacts: vi.fn(), remoteLauncher: vi.fn(), registerPrp: vi.fn(), ingressConnect: vi.fn() }));
const nativeProbeRuntimeDirectories = vi.hoisted(() => [] as string[]);
vi.mock("node:fs/promises", async original => {
const filesystem = await original<typeof import("node:fs/promises")>();
return { ...filesystem, mkdtemp: async (...args: Parameters<typeof filesystem.mkdtemp>) => {
const directory = await filesystem.mkdtemp(...args);
if (typeof args[0] === "string" && args[0].endsWith("paperclip-native-setup-") && typeof directory === "string") nativeProbeRuntimeDirectories.push(directory);
return directory;
} };
});
vi.mock("@paperclipai/adapter-utils/execution-target", () => ({ runAdapterExecutionTargetShellCommand: execute }));
vi.mock("./native-ssh-command-runner.js", () => ({ createNativeSshCommandRunner: sshRunner }));
vi.mock("../../realtime/runner-prp-ws.js", () => ({ registerRunnerPrpAuthority: registerPrp }));
@@ -13,13 +25,13 @@ vi.mock("../../vendor/paperclip-runner/index.js", async original => ({ ...await
vi.mock("./native-session-executor.js", async original => ({ ...await original<typeof import("./native-session-executor.js")>(), createRemoteNativeArtifactPreparation: nativeArtifacts, createRemoteRunnerProcessLauncher: remoteLauncher }));
import { testNativeRunnerAuthentication } from "./setup-readiness.js";
const context = {
companyId: "company", adapterType: "paperclip_runner", config: {},
companyId: "company", adapterType: "paperclip_runner", config: { externallySandboxed: false, env: { PAPERCLIP_RUNNER_EXTERNAL_SANDBOX: "false" } },
executionTarget: { kind: "remote" as const, transport: "sandbox" as const, providerKey: "test", remoteCwd: "/workspace", runner: { execute: vi.fn() } },
};
describe("Codex selected native account verification", () => {
const receipt = (provider: "codex", model: string | null) => ({ provider, effectiveModel: model, providerDriver: "codex_app_server", helloProbePassed: true, cleanupConfirmed: true });
beforeEach(() => {
execute.mockReset(); nativeProbe.mockReset();
execute.mockReset(); nativeProbe.mockReset(); nativeProbeRuntimeDirectories.splice(0);
sshExecute.mockReset().mockResolvedValue({ exitCode: 0, timedOut: false, stdout: "", stderr: "" });
sshRunner.mockReset().mockReturnValue({ execute: sshExecute });
nativeArtifacts.mockReset().mockReturnValue({ prepare: vi.fn().mockResolvedValue(undefined) });
@@ -37,17 +49,18 @@ describe("Codex selected native account verification", () => {
afterEach(async () => {
vi.unstubAllGlobals();
vi.unstubAllEnvs();
for (const [input] of nativeProbe.mock.calls) if (input.runtimeDirectory) await rm(input.runtimeDirectory, { recursive: true, force: true });
for (const root of nativeProbeRuntimeDirectories) await rm(root, { recursive: true, force: true });
});
it.each([["codex", "gpt-6.1-sol", "OPENAI_API_KEY"]] as const)("requires the native %s host and only the selected credential", async (provider, model, key) => {
vi.stubEnv(key, "ambient-account");
vi.stubEnv("ANTHROPIC_API_KEY", "ambient-anthropic");
nativeProbe.mockImplementation(async input => { await input.onCleanupConfirmed(); return receipt(provider, model); });
const result = await testNativeRunnerAuthentication({ companyId: "company", adapterType: "paperclip_runner", config: { env: { [key]: "bound-account" }, modelReasoningEffort: "low" } }, provider, model);
const result = await testNativeRunnerAuthentication({ companyId: "company", adapterType: "paperclip_runner", config: { externallySandboxed: true, env: { [key]: "bound-account", PAPERCLIP_RUNNER_EXTERNAL_SANDBOX: "true" }, modelReasoningEffort: "low" } }, provider, model);
expect(result.status).toBe("pass");
const input = nativeProbe.mock.calls[0][0];
expect(input).toMatchObject({ provider, model, timeoutMs: 45_000, environment: { [key]: "bound-account" } });
expect(input.transportOptions.runnerBinary).toBe("/fixture/bin/paperclip-runnerd");
expect(input.transportOptions.externallySandboxed).toBe(false);
expect(selectedRunner).toHaveBeenCalledOnce();
expect(input.environment.ANTHROPIC_API_KEY).toBeUndefined();
if (provider === "codex") expect(input.reasoningEffort).toBe("low");
@@ -55,6 +68,45 @@ describe("Codex selected native account verification", () => {
await expect(stat(input.runtimeDirectory)).rejects.toThrow();
expect(execute).not.toHaveBeenCalled();
});
it("rejects a missing local daemon before creating private credential staging", async () => {
const home = await realpath(await mkdtemp(join(tmpdir(), "native-preflight-daemon-")));
const auth = '{"tokens":{"account_id":"fixture-account","access_token":"fixture-access"}}';
const readCredential = vi.spyOn(localCredentialFile, "readLocalAiCredentialFile");
try {
await writeFile(join(home, "account-store.json"), auth, { mode: 0o600 });
await symlink(join(home, "account-store.json"), join(home, "auth.json"));
selectedRunner.mockImplementation(() => { throw new Error("native executable is missing"); });
const result = await testNativeRunnerAuthentication({ companyId: "company", adapterType: "paperclip_runner", managedAiCredentialHome: home, config: {} }, "codex", "gpt-6.1-sol");
expect(result.status).toBe("fail");
expect(result.checks[0].message).toBe("native executable is missing");
expect(readCredential).toHaveBeenCalledOnce();
expect(nativeProbeRuntimeDirectories).toEqual([]);
expect(nativeProbe).not.toHaveBeenCalled();
expect(await readFile(join(home, "account-store.json"), "utf8")).toBe(auth);
expect(await readlink(join(home, "auth.json"))).toBe(join(home, "account-store.json"));
expect((await readdir(home)).sort()).toEqual(["account-store.json", "auth.json"]);
} finally { readCredential.mockRestore(); await rm(home, { recursive: true, force: true }); }
});
it.each(["local", "remote"])("cleans its own private root when %s credential staging fails before probe handoff", async transport => {
const home = await realpath(await mkdtemp(join(tmpdir(), "native-preflight-staging-")));
const auth = '{"tokens":{"account_id":"fixture-account","access_token":"fixture-access"}}';
try {
await writeFile(join(home, "account-store.json"), auth, { mode: 0o600 });
await symlink(join(home, "account-store.json"), join(home, "auth.json"));
await writeFile(join(home, "config.toml"), "x".repeat(1024 * 1024 + 1), { mode: 0o600 });
const selectedContext = transport === "remote" ? sshContext : { companyId: "company", adapterType: "paperclip_runner", config: {} };
const result = await testNativeRunnerAuthentication({ ...selectedContext, managedAiCredentialHome: home }, "codex", "gpt-6.1-sol");
expect(result.status).toBe("fail");
expect(result.checks[0].message).toContain("configuration exceeds its size limit");
expect(nativeProbe).not.toHaveBeenCalled();
expect(sshExecute).not.toHaveBeenCalled();
expect(nativeProbeRuntimeDirectories).toHaveLength(1);
await expect(stat(nativeProbeRuntimeDirectories[0]!)).rejects.toMatchObject({ code: "ENOENT" });
expect(await readFile(join(home, "account-store.json"), "utf8")).toBe(auth);
expect((await stat(join(home, "config.toml"))).size).toBe(1024 * 1024 + 1);
expect(await readlink(join(home, "auth.json"))).toBe(join(home, "account-store.json"));
} finally { await rm(home, { recursive: true, force: true }); }
});
it.each(["host-home", "host-codex-home", "configured-home", "managed-home"] as const)("reuses the task's authorized local Codex home before isolation (%s)", async mode => {
vi.stubEnv("HOME", "/host-home");
vi.stubEnv("CODEX_HOME", mode === "host-home" ? "" : "/host-codex");
@@ -69,13 +121,12 @@ describe("Codex selected native account verification", () => {
expect(input.environment.HOME).toBeUndefined();
expect(input.runtimeDirectory).not.toContain("host-home");
});
it.each(["same-account", "another-account", "bound-key", "invalid-handoff"] as const)("preserves the authorized unmanaged login refresh before local cleanup (%s)", async mode => {
it.each(["same-account", "another-account", "bound-key", "invalid-handoff"] as const)("preserves the server-selected login refresh before local cleanup (%s)", async mode => {
const home = await realpath(await mkdtemp(join(tmpdir(), "native-local-login-")));
const auth = (account: string, marker: string, age: number) => JSON.stringify({ tokens: { account_id: account, id_token: `id-${marker}`, access_token: `access-${marker}`, refresh_token: `refresh-${marker}` }, last_refresh: new Date(Date.now() - age).toISOString() });
const store = join(home, "account.json");
try {
await writeFile(store, auth("qa-account", "old", 120_000), { mode: 0o600 });
// Existing per-agent homes can point at their authorized shared account.
await symlink(store, join(home, "auth.json"));
nativeProbe.mockImplementation(async input => {
const privateHome = join(input.runtimeDirectory, "codex-home");
@@ -87,7 +138,7 @@ describe("Codex selected native account verification", () => {
await input.onCleanupConfirmed();
return receipt("codex", "gpt-6.1-sol");
});
const result = await testNativeRunnerAuthentication({ companyId: "company", adapterType: "paperclip_runner", config: { env: { CODEX_HOME: home, ...(mode === "bound-key" ? { OPENAI_API_KEY: "selected-key" } : {}) } } }, "codex", "gpt-6.1-sol");
const result = await testNativeRunnerAuthentication({ companyId: "company", adapterType: "paperclip_runner", managedAiCredentialHome: home, config: { env: { CODEX_HOME: home, ...(mode === "bound-key" ? { OPENAI_API_KEY: "selected-key" } : {}) } } }, "codex", "gpt-6.1-sol");
expect(result.status).toBe(mode === "invalid-handoff" ? "fail" : "pass");
expect(JSON.parse(await readFile(store, "utf8")).tokens.access_token).toBe(mode === "same-account" ? "access-new" : "access-old");
expect((await stat(store)).mode & 0o777).toBe(0o600);
@@ -99,6 +150,186 @@ describe("Codex selected native account verification", () => {
const sshContext = { companyId: "company", adapterType: "paperclip_runner", config: {}, executionTarget: {
kind: "remote" as const, transport: "ssh" as const, remoteCwd: "/qa-workspace", spec: { host: "qa-host", username: "qa-user", port: 22, remoteCwd: "/qa-workspace", remoteWorkspacePath: "/qa-workspace", privateKey: null, knownHosts: null, strictHostKeyChecking: true },
} };
it.each(["local", "remote"].flatMap(transport => ["configured", "operator"].flatMap(source => [false, true].map(linked => ({ transport, source, linked })))))
("matches actual task auth seeding before $transport setup (source=$source, linked=$linked)", async ({ transport, source, linked }) => {
const home = await realpath(await mkdtemp(join(tmpdir(), "native-unbound-auth-")));
const auth = '{"tokens":{"account_id":"fixture-unbound-account","access_token":"fixture-unbound-access","refresh_token":"fixture-unbound-refresh","id_token":"fixture-unbound-id"}}';
try {
const stored = join(home, linked ? "account-store.json" : "auth.json");
await writeFile(stored, auth, { mode: 0o600 });
if (linked) await symlink(stored, join(home, "auth.json"));
const taskHome = join(home, "actual-task-home");
await prepareIsolatedCodexHome({ context: null, codexHome: taskHome, sourceCodexHome: home });
const taskAuth = await readFile(join(taskHome, "auth.json"), "utf8").catch(() => null);
expect(taskAuth).toBe(linked ? null : auth);
vi.stubEnv("CODEX_HOME", source === "operator" ? home : "");
nativeProbe.mockImplementation(async input => {
const probeHome = join(input.runtimeDirectory, "codex-home");
await prepareIsolatedCodexHome({ context: null, codexHome: probeHome, sourceCodexHome: input.transportOptions.sourceCodexHome });
expect(await readFile(join(probeHome, "auth.json"), "utf8")).toBe(auth);
await input.onCleanupConfirmed();
return receipt("codex", "gpt-6.1-sol");
});
const testContext = transport === "remote" ? sshContext : { companyId: "company", adapterType: "paperclip_runner", config: {} };
const result = await testNativeRunnerAuthentication({ ...testContext, config: {
managedAiCredentialHome: home,
env: source === "configured" ? { CODEX_HOME: home } : {},
} }, "codex", "gpt-6.1-sol");
expect(result.status).toBe(linked ? "fail" : "pass");
if (linked) {
expect(nativeProbe).not.toHaveBeenCalled();
expect(sshExecute).not.toHaveBeenCalled();
expect(result.checks[0].message).toContain(source === "configured" ? "env.CODEX_HOME" : "operator CODEX_HOME");
expect(result.checks[0].message).toContain("Legacy runner");
expect(result.checks[0].message).toContain("managed Codex Connection");
} else expect(nativeProbe).toHaveBeenCalledOnce();
expect(await readFile(stored, "utf8")).toBe(auth);
expect(JSON.stringify(result)).not.toContain("fixture-unbound-access");
} finally { await rm(home, { recursive: true, force: true }); }
});
it.each(["local", "remote"].flatMap(transport => ["OPENAI_API_KEY", "CODEX_API_KEY"].flatMap(key => ["valid", "dangling", "invalid-store", "probe-error"].map(authState => ({ transport, key, authState })))))
("keeps the configured $key task credential authoritative over $authState linked auth during $transport setup", async ({ transport, key, authState }) => {
const home = await realpath(await mkdtemp(join(tmpdir(), "native-key-linked-auth-")));
const auth = '{"tokens":{"account_id":"unused-subscription","access_token":"unused-subscription-access"}}';
const stored = join(home, "account-store.json");
const readCredential = vi.spyOn(localCredentialFile, "readLocalAiCredentialFile");
const copyBack = vi.spyOn(codexCredentials, "copyBackCodexAuth");
try {
if (authState === "valid" || authState === "probe-error") await writeFile(stored, auth, { mode: 0o600 });
else if (authState === "invalid-store") await mkdir(stored, { mode: 0o700 });
await symlink(stored, join(home, "auth.json"));
const taskHome = join(home, "actual-key-task-home");
await prepareIsolatedCodexHome({ context: null, codexHome: taskHome, sourceCodexHome: home, apiKey: "configured-fixture-key" });
expect(JSON.parse(await readFile(join(taskHome, "auth.json"), "utf8"))).toEqual({ OPENAI_API_KEY: "configured-fixture-key" });
nativeProbe.mockImplementation(async input => {
expect(input.onCodexCredentialRefresh).toBeUndefined();
expect(input.transportOptions.sourceCodexHome).toBe(home);
const probeHome = join(input.runtimeDirectory, "codex-home");
await prepareIsolatedCodexHome({ context: null, codexHome: probeHome, sourceCodexHome: input.transportOptions.sourceCodexHome,
apiKey: input.environment.CODEX_API_KEY ?? input.environment.OPENAI_API_KEY });
expect(JSON.parse(await readFile(join(probeHome, "auth.json"), "utf8"))).toEqual({ OPENAI_API_KEY: "configured-fixture-key" });
await input.onCleanupConfirmed();
if (authState === "probe-error") throw new Error("authentication failed: configured-fixture-key");
return receipt("codex", "gpt-6.1-sol");
});
const testContext = transport === "remote" ? sshContext : { companyId: "company", adapterType: "paperclip_runner", config: {} };
const result = await testNativeRunnerAuthentication({ ...testContext, config: { env: { CODEX_HOME: home, [key]: "configured-fixture-key" } } }, "codex", "gpt-6.1-sol");
expect(result.status).toBe(authState === "probe-error" ? "fail" : "pass");
if (authState === "probe-error") expect(result.checks[0]).toMatchObject({ code: "codex_hello_probe_auth_required", message: "authentication failed: [REDACTED]" });
expect(nativeProbe).toHaveBeenCalledOnce();
expect(readCredential).not.toHaveBeenCalled();
expect(copyBack).not.toHaveBeenCalled();
expect((await lstat(join(home, "auth.json"))).isSymbolicLink()).toBe(true);
expect(await readlink(join(home, "auth.json"))).toBe(stored);
if (authState === "valid" || authState === "probe-error") expect(await readFile(stored, "utf8")).toBe(auth);
else if (authState === "invalid-store") expect(await readdir(stored)).toEqual([]);
else await expect(lstat(stored)).rejects.toMatchObject({ code: "ENOENT" });
expect(JSON.stringify(result)).not.toContain("configured-fixture-key");
} finally { readCredential.mockRestore(); copyBack.mockRestore(); await rm(home, { recursive: true, force: true }); }
});
it.each(["local", "remote"].flatMap(transport => [false, true].map(linked => ({ transport, linked }))))
("seeds the selected subscription before isolated $transport setup (linked=$linked)", async ({ transport, linked }) => {
const home = await realpath(await mkdtemp(join(tmpdir(), "native-selected-auth-")));
const auth = JSON.stringify({ tokens: { account_id: "fixture-account", id_token: "fixture-id", access_token: "fixture-access", refresh_token: "fixture-refresh" }, last_refresh: "2026-01-01T00:00:00.000Z" });
const stored = join(home, linked ? "account-store.json" : "auth.json");
let seeded = false;
try {
await writeFile(stored, auth, { mode: 0o600 });
if (linked) await symlink(stored, join(home, "auth.json"));
await writeFile(join(home, "config.toml"), 'cli_auth_credentials_store = "file"\n[mcp_servers.host_only]\ncommand = "untrusted-host-tool"\n', { mode: 0o600 });
await writeFile(join(home, "unassigned-private-file"), "must-not-copy");
nativeProbe.mockImplementation(async input => {
expect(input.environment._PAPERCLIP_NATIVE_SETUP_CODEX_AUTH_JSON_SECRET).toBeUndefined();
const codexHome = join(input.runtimeDirectory, "codex-home");
await prepareIsolatedCodexHome({ context: null, codexHome, sourceCodexHome: input.transportOptions.sourceCodexHome,
apiKey: input.environment.CODEX_API_KEY ?? input.environment.OPENAI_API_KEY });
const privateAuth = await readFile(join(codexHome, "auth.json"), "utf8").catch(() => null);
seeded = privateAuth === auth;
expect((await readFile(join(codexHome, "config.toml"), "utf8"))).not.toContain("untrusted-host-tool");
if (!seeded) throw new Error("authentication required");
if (linked) {
const source = join(input.runtimeDirectory, "codex-credential-source");
expect(input.transportOptions.sourceCodexHome).toBe(source);
expect((await stat(source)).mode & 0o777).toBe(0o700);
expect((await stat(join(source, "auth.json"))).mode & 0o777).toBe(0o600);
expect((await readdir(source)).sort()).toEqual(["auth.json", "config.toml"]);
}
if (transport === "remote") {
await remoteLauncher.mock.calls[0][0].ensureArtifact();
const upload = sshExecute.mock.calls.find(([value]) => value.args?.[1]?.includes("/codex-home/auth.json"))?.[0];
expect(upload).toBeDefined();
expect(Buffer.from(upload!.stdin, "base64").toString("utf8")).toBe(auth);
expect(upload!.args[1]).toContain("chmod 600");
}
await input.onCleanupConfirmed();
return receipt("codex", "gpt-6.1-sol");
});
const testContext = transport === "remote" ? sshContext : { companyId: "company", adapterType: "paperclip_runner", config: {} };
const result = await testNativeRunnerAuthentication({ ...testContext, config: { env: { CODEX_HOME: "/untrusted-configured-home", _PAPERCLIP_NATIVE_SETUP_CODEX_AUTH_JSON_SECRET: "spoofed-auth" } }, managedAiCredentialHome: home }, "codex", "gpt-6.1-sol");
expect(result.status).toBe("pass");
expect(seeded).toBe(true);
expect(await readFile(stored, "utf8")).toBe(auth);
expect(JSON.stringify(result)).not.toContain("fixture-access");
await expect(stat(nativeProbe.mock.calls[0][0].runtimeDirectory)).rejects.toMatchObject({ code: "ENOENT" });
expect(execute).not.toHaveBeenCalled();
} finally { await rm(home, { recursive: true, force: true }); }
});
it.each(["local", "remote"].flatMap(transport => ["dangling", "foreign-owner", "unsafe-mode", "oversize"].map(mode => ({ transport, mode }))))
("rejects $mode linked subscription before $transport provider admission", async ({ transport, mode }) => {
const home = await realpath(await mkdtemp(join(tmpdir(), "native-invalid-link-")));
const stored = join(home, "account-store.json");
let owner: ReturnType<typeof vi.spyOn> | undefined;
try {
if (mode !== "dangling") await writeFile(stored, mode === "oversize" ? "x".repeat(65_537) : '{"tokens":{"account_id":"fixture-account"}}', { mode: 0o600 });
await symlink(stored, join(home, "auth.json"));
if (mode === "unsafe-mode") await chmod(stored, 0o644);
if (mode === "foreign-owner") owner = vi.spyOn(process, "getuid").mockReturnValue(process.getuid!() + 1);
nativeProbe.mockResolvedValue(receipt("codex", "gpt-6.1-sol"));
const testContext = transport === "remote" ? sshContext : { companyId: "company", adapterType: "paperclip_runner", config: {} };
const result = await testNativeRunnerAuthentication({ ...testContext, managedAiCredentialHome: home }, "codex", "gpt-6.1-sol");
expect(result.status).toBe("fail");
expect(nativeProbe).not.toHaveBeenCalled();
expect(sshExecute).not.toHaveBeenCalled();
if (mode === "dangling") expect(result.checks.some(check => check.code === ADAPTER_AUTH_MISSING_CHECK_CODE)).toBe(true);
} finally { owner?.mockRestore(); await rm(home, { recursive: true, force: true }); }
});
it.each(["local", "remote"])("redacts checked file credentials from %s native probe errors without exporting them to env", async transport => {
const home = await realpath(await mkdtemp(join(tmpdir(), "native-file-redaction-")));
const auth = JSON.stringify({ tokens: { account_id: "fixture-account", id_token: "fixture-id", access_token: "fixture-access", refresh_token: "fixture-refresh" } });
try {
await writeFile(join(home, "account-store.json"), auth, { mode: 0o600 });
await symlink(join(home, "account-store.json"), join(home, "auth.json"));
nativeProbe.mockImplementation(async input => {
expect(input.environment._PAPERCLIP_NATIVE_SETUP_CODEX_AUTH_JSON_SECRET).toBeUndefined();
throw new Error("authentication failed: " + auth + " fixture-access fixture-refresh");
});
const testContext = transport === "remote" ? sshContext : { companyId: "company", adapterType: "paperclip_runner", config: {} };
const result = await testNativeRunnerAuthentication({ ...testContext, managedAiCredentialHome: home }, "codex", "gpt-6.1-sol");
expect(result.status).toBe("fail");
expect(JSON.stringify(result)).not.toMatch(/fixture-account|fixture-access|fixture-refresh|fixture-id/);
expect(result.checks[0].message).toContain("[REDACTED]");
} finally { await rm(home, { recursive: true, force: true }); }
});
it.each(["local", "remote"].flatMap(transport => [false, true].map(confirmed => ({ transport, confirmed }))))
("settles private linked credentials only after confirmed canceled $transport setup (confirmed=$confirmed)", async ({ transport, confirmed }) => {
const home = await realpath(await mkdtemp(join(tmpdir(), "native-linked-cancel-")));
const auth = '{"tokens":{"account_id":"fixture-account","access_token":"fixture-access"}}';
try {
await writeFile(join(home, "account-store.json"), auth, { mode: 0o600 });
await symlink(join(home, "account-store.json"), join(home, "auth.json"));
nativeProbe.mockImplementation(async input => {
expect(await readFile(join(input.transportOptions.sourceCodexHome, "auth.json"), "utf8")).toBe(auth);
if (confirmed) await input.onCleanupConfirmed();
throw new Error("Native provider setup canceled");
});
const testContext = transport === "remote" ? sshContext : { companyId: "company", adapterType: "paperclip_runner", config: {} };
expect((await testNativeRunnerAuthentication({ ...testContext, managedAiCredentialHome: home }, "codex", "gpt-6.1-sol")).status).toBe("fail");
const root = nativeProbe.mock.calls[0][0].runtimeDirectory;
if (confirmed) await expect(stat(root)).rejects.toMatchObject({ code: "ENOENT" });
else expect(await readFile(join(root, "codex-credential-source", "auth.json"), "utf8")).toBe(auth);
expect(await readFile(join(home, "account-store.json"), "utf8")).toBe(auth);
} finally { await rm(home, { recursive: true, force: true }); }
});
it.each(["linux-arm64", "linux-x64"])("uses task staging and the selected %s SSH daemon without a provider pack", async target => {
const selectedBinary = `/fixture/bin/${target}/paperclip-runnerd`;
selectedRemoteRunner.mockResolvedValue(selectedBinary);
@@ -111,10 +342,10 @@ describe("Codex selected native account verification", () => {
await input.onCleanupConfirmed();
return receipt("codex", "gpt-6.1-sol");
});
expect((await testNativeRunnerAuthentication({ ...sshContext, config: { env: { OPENAI_API_KEY: "company-account" }, modelReasoningEffort: "low" } }, "codex", "gpt-6.1-sol")).status).toBe("pass");
expect((await testNativeRunnerAuthentication({ ...sshContext, config: { externallySandboxed: true, env: { OPENAI_API_KEY: "company-account", PAPERCLIP_RUNNER_EXTERNAL_SANDBOX: "true" }, modelReasoningEffort: "low" } }, "codex", "gpt-6.1-sol")).status).toBe("pass");
const input = nativeProbe.mock.calls[0][0];
expect(input).toMatchObject({ reasoningEffort: "low", environment: { OPENAI_API_KEY: "company-account" }, workingDirectory: expect.stringMatching(/^\/qa-workspace\/.paperclip-runtime\/paperclip-native-setup-.*\/filesystem\/workspace$/) });
expect(input.transportOptions).toMatchObject({ runnerBinary: selectedBinary, codexCommand: expect.stringMatching(/\/bin\/codex$/), runnerFilesystemRoot: expect.stringMatching(/\/filesystem$/) });
expect(input.transportOptions).toMatchObject({ runnerBinary: selectedBinary, codexCommand: expect.stringMatching(/\/bin\/codex$/), runnerFilesystemRoot: expect.stringMatching(/\/filesystem$/), externallySandboxed: false });
expect(nativeArtifacts).toHaveBeenCalledWith(expect.objectContaining({ controllerRunnerBinary: selectedBinary, model: "gpt-6.1-sol", targetPlatformArtifactVerified: true }));
expect(selectedRemoteRunner).toHaveBeenCalledExactlyOnceWith({ runner: { execute: sshExecute }, remoteCwd: "/qa-workspace", configuredPath: null });
expect(selectedRunner).not.toHaveBeenCalled();
@@ -162,7 +393,8 @@ describe("Codex selected native account verification", () => {
const home = await mkdtemp(join(tmpdir(), "native-ssh-managed-account-"));
const auth = (account: string, marker: string, age: number) => JSON.stringify({ tokens: { account_id: account, id_token: `id-${marker}`, access_token: `access-${marker}`, refresh_token: `refresh-${marker}` }, last_refresh: new Date(Date.now() - age).toISOString() });
try {
await writeFile(join(home, "auth.json"), auth("qa-account", "old", 120_000), { mode: 0o600 });
await writeFile(join(home, "account-store.json"), auth("qa-account", "old", 120_000), { mode: 0o600 });
await symlink(join(home, "account-store.json"), join(home, "auth.json"));
const refreshed = auth(mode === "another-account" ? "different-account" : "qa-account", "new", 60_000);
sshExecute.mockImplementation(async input => ({ exitCode: input.args?.[2] === "paperclip-native-refresh" && mode === "read-failure" ? 1 : 0, timedOut: false,
stdout: input.args?.[2] === "paperclip-native-refresh" ? Buffer.from(refreshed).toString("base64") : "", stderr: "" }));
@@ -182,7 +414,7 @@ describe("Codex selected native account verification", () => {
expect(JSON.stringify(result)).not.toContain("access-new");
const read = sshExecute.mock.calls.find(([input]) => input.args?.[2] === "paperclip-native-refresh")![0];
expect(read.command).toBe("sh"); expect(read.args[1]).toContain("head -c 65537"); expect(read.args[1]).toContain("test ! -L");
expect(nativeProbe.mock.calls[0][0].transportOptions.sourceCodexHome).toBe(home);
expect(nativeProbe.mock.calls[0][0].transportOptions.sourceCodexHome).toBe(join(nativeProbe.mock.calls[0][0].runtimeDirectory, "codex-credential-source"));
if (mode === "read-failure") expect(result.checks[0].message).toContain("refresh handoff unavailable");
if (mode === "uncertain-close") expect(result.checks[0].message).toContain("teardown was not confirmed");
} finally { await rm(home, { recursive: true, force: true }); }
@@ -258,6 +490,7 @@ describe("Codex selected native account verification", () => {
expect(selectedRunner).not.toHaveBeenCalled();
expect(nativeArtifacts).toHaveBeenCalledWith(expect.objectContaining({ target, runner: target.runner, controllerRunnerBinary: selectedBinary, targetPlatformArtifactVerified: true }));
expect(nativeProbe.mock.calls[0][0].transportOptions.runnerBinary).toBe(selectedBinary);
expect(nativeProbe.mock.calls[0][0].transportOptions.externallySandboxed).toBe(true);
expect(nativeArtifacts.mock.results[0].value.prepare).toHaveBeenCalledWith("dial_wss");
expect(execute).not.toHaveBeenCalled();
});
@@ -289,6 +522,7 @@ describe("Codex selected native account verification", () => {
const target = { ...context.executionTarget, providerKey: "daytona", runner: { execute: sshExecute }, leaseId: "lease", effectiveCapabilities: { reusableLeases: false, nativeSyncIn: false, nativeSyncOut: false, persistentProcessSessions: false, independentControlCommands: true, incrementalSessionOutput: false, concurrentSyncOperations: false, duplexCommandStream: false, runnerWebSocketIngress: true }, getRunnerIngressEndpoint };
expect((await testNativeRunnerAuthentication({ ...context, executionTarget: target }, "codex", "gpt-6.1-sol")).status).toBe("pass");
expect(order).toEqual(["artifacts", "ingress", "connect"]);
expect(nativeProbe.mock.calls[0][0].transportOptions.externallySandboxed).toBe(true);
expect(connectionClose).toHaveBeenCalledOnce(); expect(registerPrp).not.toHaveBeenCalled();
});
});
@@ -1,4 +1,5 @@
import { mkdtemp, realpath, rm, stat } from "node:fs/promises";
import { constants } from "node:fs";
import { lstat, mkdir, mkdtemp, open, realpath, rm, stat, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join, posix } from "node:path";
import { copyBackCodexAuth } from "@paperclipai/adapter-codex-local/server";
@@ -28,7 +29,59 @@ function redactNativeProbeMessage(message: string, environment: Record<string, s
return message.slice(0, 2000);
}
/** Remote setup shares task artifacts, PRP, isolated launch home, and process ownership. */
async function prepareProbeCodexSource(runtimeDirectory: string, sourceCodexHome: string, authJson?: string): Promise<string> {
if (authJson === undefined) return sourceCodexHome;
let config: Buffer | undefined;
const source = await open(join(sourceCodexHome, "config.toml"), constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK)
.catch((error: NodeJS.ErrnoException) => { if (error.code === "ENOENT" || error.code === "ELOOP") return null; throw error; });
if (source) {
try {
const metadata = await source.stat();
if (metadata.isFile()) {
const bytes = Buffer.alloc(1024 * 1024 + 1);
let size = 0;
while (size < bytes.length) {
const result = await source.read(bytes, size, bytes.length - size, size);
if (!result.bytesRead) break;
size += result.bytesRead;
}
if (size > 1024 * 1024) throw new Error("The selected Codex configuration exceeds its size limit.");
config = bytes.subarray(0, size);
}
} finally { await source.close(); }
}
const credentialSource = join(runtimeDirectory, "codex-credential-source");
let created = false;
try {
await mkdir(credentialSource, { mode: 0o700 });
created = true;
await writeFile(join(credentialSource, "auth.json"), authJson, { mode: 0o600, flag: "wx" });
if (config) await writeFile(join(credentialSource, "config.toml"), config, { mode: 0o600, flag: "wx" });
return credentialSource;
} catch (error) {
if (created) await rm(credentialSource, { recursive: true, force: true });
throw error;
}
}
async function probePreparedNativeRuntime(runtimeDirectory: string, prepare: () => Promise<Parameters<typeof probeNativeRunnerEnvironment>[0]>) {
let handedOff = false;
try {
const options = await prepare();
handedOff = true;
return await probeNativeRunnerEnvironment(options);
} catch (error) {
if (!handedOff) {
try { await rm(runtimeDirectory, { recursive: true, force: true }); }
catch (cleanupError) {
const message = error instanceof Error ? error.message : "Native setup preparation failed.";
throw new AggregateError([error, cleanupError], `${message}; Native setup preparation cleanup is incomplete.`, { cause: error });
}
}
throw error;
}
}
async function probeRemoteNativeCodex(options: {
context: AdapterEnvironmentTestContext;
model: string | null;
@@ -36,6 +89,7 @@ async function probeRemoteNativeCodex(options: {
environment: Record<string, string>;
sourceCodexHome: string;
credentialRefreshAuthPath?: string;
sourceAuthJson?: string;
timeoutMs: number;
}) {
const target = options.context.executionTarget;
@@ -45,11 +99,7 @@ async function probeRemoteNativeCodex(options: {
if (!runner) throw new Error("runner_transport_ineligible: remote process runner is unavailable");
const id = crypto.randomUUID();
const runnerInstanceId = `setup-${id}`;
// Eligibility is checked before writing to the selected host, using the
// same server-owned public URL and CA configuration as ordinary tasks.
const ingress = target.transport === "sandbox" && target.effectiveCapabilities?.runnerWebSocketIngress === true;
// Outbound eligibility is read-only; acquire provider ingress only after the
// exact daemon has been prepared and its listener capability verified.
const outbound = !ingress ? await resolvePaperclipRunnerTransport({ target, runId: id, localConnectUrl: "ws://127.0.0.1/unused",
runnerPublicUrl: process.env.PAPERCLIP_RUNNER_PUBLIC_URL?.trim() || null,
runnerCaBundlePath: process.env.PAPERCLIP_RUNNER_CA_BUNDLE_PATH?.trim() || null, runnerIngressAuthorized: true }) : null;
@@ -71,85 +121,82 @@ async function probeRemoteNativeCodex(options: {
if (result.timedOut || result.exitCode !== 0) throw new Error(message);
};
const runtimeDirectory = await realpath(await mkdtemp(join(tmpdir(), "paperclip-native-setup-")));
let remoteDirectoryCreated = false;
let remoteCaBundlePath: string | undefined;
let signalSpawned!: () => void;
const spawned = new Promise<void>(resolve => { signalSpawned = resolve; });
return probeNativeRunnerEnvironment({ runtimeDirectory: runtimeDirectory, provider: "codex", model: options.model, reasoningEffort: options.reasoningEffort,
workingDirectory: remoteWorkspace, environment: options.environment, timeoutMs: options.timeoutMs,
transportOptions: {
runnerBinary: controllerRunnerBinary, codexCommand: remoteCodexBinary, sourceCodexHome: options.sourceCodexHome,
runnerFilesystemRoot: remoteFilesystemRoot, runnerStateDirectory: remoteStateDirectory,
prpIdentity: { runnerInstanceId, runId: id, environmentLeaseId: `setup-${id}`, normalizedSessionId: `setup-${id}`, turnId: `turn-${id}`, itemId: `item-${id}` },
readRunnerState: () => readRemoteRunnerState({ runner, stateDirectory: remoteStateDirectory }),
controlPlaneRegistration: async authority => {
// Claim a private per-probe root. Never merge credentials or state into
// another session's directory on the remote host.
const created = await runner.execute({ command: "sh", args: ["-c", 'set -eu; umask 077; mkdir -p -- "$1"; test -d "$1" && test ! -L "$1"; mkdir -- "$2"; install -d -m 0700 "$3" "$4" "$5"',
"paperclip-native-setup", posix.dirname(remoteRoot), remoteRoot, remoteFilesystemRoot, remoteWorkspace, posix.join(remoteFilesystemRoot, "probe-home")], cwd: target.remoteCwd, bypassSession: true, timeoutMs: 10_000 });
requireSuccess(created, "Native setup could not claim a private runtime directory.");
remoteDirectoryCreated = true;
await artifacts.prepare(ingress ? "listen_ws" : "dial_wss");
if (outbound?.mode === "direct_outbound" && outbound.caBundlePath) {
remoteCaBundlePath = posix.join(remoteRoot, "bin", "runner-ca-bundle.pem");
await stageRemoteRunnerFile({ target, runner, sourcePath: outbound.caBundlePath, targetPath: remoteCaBundlePath, mode: 0o600 });
}
if (outbound?.mode === "direct_outbound") {
const registration = await registerRunnerPrpAuthority({ companyId: options.context.companyId, runId: id, authority });
return { connection: { mode: "connect" as const, connectUrl: outbound.connectUrl, ...(remoteCaBundlePath ? { caBundlePath: remoteCaBundlePath } : {}) },
startupFailureCode: "runner_direct_wss_failed" as const, release: registration.release };
}
const transport = await resolvePaperclipRunnerTransport({ target, runId: id, localConnectUrl: "ws://127.0.0.1/unused", runnerIngressAuthorized: true });
if (transport.mode !== "provider_ingress") throw new Error("runner_transport_ineligible: expected provider ingress");
let connection: ReturnType<typeof connectRunnerPrpIngress> | undefined;
let activation: Promise<void> | undefined;
return {
connection: { mode: "listen" as const, listenAddress: transport.listenAddress, listenPort: transport.listenPort, listenPath: transport.listenPath },
activate: () => {
activation = spawned.then(() => { connection = connectRunnerPrpIngress({ authority, endpoint: transport.ingress }); });
void activation.catch(() => undefined);
},
ready: async () => { await activation; if (!connection) throw new Error("runner_ingress_unavailable"); await connection.ready; },
get failure() { return connection?.failure; },
startupFailureCode: "runner_ingress_unavailable" as const,
release: async () => { if (connection) await connection.close(); else await transport.ingress.close(); },
};
},
runnerProcessLauncher: createRemoteRunnerProcessLauncher({ target, runner, remoteBinary, stateDirectory: remoteStateDirectory,
onRunnerProcessSpawned: signalSpawned, processIdentityPath: posix.join(remoteStateDirectory, "runner-process.identity"), diagnosticsDirectory: posix.join(remoteRoot, "diagnostics"), runnerInstanceId,
ensureArtifact: async () => {
// The normal transport has already materialized the bound account and
// its managed config locally. Publish only those two fresh files.
for (const name of ["auth.json", "config.toml"] as const) {
const sourcePath = join(runtimeDirectory, "codex-home", name);
try { await stat(sourcePath); } catch (error) { if ((error as NodeJS.ErrnoException).code === "ENOENT") continue; throw error; }
await stageRemoteRunnerFile({ target, runner, sourcePath, targetPath: posix.join(remoteFilesystemRoot, "codex-home", name), mode: 0o600 });
return probePreparedNativeRuntime(runtimeDirectory, async () => {
const sourceCodexHome = await prepareProbeCodexSource(runtimeDirectory, options.sourceCodexHome, options.sourceAuthJson);
let remoteDirectoryCreated = false;
let remoteCaBundlePath: string | undefined;
let signalSpawned!: () => void;
const spawned = new Promise<void>(resolve => { signalSpawned = resolve; });
return { runtimeDirectory: runtimeDirectory, provider: "codex", model: options.model, reasoningEffort: options.reasoningEffort,
workingDirectory: remoteWorkspace, environment: options.environment, timeoutMs: options.timeoutMs,
transportOptions: {
externallySandboxed: target.transport === "sandbox",
runnerBinary: controllerRunnerBinary, codexCommand: remoteCodexBinary, sourceCodexHome,
runnerFilesystemRoot: remoteFilesystemRoot, runnerStateDirectory: remoteStateDirectory,
prpIdentity: { runnerInstanceId, runId: id, environmentLeaseId: `setup-${id}`, normalizedSessionId: `setup-${id}`, turnId: `turn-${id}`, itemId: `item-${id}` },
readRunnerState: () => readRemoteRunnerState({ runner, stateDirectory: remoteStateDirectory }),
controlPlaneRegistration: async authority => {
const created = await runner.execute({ command: "sh", args: ["-c", 'set -eu; umask 077; mkdir -p -- "$1"; test -d "$1" && test ! -L "$1"; mkdir -- "$2"; install -d -m 0700 "$3" "$4" "$5"',
"paperclip-native-setup", posix.dirname(remoteRoot), remoteRoot, remoteFilesystemRoot, remoteWorkspace, posix.join(remoteFilesystemRoot, "probe-home")], cwd: target.remoteCwd, bypassSession: true, timeoutMs: 10_000 });
requireSuccess(created, "Native setup could not claim a private runtime directory.");
remoteDirectoryCreated = true;
await artifacts.prepare(ingress ? "listen_ws" : "dial_wss");
if (outbound?.mode === "direct_outbound" && outbound.caBundlePath) {
remoteCaBundlePath = posix.join(remoteRoot, "bin", "runner-ca-bundle.pem");
await stageRemoteRunnerFile({ target, runner, sourcePath: outbound.caBundlePath, targetPath: remoteCaBundlePath, mode: 0o600 });
}
} }),
},
...(options.credentialRefreshAuthPath ? { onCodexCredentialRefresh: async (filename: string) => {
if (filename !== posix.join(remoteFilesystemRoot, "codex-home", "auth.json")) throw new Error("The native Codex credential refresh handoff is invalid.");
await copyBackCodexAuth({ hostAuthPath: options.credentialRefreshAuthPath!, log: () => {}, readSandboxAuth: async () => {
// Reuse the task's shell/base64 handoff without requiring a guest JS
// runtime. Bound and validate the private owner-only credential file.
const read = await runner.execute({ command: "sh", args: ["-c", 'set -eu; file=$1; test -f "$file" || exit 66; test ! -L "$file"; parent=$(dirname -- "$file"); while test "$parent" != /; do test -d "$parent" && test ! -L "$parent"; parent=$(dirname -- "$parent"); done; metadata=$(stat -c "%u %a %s" "$file" 2>/dev/null || stat -f "%u %Lp %z" "$file"); set -- $metadata; test "$1" = "$(id -u)" && test "$2" = 600 && test "$3" -le 65536; head -c 65537 -- "$file" | base64', "paperclip-native-refresh", filename], bypassSession: true, timeoutMs: 10_000 });
if (read.timedOut || read.exitCode !== 0) throw Object.assign(new Error("Native Codex credential refresh handoff unavailable."), { code: read.exitCode === 66 ? "ENOENT" : "INVALID_CREDENTIAL" });
const body = Buffer.from(read.stdout, "base64");
if (body.length > 65_536) throw new Error("Native Codex credential refresh handoff exceeds the credential size limit.");
return body;
} });
} } : {}),
onCleanupConfirmed: async () => {
if (remoteDirectoryCreated) {
const cleaned = await runner.execute({ command: "rm", args: ["-rf", "--", remoteRoot], bypassSession: true, timeoutMs: 10_000 });
requireSuccess(cleaned, "Native setup cleanup is incomplete; private recovery state was retained.");
}
await rm(runtimeDirectory, { recursive: true, force: true });
},
if (outbound?.mode === "direct_outbound") {
const registration = await registerRunnerPrpAuthority({ companyId: options.context.companyId, runId: id, authority });
return { connection: { mode: "connect" as const, connectUrl: outbound.connectUrl, ...(remoteCaBundlePath ? { caBundlePath: remoteCaBundlePath } : {}) },
startupFailureCode: "runner_direct_wss_failed" as const, release: registration.release };
}
const transport = await resolvePaperclipRunnerTransport({ target, runId: id, localConnectUrl: "ws://127.0.0.1/unused", runnerIngressAuthorized: true });
if (transport.mode !== "provider_ingress") throw new Error("runner_transport_ineligible: expected provider ingress");
let connection: ReturnType<typeof connectRunnerPrpIngress> | undefined;
let activation: Promise<void> | undefined;
return {
connection: { mode: "listen" as const, listenAddress: transport.listenAddress, listenPort: transport.listenPort, listenPath: transport.listenPath },
activate: () => {
activation = spawned.then(() => { connection = connectRunnerPrpIngress({ authority, endpoint: transport.ingress }); });
void activation.catch(() => undefined);
},
ready: async () => { await activation; if (!connection) throw new Error("runner_ingress_unavailable"); await connection.ready; },
get failure() { return connection?.failure; },
startupFailureCode: "runner_ingress_unavailable" as const,
release: async () => { if (connection) await connection.close(); else await transport.ingress.close(); },
};
},
runnerProcessLauncher: createRemoteRunnerProcessLauncher({ target, runner, remoteBinary, stateDirectory: remoteStateDirectory,
onRunnerProcessSpawned: signalSpawned, processIdentityPath: posix.join(remoteStateDirectory, "runner-process.identity"), diagnosticsDirectory: posix.join(remoteRoot, "diagnostics"), runnerInstanceId,
ensureArtifact: async () => {
for (const name of ["auth.json", "config.toml"] as const) {
const sourcePath = join(runtimeDirectory, "codex-home", name);
try { await stat(sourcePath); } catch (error) { if ((error as NodeJS.ErrnoException).code === "ENOENT") continue; throw error; }
await stageRemoteRunnerFile({ target, runner, sourcePath, targetPath: posix.join(remoteFilesystemRoot, "codex-home", name), mode: 0o600 });
}
} }),
},
...(options.credentialRefreshAuthPath ? { onCodexCredentialRefresh: async (filename: string) => {
if (filename !== posix.join(remoteFilesystemRoot, "codex-home", "auth.json")) throw new Error("The native Codex credential refresh handoff is invalid.");
await copyBackCodexAuth({ hostAuthPath: options.credentialRefreshAuthPath!, log: () => {}, readSandboxAuth: async () => {
const read = await runner.execute({ command: "sh", args: ["-c", 'set -eu; file=$1; test -f "$file" || exit 66; test ! -L "$file"; parent=$(dirname -- "$file"); while test "$parent" != /; do test -d "$parent" && test ! -L "$parent"; parent=$(dirname -- "$parent"); done; metadata=$(stat -c "%u %a %s" "$file" 2>/dev/null || stat -f "%u %Lp %z" "$file"); set -- $metadata; test "$1" = "$(id -u)" && test "$2" = 600 && test "$3" -le 65536; head -c 65537 -- "$file" | base64', "paperclip-native-refresh", filename], bypassSession: true, timeoutMs: 10_000 });
if (read.timedOut || read.exitCode !== 0) throw Object.assign(new Error("Native Codex credential refresh handoff unavailable."), { code: read.exitCode === 66 ? "ENOENT" : "INVALID_CREDENTIAL" });
const body = Buffer.from(read.stdout, "base64");
if (body.length > 65_536) throw new Error("Native Codex credential refresh handoff exceeds the credential size limit.");
return body;
} });
} } : {}),
onCleanupConfirmed: async () => {
if (remoteDirectoryCreated) {
const cleaned = await runner.execute({ command: "rm", args: ["-rf", "--", remoteRoot], bypassSession: true, timeoutMs: 10_000 });
requireSuccess(cleaned, "Native setup cleanup is incomplete; private recovery state was retained.");
}
await rm(runtimeDirectory, { recursive: true, force: true });
},
};
});
}
/** Codex readiness uses the same daemon, bound credentials and cleanup as task execution. */
export async function testNativeRunnerAuthentication(context: AdapterEnvironmentTestContext, provider: "codex", model: string | null): Promise<AdapterEnvironmentTestResult> {
const remote = context.executionTarget?.kind === "remote";
const configured = context.config.env;
@@ -166,39 +213,50 @@ export async function testNativeRunnerAuthentication(context: AdapterEnvironment
const effort = context.config.modelReasoningEffort ?? context.config.reasoningEffort ?? context.config.effort;
const timeoutMs = remote ? 90_000 : 45_000;
let runtimeDirectory: string | undefined;
let sourceAuthJson: string | undefined;
try {
// Match task execution's closed host projection before the probe replaces
// HOME. API keys never enter this projection from the ambient process.
if (provider === "codex") {
const { buildNativeProviderEnvironment } = await import("./native-session-executor.js");
sourceCodexHome = context.managedAiCredentialHome ?? resolveSourceCodexHome(buildNativeProviderEnvironment(environment));
}
// Preserve refreshes in the authorized source account before deleting the
// private probe home. API-key probes must never write to a host login.
let credentialRefreshAuthPath: string | undefined;
if (sourceCodexHome && !environment.OPENAI_API_KEY?.trim() && !environment.PAPERCLIP_AI_PROVIDER_KEY?.trim()) {
if (sourceCodexHome && !environment.CODEX_API_KEY?.trim() && !environment.OPENAI_API_KEY?.trim() && !environment.PAPERCLIP_AI_PROVIDER_KEY?.trim()) {
let linked = false;
try {
credentialRefreshAuthPath = await realpath(join(sourceCodexHome, "auth.json"));
const source = await stat(credentialRefreshAuthPath);
const authPath = join(sourceCodexHome, "auth.json");
linked = (await lstat(authPath)).isSymbolicLink();
if (linked && !context.managedAiCredentialHome?.trim()) {
const field = environment.CODEX_HOME?.trim() ? "env.CODEX_HOME" : "operator CODEX_HOME";
throw new Error(`${field} uses a linked auth.json that Paperclip Runner tasks cannot read. Choose Legacy runner in Advanced or use a managed Codex Connection.`);
}
const resolvedAuthPath = await realpath(authPath);
const source = await stat(resolvedAuthPath);
if (!source.isFile() || source.uid !== process.getuid?.()) throw new Error("Invalid Codex source credential owner.");
if (linked) sourceAuthJson = await readLocalAiCredentialFile(resolvedAuthPath);
credentialRefreshAuthPath = resolvedAuthPath;
} catch (error) {
if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
if (linked) throw new Error("Codex authentication required: the selected credential link is unavailable.");
}
}
let receipt: Awaited<ReturnType<typeof probeNativeRunnerEnvironment>>;
if (context.executionTarget?.kind === "remote") {
receipt = await probeRemoteNativeCodex({ context, model, environment, sourceCodexHome: sourceCodexHome ?? "", credentialRefreshAuthPath, timeoutMs,
receipt = await probeRemoteNativeCodex({ context, model, environment, sourceCodexHome: sourceCodexHome ?? "", credentialRefreshAuthPath, sourceAuthJson, timeoutMs,
...(typeof effort === "string" && effort ? { reasoningEffort: effort } : {}) });
} else {
const runnerBinary = resolvePaperclipRunnerBinary();
runtimeDirectory = await realpath(await mkdtemp(join(tmpdir(), "paperclip-native-setup-")));
receipt = await probeNativeRunnerEnvironment({ runtimeDirectory, provider, model, environment, timeoutMs,
...(provider === "codex" && typeof effort === "string" && effort ? { reasoningEffort: effort } : {}),
onCleanupConfirmed: async () => { await rm(runtimeDirectory!, { recursive: true, force: true }); },
transportOptions: { runnerBinary: resolvePaperclipRunnerBinary(), sourceCodexHome: sourceCodexHome ?? "" },
...(credentialRefreshAuthPath ? { onCodexCredentialRefresh: async (filename: string) => {
if (filename !== join(runtimeDirectory!, "codex-home", "auth.json")) throw new Error("The native Codex credential refresh handoff is invalid.");
await copyBackCodexAuth({ hostAuthPath: credentialRefreshAuthPath!, log: () => {}, readSandboxAuth: async () => Buffer.from(await readLocalAiCredentialFile(filename)) });
} } : {}),
receipt = await probePreparedNativeRuntime(runtimeDirectory, async () => {
const probeSourceCodexHome = await prepareProbeCodexSource(runtimeDirectory!, sourceCodexHome ?? "", sourceAuthJson);
return { runtimeDirectory: runtimeDirectory!, provider, model, environment, timeoutMs,
...(provider === "codex" && typeof effort === "string" && effort ? { reasoningEffort: effort } : {}),
onCleanupConfirmed: async () => { await rm(runtimeDirectory!, { recursive: true, force: true }); },
transportOptions: { runnerBinary, sourceCodexHome: probeSourceCodexHome, externallySandboxed: false },
...(credentialRefreshAuthPath ? { onCodexCredentialRefresh: async (filename: string) => {
if (filename !== join(runtimeDirectory!, "codex-home", "auth.json")) throw new Error("The native Codex credential refresh handoff is invalid.");
await copyBackCodexAuth({ hostAuthPath: credentialRefreshAuthPath!, log: () => {}, readSandboxAuth: async () => Buffer.from(await readLocalAiCredentialFile(filename)) });
} } : {}),
};
});
}
if (receipt.helloProbePassed !== true || receipt.provider !== provider || typeof receipt.effectiveModel !== "string" || !receipt.effectiveModel.trim() || receipt.effectiveModel === "unknown" || (model !== null && receipt.effectiveModel !== model)
@@ -206,7 +264,7 @@ export async function testNativeRunnerAuthentication(context: AdapterEnvironment
return { adapterType: "paperclip_runner", status: "pass", testedAt: new Date().toISOString(), checks: [{ code: `${provider}_hello_probe_passed`, level: "info",
message: `The native ${provider} runtime verified the selected account and model in ${remote ? "the selected environment" : "the Paperclip host"}.` }] };
} catch (error) {
const message = redactNativeProbeMessage(error instanceof Error ? error.message : "The selected native runtime could not verify this account.", environment);
const message = redactNativeProbeMessage(error instanceof Error ? error.message : "The selected native runtime could not verify this account.", { ...environment, ...(sourceAuthJson ? { [authKey]: sourceAuthJson } : {}) });
const authentication = /(?:invalid (?:api[- ]?key|auth(?:entication)? token)|authentication (?:failed|required)|unauthenticated|unauthorized|not authenticated|please (?:log|sign) in|not logged in|\b(?:401|403)\b)/i.test(message);
return { adapterType: "paperclip_runner", status: "fail", testedAt: new Date().toISOString(), checks: [{ code: `${provider}_hello_probe_${authentication ? "auth_required" : /timed out/i.test(message) ? "timeout" : "failed"}`, level: "error", message,
hint: "Check the selected account, model access, and native runtime prerequisites, then retry. Legacy runner is available explicitly in Advanced." },