feat(runner): authorize server Codex tools (#12385)

## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work.
> - The hidden native coordinator already computes a run-scoped semantic
tool projection.
> - The durable Codex backend now accepts and enforces that projection.
> - The server did not include the projection in its `run.prepare`
command.
> - Codex therefore received no production semantic tools even when the
server authorized them.
> - This pull request adds the deterministic wire projection and sends
it to runnerd.
> - The benefit is one fail-closed authorization catalog from the server
through Codex.

## Linked Issues or Issue Description

Refs #12384

**What existing behavior does this improve?**

This improves the existing flagged Paperclip Runner Codex path.

**Current behavior**

The server creates a run-scoped list of authorized read tools. It does
not pass that list to runnerd, so the production Codex session starts
with no tools.

**Proposed behavior**

The server maps the authorized definitions to the versioned runner
contract. It computes a cross-language catalog digest. It includes that
immutable contract in `run.prepare`.

**Reason and benefit**

Runnerd and the server now enforce the same catalog identity. Unknown,
duplicate, changed, or malformed tool contracts fail before Codex can
use them.

**Breaking changes**

None. Direct adapters are unchanged. A native run with an empty server
projection still starts with no dynamic tools.

## What Changed

- Add a deterministic semantic-definition to runner-authorization
projection.
- Match the Rust canonical digest with a shared test vector.
- Include the server coordinator projection in the native Codex
`run.prepare` command.
- Extend the native Codex vertical slice to require and execute a
semantic tool.
- Verify the production prepare payload in a host-independent server
test.

## Verification

- `pnpm --filter @paperclipai/paperclip-runner test:typescript` (354
tests pass)
- `pnpm --filter @paperclipai/server exec vitest run
src/services/native-runtime/native-codex-runner.test.ts`
- `pnpm --filter @paperclipai/server typecheck`
- `pnpm -r typecheck`
- `pnpm build`
- The embedded-Postgres vertical slice is present for CI. This local
host reports that embedded Postgres is unavailable, so Vitest skips that
host-dependent test locally.
- Confirmed that the PR changes 7 files against
`runner-codex-durable-tools`.
- Confirmed that `pnpm-lock.yaml` is unchanged.

## Risks

The main risk is a catalog digest mismatch between TypeScript and Rust.
Both implementations use canonical JSON. They share the same fixed
digest vector. Runnerd also recomputes the digest and rejects a
mismatch.

The rollout flag and the existing native runtime selection rules remain
unchanged.

## Model Used

OpenAI Codex with GPT-5 and repository tool use.

## Checklist

- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have searched GitHub for duplicate or related PRs and linked
them above
- [x] I have either (a) linked existing issues with `Fixes: #` / `Closes
#` / `Refs #` OR (b) described the issue in-PR following the relevant
issue template
- [x] I have not referenced internal/instance-local Paperclip issues or
links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip`
URLs)
- [x] My branch name describes the change (e.g. `docs/...`, `fix/...`)
and contains no internal Paperclip ticket id or instance-derived details
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [ ] All Paperclip CI gates are green
- [ ] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
- [x] I will address all Greptile and reviewer comments before
requesting merge
This commit is contained in:
Dotta 2026-08-30 11:43:34 -05:00 committed by GitHub
parent 6c85fa060e
commit 7a3abb88a0
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
9 changed files with 449 additions and 25 deletions

View File

@ -56,12 +56,144 @@ fn has_task_context_tool(message: &Value) -> bool {
.is_some_and(|tools| {
tools.iter().any(|tool| {
tool.get("name").and_then(Value::as_str) == Some("get_task_context")
&& tool.get("description").and_then(Value::as_str) == Some("Read task context.")
&& tool
.get("description")
.and_then(Value::as_str)
.is_some_and(|description| !description.trim().is_empty())
&& tool.pointer("/inputSchema/type").and_then(Value::as_str) == Some("object")
})
})
}
fn matches_task_context_result(result: &Value, expected_canonical: Option<&Value>) -> bool {
let Some(expected) = expected_canonical else {
return result == &json!({"ok": true, "task": {"id": "task-1"}});
};
if result.get("ok") != Some(&json!(true))
|| result.get("operationId").and_then(Value::as_str) != Some("get_task_context")
|| result.get("callId").and_then(Value::as_str) != Some("semantic-call-1")
{
return false;
}
[
("/value/company/id", "/companyId"),
("/value/actor/id", "/actorId"),
("/value/activeTask/id", "/taskId"),
("/value/run/id", "/runId"),
]
.into_iter()
.all(|(actual_pointer, expected_pointer)| {
let actual = result.pointer(actual_pointer).and_then(Value::as_str);
let expected = expected.pointer(expected_pointer).and_then(Value::as_str);
actual.is_some_and(|value| !value.is_empty()) && actual == expected
})
}
#[cfg(test)]
mod tests {
use super::*;
fn thread_start(tool: Value) -> Value {
json!({
"method": "thread/start",
"params": {"dynamicTools": [tool]},
})
}
#[test]
fn task_context_tool_accepts_any_non_empty_description() {
let message = thread_start(json!({
"name": "get_task_context",
"description": "Read the active task, actor, wake context, ancestors, and budget summary.",
"inputSchema": {"type": "object"},
}));
assert!(has_task_context_tool(&message));
}
#[test]
fn task_context_tool_rejects_blank_descriptions_and_wrong_schemas() {
for tool in [
json!({
"name": "get_task_context",
"description": " ",
"inputSchema": {"type": "object"},
}),
json!({
"name": "get_task_context",
"description": "Read task context.",
"inputSchema": {"type": "string"},
}),
json!({
"name": "get_task_history",
"description": "Read task context.",
"inputSchema": {"type": "object"},
}),
] {
assert!(!has_task_context_tool(&thread_start(tool)));
}
}
#[test]
fn task_context_result_preserves_exact_legacy_fixture_by_default() {
assert!(matches_task_context_result(
&json!({"ok": true, "task": {"id": "task-1"}}),
None,
));
assert!(!matches_task_context_result(
&json!({
"ok": true,
"operationId": "get_task_context",
"callId": "semantic-call-1",
"value": {
"company": {"id": "company-1"},
"actor": {"id": "actor-1"},
"activeTask": {"id": "task-1"},
"run": {"id": "run-1"},
},
}),
None,
));
}
#[test]
fn task_context_result_accepts_only_the_expected_canonical_binding() {
let expected = json!({
"companyId": "company-1",
"actorId": "actor-1",
"taskId": "task-1",
"runId": "run-1",
});
let canonical = json!({
"ok": true,
"operationId": "get_task_context",
"callId": "semantic-call-1",
"value": {
"company": {"id": "company-1"},
"actor": {"id": "actor-1"},
"activeTask": {"id": "task-1"},
"run": {"id": "run-1"},
},
});
assert!(matches_task_context_result(&canonical, Some(&expected)));
assert!(!matches_task_context_result(
&json!({
"ok": true,
"operationId": "get_task_context",
"callId": "semantic-call-1",
"value": {
"company": {"id": "company-1"},
"actor": {"id": "actor-1"},
"activeTask": {"id": "wrong-task"},
"run": {"id": "run-1"},
},
}),
Some(&expected),
));
}
}
fn finish_turn(state_path: &Path, state: &mut FakeState, status: &str) -> io::Result<()> {
let turn_id = state
.active_turn_id
@ -210,6 +342,9 @@ fn run() -> Result<(), Box<dyn std::error::Error>> {
.iter()
.any(|value| value == "--finish-turn-with-pending-tool");
let require_dynamic_tool = args.iter().any(|value| value == "--require-dynamic-tool");
let expected_canonical_task_context = argument(&args, "--expected-canonical-task-context")
.map(|value| serde_json::from_str::<Value>(&value))
.transpose()?;
let hold_turn = args.iter().any(|value| value == "--hold-turn");
let exit_after_turn_start = args.iter().any(|value| value == "--exit-after-turn-start");
let exit_after_turn_completion = args
@ -359,7 +494,7 @@ fn run() -> Result<(), Box<dyn std::error::Error>> {
.and_then(Value::as_str)
.ok_or("semantic tool response omitted content text")?;
let result: Value = serde_json::from_str(text)?;
if result != json!({"ok": true, "task": {"id": "task-1"}}) {
if !matches_task_context_result(&result, expected_canonical_task_context.as_ref()) {
return Err("semantic tool response changed the operation result".into());
}
log_call(call_log.as_deref(), &format!("tool-response:{text}"))?;

View File

@ -1297,7 +1297,12 @@ fn canonical_json_number(value: &serde_json::Number) -> String {
return "0".to_owned();
}
let body = if (1e-6..1e21).contains(&float.abs()) {
let magnitude = float.abs();
// The lower ECMAScript threshold is inclusive: JSON.stringify(1e-6)
// produces decimal notation, while values below it use an exponent.
// Spell both comparisons out so that the wire-digest boundary is explicit.
let uses_decimal_notation = magnitude >= 1e-6 && magnitude < 1e21;
let body = if uses_decimal_notation {
if decimal_position <= 0 {
format!("0.{}{}", "0".repeat((-decimal_position) as usize), digits)
} else if decimal_position >= digits.len() as i32 {
@ -1415,6 +1420,22 @@ mod tests {
use super::*;
use serde_json::json;
#[test]
fn canonical_number_uses_decimal_notation_at_javascript_lower_boundary() {
for encoded in ["1e-6", "0.000001"] {
let value: Value = serde_json::from_str(encoded).unwrap();
assert_eq!(canonical_json(&value), "0.000001");
}
for encoded in ["-1e-6", "-0.000001"] {
let value: Value = serde_json::from_str(encoded).unwrap();
assert_eq!(canonical_json(&value), "-0.000001");
}
let below_boundary: Value = serde_json::from_str("9.99999e-7").unwrap();
assert_eq!(canonical_json(&below_boundary), "9.99999e-7");
}
#[test]
fn worst_case_result_identity_overhead_fits_the_admission_reserve() {
let call_id = "x".repeat(160);

View File

@ -3,4 +3,5 @@ export * from "./discovery.js";
export * from "./dispatcher.js";
export * from "./receipts.js";
export * from "./redaction.js";
export * from "./runner-authorized-tools.js";
export * from "./types.js";

View File

@ -0,0 +1,86 @@
import { describe, expect, it } from "vitest";
import type { PaperclipSemanticToolDefinition } from "./types.js";
import { createPaperclipRunnerAuthorizedToolSet } from "./runner-authorized-tools.js";
function definition(
name: "get_task_context" | "get_task_history",
): PaperclipSemanticToolDefinition {
return {
name,
description:
name === "get_task_context"
? "Read the active task context."
: "Read bounded comments on the active task.",
inputSchema: { type: "object" },
outputSchema: { type: "object" },
annotations: {
semanticContract: "paperclip.semantic-action.v1",
version: 1,
placement: "always",
effect: "read",
requiredClaims: [],
},
};
}
describe("runner authorized tool projection", () => {
it("matches the Rust catalog digest vector", () => {
const set = createPaperclipRunnerAuthorizedToolSet([
definition("get_task_context"),
]);
expect(set).toMatchObject({
schema: "paperclip.runner.authorized-tools.v1",
schemaVersion: 1,
catalogDigest:
"sha256:4e0332535c9e2ff1f5e43089517ee1b46654bfc9cb2ed51efbea4be50db21009",
operations: [
{
operationId: "get_task_context",
version: 1,
responseSchema: { type: "object" },
},
],
});
});
it("matches Rust number canonicalization at JavaScript's decimal boundary", () => {
const base = definition("get_task_context");
const set = createPaperclipRunnerAuthorizedToolSet([
{
...base,
inputSchema: {
type: "object",
properties: {
limit: { type: "number", default: 1.0 },
epsilon: { type: "number", default: 1e-6 },
},
},
},
]);
expect(set.catalogDigest).toBe(
"sha256:1c93693d9b5b48b46c83cd1c11d1ea329774f1b9b0ae741197cb2b8e992c4b8d",
);
});
it("uses operation identity order and rejects duplicates", () => {
const reverse = createPaperclipRunnerAuthorizedToolSet([
definition("get_task_history"),
definition("get_task_context"),
]);
const ordered = createPaperclipRunnerAuthorizedToolSet([
definition("get_task_context"),
definition("get_task_history"),
]);
expect(reverse).toEqual(ordered);
expect(() =>
createPaperclipRunnerAuthorizedToolSet([
definition("get_task_context"),
definition("get_task_context"),
]),
).toThrow("paperclip_runner_authorized_tools_invalid");
});
});

View File

@ -0,0 +1,90 @@
import { createHash } from "node:crypto";
import type { PaperclipJsonSchema } from "../catalog/semantic-action-types.js";
import type { PaperclipSemanticToolDefinition } from "./types.js";
export const PAPERCLIP_RUNNER_AUTHORIZED_TOOLS_SCHEMA =
"paperclip.runner.authorized-tools.v1" as const;
export interface PaperclipRunnerAuthorizedTool {
readonly operationId: string;
readonly version: 1;
readonly description: string;
readonly inputSchema: PaperclipJsonSchema;
readonly responseSchema: PaperclipJsonSchema;
}
export interface PaperclipRunnerAuthorizedToolSet {
readonly schema: typeof PAPERCLIP_RUNNER_AUTHORIZED_TOOLS_SCHEMA;
readonly schemaVersion: 1;
readonly catalogDigest: string;
readonly operations: readonly PaperclipRunnerAuthorizedTool[];
}
export function createPaperclipRunnerAuthorizedToolSet(
definitions: readonly PaperclipSemanticToolDefinition[],
): PaperclipRunnerAuthorizedToolSet {
const names = new Set<string>();
const operations = definitions
.map((definition): PaperclipRunnerAuthorizedTool => {
if (definition.annotations.version !== 1 || names.has(definition.name)) {
throw new Error("paperclip_runner_authorized_tools_invalid");
}
names.add(definition.name);
return {
operationId: definition.name,
version: 1,
description: definition.description,
inputSchema: definition.inputSchema,
responseSchema: definition.outputSchema,
};
})
.sort((left, right) =>
left.operationId < right.operationId
? -1
: left.operationId > right.operationId
? 1
: 0,
);
return deepFreeze({
schema: PAPERCLIP_RUNNER_AUTHORIZED_TOOLS_SCHEMA,
schemaVersion: 1,
catalogDigest: digestOperations(operations),
operations,
});
}
function deepFreeze<T>(value: T): T {
if (typeof value !== "object" || value === null || Object.isFrozen(value)) {
return value;
}
Object.freeze(value);
for (const child of Object.values(value)) deepFreeze(child);
return value;
}
function digestOperations(
operations: readonly PaperclipRunnerAuthorizedTool[],
): string {
return `sha256:${createHash("sha256")
.update(canonicalJson(operations))
.digest("hex")}`;
}
function canonicalJson(value: unknown): string {
if (value === null || typeof value !== "object") {
const encoded = JSON.stringify(value);
if (encoded === undefined) {
throw new Error("paperclip_runner_authorized_tools_invalid");
}
return encoded;
}
if (Array.isArray(value)) {
return `[${value.map(canonicalJson).join(",")}]`;
}
const object = value as Record<string, unknown>;
return `{${Object.keys(object)
.sort()
.map((key) => `${JSON.stringify(key)}:${canonicalJson(object[key])}`)
.join(",")}}`;
}

View File

@ -201,6 +201,15 @@ describeEmbeddedPostgres("native Codex server vertical slice", () => {
resolve(runtimeRoot, "fake-codex-state.json"),
"--call-log",
resolve(runtimeRoot, "fake-codex-calls.log"),
"--require-dynamic-tool",
"--emit-tool-call",
"--expected-canonical-task-context",
JSON.stringify({
companyId,
actorId: agentId,
taskId: issueId,
runId,
}),
],
providerVersion: "fake-codex-v1",
},
@ -256,6 +265,8 @@ describeEmbeddedPostgres("native Codex server vertical slice", () => {
.from(heartbeatRunEvents)
.where(eq(heartbeatRunEvents.runId, runId));
expect(eventTypes.map((event) => event.eventType)).toEqual(expect.arrayContaining([
"semantic_tool.input",
"semantic_tool.result",
"turn.completed",
"run.result.proposed",
"run.terminal",

View File

@ -1,6 +1,10 @@
import { describe, expect, it } from "vitest";
import { buildNativeRunnerArguments } from "./native-codex-runner.js";
import type { PaperclipSemanticToolDefinition } from "../../vendor/paperclip-runner/index.js";
import {
buildNativeRunnerArguments,
buildNativeRunnerPreparePayload,
} from "./native-codex-runner.js";
describe("buildNativeRunnerArguments", () => {
it("binds every durable identity without exposing the bootstrap ticket", () => {
@ -21,3 +25,47 @@ describe("buildNativeRunnerArguments", () => {
expect(args.join(" ")).not.toContain("bootstrap");
});
});
const tool: PaperclipSemanticToolDefinition = {
name: "get_task_context",
description: "Read the active task context.",
inputSchema: { type: "object" },
outputSchema: { type: "object" },
annotations: {
semanticContract: "paperclip.semantic-action.v1",
version: 1,
placement: "always",
effect: "read",
requiredClaims: [],
},
};
describe("buildNativeRunnerPreparePayload", () => {
it("binds the coordinator tool projection to run.prepare", () => {
expect(buildNativeRunnerPreparePayload({
cwd: "/workspace",
model: "test-model",
resumeProviderSessionId: "thread-1",
completionContract: { revision: "1", criterionIds: ["objective"] },
semanticTools: [tool],
providerLaunch: {
command: "/bin/fake-codex",
args: ["app-server"],
providerVersion: "fake-1",
},
})).toMatchObject({
provider: {
provider: "codex",
driver: "codex_app_server",
providerSessionId: "thread-1",
},
authorizedTools: {
schema: "paperclip.runner.authorized-tools.v1",
schemaVersion: 1,
catalogDigest:
"sha256:4e0332535c9e2ff1f5e43089517ee1b46654bfc9cb2ed51efbea4be50db21009",
operations: [{ operationId: "get_task_context", version: 1 }],
},
});
});
});

View File

@ -8,11 +8,53 @@ import type { AdapterExecutionResult } from "@paperclipai/adapter-utils";
import type { Db } from "@paperclipai/db";
import { resolvePaperclipInstanceRoot } from "../../home-paths.js";
import {
createPaperclipRunnerAuthorizedToolSet,
type PaperclipSemanticToolDefinition,
} from "../../vendor/paperclip-runner/index.js";
import { runnerPrpCoordinator } from "./runner-prp-coordinator.js";
const moduleDirectory = dirname(fileURLToPath(import.meta.url));
const RUNNER_VERSION = "paperclip-runner-v1";
interface NativeRunnerProviderLaunch {
readonly command: string;
readonly args: string[];
readonly providerVersion?: string;
}
interface NativeRunnerPrepareInput {
readonly cwd: string;
readonly model: string | null;
readonly resumeProviderSessionId: string | null;
readonly completionContract: { revision: string; criterionIds: string[] };
readonly semanticTools: readonly PaperclipSemanticToolDefinition[];
readonly providerLaunch?: NativeRunnerProviderLaunch;
}
export function buildNativeRunnerPreparePayload(
input: NativeRunnerPrepareInput,
): Record<string, unknown> {
return {
provider: {
provider: "codex",
driver: "codex_app_server",
providerVersion: input.providerLaunch?.providerVersion ?? "codex-app-server-v1",
command: input.providerLaunch?.command ?? "codex",
args: input.providerLaunch?.args ?? ["app-server"],
cwd: input.cwd,
...(input.model ? { model: input.model } : {}),
...(input.resumeProviderSessionId
? { providerSessionId: input.resumeProviderSessionId }
: {}),
instructions: "",
approvalPolicy: "never",
},
completionContract: input.completionContract,
authorizedTools: createPaperclipRunnerAuthorizedToolSet(input.semanticTools),
};
}
function executableName(): string {
return process.platform === "win32" ? "paperclip-runnerd.exe" : "paperclip-runnerd";
}
@ -152,11 +194,7 @@ export async function executeNativeCodexRunner(input: {
/** Internal test seam; production always uses the instance runtime root. */
runtimeRoot?: string;
/** Internal conformance seam; production always launches `codex app-server`. */
providerLaunch?: {
command: string;
args: string[];
providerVersion?: string;
};
providerLaunch?: NativeRunnerProviderLaunch;
onLog: (stream: "stdout" | "stderr", chunk: string) => Promise<void>;
onSpawn: (meta: {
pid: number;
@ -191,23 +229,14 @@ export async function executeNativeCodexRunner(input: {
runnerDigest,
});
prepared.queueCommand("run.prepare", {
provider: {
provider: "codex",
driver: "codex_app_server",
providerVersion: input.providerLaunch?.providerVersion ?? "codex-app-server-v1",
command: input.providerLaunch?.command ?? "codex",
args: input.providerLaunch?.args ?? ["app-server"],
cwd: input.cwd,
...(input.model ? { model: input.model } : {}),
...(input.resumeProviderSessionId
? { providerSessionId: input.resumeProviderSessionId }
: {}),
instructions: "",
approvalPolicy: "never",
},
prepared.queueCommand("run.prepare", buildNativeRunnerPreparePayload({
cwd: input.cwd,
model: input.model,
resumeProviderSessionId: input.resumeProviderSessionId,
completionContract: input.completionContract,
}, `prepare_${input.runId}`);
semanticTools: prepared.semanticTools,
...(input.providerLaunch ? { providerLaunch: input.providerLaunch } : {}),
}), `prepare_${input.runId}`);
prepared.queueCommand("session.open", {}, `open_${input.runId}`);
prepared.queueCommand("turn.start", { text: input.prompt }, `turn_${input.runId}`);

View File

@ -19,6 +19,7 @@ export type {
PaperclipSemanticToolCall,
PaperclipSemanticToolDefinition,
PaperclipSemanticToolResult,
PaperclipRunnerAuthorizedToolSet,
PaperclipQuestionSet,
PaperclipRuntimeInputRequest,
PrpEvent,
@ -38,6 +39,8 @@ const runner = await import(sourceUrl.href) as RunnerModule;
export const DurablePrpControlPlane = runner.DurablePrpControlPlane;
export const PaperclipSemanticDispatcher = runner.PaperclipSemanticDispatcher;
export const createPaperclipRunnerAuthorizedToolSet =
runner.createPaperclipRunnerAuthorizedToolSet;
export const parsePaperclipQuestionSet = runner.parsePaperclipQuestionSet;
export const parsePaperclipQuestionResponse = runner.parsePaperclipQuestionResponse;
export const validatePrpEvent = runner.validatePrpEvent;