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:
parent
6c85fa060e
commit
7a3abb88a0
|
|
@ -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}"))?;
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
});
|
||||
});
|
||||
|
|
@ -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(",")}}`;
|
||||
}
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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 }],
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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}`);
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Reference in New Issue