Define native runner execution contracts (#12351)
## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work > - The Paperclip Runner package gives providers one stable execution boundary > - Later runner backends need one validated input and result contract > - Provider-specific shapes cannot leak into server orchestration > - This pull request adds the provider-neutral native execution contracts > - It also keeps legacy input versions readable while it validates new input strictly > - The benefit is a stable boundary for later backend and server pull requests ## Linked Issues or Issue Description **Subsystem affected** packages/paperclip-runner **Problem or motivation** The runner has protocol transport and Codex support, but later backends do not have one public contract for execution input, runtime context, completion output, and recovery state. **Proposed solution** Add versioned native execution input parsers, model envelopes, completion result helpers, runtime context contracts, and durable recovery types. Keep these changes package-local. Do not change server runtime selection. **Alternatives considered** Each provider could define its own server-facing input. That would couple server orchestration to provider details and make compatibility harder to verify. **Roadmap alignment** This is part of the existing experimental Paperclip Runner rollout. It does not enable a new production adapter. ## What Changed - Add versioned native execution input contracts and strict parsers. - Add provider-neutral runtime context and completion contracts. - Add durable recovery and local runner types. - Add structured question compatibility and parser tests. - Export only the contracts that exist in this stack layer. ## Verification - `pnpm --filter @paperclipai/paperclip-runner test:typescript` - `pnpm --filter @paperclipai/paperclip-runner typecheck:typescript` - `pnpm -r typecheck` - Protocol generation and manifest checks pass. - The branch changes 12 files relative to its declared base. ## Risks The main risk is accepting malformed or future input as a supported contract. The parsers reject unknown required versions and unknown fields. The change is package-local and does not select or start the experimental runner. > For core feature work, check [`ROADMAP.md`](ROADMAP.md) first and discuss it in `#dev` before opening the PR. Feature PRs that overlap with planned core work may need to be redirected — check the roadmap first. See `CONTRIBUTING.md`. ## Model Used OpenAI Codex, `gpt-5`, with agentic reasoning, tool use, and code execution. ## 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 - [x] All Paperclip CI gates are green - [x] 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
b1cd261212
commit
d8dfa27a1e
|
|
@ -0,0 +1,182 @@
|
|||
import type {
|
||||
PrpCapabilities,
|
||||
PrpEvent,
|
||||
PrpStructuredRunResult,
|
||||
} from "../protocol/replay-contract.js";
|
||||
import type { SessionSnapshot } from "../reducer/session-reducer.js";
|
||||
import {
|
||||
PRP_BLOCK_RESULT_OUTPUT_SCHEMA,
|
||||
PRP_BLOCK_RESULT_PROVIDER_INPUT_SCHEMA,
|
||||
PRP_BLOCK_TOOL_NAME,
|
||||
PRP_COMPLETION_RESULT_OUTPUT_SCHEMA,
|
||||
PRP_COMPLETION_RESULT_PROVIDER_INPUT_SCHEMA,
|
||||
PRP_COMPLETION_TOOL_NAME,
|
||||
PRP_SEMANTIC_TOOL_NAMES,
|
||||
} from "./completion-result.js";
|
||||
|
||||
export const CODEX_TASK_ENVELOPE_SCHEMA = "paperclip.skillless_task.v1" as const;
|
||||
export const CODEX_CODEX_PROTOCOL_VERSION = "v2" as const;
|
||||
/** @deprecated Use the provider-neutral PRP completion contract exports. */
|
||||
export const CODEX_COMPLETION_TOOL_NAME = PRP_COMPLETION_TOOL_NAME;
|
||||
/** @deprecated Use the provider-neutral PRP completion contract exports. */
|
||||
export const CODEX_BLOCK_TOOL_NAME = PRP_BLOCK_TOOL_NAME;
|
||||
/** @deprecated Use the provider-neutral PRP completion contract exports. */
|
||||
export const CODEX_SEMANTIC_TOOL_NAMES = PRP_SEMANTIC_TOOL_NAMES;
|
||||
export const CODEX_SKILLLESS_BASE_INSTRUCTIONS =
|
||||
"Complete only the supplied task envelope. Do not discover or invoke skills. Do not call a control-plane API. Return exactly one semantic completion result; use paperclip_finish when the work is done or needs review, and paperclip_block only when work cannot continue." as const;
|
||||
|
||||
export interface CodexTaskEnvelope {
|
||||
schema: typeof CODEX_TASK_ENVELOPE_SCHEMA;
|
||||
objective: string;
|
||||
completionContract: {
|
||||
revision: string;
|
||||
criteria: Array<{ id: string; requirement: string }>;
|
||||
};
|
||||
constraints: string[];
|
||||
expectedResultSchema: "paperclip.run_result.v1";
|
||||
}
|
||||
|
||||
export interface CodexModelContextSnapshot {
|
||||
protocolVersion: typeof CODEX_CODEX_PROTOCOL_VERSION;
|
||||
codexVersion: string;
|
||||
clientInfo: {
|
||||
name: "paperclip-runner";
|
||||
title: "Paperclip Runner";
|
||||
version: string;
|
||||
};
|
||||
model: string;
|
||||
modelProvider: string;
|
||||
workingDirectory: string;
|
||||
collaborationMode: "default" | "plan";
|
||||
sandbox: unknown;
|
||||
approvalPolicy: unknown;
|
||||
baseInstructions: string;
|
||||
instructionSources: string[];
|
||||
instructionPolicy: {
|
||||
skillInstructions: boolean;
|
||||
appInstructions: false;
|
||||
collaborationInstructions: boolean;
|
||||
};
|
||||
environmentKeys: string[];
|
||||
dynamicToolNames: string[];
|
||||
modelInputKinds: ["text"];
|
||||
liveConsole?: {
|
||||
conversationMode?: "task" | "direct";
|
||||
runtimeRequestResolution: boolean;
|
||||
goals: boolean;
|
||||
threadLineage: boolean;
|
||||
};
|
||||
envelope: CodexTaskEnvelope;
|
||||
}
|
||||
|
||||
export interface CodexRunMetadata {
|
||||
schema: "paperclip.runner.codex.metadata.v1";
|
||||
fixtureName: string;
|
||||
identity: {
|
||||
schema: "paperclip.prp.identity.v1";
|
||||
companyId: string;
|
||||
issueId: string;
|
||||
runId: string;
|
||||
environmentLeaseId: string;
|
||||
runnerInstanceId: string;
|
||||
normalizedSessionId: string;
|
||||
driverSessionId?: string;
|
||||
providerSessionId?: string;
|
||||
};
|
||||
capabilities: PrpCapabilities;
|
||||
}
|
||||
|
||||
export interface CodexRunTrace {
|
||||
schema: "paperclip.runner.codex.trace.v1";
|
||||
metadata: CodexRunMetadata;
|
||||
context: CodexModelContextSnapshot;
|
||||
events: PrpEvent[];
|
||||
proposedResult: unknown | null;
|
||||
result: PrpStructuredRunResult | null;
|
||||
resultDecision: CodexResultDecision;
|
||||
liveSnapshot: SessionSnapshot;
|
||||
replaySnapshot: SessionSnapshot;
|
||||
diagnostics: string[];
|
||||
assertions: {
|
||||
exactlyOneTerminalResult: boolean;
|
||||
proposalAccepted: boolean;
|
||||
liveReplayParity: boolean;
|
||||
stableIdentity: boolean;
|
||||
sourceSequenceContinuous: boolean;
|
||||
stableItemIdentity: boolean;
|
||||
contextIsSkillless: boolean;
|
||||
unrelatedSkillsAbsent: boolean;
|
||||
credentialsAbsent: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
export interface CodexResultValidationIssue {
|
||||
code:
|
||||
| "schema_validation"
|
||||
| "contract_revision_mismatch"
|
||||
| "unknown_criterion"
|
||||
| "missing_criterion"
|
||||
| "duplicate_criterion"
|
||||
| "invalid_disposition";
|
||||
path: string;
|
||||
message: string;
|
||||
}
|
||||
|
||||
export type CodexResultDecision =
|
||||
| { status: "accepted"; result: PrpStructuredRunResult; issues: [] }
|
||||
| { status: "rejected"; result: null; issues: CodexResultValidationIssue[] };
|
||||
|
||||
export function createCodexTaskEnvelope(input: {
|
||||
objective: string;
|
||||
contractRevision?: string;
|
||||
criteria?: Array<{ id: string; requirement: string }>;
|
||||
constraints?: string[];
|
||||
}): CodexTaskEnvelope {
|
||||
return {
|
||||
schema: CODEX_TASK_ENVELOPE_SCHEMA,
|
||||
objective: input.objective,
|
||||
completionContract: {
|
||||
revision: input.contractRevision ?? "codex-demo-v1",
|
||||
criteria:
|
||||
input.criteria ?? [{ id: "objective", requirement: "Complete the objective safely." }],
|
||||
},
|
||||
constraints: input.constraints ?? [
|
||||
"Work only inside the supplied working directory.",
|
||||
"Do not discover or invoke skills.",
|
||||
"Do not call a control-plane API.",
|
||||
"Return one semantic completion result.",
|
||||
],
|
||||
expectedResultSchema: "paperclip.run_result.v1",
|
||||
};
|
||||
}
|
||||
|
||||
/** @deprecated Use PRP_COMPLETION_RESULT_OUTPUT_SCHEMA. */
|
||||
export const CODEX_RESULT_OUTPUT_SCHEMA = PRP_COMPLETION_RESULT_OUTPUT_SCHEMA;
|
||||
/** @deprecated Use PRP_BLOCK_RESULT_OUTPUT_SCHEMA. */
|
||||
export const CODEX_BLOCK_RESULT_OUTPUT_SCHEMA = PRP_BLOCK_RESULT_OUTPUT_SCHEMA;
|
||||
export const CODEX_RESULT_PROVIDER_INPUT_SCHEMA = PRP_COMPLETION_RESULT_PROVIDER_INPUT_SCHEMA;
|
||||
export const CODEX_BLOCK_RESULT_PROVIDER_INPUT_SCHEMA = PRP_BLOCK_RESULT_PROVIDER_INPUT_SCHEMA;
|
||||
|
||||
export function isSkilllessCodexContext(
|
||||
context: CodexModelContextSnapshot,
|
||||
options: { dynamicTools: boolean } = { dynamicTools: true },
|
||||
): boolean {
|
||||
const serialized = JSON.stringify(context).toLowerCase();
|
||||
const expectedTools = options.dynamicTools ? [...CODEX_SEMANTIC_TOOL_NAMES] : [];
|
||||
return (
|
||||
context.instructionSources.length === 0 &&
|
||||
context.baseInstructions === CODEX_SKILLLESS_BASE_INSTRUCTIONS &&
|
||||
context.modelInputKinds.length === 1 &&
|
||||
context.modelInputKinds[0] === "text" &&
|
||||
context.dynamicToolNames.length === expectedTools.length &&
|
||||
context.dynamicToolNames.every((name) =>
|
||||
expectedTools.includes(name as (typeof expectedTools)[number]),
|
||||
) &&
|
||||
context.instructionPolicy.skillInstructions === false &&
|
||||
context.instructionPolicy.appInstructions === false &&
|
||||
!serialized.includes("paperclip_api_key") &&
|
||||
!serialized.includes("authorization: bearer") &&
|
||||
!serialized.includes("/api/issues/") &&
|
||||
!serialized.includes('"type":"skill"')
|
||||
);
|
||||
}
|
||||
|
|
@ -12,9 +12,7 @@ const baseResult = {
|
|||
completionClaim: {
|
||||
contractRevision: "1",
|
||||
objectiveSatisfied: true,
|
||||
criteria: [
|
||||
{ criterionId: "objective", status: "satisfied", evidenceRefs: [] },
|
||||
],
|
||||
criteria: [{ criterionId: "objective", status: "satisfied", evidenceRefs: [] }],
|
||||
remainingWork: [],
|
||||
},
|
||||
evidence: [],
|
||||
|
|
@ -24,68 +22,52 @@ const baseResult = {
|
|||
};
|
||||
|
||||
describe("provider-neutral completion result schema", () => {
|
||||
const validate = new Ajv2020({ allErrors: true, strict: false }).compile(
|
||||
PRP_COMPLETION_RESULT_OUTPUT_SCHEMA,
|
||||
);
|
||||
const validate = new Ajv2020({ allErrors: true, strict: false })
|
||||
.compile(PRP_COMPLETION_RESULT_OUTPUT_SCHEMA);
|
||||
|
||||
it("allows done with no verification and no actionable attention", () => {
|
||||
expect(validate(structuredClone(baseResult))).toBe(true);
|
||||
});
|
||||
|
||||
it("allows provider tool callers to omit the constant schema discriminator", () => {
|
||||
const providerValidate = new Ajv2020({
|
||||
allErrors: true,
|
||||
strict: false,
|
||||
}).compile(PRP_COMPLETION_RESULT_PROVIDER_INPUT_SCHEMA);
|
||||
const providerResult = structuredClone(baseResult) as Record<
|
||||
string,
|
||||
unknown
|
||||
>;
|
||||
const providerValidate = new Ajv2020({ allErrors: true, strict: false })
|
||||
.compile(PRP_COMPLETION_RESULT_PROVIDER_INPUT_SCHEMA);
|
||||
const providerResult = structuredClone(baseResult) as Record<string, unknown>;
|
||||
delete providerResult.schema;
|
||||
expect(providerValidate(providerResult)).toBe(true);
|
||||
});
|
||||
|
||||
it("admits known smaller-model aliases at the provider boundary for canonical normalization", () => {
|
||||
const providerValidate = new Ajv2020({
|
||||
allErrors: true,
|
||||
strict: false,
|
||||
}).compile(PRP_COMPLETION_RESULT_PROVIDER_INPUT_SCHEMA);
|
||||
const providerValidate = new Ajv2020({ allErrors: true, strict: false })
|
||||
.compile(PRP_COMPLETION_RESULT_PROVIDER_INPUT_SCHEMA);
|
||||
const providerResult = structuredClone(baseResult);
|
||||
providerResult.schema = "paperclip_paperclip_finish";
|
||||
providerResult.reportedWorkDisposition = "completed";
|
||||
providerResult.completionClaim.criteria[0]!.status = "passed";
|
||||
providerResult.verification = [
|
||||
{ commandOrCheck: "model check", status: "pass" } as never,
|
||||
];
|
||||
providerResult.verification = [{ commandOrCheck: "model check", status: "pass" } as never];
|
||||
expect(providerValidate(providerResult)).toBe(true);
|
||||
});
|
||||
|
||||
it("requires a reason code for verification that was not run", () => {
|
||||
const result = structuredClone(baseResult);
|
||||
result.verification = [
|
||||
{ commandOrCheck: "Run tests", status: "not_run" } as never,
|
||||
];
|
||||
result.verification = [{ commandOrCheck: "Run tests", status: "not_run" } as never];
|
||||
expect(validate(result)).toBe(false);
|
||||
|
||||
result.verification = [
|
||||
{
|
||||
commandOrCheck: "Run tests",
|
||||
status: "not_run",
|
||||
reasonCode: "tool_unavailable",
|
||||
} as never,
|
||||
];
|
||||
result.verification = [{
|
||||
commandOrCheck: "Run tests",
|
||||
status: "not_run",
|
||||
reasonCode: "tool_unavailable",
|
||||
} as never];
|
||||
expect(validate(result)).toBe(true);
|
||||
});
|
||||
|
||||
it("requires needs_review for actionable attention", () => {
|
||||
const result = structuredClone(baseResult);
|
||||
result.attentionRequests = [
|
||||
{
|
||||
kind: "review",
|
||||
summary: "Confirm the external result.",
|
||||
ownerClass: "human",
|
||||
} as never,
|
||||
];
|
||||
result.attentionRequests = [{
|
||||
kind: "review",
|
||||
summary: "Confirm the external result.",
|
||||
ownerClass: "human",
|
||||
} as never];
|
||||
expect(validate(result)).toBe(false);
|
||||
|
||||
result.reportedWorkDisposition = "needs_review";
|
||||
|
|
@ -95,13 +77,11 @@ describe("provider-neutral completion result schema", () => {
|
|||
it("requires agent-owned attention to identify its target agent", () => {
|
||||
const result = structuredClone(baseResult);
|
||||
result.reportedWorkDisposition = "needs_review";
|
||||
result.attentionRequests = [
|
||||
{
|
||||
kind: "agent_handoff",
|
||||
summary: "Ask the deployment agent to continue.",
|
||||
ownerClass: "agent",
|
||||
} as never,
|
||||
];
|
||||
result.attentionRequests = [{
|
||||
kind: "agent_handoff",
|
||||
summary: "Ask the deployment agent to continue.",
|
||||
ownerClass: "agent",
|
||||
} as never];
|
||||
expect(validate(result)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -36,12 +36,7 @@ export const PRP_ATTENTION_OWNER_CLASSES = [
|
|||
const completionClaimSchema = {
|
||||
type: "object",
|
||||
additionalProperties: false,
|
||||
required: [
|
||||
"contractRevision",
|
||||
"objectiveSatisfied",
|
||||
"criteria",
|
||||
"remainingWork",
|
||||
],
|
||||
required: ["contractRevision", "objectiveSatisfied", "criteria", "remainingWork"],
|
||||
properties: {
|
||||
contractRevision: { type: "string", minLength: 1 },
|
||||
objectiveSatisfied: { type: "boolean" },
|
||||
|
|
@ -100,21 +95,15 @@ const verificationSchema = {
|
|||
},
|
||||
reasonCode: {
|
||||
enum: [...PRP_VERIFICATION_REASON_CODES],
|
||||
description:
|
||||
"Required for not_run; identify why the check had no meaningful verdict.",
|
||||
description: "Required for not_run; identify why the check had no meaningful verdict.",
|
||||
},
|
||||
detail: { type: "string" },
|
||||
artifactRef: { type: "string", minLength: 1 },
|
||||
},
|
||||
allOf: [
|
||||
{
|
||||
if: {
|
||||
properties: { status: { const: "not_run" } },
|
||||
required: ["status"],
|
||||
},
|
||||
then: { required: ["reasonCode"] },
|
||||
},
|
||||
],
|
||||
allOf: [{
|
||||
if: { properties: { status: { const: "not_run" } }, required: ["status"] },
|
||||
then: { required: ["reasonCode"] },
|
||||
}],
|
||||
},
|
||||
} as const;
|
||||
|
||||
|
|
@ -130,15 +119,10 @@ const attentionRequestsSchema = {
|
|||
ownerClass: { enum: [...PRP_ATTENTION_OWNER_CLASSES] },
|
||||
targetAgentId: { type: "string", minLength: 1 },
|
||||
},
|
||||
allOf: [
|
||||
{
|
||||
if: {
|
||||
properties: { ownerClass: { const: "agent" } },
|
||||
required: ["ownerClass"],
|
||||
},
|
||||
then: { required: ["targetAgentId"] },
|
||||
},
|
||||
],
|
||||
allOf: [{
|
||||
if: { properties: { ownerClass: { const: "agent" } }, required: ["ownerClass"] },
|
||||
then: { required: ["targetAgentId"] },
|
||||
}],
|
||||
},
|
||||
} as const;
|
||||
|
||||
|
|
@ -183,17 +167,11 @@ export const PRP_COMPLETION_RESULT_OUTPUT_SCHEMA = {
|
|||
},
|
||||
allOf: [
|
||||
{
|
||||
if: {
|
||||
properties: { reportedWorkDisposition: { const: "done" } },
|
||||
required: ["reportedWorkDisposition"],
|
||||
},
|
||||
if: { properties: { reportedWorkDisposition: { const: "done" } }, required: ["reportedWorkDisposition"] },
|
||||
then: { properties: { attentionRequests: { maxItems: 0 } } },
|
||||
},
|
||||
{
|
||||
if: {
|
||||
properties: { reportedWorkDisposition: { const: "needs_review" } },
|
||||
required: ["reportedWorkDisposition"],
|
||||
},
|
||||
if: { properties: { reportedWorkDisposition: { const: "needs_review" } }, required: ["reportedWorkDisposition"] },
|
||||
then: { properties: { attentionRequests: { minItems: 1 } } },
|
||||
},
|
||||
],
|
||||
|
|
@ -215,11 +193,7 @@ export const PRP_BLOCK_RESULT_OUTPUT_SCHEMA = {
|
|||
properties: {
|
||||
...commonResultProperties,
|
||||
reportedWorkDisposition: { type: "string", const: "blocked" },
|
||||
attentionRequests: {
|
||||
type: "array",
|
||||
maxItems: 0,
|
||||
items: attentionRequestsSchema.items,
|
||||
},
|
||||
attentionRequests: { type: "array", maxItems: 0, items: attentionRequestsSchema.items },
|
||||
blocker: {
|
||||
type: "object",
|
||||
additionalProperties: false,
|
||||
|
|
@ -254,24 +228,13 @@ const providerVerificationCompatibilitySchema = {
|
|||
commandOrCheck: { type: "string", minLength: 1 },
|
||||
command: { type: "string", minLength: 1 },
|
||||
status: {
|
||||
enum: [
|
||||
"passed",
|
||||
"failed",
|
||||
"not_run",
|
||||
"blocked",
|
||||
"skipped",
|
||||
"pass",
|
||||
"success",
|
||||
"succeeded",
|
||||
"fail",
|
||||
],
|
||||
enum: ["passed", "failed", "not_run", "blocked", "skipped", "pass", "success", "succeeded", "fail"],
|
||||
description:
|
||||
"passed: the check ran and succeeded. failed: the check ran to a meaningful verdict and found the work incorrect. not_run: no meaningful verdict because the check was not attempted or could not complete. Prefer not_run over legacy blocked/skipped, and include reasonCode for any unavailable check.",
|
||||
},
|
||||
reasonCode: {
|
||||
enum: [...PRP_VERIFICATION_REASON_CODES],
|
||||
description:
|
||||
"Required for not_run; identify why the check had no meaningful verdict.",
|
||||
description: "Required for not_run; identify why the check had no meaningful verdict.",
|
||||
},
|
||||
detail: { type: "string" },
|
||||
result: { type: "string" },
|
||||
|
|
|
|||
|
|
@ -0,0 +1,160 @@
|
|||
export const DURABLE_RECOVERY_FAULTS = [
|
||||
"none",
|
||||
"socket-drop",
|
||||
"lost-ack",
|
||||
"duplicate-command",
|
||||
"runner-restart",
|
||||
"harness-restart",
|
||||
"malformed-input",
|
||||
"lease-expiry",
|
||||
"storage-pressure",
|
||||
"drain",
|
||||
"revoke",
|
||||
] as const;
|
||||
|
||||
export type DurableRecoveryFault = (typeof DURABLE_RECOVERY_FAULTS)[number];
|
||||
|
||||
export interface DurableRecoveryIdentity {
|
||||
runnerInstanceId: string;
|
||||
environmentLeaseId: string;
|
||||
runId: string;
|
||||
normalizedSessionId: string;
|
||||
turnId: string;
|
||||
itemId: string;
|
||||
}
|
||||
|
||||
export interface DurableRecoveryStoredEvent {
|
||||
sourceSeq: number;
|
||||
sourceEventId: string;
|
||||
priority: 0 | 1 | 2;
|
||||
eventType: string;
|
||||
itemId?: string;
|
||||
envelope: Record<string, unknown>;
|
||||
byteSize: number;
|
||||
}
|
||||
|
||||
export interface DurableRecoveryProcessedCommand {
|
||||
commandId: string;
|
||||
controllerSeq: number;
|
||||
commandDigest: string;
|
||||
status: "completed" | "failed" | "rejected";
|
||||
logicalEffectCount: number;
|
||||
result: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface DurableRecoveryRunnerState extends DurableRecoveryIdentity {
|
||||
schema: "paperclip.runner.durable.state.v1";
|
||||
lifecycle:
|
||||
| "connecting"
|
||||
| "ready"
|
||||
| "terminal"
|
||||
| "backpressure"
|
||||
| "draining"
|
||||
| "revoked"
|
||||
| "stopped"
|
||||
| "recoverable_failure"
|
||||
| "unrecoverable";
|
||||
nextSourceSeq: number;
|
||||
ackedSourceSeq: number;
|
||||
lastControllerCommandSeq: number;
|
||||
reconnectCount: number;
|
||||
maxOutboxBytes: number;
|
||||
peakOutboxBytes: number;
|
||||
outbox: DurableRecoveryStoredEvent[];
|
||||
processedCommands: Record<string, DurableRecoveryProcessedCommand>;
|
||||
compactedCommandFilter: string;
|
||||
compactedCommandCount: number;
|
||||
diagnostics: string[];
|
||||
backpressure: boolean;
|
||||
recoverableFailure: string | null;
|
||||
unrecoverableOutcome: string | null;
|
||||
harnessGeneration: number;
|
||||
stopAfterFlush: boolean;
|
||||
}
|
||||
|
||||
export interface DurableRecoveryCoreCommand {
|
||||
schema: "paperclip.prp.command.v1";
|
||||
commandId: string;
|
||||
controllerSeq: number;
|
||||
type: string;
|
||||
issuedAt: string;
|
||||
payload: Record<string, unknown>;
|
||||
status: "pending" | "completed" | "failed" | "rejected";
|
||||
result: Record<string, unknown> | null;
|
||||
}
|
||||
|
||||
export interface DurableRecoveryCommittedEvent {
|
||||
sourceSeq: number;
|
||||
sourceEventId: string;
|
||||
eventType: string;
|
||||
priority: 0 | 1 | 2;
|
||||
envelope: Record<string, unknown>;
|
||||
deliveryCount: number;
|
||||
logicalEffectCount: number;
|
||||
}
|
||||
|
||||
export interface DurableRecoveryDiagnostics {
|
||||
schema: "paperclip.runner.durable.diagnostics.v1";
|
||||
fault: DurableRecoveryFault;
|
||||
connection: {
|
||||
state: string;
|
||||
connectionCount: number;
|
||||
reconnectCount: number;
|
||||
leaseId: string | null;
|
||||
leaseExpiresAt: string | null;
|
||||
};
|
||||
identity: DurableRecoveryIdentity;
|
||||
cursors: {
|
||||
runnerAckedSourceSeq: number;
|
||||
runnerNextSourceSeq: number;
|
||||
coreAckedSourceSeq: number;
|
||||
highestCommittedSourceSeq: number;
|
||||
};
|
||||
outbox: {
|
||||
events: number;
|
||||
bytes: number;
|
||||
peakBytes: number;
|
||||
maxBytes: number;
|
||||
backpressure: boolean;
|
||||
p0Committed: number;
|
||||
p0Lost: number;
|
||||
};
|
||||
commands: {
|
||||
issued: number;
|
||||
completed: number;
|
||||
rejected: number;
|
||||
logicalEffects: number;
|
||||
duplicateDeliveries: number;
|
||||
};
|
||||
recovery: {
|
||||
replayDeliveries: number;
|
||||
runnerRestarts: number;
|
||||
harnessRestarts: number;
|
||||
malformedFrames: number;
|
||||
freshBootstraps: number;
|
||||
outcome: "recovered" | "drained" | "revoked" | "unrecoverable";
|
||||
reason: string;
|
||||
};
|
||||
security: {
|
||||
bootstrapTicketPersisted: boolean;
|
||||
connectionLeaseTokenPersisted: boolean;
|
||||
secretLeakCount: number;
|
||||
};
|
||||
committedEvents: DurableRecoveryCommittedEvent[];
|
||||
}
|
||||
|
||||
export interface DurableRecoveryRunTrace {
|
||||
schema: "paperclip.runner.durable.trace.v1";
|
||||
diagnostics: DurableRecoveryDiagnostics;
|
||||
runnerState: DurableRecoveryRunnerState;
|
||||
commands: DurableRecoveryCoreCommand[];
|
||||
assertions: {
|
||||
stableIdentity: boolean;
|
||||
sourceCursorContinuous: boolean;
|
||||
oneLogicalEffectPerAcceptedCommand: boolean;
|
||||
noDuplicateLogicalEvents: boolean;
|
||||
p0Preserved: boolean;
|
||||
boundedStorage: boolean;
|
||||
secretsRedacted: boolean;
|
||||
};
|
||||
}
|
||||
|
|
@ -0,0 +1,51 @@
|
|||
import type {
|
||||
PrpCapabilities,
|
||||
PrpCommand,
|
||||
PrpEvent,
|
||||
PrpIdentity,
|
||||
PrpRequest,
|
||||
PrpStructuredRunResult,
|
||||
} from "../protocol/replay-contract.js";
|
||||
|
||||
export const LOCAL_RUNNER_SCENARIOS = [
|
||||
"happy-path",
|
||||
"permission-input",
|
||||
"interrupted",
|
||||
"error",
|
||||
"duplicate-terminal",
|
||||
] as const;
|
||||
|
||||
export type LocalRunnerScenario = (typeof LOCAL_RUNNER_SCENARIOS)[number];
|
||||
|
||||
export interface LocalRunnerCommandReceipt {
|
||||
commandId: string;
|
||||
controllerSeq: number;
|
||||
status: "accepted" | "duplicate" | "rejected";
|
||||
detail: string;
|
||||
}
|
||||
|
||||
export interface LocalRunnerProcessExitFact {
|
||||
exitCode: number | null;
|
||||
success: boolean;
|
||||
signal: number | null;
|
||||
}
|
||||
|
||||
export interface LocalRunnerRunMetadata {
|
||||
schema: "paperclip.runner.local-runner.metadata.v1";
|
||||
scenario: LocalRunnerScenario;
|
||||
fixtureName: string;
|
||||
identity: PrpIdentity;
|
||||
capabilities: PrpCapabilities;
|
||||
}
|
||||
|
||||
export interface LocalRunnerRunTrace extends Omit<LocalRunnerRunMetadata, "schema"> {
|
||||
schema: "paperclip.runner.local-runner.trace.v1";
|
||||
commands: PrpCommand[];
|
||||
commandReceipts: LocalRunnerCommandReceipt[];
|
||||
events: PrpEvent[];
|
||||
requests: PrpRequest[];
|
||||
result: PrpStructuredRunResult | null;
|
||||
harnessProcessExit: LocalRunnerProcessExitFact | null;
|
||||
runnerProcessExit: { code: number | null; signal: string | null };
|
||||
runnerDiagnostics: string[];
|
||||
}
|
||||
|
|
@ -0,0 +1,414 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { buildNativeModelEnvelope, parseNativeExecutionInput, type NativeExecutionInputV1 } from "./native-execution.js";
|
||||
import {
|
||||
NATIVE_RUNTIME_ASSET_SCHEMA,
|
||||
PAPERCLIP_EXECUTION_PROMPT,
|
||||
PAPERCLIP_EXECUTION_PROMPT_REVISION,
|
||||
canonicalNativeRuntimeContextDigest,
|
||||
composeNativeSystemInstructions,
|
||||
nativeRuntimePromptDigest,
|
||||
} from "./runtime-context.js";
|
||||
|
||||
const input: NativeExecutionInputV1 = {
|
||||
schema: "paperclip.native-execution-input.v1",
|
||||
binding: {
|
||||
companyId: "company-1",
|
||||
runId: "run-1",
|
||||
issueId: "issue-1",
|
||||
agentId: "agent-1",
|
||||
executionWorkspaceId: "workspace-1",
|
||||
},
|
||||
task: {
|
||||
identifier: "PAP-1",
|
||||
title: "Safe task",
|
||||
description: null,
|
||||
prompt: "# PAP-1: Safe task\n\nPlease address the latest comment.",
|
||||
workMode: "standard",
|
||||
},
|
||||
workspace: { cwd: "/safe/workspace", repoUrl: null, repoRef: null, branchName: null },
|
||||
session: {
|
||||
normalizedSessionId: null,
|
||||
driverKind: "codex_app_server",
|
||||
protocolVersion: 1,
|
||||
lifecyclePolicy: { mode: "per_turn", idleTimeoutMs: null },
|
||||
},
|
||||
provider: { kind: "codex", model: null },
|
||||
completionContract: {
|
||||
id: "contract-1",
|
||||
sha256: "abc123",
|
||||
schemaVersion: "paperclip.completion-contract.v1",
|
||||
contract: {
|
||||
revision: "1",
|
||||
objective: "Complete the safe task.",
|
||||
criteria: [{ id: "objective", requirement: "The task is complete." }],
|
||||
},
|
||||
},
|
||||
interactionResponses: [],
|
||||
credentialBindings: [{
|
||||
bindingId: "opaque-binding",
|
||||
service: "github",
|
||||
destination: "github.com",
|
||||
expiresAt: null,
|
||||
displayName: "GitHub",
|
||||
}],
|
||||
};
|
||||
|
||||
describe("NativeExecutionInputV1", () => {
|
||||
it("parses v3 immutable runtime context without changing the model task envelope", () => {
|
||||
const digest = "0".repeat(64);
|
||||
const context = {
|
||||
prompt: { revision: PAPERCLIP_EXECUTION_PROMPT_REVISION, text: PAPERCLIP_EXECUTION_PROMPT, digest: nativeRuntimePromptDigest() },
|
||||
instructions: {
|
||||
entryPath: "AGENTS.md",
|
||||
bundle: { schema: NATIVE_RUNTIME_ASSET_SCHEMA, digest, manifestDigest: digest, rootPath: "/runtime/instructions", fileCount: 2, totalBytes: 42 },
|
||||
},
|
||||
skills: [{
|
||||
key: "company/research",
|
||||
runtimeName: "research",
|
||||
versionId: "version-1",
|
||||
bundle: { schema: NATIVE_RUNTIME_ASSET_SCHEMA, digest, manifestDigest: digest, rootPath: "/runtime/skills/research", fileCount: 2, totalBytes: 42 },
|
||||
}],
|
||||
mcp: { assignmentSetId: "sha256:test", digest, bindingId: "native-mcp:run-1" },
|
||||
} as const;
|
||||
const parsed = parseNativeExecutionInput({
|
||||
...input,
|
||||
schema: "paperclip.native-execution-input.v3",
|
||||
executionMode: "default",
|
||||
planningContext: null,
|
||||
runtimeContext: { ...context, aggregateDigest: canonicalNativeRuntimeContextDigest(context) },
|
||||
});
|
||||
expect(parsed.schema).toBe("paperclip.native-execution-input.v3");
|
||||
const envelope = buildNativeModelEnvelope(parsed);
|
||||
expect(envelope.task).toEqual(buildNativeModelEnvelope(input).task);
|
||||
expect(envelope.completionContract).toEqual(buildNativeModelEnvelope(input).completionContract);
|
||||
expect(JSON.stringify(envelope)).not.toContain("runtimeContext");
|
||||
expect(JSON.stringify(envelope)).not.toContain(PAPERCLIP_EXECUTION_PROMPT);
|
||||
expect(composeNativeSystemInstructions(parsed.runtimeContext, "Follow sibling.md")).toBe(
|
||||
`${PAPERCLIP_EXECUTION_PROMPT}\n\nFollow sibling.md\n\nRead-only instruction sibling root: /runtime/instructions`,
|
||||
);
|
||||
expect(canonicalNativeRuntimeContextDigest({
|
||||
...context,
|
||||
mcp: { ...context.mcp, bindingId: "native-mcp:run-2" },
|
||||
})).toBe(parsed.runtimeContext.aggregateDigest);
|
||||
const current = parseNativeExecutionInput({
|
||||
...parsed,
|
||||
schema: "paperclip.native-execution-input.v4",
|
||||
provider: { kind: "codex", model: null, approvalPolicy: "on-request" },
|
||||
});
|
||||
expect(current).toMatchObject({
|
||||
schema: "paperclip.native-execution-input.v4",
|
||||
provider: { kind: "codex", approvalPolicy: "on-request" },
|
||||
});
|
||||
expect(() => parseNativeExecutionInput({
|
||||
...parsed,
|
||||
schema: "paperclip.native-execution-input.v4",
|
||||
provider: { kind: "codex", model: null, approvalPolicy: "sometimes" },
|
||||
})).toThrow("approvalPolicy");
|
||||
});
|
||||
|
||||
it("rejects runtime-context traversal and aggregate digest drift", () => {
|
||||
const digest = "0".repeat(64);
|
||||
const context = {
|
||||
prompt: { revision: PAPERCLIP_EXECUTION_PROMPT_REVISION, text: PAPERCLIP_EXECUTION_PROMPT, digest: nativeRuntimePromptDigest() },
|
||||
instructions: {
|
||||
entryPath: "../AGENTS.md",
|
||||
bundle: { schema: NATIVE_RUNTIME_ASSET_SCHEMA, digest, manifestDigest: digest, rootPath: "/runtime/instructions", fileCount: 1, totalBytes: 1 },
|
||||
},
|
||||
skills: [],
|
||||
mcp: { assignmentSetId: "none", digest, bindingId: null },
|
||||
aggregateDigest: digest,
|
||||
} as const;
|
||||
expect(() => parseNativeExecutionInput({ ...input, schema: "paperclip.native-execution-input.v3", executionMode: "default", planningContext: null, runtimeContext: context })).toThrow("bundle root");
|
||||
expect(() => parseNativeExecutionInput({
|
||||
...input,
|
||||
schema: "paperclip.native-execution-input.v3",
|
||||
executionMode: "default",
|
||||
planningContext: null,
|
||||
runtimeContext: { ...context, instructions: { ...context.instructions, entryPath: "AGENTS.md" } },
|
||||
})).toThrow("aggregateDigest");
|
||||
});
|
||||
|
||||
it("builds a model envelope without authority or credential bindings", () => {
|
||||
const parsed = parseNativeExecutionInput(input);
|
||||
const model = buildNativeModelEnvelope(parsed);
|
||||
const serialized = JSON.stringify(model);
|
||||
expect(serialized).not.toContain("company-1");
|
||||
expect(serialized).not.toContain("run-1");
|
||||
expect(serialized).not.toContain("opaque-binding");
|
||||
expect(model.task.title).toBe("Safe task");
|
||||
expect(model.task.prompt).toContain("latest comment");
|
||||
});
|
||||
|
||||
it("rejects unknown context or environment escape hatches", () => {
|
||||
expect(() => parseNativeExecutionInput({ ...input, context: { secret: "canary" } })).toThrow(
|
||||
"unknown field context",
|
||||
);
|
||||
expect(() => parseNativeExecutionInput({
|
||||
...input,
|
||||
workspace: { ...input.workspace, env: { PAPERCLIP_API_KEY: "canary" } },
|
||||
})).toThrow("unknown field env");
|
||||
});
|
||||
|
||||
it("accepts a persisted OpenCode driver/model pair and rejects mismatches", () => {
|
||||
const opencode = parseNativeExecutionInput({
|
||||
...input,
|
||||
session: { ...input.session, driverKind: "opencode_server" },
|
||||
provider: { kind: "opencode", model: "openrouter/deepseek/deepseek-v4-flash-0731" },
|
||||
});
|
||||
expect(opencode.provider).toEqual({
|
||||
kind: "opencode",
|
||||
model: "openrouter/deepseek/deepseek-v4-flash-0731",
|
||||
});
|
||||
expect(parseNativeExecutionInput(opencode)).toEqual(opencode);
|
||||
expect(() => parseNativeExecutionInput({
|
||||
...input,
|
||||
session: { ...input.session, driverKind: "opencode_server" },
|
||||
provider: { kind: "codex", model: null },
|
||||
})).toThrow("does not match");
|
||||
});
|
||||
|
||||
it("deserializes pre-provider Codex state as Codex", () => {
|
||||
const legacy = structuredClone(input) as Record<string, unknown>;
|
||||
delete legacy.provider;
|
||||
expect(parseNativeExecutionInput(legacy).provider).toEqual({
|
||||
kind: "codex",
|
||||
model: null,
|
||||
});
|
||||
expect(parseNativeExecutionInput(parseNativeExecutionInput(legacy))).toEqual(
|
||||
parseNativeExecutionInput(legacy),
|
||||
);
|
||||
});
|
||||
|
||||
it("accepts an immutable Claude Managed Agent profile and rejects driver or beta drift", () => {
|
||||
const claudeManaged = {
|
||||
...input,
|
||||
session: { ...input.session, driverKind: "claude_managed_agents_api" },
|
||||
provider: {
|
||||
kind: "claude_managed",
|
||||
model: "claude-sonnet-5",
|
||||
managedProfile: {
|
||||
profileId: "managed-profile-1",
|
||||
anthropicAgentId: "agent_01",
|
||||
agentVersion: "3",
|
||||
environmentId: "environment_01",
|
||||
betaVersion: "managed-agents-2026-04-01",
|
||||
},
|
||||
maxSessionListCostUsd: 1,
|
||||
},
|
||||
} as const;
|
||||
const parsed = parseNativeExecutionInput(claudeManaged);
|
||||
expect(parsed.provider).toEqual(claudeManaged.provider);
|
||||
expect(buildNativeModelEnvelope(parsed).workspace).toBeNull();
|
||||
expect(() => parseNativeExecutionInput({
|
||||
...claudeManaged,
|
||||
session: { ...claudeManaged.session, driverKind: "codex_app_server" },
|
||||
})).toThrow("does not match");
|
||||
expect(() => parseNativeExecutionInput({
|
||||
...claudeManaged,
|
||||
provider: {
|
||||
...claudeManaged.provider,
|
||||
managedProfile: { ...claudeManaged.provider.managedProfile, betaVersion: "future-beta" },
|
||||
},
|
||||
})).toThrow("betaVersion");
|
||||
});
|
||||
|
||||
it("accepts a closed AWS AgentCore Harness snapshot and rejects drift or unsafe limits", () => {
|
||||
const awsAgentCore = {
|
||||
...input,
|
||||
session: { ...input.session, driverKind: "aws_agentcore_harness_api" },
|
||||
provider: {
|
||||
kind: "aws_agentcore",
|
||||
model: "global.anthropic.claude-sonnet-4-6",
|
||||
agentCoreProfile: {
|
||||
profileId: "agentcore-development",
|
||||
region: "us-east-1",
|
||||
accountId: "123456789012",
|
||||
harnessArn: "arn:aws:bedrock-agentcore:us-east-1:123456789012:harness/harness-1",
|
||||
harnessVersion: "3",
|
||||
endpointArn: "arn:aws:bedrock-agentcore:us-east-1:123456789012:harness-endpoint/harness-1/paperclip",
|
||||
endpointQualifier: "paperclip",
|
||||
agentRuntimeArn: "arn:aws:bedrock-agentcore:us-east-1:123456789012:runtime/runtime-1",
|
||||
memoryArn: "arn:aws:bedrock-agentcore:us-east-1:123456789012:memory/memory-1",
|
||||
memoryId: "memory-1",
|
||||
invocationRoleArn: "arn:aws:iam::123456789012:role/paperclip-agentcore-runner",
|
||||
contextBucket: "paperclip-agentcore-context",
|
||||
contextPrefix: "paperclip/runtime",
|
||||
contextKmsKeyArn: "arn:aws:kms:us-east-1:123456789012:key/test",
|
||||
qualificationRevision: "aws-agentcore-harness-v1",
|
||||
eventExpiryDays: 90,
|
||||
},
|
||||
maxEstimatedSessionCostUsd: 1,
|
||||
invocationLimits: { maxIterations: 8, maxOutputTokens: 4096, timeoutSeconds: 300 },
|
||||
},
|
||||
} as const;
|
||||
const parsed = parseNativeExecutionInput(awsAgentCore);
|
||||
expect(parsed.provider).toEqual(awsAgentCore.provider);
|
||||
expect(buildNativeModelEnvelope(parsed).workspace).toBeNull();
|
||||
expect(JSON.stringify(parsed)).not.toContain("AWS_SECRET_ACCESS_KEY");
|
||||
expect(() => parseNativeExecutionInput({
|
||||
...awsAgentCore,
|
||||
session: { ...awsAgentCore.session, driverKind: "codex_app_server" },
|
||||
})).toThrow("does not match");
|
||||
expect(() => parseNativeExecutionInput({
|
||||
...awsAgentCore,
|
||||
provider: { ...awsAgentCore.provider, invocationLimits: { ...awsAgentCore.provider.invocationLimits, maxIterations: 9 } },
|
||||
})).toThrow("maxIterations");
|
||||
expect(() => parseNativeExecutionInput({
|
||||
...awsAgentCore,
|
||||
provider: { ...awsAgentCore.provider, agentCoreProfile: { ...awsAgentCore.provider.agentCoreProfile, eventExpiryDays: 30 } },
|
||||
})).toThrow("eventExpiryDays");
|
||||
});
|
||||
|
||||
it("accepts only a closed ACPX profile matching the driver and agent", () => {
|
||||
const provider = {
|
||||
kind: "acpx",
|
||||
agent: "pi",
|
||||
model: "openrouter/deepseek/deepseek-v4-flash-0731",
|
||||
permissionPolicy: "interactive",
|
||||
profile: {
|
||||
driverKind: "acpx_runtime",
|
||||
protocolVersion: 1,
|
||||
acpxVersion: "0.13.1",
|
||||
agent: "pi",
|
||||
agentProfileVersion: 1,
|
||||
agentServerPackage: "pi-acp",
|
||||
agentServerVersion: "0.0.33",
|
||||
agentRuntimePackage: "@earendil-works/pi-coding-agent",
|
||||
agentRuntimeVersion: "0.84.2",
|
||||
commandDigest: "sha256:24ff73fda6e3c76ddce2d359a79f5c4b8f292eb290e4d2ab85aac94676b2c2dc",
|
||||
},
|
||||
} as const;
|
||||
const parsed = parseNativeExecutionInput({
|
||||
...input,
|
||||
session: { ...input.session, driverKind: "acpx_runtime" },
|
||||
provider,
|
||||
});
|
||||
expect(parsed.provider).toEqual({
|
||||
kind: "acpx",
|
||||
agent: "pi",
|
||||
model: "openrouter/deepseek/deepseek-v4-flash-0731",
|
||||
permissionPolicy: "interactive",
|
||||
profile: provider.profile,
|
||||
});
|
||||
expect(parseNativeExecutionInput(parsed)).toEqual(parsed);
|
||||
expect(buildNativeModelEnvelope(parsed).workspace).toEqual({ cwd: "/safe/workspace" });
|
||||
expect(() => parseNativeExecutionInput({
|
||||
...input,
|
||||
session: { ...input.session, driverKind: "acpx_runtime" },
|
||||
provider: { ...provider, profile: { ...provider.profile, agent: "claude" } },
|
||||
})).toThrow("qualified ACPX v1 profile");
|
||||
expect(() => parseNativeExecutionInput({
|
||||
...input,
|
||||
session: { ...input.session, driverKind: "opencode_server" },
|
||||
provider,
|
||||
})).toThrow("does not match");
|
||||
});
|
||||
|
||||
it("defaults legacy lifecycle state to per-turn and validates warm timeouts", () => {
|
||||
const legacy = structuredClone(input) as Record<string, unknown>;
|
||||
delete (legacy.session as Record<string, unknown>).lifecyclePolicy;
|
||||
expect(parseNativeExecutionInput(legacy).session.lifecyclePolicy).toEqual({
|
||||
mode: "per_turn",
|
||||
idleTimeoutMs: null,
|
||||
});
|
||||
expect(parseNativeExecutionInput({
|
||||
...input,
|
||||
session: {
|
||||
...input.session,
|
||||
lifecyclePolicy: { mode: "warm", idleTimeoutMs: 300_000 },
|
||||
},
|
||||
}).session.lifecyclePolicy).toEqual({ mode: "warm", idleTimeoutMs: 300_000 });
|
||||
expect(() => parseNativeExecutionInput({
|
||||
...input,
|
||||
session: {
|
||||
...input.session,
|
||||
lifecyclePolicy: { mode: "warm", idleTimeoutMs: 0 },
|
||||
},
|
||||
})).toThrow("positive integer");
|
||||
});
|
||||
});
|
||||
|
||||
describe("NativeExecutionInputV2 planning", () => {
|
||||
const planning = {
|
||||
...input,
|
||||
schema: "paperclip.native-execution-input.v2",
|
||||
executionMode: "plan",
|
||||
task: { ...input.task, workMode: "planning" },
|
||||
planningContext: {
|
||||
documentId: "document-1",
|
||||
baseRevisionId: "revision-3",
|
||||
baseRevisionNumber: 3,
|
||||
markdown: "# Existing plan",
|
||||
sha256: "plan-digest",
|
||||
reviewContext: { threads: [{ id: "annotation-1" }] },
|
||||
},
|
||||
} as const;
|
||||
|
||||
it("round-trips native plan mode and its pinned canonical revision into model context", () => {
|
||||
const parsed = parseNativeExecutionInput(planning);
|
||||
expect(parsed).toMatchObject({ schema: "paperclip.native-execution-input.v2", executionMode: "plan" });
|
||||
expect(buildNativeModelEnvelope(parsed)).toMatchObject({
|
||||
schema: "paperclip.native-model-envelope.v2",
|
||||
executionMode: "plan",
|
||||
planningContext: { baseRevisionId: "revision-3", baseRevisionNumber: 3 },
|
||||
});
|
||||
});
|
||||
|
||||
it("fails closed when plan mode omits its pinned Paperclip context", () => {
|
||||
expect(() => parseNativeExecutionInput({ ...planning, planningContext: null }))
|
||||
.toThrow("planningContext is required");
|
||||
expect(() => parseNativeExecutionInput({
|
||||
...planning,
|
||||
task: { ...planning.task, workMode: "standard" },
|
||||
})).toThrow("requires planning work mode");
|
||||
});
|
||||
|
||||
it("allows an accepted planning issue to continue in fresh default execution mode", () => {
|
||||
expect(parseNativeExecutionInput({
|
||||
...planning,
|
||||
executionMode: "default",
|
||||
planningContext: null,
|
||||
})).toMatchObject({ task: { workMode: "planning" }, executionMode: "default" });
|
||||
});
|
||||
});
|
||||
|
||||
describe("NativeExecutionInputV2 ask mode", () => {
|
||||
it("round-trips ask mode as a default execution without planning context", () => {
|
||||
const parsed = parseNativeExecutionInput({
|
||||
...input,
|
||||
schema: "paperclip.native-execution-input.v2",
|
||||
executionMode: "default",
|
||||
task: { ...input.task, workMode: "ask" },
|
||||
planningContext: null,
|
||||
});
|
||||
expect(parsed).toMatchObject({
|
||||
schema: "paperclip.native-execution-input.v2",
|
||||
executionMode: "default",
|
||||
task: { workMode: "ask" },
|
||||
});
|
||||
expect(buildNativeModelEnvelope(parsed)).toMatchObject({
|
||||
schema: "paperclip.native-model-envelope.v2",
|
||||
task: { workMode: "ask" },
|
||||
executionMode: "default",
|
||||
planningContext: null,
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects plan execution for ask mode", () => {
|
||||
expect(() => parseNativeExecutionInput({
|
||||
...input,
|
||||
schema: "paperclip.native-execution-input.v2",
|
||||
executionMode: "plan",
|
||||
task: { ...input.task, workMode: "ask" },
|
||||
planningContext: {
|
||||
documentId: null,
|
||||
baseRevisionId: null,
|
||||
baseRevisionNumber: 0,
|
||||
markdown: "",
|
||||
sha256: "digest",
|
||||
reviewContext: {},
|
||||
},
|
||||
})).toThrow("plan execution mode requires planning work mode");
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,732 @@
|
|||
import type { PrpStructuredRunResult, PrpTerminalState } from "../protocol/replay-contract.js";
|
||||
import { parseNativeRuntimeContext, type NativeRuntimeContextSnapshot } from "./runtime-context.js";
|
||||
|
||||
export const NATIVE_EXECUTION_INPUT_SCHEMA_V1 = "paperclip.native-execution-input.v1" as const;
|
||||
export const NATIVE_EXECUTION_INPUT_SCHEMA_V2 = "paperclip.native-execution-input.v2" as const;
|
||||
export const NATIVE_EXECUTION_INPUT_SCHEMA_V3 = "paperclip.native-execution-input.v3" as const;
|
||||
export const NATIVE_EXECUTION_INPUT_SCHEMA = "paperclip.native-execution-input.v4" as const;
|
||||
export const NATIVE_MODEL_ENVELOPE_SCHEMA_V1 = "paperclip.native-model-envelope.v1" as const;
|
||||
export const NATIVE_MODEL_ENVELOPE_SCHEMA = "paperclip.native-model-envelope.v2" as const;
|
||||
|
||||
export type NativeExecutionMode = "default" | "plan";
|
||||
|
||||
export interface NativePlanningContext {
|
||||
documentId: string | null;
|
||||
baseRevisionId: string | null;
|
||||
baseRevisionNumber: number;
|
||||
markdown: string;
|
||||
sha256: string;
|
||||
reviewContext: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface StrictCompletionContractInput {
|
||||
revision: string;
|
||||
objective: string;
|
||||
criteria: Array<{ id: string; requirement: string }>;
|
||||
}
|
||||
|
||||
export interface NativeInteractionResponseEnvelope {
|
||||
interactionId: string;
|
||||
kind:
|
||||
| "suggest_tasks"
|
||||
| "ask_user_questions"
|
||||
| "request_confirmation"
|
||||
| "request_checkbox_confirmation"
|
||||
| "request_item_verdicts";
|
||||
response: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface NativeCredentialBindingRef {
|
||||
bindingId: string;
|
||||
service: string;
|
||||
destination: string;
|
||||
expiresAt: string | null;
|
||||
displayName: string | null;
|
||||
}
|
||||
|
||||
export type NativeSessionLifecyclePolicy =
|
||||
| { mode: "per_turn"; idleTimeoutMs: null }
|
||||
| { mode: "warm"; idleTimeoutMs: number };
|
||||
|
||||
export interface NativeManagedAgentProfileSnapshot {
|
||||
profileId: string;
|
||||
anthropicAgentId: string;
|
||||
agentVersion: string;
|
||||
environmentId: string;
|
||||
betaVersion: "managed-agents-2026-04-01";
|
||||
}
|
||||
|
||||
export interface NativeAwsAgentCoreProfileSnapshot {
|
||||
profileId: string;
|
||||
region: string;
|
||||
accountId: string;
|
||||
harnessArn: string;
|
||||
harnessVersion: string;
|
||||
endpointArn: string;
|
||||
endpointQualifier: string;
|
||||
agentRuntimeArn: string;
|
||||
memoryArn: string;
|
||||
memoryId: string;
|
||||
invocationRoleArn: string;
|
||||
contextBucket: string;
|
||||
contextPrefix: string;
|
||||
contextKmsKeyArn: string;
|
||||
qualificationRevision: string;
|
||||
eventExpiryDays: 90;
|
||||
}
|
||||
|
||||
export type NativeAcpxAgent = "pi" | "claude" | "codex";
|
||||
export type NativeCodexApprovalPolicy = "never" | "on-request" | "untrusted";
|
||||
export type NativeOpenCodePermissionMode = "allow" | "ask" | "deny";
|
||||
export type NativeAcpxPermissionMode = "approve-all" | "approve-reads" | "deny-all";
|
||||
|
||||
export interface NativeAcpxProfileSnapshot {
|
||||
driverKind: "acpx_runtime";
|
||||
protocolVersion: 1;
|
||||
acpxVersion: "0.13.1";
|
||||
agent: NativeAcpxAgent;
|
||||
agentProfileVersion: 1;
|
||||
agentServerPackage: string;
|
||||
agentServerVersion: string;
|
||||
agentRuntimePackage: string | null;
|
||||
agentRuntimeVersion: string | null;
|
||||
commandDigest: string;
|
||||
}
|
||||
|
||||
export type NativeProviderConfig =
|
||||
| { kind: "codex"; model: string | null; approvalPolicy?: NativeCodexApprovalPolicy }
|
||||
| { kind: "opencode"; model: string; permissionMode?: NativeOpenCodePermissionMode }
|
||||
| {
|
||||
kind: "claude_managed";
|
||||
model: string;
|
||||
managedProfile: NativeManagedAgentProfileSnapshot;
|
||||
maxSessionListCostUsd: number;
|
||||
}
|
||||
| {
|
||||
kind: "aws_agentcore";
|
||||
model: string;
|
||||
agentCoreProfile: NativeAwsAgentCoreProfileSnapshot;
|
||||
maxEstimatedSessionCostUsd: number;
|
||||
invocationLimits: {
|
||||
maxIterations: number;
|
||||
maxOutputTokens: number;
|
||||
timeoutSeconds: number;
|
||||
};
|
||||
}
|
||||
| {
|
||||
kind: "acpx";
|
||||
agent: NativeAcpxAgent;
|
||||
model: string;
|
||||
permissionMode?: NativeAcpxPermissionMode;
|
||||
/** Present only in persisted v1-v3 inputs. */
|
||||
permissionPolicy?: "interactive";
|
||||
profile: NativeAcpxProfileSnapshot;
|
||||
};
|
||||
|
||||
export type NativeProviderConfigV4 =
|
||||
| { kind: "codex"; model: string | null; approvalPolicy: NativeCodexApprovalPolicy }
|
||||
| { kind: "opencode"; model: string; permissionMode: NativeOpenCodePermissionMode }
|
||||
| Extract<NativeProviderConfig, { kind: "claude_managed" | "aws_agentcore" }>
|
||||
| {
|
||||
kind: "acpx";
|
||||
agent: NativeAcpxAgent;
|
||||
model: string;
|
||||
permissionMode: NativeAcpxPermissionMode;
|
||||
profile: NativeAcpxProfileSnapshot;
|
||||
};
|
||||
|
||||
export interface NativeExecutionInputV1 {
|
||||
schema: typeof NATIVE_EXECUTION_INPUT_SCHEMA_V1;
|
||||
binding: {
|
||||
companyId: string;
|
||||
runId: string;
|
||||
issueId: string;
|
||||
agentId: string;
|
||||
executionWorkspaceId: string;
|
||||
};
|
||||
task: {
|
||||
identifier: string;
|
||||
title: string;
|
||||
description: string | null;
|
||||
/** Redacted, server-authored task markdown including the current wake comment. */
|
||||
prompt: string;
|
||||
workMode: "standard";
|
||||
};
|
||||
workspace: {
|
||||
cwd: string;
|
||||
repoUrl: string | null;
|
||||
repoRef: string | null;
|
||||
branchName: string | null;
|
||||
};
|
||||
session: {
|
||||
normalizedSessionId: string | null;
|
||||
driverKind: "codex_app_server" | "opencode_server" | "claude_managed_agents_api" | "aws_agentcore_harness_api" | "acpx_runtime";
|
||||
protocolVersion: 1;
|
||||
lifecyclePolicy: NativeSessionLifecyclePolicy;
|
||||
};
|
||||
provider: NativeProviderConfig;
|
||||
completionContract: {
|
||||
id: string;
|
||||
sha256: string;
|
||||
schemaVersion: string;
|
||||
contract: StrictCompletionContractInput;
|
||||
};
|
||||
interactionResponses: NativeInteractionResponseEnvelope[];
|
||||
credentialBindings: NativeCredentialBindingRef[];
|
||||
}
|
||||
|
||||
export interface NativeExecutionInputV2 extends Omit<NativeExecutionInputV1, "schema" | "task"> {
|
||||
schema: typeof NATIVE_EXECUTION_INPUT_SCHEMA_V2;
|
||||
executionMode: NativeExecutionMode;
|
||||
task: Omit<NativeExecutionInputV1["task"], "workMode"> & {
|
||||
workMode: "standard" | "planning" | "ask";
|
||||
};
|
||||
planningContext: NativePlanningContext | null;
|
||||
}
|
||||
|
||||
export interface NativeExecutionInputV3 extends Omit<NativeExecutionInputV2, "schema"> {
|
||||
schema: typeof NATIVE_EXECUTION_INPUT_SCHEMA_V3;
|
||||
runtimeContext: NativeRuntimeContextSnapshot;
|
||||
}
|
||||
|
||||
export interface NativeExecutionInputV4 extends Omit<NativeExecutionInputV3, "schema" | "provider"> {
|
||||
schema: typeof NATIVE_EXECUTION_INPUT_SCHEMA;
|
||||
provider: NativeProviderConfigV4;
|
||||
}
|
||||
|
||||
export type NativeExecutionInput = NativeExecutionInputV1 | NativeExecutionInputV2 | NativeExecutionInputV3 | NativeExecutionInputV4;
|
||||
|
||||
/** The only task data that may enter provider-visible model input. */
|
||||
export interface NativeModelEnvelopeV1 {
|
||||
schema: typeof NATIVE_MODEL_ENVELOPE_SCHEMA_V1;
|
||||
task: NativeExecutionInputV1["task"];
|
||||
/** Remote providers have no Paperclip workspace mounted in their service. */
|
||||
workspace: Pick<NativeExecutionInputV1["workspace"], "cwd"> | null;
|
||||
completionContract: StrictCompletionContractInput;
|
||||
interactionResponses: NativeInteractionResponseEnvelope[];
|
||||
}
|
||||
|
||||
export interface NativeModelEnvelopeV2 {
|
||||
schema: typeof NATIVE_MODEL_ENVELOPE_SCHEMA;
|
||||
task: NativeExecutionInputV2["task"];
|
||||
executionMode: NativeExecutionMode;
|
||||
planningContext: NativePlanningContext | null;
|
||||
workspace: Pick<NativeExecutionInputV2["workspace"], "cwd"> | null;
|
||||
completionContract: StrictCompletionContractInput;
|
||||
interactionResponses: NativeInteractionResponseEnvelope[];
|
||||
}
|
||||
|
||||
export interface NativeSessionExecutionResult {
|
||||
result: PrpStructuredRunResult;
|
||||
terminal: PrpTerminalState;
|
||||
turnId: string | null;
|
||||
normalizedSessionId: string;
|
||||
providerSessionId: string | null;
|
||||
driverKind: string;
|
||||
driverVersion: string;
|
||||
nativeEventCount: number;
|
||||
highestContiguousSourceSeq: number;
|
||||
usage: Record<string, unknown> | null;
|
||||
}
|
||||
|
||||
export class NativeExecutionInputError extends Error {
|
||||
readonly code = "native_execution_input_invalid" as const;
|
||||
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
this.name = "NativeExecutionInputError";
|
||||
}
|
||||
}
|
||||
|
||||
function record(value: unknown, path: string): Record<string, unknown> {
|
||||
if (typeof value !== "object" || value === null || Array.isArray(value)) {
|
||||
throw new NativeExecutionInputError(`${path} must be an object`);
|
||||
}
|
||||
return value as Record<string, unknown>;
|
||||
}
|
||||
|
||||
function exactKeys(value: Record<string, unknown>, keys: readonly string[], path: string): void {
|
||||
const allowed = new Set(keys);
|
||||
const unknown = Object.keys(value).filter((key) => !allowed.has(key));
|
||||
if (unknown.length > 0) {
|
||||
throw new NativeExecutionInputError(`${path} contains unknown field ${unknown[0]}`);
|
||||
}
|
||||
}
|
||||
|
||||
function text(value: unknown, path: string): string {
|
||||
if (typeof value !== "string" || value.trim().length === 0) {
|
||||
throw new NativeExecutionInputError(`${path} must be a non-empty string`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function nullableText(value: unknown, path: string): string | null {
|
||||
return value === null ? null : text(value, path);
|
||||
}
|
||||
|
||||
/**
|
||||
* Strictly validates the closed Paperclip-to-runner launch contract. This is
|
||||
* intentionally not an extensible metadata bag: new fields require a contract
|
||||
* revision and an explicit security review.
|
||||
*/
|
||||
export function parseNativeExecutionInput(value: unknown): NativeExecutionInput {
|
||||
const input = record(value, "input");
|
||||
const isV4 = input.schema === NATIVE_EXECUTION_INPUT_SCHEMA;
|
||||
const isV3 = isV4 || input.schema === NATIVE_EXECUTION_INPUT_SCHEMA_V3;
|
||||
const isV2 = isV3 || input.schema === NATIVE_EXECUTION_INPUT_SCHEMA_V2;
|
||||
exactKeys(input, [
|
||||
"schema",
|
||||
"binding",
|
||||
"task",
|
||||
"workspace",
|
||||
"session",
|
||||
"provider",
|
||||
"completionContract",
|
||||
"interactionResponses",
|
||||
"credentialBindings",
|
||||
...(isV2 ? ["executionMode", "planningContext"] : []),
|
||||
...(isV3 ? ["runtimeContext"] : []),
|
||||
], "input");
|
||||
if (!isV2 && input.schema !== NATIVE_EXECUTION_INPUT_SCHEMA_V1) {
|
||||
throw new NativeExecutionInputError(
|
||||
`input.schema must be ${NATIVE_EXECUTION_INPUT_SCHEMA_V1}, ${NATIVE_EXECUTION_INPUT_SCHEMA_V2}, ${NATIVE_EXECUTION_INPUT_SCHEMA_V3}, or ${NATIVE_EXECUTION_INPUT_SCHEMA}`,
|
||||
);
|
||||
}
|
||||
|
||||
const binding = record(input.binding, "input.binding");
|
||||
exactKeys(binding, ["companyId", "runId", "issueId", "agentId", "executionWorkspaceId"], "input.binding");
|
||||
const task = record(input.task, "input.task");
|
||||
exactKeys(task, ["identifier", "title", "description", "prompt", "workMode"], "input.task");
|
||||
const workspace = record(input.workspace, "input.workspace");
|
||||
exactKeys(workspace, ["cwd", "repoUrl", "repoRef", "branchName"], "input.workspace");
|
||||
const session = record(input.session, "input.session");
|
||||
exactKeys(session, ["normalizedSessionId", "driverKind", "protocolVersion", "lifecyclePolicy"], "input.session");
|
||||
const completionContract = record(input.completionContract, "input.completionContract");
|
||||
exactKeys(completionContract, ["id", "sha256", "schemaVersion", "contract"], "input.completionContract");
|
||||
const contract = record(completionContract.contract, "input.completionContract.contract");
|
||||
exactKeys(contract, ["revision", "objective", "criteria"], "input.completionContract.contract");
|
||||
|
||||
if (task.workMode !== "standard" && (!isV2 || task.workMode !== "planning" && task.workMode !== "ask")) {
|
||||
throw new NativeExecutionInputError("input.task.workMode must be standard, planning, or ask");
|
||||
}
|
||||
const executionMode: NativeExecutionMode = isV2 && input.executionMode === "plan" ? "plan" : "default";
|
||||
if (isV2 && input.executionMode !== "default" && input.executionMode !== "plan") {
|
||||
throw new NativeExecutionInputError("input.executionMode must be default or plan");
|
||||
}
|
||||
if (isV2 && task.workMode !== "planning" && executionMode === "plan") {
|
||||
throw new NativeExecutionInputError("plan execution mode requires planning work mode");
|
||||
}
|
||||
let planningContext: NativePlanningContext | null = null;
|
||||
if (isV2 && input.planningContext !== null) {
|
||||
const context = record(input.planningContext, "input.planningContext");
|
||||
exactKeys(context, [
|
||||
"documentId", "baseRevisionId", "baseRevisionNumber", "markdown", "sha256", "reviewContext",
|
||||
], "input.planningContext");
|
||||
if (!Number.isSafeInteger(context.baseRevisionNumber) || Number(context.baseRevisionNumber) < 0) {
|
||||
throw new NativeExecutionInputError("input.planningContext.baseRevisionNumber must be a non-negative integer");
|
||||
}
|
||||
planningContext = {
|
||||
documentId: context.documentId === null ? null : text(context.documentId, "input.planningContext.documentId"),
|
||||
baseRevisionId: context.baseRevisionId === null
|
||||
? null
|
||||
: text(context.baseRevisionId, "input.planningContext.baseRevisionId"),
|
||||
baseRevisionNumber: Number(context.baseRevisionNumber),
|
||||
markdown: typeof context.markdown === "string"
|
||||
? context.markdown
|
||||
: (() => { throw new NativeExecutionInputError("input.planningContext.markdown must be a string"); })(),
|
||||
sha256: text(context.sha256, "input.planningContext.sha256"),
|
||||
reviewContext: structuredClone(record(context.reviewContext, "input.planningContext.reviewContext")),
|
||||
};
|
||||
}
|
||||
if (isV2 && executionMode === "plan" && planningContext === null) {
|
||||
throw new NativeExecutionInputError("input.planningContext is required in plan execution mode");
|
||||
}
|
||||
if (isV2 && executionMode === "default" && input.planningContext !== null) {
|
||||
throw new NativeExecutionInputError("input.planningContext is only valid in plan execution mode");
|
||||
}
|
||||
if (
|
||||
(
|
||||
session.driverKind !== "codex_app_server"
|
||||
&& session.driverKind !== "opencode_server"
|
||||
&& session.driverKind !== "claude_managed_agents_api"
|
||||
&& session.driverKind !== "aws_agentcore_harness_api"
|
||||
&& session.driverKind !== "acpx_runtime"
|
||||
)
|
||||
|| session.protocolVersion !== 1
|
||||
) {
|
||||
throw new NativeExecutionInputError("input.session must select a supported protocol version 1 driver");
|
||||
}
|
||||
const driverKind = session.driverKind as NativeExecutionInputV1["session"]["driverKind"];
|
||||
const lifecyclePolicyValue = session.lifecyclePolicy === undefined
|
||||
? { mode: "per_turn", idleTimeoutMs: null }
|
||||
: record(session.lifecyclePolicy, "input.session.lifecyclePolicy");
|
||||
exactKeys(lifecyclePolicyValue, ["mode", "idleTimeoutMs"], "input.session.lifecyclePolicy");
|
||||
let lifecyclePolicy: NativeSessionLifecyclePolicy;
|
||||
if (lifecyclePolicyValue.mode === "per_turn") {
|
||||
if (lifecyclePolicyValue.idleTimeoutMs !== null) {
|
||||
throw new NativeExecutionInputError("input.session.lifecyclePolicy.idleTimeoutMs must be null for per_turn");
|
||||
}
|
||||
lifecyclePolicy = { mode: "per_turn", idleTimeoutMs: null };
|
||||
} else if (lifecyclePolicyValue.mode === "warm") {
|
||||
if (!Number.isSafeInteger(lifecyclePolicyValue.idleTimeoutMs) || Number(lifecyclePolicyValue.idleTimeoutMs) <= 0) {
|
||||
throw new NativeExecutionInputError("input.session.lifecyclePolicy.idleTimeoutMs must be a positive integer for warm");
|
||||
}
|
||||
lifecyclePolicy = { mode: "warm", idleTimeoutMs: Number(lifecyclePolicyValue.idleTimeoutMs) };
|
||||
} else {
|
||||
throw new NativeExecutionInputError("input.session.lifecyclePolicy.mode must be per_turn or warm");
|
||||
}
|
||||
const provider = input.provider === undefined
|
||||
? { kind: "codex", model: null }
|
||||
: record(input.provider, "input.provider");
|
||||
if (
|
||||
provider.kind !== "codex"
|
||||
&& provider.kind !== "opencode"
|
||||
&& provider.kind !== "claude_managed"
|
||||
&& provider.kind !== "aws_agentcore"
|
||||
&& provider.kind !== "acpx"
|
||||
) {
|
||||
throw new NativeExecutionInputError("input.provider.kind must be codex, opencode, claude_managed, aws_agentcore, or acpx");
|
||||
}
|
||||
exactKeys(
|
||||
provider,
|
||||
provider.kind === "claude_managed"
|
||||
? ["kind", "model", "managedProfile", "maxSessionListCostUsd"]
|
||||
: provider.kind === "aws_agentcore"
|
||||
? ["kind", "model", "agentCoreProfile", "maxEstimatedSessionCostUsd", "invocationLimits"]
|
||||
: provider.kind === "acpx"
|
||||
? ["kind", "agent", "model", isV4 ? "permissionMode" : "permissionPolicy", "profile"]
|
||||
: provider.kind === "codex" && isV4
|
||||
? ["kind", "model", "approvalPolicy"]
|
||||
: provider.kind === "opencode" && isV4
|
||||
? ["kind", "model", "permissionMode"]
|
||||
: ["kind", "model"],
|
||||
"input.provider",
|
||||
);
|
||||
if (
|
||||
(provider.kind === "codex" && session.driverKind !== "codex_app_server")
|
||||
|| (provider.kind === "opencode" && session.driverKind !== "opencode_server")
|
||||
|| (provider.kind === "claude_managed" && session.driverKind !== "claude_managed_agents_api")
|
||||
|| (provider.kind === "aws_agentcore" && session.driverKind !== "aws_agentcore_harness_api")
|
||||
|| (provider.kind === "acpx" && session.driverKind !== "acpx_runtime")
|
||||
) {
|
||||
throw new NativeExecutionInputError("input.provider.kind does not match input.session.driverKind");
|
||||
}
|
||||
const providerModel = provider.model === null || provider.model === undefined
|
||||
? null
|
||||
: text(provider.model, "input.provider.model");
|
||||
if (provider.kind === "opencode" && (providerModel === null || !providerModel.includes("/"))) {
|
||||
throw new NativeExecutionInputError("input.provider.model is required for opencode in provider/model form");
|
||||
}
|
||||
let parsedProvider: NativeProviderConfig;
|
||||
if (provider.kind === "claude_managed") {
|
||||
if (providerModel === null) {
|
||||
throw new NativeExecutionInputError("input.provider.model is required for claude_managed");
|
||||
}
|
||||
const managedProfile = record(provider.managedProfile, "input.provider.managedProfile");
|
||||
exactKeys(
|
||||
managedProfile,
|
||||
["profileId", "anthropicAgentId", "agentVersion", "environmentId", "betaVersion"],
|
||||
"input.provider.managedProfile",
|
||||
);
|
||||
if (managedProfile.betaVersion !== "managed-agents-2026-04-01") {
|
||||
throw new NativeExecutionInputError(
|
||||
"input.provider.managedProfile.betaVersion must be managed-agents-2026-04-01",
|
||||
);
|
||||
}
|
||||
if (
|
||||
typeof provider.maxSessionListCostUsd !== "number"
|
||||
|| !Number.isFinite(provider.maxSessionListCostUsd)
|
||||
|| provider.maxSessionListCostUsd <= 0
|
||||
) {
|
||||
throw new NativeExecutionInputError("input.provider.maxSessionListCostUsd must be a positive finite number");
|
||||
}
|
||||
parsedProvider = {
|
||||
kind: "claude_managed",
|
||||
model: providerModel,
|
||||
managedProfile: {
|
||||
profileId: text(managedProfile.profileId, "input.provider.managedProfile.profileId"),
|
||||
anthropicAgentId: text(
|
||||
managedProfile.anthropicAgentId,
|
||||
"input.provider.managedProfile.anthropicAgentId",
|
||||
),
|
||||
agentVersion: text(managedProfile.agentVersion, "input.provider.managedProfile.agentVersion"),
|
||||
environmentId: text(managedProfile.environmentId, "input.provider.managedProfile.environmentId"),
|
||||
betaVersion: "managed-agents-2026-04-01",
|
||||
},
|
||||
maxSessionListCostUsd: provider.maxSessionListCostUsd,
|
||||
};
|
||||
} else if (provider.kind === "aws_agentcore") {
|
||||
if (providerModel === null) {
|
||||
throw new NativeExecutionInputError("input.provider.model is required for aws_agentcore");
|
||||
}
|
||||
const profile = record(provider.agentCoreProfile, "input.provider.agentCoreProfile");
|
||||
exactKeys(profile, [
|
||||
"profileId", "region", "accountId", "harnessArn", "harnessVersion", "endpointArn",
|
||||
"endpointQualifier", "agentRuntimeArn", "memoryArn", "memoryId", "invocationRoleArn",
|
||||
"contextBucket", "contextPrefix", "contextKmsKeyArn", "qualificationRevision", "eventExpiryDays",
|
||||
], "input.provider.agentCoreProfile");
|
||||
if (profile.eventExpiryDays !== 90) {
|
||||
throw new NativeExecutionInputError("input.provider.agentCoreProfile.eventExpiryDays must be 90");
|
||||
}
|
||||
if (
|
||||
typeof provider.maxEstimatedSessionCostUsd !== "number"
|
||||
|| !Number.isFinite(provider.maxEstimatedSessionCostUsd)
|
||||
|| provider.maxEstimatedSessionCostUsd <= 0
|
||||
) {
|
||||
throw new NativeExecutionInputError("input.provider.maxEstimatedSessionCostUsd must be a positive finite number");
|
||||
}
|
||||
const limits = record(provider.invocationLimits, "input.provider.invocationLimits");
|
||||
exactKeys(limits, ["maxIterations", "maxOutputTokens", "timeoutSeconds"], "input.provider.invocationLimits");
|
||||
const positiveInteger = (value: unknown, path: string): number => {
|
||||
if (!Number.isSafeInteger(value) || Number(value) <= 0) {
|
||||
throw new NativeExecutionInputError(`${path} must be a positive integer`);
|
||||
}
|
||||
return Number(value);
|
||||
};
|
||||
const maxIterations = positiveInteger(limits.maxIterations, "input.provider.invocationLimits.maxIterations");
|
||||
const maxOutputTokens = positiveInteger(limits.maxOutputTokens, "input.provider.invocationLimits.maxOutputTokens");
|
||||
const timeoutSeconds = positiveInteger(limits.timeoutSeconds, "input.provider.invocationLimits.timeoutSeconds");
|
||||
if (maxIterations > 8) throw new NativeExecutionInputError("input.provider.invocationLimits.maxIterations exceeds 8");
|
||||
if (maxOutputTokens > 4096) throw new NativeExecutionInputError("input.provider.invocationLimits.maxOutputTokens exceeds 4096");
|
||||
if (timeoutSeconds > 300) throw new NativeExecutionInputError("input.provider.invocationLimits.timeoutSeconds exceeds 300");
|
||||
parsedProvider = {
|
||||
kind: "aws_agentcore",
|
||||
model: providerModel,
|
||||
agentCoreProfile: {
|
||||
profileId: text(profile.profileId, "input.provider.agentCoreProfile.profileId"),
|
||||
region: text(profile.region, "input.provider.agentCoreProfile.region"),
|
||||
accountId: text(profile.accountId, "input.provider.agentCoreProfile.accountId"),
|
||||
harnessArn: text(profile.harnessArn, "input.provider.agentCoreProfile.harnessArn"),
|
||||
harnessVersion: text(profile.harnessVersion, "input.provider.agentCoreProfile.harnessVersion"),
|
||||
endpointArn: text(profile.endpointArn, "input.provider.agentCoreProfile.endpointArn"),
|
||||
endpointQualifier: text(profile.endpointQualifier, "input.provider.agentCoreProfile.endpointQualifier"),
|
||||
agentRuntimeArn: text(profile.agentRuntimeArn, "input.provider.agentCoreProfile.agentRuntimeArn"),
|
||||
memoryArn: text(profile.memoryArn, "input.provider.agentCoreProfile.memoryArn"),
|
||||
memoryId: text(profile.memoryId, "input.provider.agentCoreProfile.memoryId"),
|
||||
invocationRoleArn: text(profile.invocationRoleArn, "input.provider.agentCoreProfile.invocationRoleArn"),
|
||||
contextBucket: text(profile.contextBucket, "input.provider.agentCoreProfile.contextBucket"),
|
||||
contextPrefix: text(profile.contextPrefix, "input.provider.agentCoreProfile.contextPrefix"),
|
||||
contextKmsKeyArn: text(profile.contextKmsKeyArn, "input.provider.agentCoreProfile.contextKmsKeyArn"),
|
||||
qualificationRevision: text(profile.qualificationRevision, "input.provider.agentCoreProfile.qualificationRevision"),
|
||||
eventExpiryDays: 90,
|
||||
},
|
||||
maxEstimatedSessionCostUsd: provider.maxEstimatedSessionCostUsd,
|
||||
invocationLimits: { maxIterations, maxOutputTokens, timeoutSeconds },
|
||||
};
|
||||
} else if (provider.kind === "acpx") {
|
||||
if (providerModel === null) {
|
||||
throw new NativeExecutionInputError("input.provider.model is required for acpx");
|
||||
}
|
||||
if (provider.agent !== "pi" && provider.agent !== "claude" && provider.agent !== "codex") {
|
||||
throw new NativeExecutionInputError("input.provider.agent must be pi, claude, or codex");
|
||||
}
|
||||
if (isV4) {
|
||||
if (provider.permissionMode !== "approve-all" && provider.permissionMode !== "approve-reads" && provider.permissionMode !== "deny-all") {
|
||||
throw new NativeExecutionInputError("input.provider.permissionMode must be approve-all, approve-reads, or deny-all");
|
||||
}
|
||||
} else if (provider.permissionPolicy !== "interactive") {
|
||||
throw new NativeExecutionInputError("input.provider.permissionPolicy must be interactive");
|
||||
}
|
||||
const profile = record(provider.profile, "input.provider.profile");
|
||||
exactKeys(profile, [
|
||||
"driverKind",
|
||||
"protocolVersion",
|
||||
"acpxVersion",
|
||||
"agent",
|
||||
"agentProfileVersion",
|
||||
"agentServerPackage",
|
||||
"agentServerVersion",
|
||||
"agentRuntimePackage",
|
||||
"agentRuntimeVersion",
|
||||
"commandDigest",
|
||||
], "input.provider.profile");
|
||||
if (
|
||||
profile.driverKind !== "acpx_runtime"
|
||||
|| profile.protocolVersion !== 1
|
||||
|| profile.acpxVersion !== "0.13.1"
|
||||
|| profile.agent !== provider.agent
|
||||
|| profile.agentProfileVersion !== 1
|
||||
) {
|
||||
throw new NativeExecutionInputError("input.provider.profile does not match the qualified ACPX v1 profile");
|
||||
}
|
||||
const runtimePackage = nullableText(profile.agentRuntimePackage, "input.provider.profile.agentRuntimePackage");
|
||||
const runtimeVersion = nullableText(profile.agentRuntimeVersion, "input.provider.profile.agentRuntimeVersion");
|
||||
if ((runtimePackage === null) !== (runtimeVersion === null)) {
|
||||
throw new NativeExecutionInputError("input.provider.profile agent runtime package and version must both be present or null");
|
||||
}
|
||||
parsedProvider = {
|
||||
kind: "acpx",
|
||||
agent: provider.agent,
|
||||
model: providerModel,
|
||||
...(isV4
|
||||
? { permissionMode: provider.permissionMode as NativeAcpxPermissionMode }
|
||||
: { permissionPolicy: "interactive" as const }),
|
||||
profile: {
|
||||
driverKind: "acpx_runtime",
|
||||
protocolVersion: 1,
|
||||
acpxVersion: "0.13.1",
|
||||
agent: provider.agent,
|
||||
agentProfileVersion: 1,
|
||||
agentServerPackage: text(profile.agentServerPackage, "input.provider.profile.agentServerPackage"),
|
||||
agentServerVersion: text(profile.agentServerVersion, "input.provider.profile.agentServerVersion"),
|
||||
agentRuntimePackage: runtimePackage,
|
||||
agentRuntimeVersion: runtimeVersion,
|
||||
commandDigest: text(profile.commandDigest, "input.provider.profile.commandDigest"),
|
||||
},
|
||||
};
|
||||
} else if (provider.kind === "opencode") {
|
||||
if (isV4 && provider.permissionMode !== "allow" && provider.permissionMode !== "ask" && provider.permissionMode !== "deny") {
|
||||
throw new NativeExecutionInputError("input.provider.permissionMode must be allow, ask, or deny");
|
||||
}
|
||||
parsedProvider = {
|
||||
kind: "opencode",
|
||||
model: providerModel!,
|
||||
...(isV4
|
||||
? { permissionMode: provider.permissionMode as NativeOpenCodePermissionMode }
|
||||
: {}),
|
||||
};
|
||||
} else {
|
||||
if (isV4 && provider.approvalPolicy !== "never" && provider.approvalPolicy !== "on-request" && provider.approvalPolicy !== "untrusted") {
|
||||
throw new NativeExecutionInputError("input.provider.approvalPolicy must be never, on-request, or untrusted");
|
||||
}
|
||||
parsedProvider = {
|
||||
kind: "codex",
|
||||
model: providerModel,
|
||||
...(isV4
|
||||
? { approvalPolicy: provider.approvalPolicy as NativeCodexApprovalPolicy }
|
||||
: {}),
|
||||
};
|
||||
}
|
||||
if (!Array.isArray(contract.criteria) || contract.criteria.length === 0) {
|
||||
throw new NativeExecutionInputError("input.completionContract.contract.criteria must not be empty");
|
||||
}
|
||||
const criteria = contract.criteria.map((entry, index) => {
|
||||
const criterion = record(entry, `input.completionContract.contract.criteria[${index}]`);
|
||||
exactKeys(criterion, ["id", "requirement"], `input.completionContract.contract.criteria[${index}]`);
|
||||
return { id: text(criterion.id, `criteria[${index}].id`), requirement: text(criterion.requirement, `criteria[${index}].requirement`) };
|
||||
});
|
||||
|
||||
if (!Array.isArray(input.interactionResponses) || !Array.isArray(input.credentialBindings)) {
|
||||
throw new NativeExecutionInputError("interactionResponses and credentialBindings must be arrays");
|
||||
}
|
||||
const interactionResponses = input.interactionResponses.map((entry, index) => {
|
||||
const response = record(entry, `input.interactionResponses[${index}]`);
|
||||
exactKeys(response, ["interactionId", "kind", "response"], `input.interactionResponses[${index}]`);
|
||||
if (![
|
||||
"suggest_tasks",
|
||||
"ask_user_questions",
|
||||
"request_confirmation",
|
||||
"request_checkbox_confirmation",
|
||||
"request_item_verdicts",
|
||||
].includes(String(response.kind))) {
|
||||
throw new NativeExecutionInputError(`input.interactionResponses[${index}].kind is unsupported`);
|
||||
}
|
||||
const kind = response.kind as NativeInteractionResponseEnvelope["kind"];
|
||||
return {
|
||||
interactionId: text(response.interactionId, `input.interactionResponses[${index}].interactionId`),
|
||||
kind,
|
||||
response: structuredClone(record(response.response, `input.interactionResponses[${index}].response`)),
|
||||
};
|
||||
});
|
||||
const credentialBindings = input.credentialBindings.map((entry, index) => {
|
||||
const bindingRef = record(entry, `input.credentialBindings[${index}]`);
|
||||
exactKeys(bindingRef, ["bindingId", "service", "destination", "expiresAt", "displayName"], `input.credentialBindings[${index}]`);
|
||||
return {
|
||||
bindingId: text(bindingRef.bindingId, `credentialBindings[${index}].bindingId`),
|
||||
service: text(bindingRef.service, `credentialBindings[${index}].service`),
|
||||
destination: text(bindingRef.destination, `credentialBindings[${index}].destination`),
|
||||
expiresAt: nullableText(bindingRef.expiresAt, `credentialBindings[${index}].expiresAt`),
|
||||
displayName: nullableText(bindingRef.displayName, `credentialBindings[${index}].displayName`),
|
||||
};
|
||||
});
|
||||
|
||||
const common = {
|
||||
binding: {
|
||||
companyId: text(binding.companyId, "input.binding.companyId"),
|
||||
runId: text(binding.runId, "input.binding.runId"),
|
||||
issueId: text(binding.issueId, "input.binding.issueId"),
|
||||
agentId: text(binding.agentId, "input.binding.agentId"),
|
||||
executionWorkspaceId: text(binding.executionWorkspaceId, "input.binding.executionWorkspaceId"),
|
||||
},
|
||||
task: {
|
||||
identifier: text(task.identifier, "input.task.identifier"),
|
||||
title: text(task.title, "input.task.title"),
|
||||
description: nullableText(task.description, "input.task.description"),
|
||||
// `prompt` was added after native run inputs were already persisted.
|
||||
// Recovery must retain those runs, so derive the same bounded model
|
||||
// prompt from their task fields instead of making them unrecoverable.
|
||||
prompt: text(task.prompt ?? task.description ?? task.title, "input.task.prompt"),
|
||||
workMode: task.workMode as "standard" | "planning" | "ask",
|
||||
},
|
||||
workspace: {
|
||||
cwd: text(workspace.cwd, "input.workspace.cwd"),
|
||||
repoUrl: nullableText(workspace.repoUrl, "input.workspace.repoUrl"),
|
||||
repoRef: nullableText(workspace.repoRef, "input.workspace.repoRef"),
|
||||
branchName: nullableText(workspace.branchName, "input.workspace.branchName"),
|
||||
},
|
||||
session: {
|
||||
normalizedSessionId: nullableText(session.normalizedSessionId, "input.session.normalizedSessionId"),
|
||||
driverKind,
|
||||
protocolVersion: 1 as const,
|
||||
lifecyclePolicy,
|
||||
},
|
||||
provider: parsedProvider,
|
||||
completionContract: {
|
||||
id: text(completionContract.id, "input.completionContract.id"),
|
||||
sha256: text(completionContract.sha256, "input.completionContract.sha256"),
|
||||
schemaVersion: text(completionContract.schemaVersion, "input.completionContract.schemaVersion"),
|
||||
contract: {
|
||||
revision: text(contract.revision, "input.completionContract.contract.revision"),
|
||||
objective: text(contract.objective, "input.completionContract.contract.objective"),
|
||||
criteria,
|
||||
},
|
||||
},
|
||||
interactionResponses,
|
||||
credentialBindings,
|
||||
};
|
||||
if (!isV2) {
|
||||
return {
|
||||
...common,
|
||||
schema: NATIVE_EXECUTION_INPUT_SCHEMA_V1,
|
||||
task: { ...common.task, workMode: "standard" },
|
||||
};
|
||||
}
|
||||
const current = {
|
||||
...common,
|
||||
executionMode,
|
||||
planningContext,
|
||||
};
|
||||
if (!isV3) return { ...current, schema: NATIVE_EXECUTION_INPUT_SCHEMA_V2 };
|
||||
const withRuntimeContext = { ...current, runtimeContext: parseNativeRuntimeContext(input.runtimeContext) };
|
||||
if (!isV4) return { ...withRuntimeContext, schema: NATIVE_EXECUTION_INPUT_SCHEMA_V3 };
|
||||
return {
|
||||
...withRuntimeContext,
|
||||
schema: NATIVE_EXECUTION_INPUT_SCHEMA,
|
||||
provider: parsedProvider as NativeProviderConfigV4,
|
||||
};
|
||||
}
|
||||
|
||||
export function buildNativeModelEnvelope(input: NativeExecutionInput): NativeModelEnvelopeV1 | NativeModelEnvelopeV2 {
|
||||
if (input.schema === NATIVE_EXECUTION_INPUT_SCHEMA_V1) {
|
||||
return {
|
||||
schema: NATIVE_MODEL_ENVELOPE_SCHEMA_V1,
|
||||
task: structuredClone(input.task),
|
||||
workspace: input.provider.kind === "claude_managed" || input.provider.kind === "aws_agentcore"
|
||||
? null
|
||||
: { cwd: input.workspace.cwd },
|
||||
completionContract: structuredClone(input.completionContract.contract),
|
||||
interactionResponses: structuredClone(input.interactionResponses),
|
||||
};
|
||||
}
|
||||
return {
|
||||
schema: NATIVE_MODEL_ENVELOPE_SCHEMA,
|
||||
task: structuredClone(input.task),
|
||||
executionMode: input.executionMode,
|
||||
planningContext: structuredClone(input.planningContext),
|
||||
workspace: input.provider.kind === "claude_managed" || input.provider.kind === "aws_agentcore"
|
||||
? null
|
||||
: { cwd: input.workspace.cwd },
|
||||
completionContract: structuredClone(input.completionContract.contract),
|
||||
interactionResponses: structuredClone(input.interactionResponses),
|
||||
};
|
||||
}
|
||||
|
|
@ -18,7 +18,7 @@ const questionSet: PaperclipQuestionSet = {
|
|||
required: true,
|
||||
answerMode: "single_select",
|
||||
options: [
|
||||
{ id: "staging", label: "Staging" },
|
||||
{ id: "staging", label: "Staging", recommended: true },
|
||||
{ id: "production", label: "Production" },
|
||||
],
|
||||
customAnswer: { enabled: true, label: "Other" },
|
||||
|
|
@ -36,15 +36,13 @@ const questionSet: PaperclipQuestionSet = {
|
|||
describe("Paperclip question-set contract", () => {
|
||||
it("round-trips the portable presentation model", () => {
|
||||
expect(parsePaperclipQuestionSet(questionSet)).toEqual(questionSet);
|
||||
expect(
|
||||
parsePaperclipQuestionResponse(questionSet, {
|
||||
schema: PAPERCLIP_QUESTION_RESPONSE_SCHEMA,
|
||||
answers: {
|
||||
environment: { selectedOptionIds: ["staging"] },
|
||||
replicas: { text: "3" },
|
||||
},
|
||||
}),
|
||||
).toEqual({
|
||||
expect(parsePaperclipQuestionResponse(questionSet, {
|
||||
schema: PAPERCLIP_QUESTION_RESPONSE_SCHEMA,
|
||||
answers: {
|
||||
environment: { selectedOptionIds: ["staging"] },
|
||||
replicas: { text: "3" },
|
||||
},
|
||||
})).toEqual({
|
||||
schema: PAPERCLIP_QUESTION_RESPONSE_SCHEMA,
|
||||
answers: {
|
||||
environment: { selectedOptionIds: ["staging"] },
|
||||
|
|
@ -54,55 +52,40 @@ describe("Paperclip question-set contract", () => {
|
|||
});
|
||||
|
||||
it("rejects missing, unknown, and provider-shaped answers", () => {
|
||||
expect(() =>
|
||||
parsePaperclipQuestionResponse(questionSet, {
|
||||
schema: PAPERCLIP_QUESTION_RESPONSE_SCHEMA,
|
||||
answers: {
|
||||
environment: { selectedOptionIds: ["unknown"] },
|
||||
replicas: { text: "3" },
|
||||
},
|
||||
}),
|
||||
).toThrow(/unknown option/);
|
||||
expect(() =>
|
||||
parsePaperclipQuestionResponse(questionSet, {
|
||||
schema: PAPERCLIP_QUESTION_RESPONSE_SCHEMA,
|
||||
answers: { environment: { selectedOptionIds: ["staging"] } },
|
||||
}),
|
||||
).toThrow(/replicas.*required/);
|
||||
expect(() =>
|
||||
parsePaperclipQuestionResponse(questionSet, {
|
||||
answers: { environment: { answers: ["Staging"] } },
|
||||
}),
|
||||
).toThrow(/paperclip.question_response.v1/);
|
||||
expect(() =>
|
||||
parsePaperclipQuestionResponse(questionSet, {
|
||||
schema: PAPERCLIP_QUESTION_RESPONSE_SCHEMA,
|
||||
answers: {
|
||||
environment: { answers: ["Staging"] },
|
||||
replicas: { text: "3" },
|
||||
},
|
||||
}),
|
||||
).toThrow(/canonical response contract/);
|
||||
expect(() => parsePaperclipQuestionResponse(questionSet, {
|
||||
schema: PAPERCLIP_QUESTION_RESPONSE_SCHEMA,
|
||||
answers: { environment: { selectedOptionIds: ["unknown"] }, replicas: { text: "3" } },
|
||||
})).toThrow(/unknown option/);
|
||||
expect(() => parsePaperclipQuestionResponse(questionSet, {
|
||||
schema: PAPERCLIP_QUESTION_RESPONSE_SCHEMA,
|
||||
answers: { environment: { selectedOptionIds: ["staging"] } },
|
||||
})).toThrow(/replicas.*required/);
|
||||
expect(() => parsePaperclipQuestionResponse(questionSet, {
|
||||
answers: { environment: { answers: ["Staging"] } },
|
||||
})).toThrow(/paperclip.question_response.v1/);
|
||||
expect(() => parsePaperclipQuestionResponse(questionSet, {
|
||||
schema: PAPERCLIP_QUESTION_RESPONSE_SCHEMA,
|
||||
answers: {
|
||||
environment: { answers: ["Staging"] },
|
||||
replicas: { text: "3" },
|
||||
},
|
||||
})).toThrow(/canonical response contract/);
|
||||
});
|
||||
|
||||
it("applies typed numeric validation before an adapter sees the answer", () => {
|
||||
expect(() =>
|
||||
parsePaperclipQuestionResponse(questionSet, {
|
||||
schema: PAPERCLIP_QUESTION_RESPONSE_SCHEMA,
|
||||
answers: {
|
||||
environment: { customText: "Canary" },
|
||||
replicas: { text: "3.5" },
|
||||
},
|
||||
}),
|
||||
).toThrow(/valid integer/);
|
||||
expect(() =>
|
||||
parsePaperclipQuestionResponse(questionSet, {
|
||||
schema: PAPERCLIP_QUESTION_RESPONSE_SCHEMA,
|
||||
answers: {
|
||||
environment: { customText: "Canary" },
|
||||
replicas: { text: "21" },
|
||||
},
|
||||
}),
|
||||
).toThrow(/at most 20/);
|
||||
expect(() => parsePaperclipQuestionResponse(questionSet, {
|
||||
schema: PAPERCLIP_QUESTION_RESPONSE_SCHEMA,
|
||||
answers: {
|
||||
environment: { customText: "Canary" },
|
||||
replicas: { text: "3.5" },
|
||||
},
|
||||
})).toThrow(/valid integer/);
|
||||
expect(() => parsePaperclipQuestionResponse(questionSet, {
|
||||
schema: PAPERCLIP_QUESTION_RESPONSE_SCHEMA,
|
||||
answers: {
|
||||
environment: { customText: "Canary" },
|
||||
replicas: { text: "21" },
|
||||
},
|
||||
})).toThrow(/at most 20/);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,17 +1,14 @@
|
|||
export const PAPERCLIP_QUESTION_SET_SCHEMA =
|
||||
"paperclip.question_set.v1" as const;
|
||||
export const PAPERCLIP_QUESTION_RESPONSE_SCHEMA =
|
||||
"paperclip.question_response.v1" as const;
|
||||
export const PAPERCLIP_RUNTIME_REQUEST_SCHEMA_V2 =
|
||||
"paperclip.runtime_request.v2" as const;
|
||||
export const PAPERCLIP_QUESTION_SET_SCHEMA = "paperclip.question_set.v1" as const;
|
||||
export const PAPERCLIP_QUESTION_RESPONSE_SCHEMA = "paperclip.question_response.v1" as const;
|
||||
export const PAPERCLIP_RUNTIME_REQUEST_SCHEMA_V2 = "paperclip.runtime_request.v2" as const;
|
||||
|
||||
export type PaperclipQuestionAnswerMode =
|
||||
"single_select" | "multi_select" | "text";
|
||||
export type PaperclipQuestionAnswerMode = "single_select" | "multi_select" | "text";
|
||||
|
||||
export interface PaperclipQuestionOption {
|
||||
id: string;
|
||||
label: string;
|
||||
description?: string;
|
||||
recommended?: boolean;
|
||||
}
|
||||
|
||||
export interface PaperclipQuestionCustomAnswer {
|
||||
|
|
@ -104,46 +101,26 @@ function rejectUnknownKeys(
|
|||
const allowedKeys = new Set(allowed);
|
||||
const unknown = Object.keys(value).find((key) => !allowedKeys.has(key));
|
||||
if (unknown !== undefined) {
|
||||
throw new PaperclipQuestionValidationError(
|
||||
`${path}/${unknown}`,
|
||||
"is not part of the canonical response contract",
|
||||
);
|
||||
throw new PaperclipQuestionValidationError(`${path}/${unknown}`, "is not part of the canonical response contract");
|
||||
}
|
||||
}
|
||||
|
||||
function requiredText(value: unknown, path: string, maxLength = 4_000): string {
|
||||
if (
|
||||
typeof value !== "string" ||
|
||||
value.length === 0 ||
|
||||
value.length > maxLength
|
||||
) {
|
||||
throw new PaperclipQuestionValidationError(
|
||||
path,
|
||||
`must be a non-empty string of at most ${maxLength} characters`,
|
||||
);
|
||||
if (typeof value !== "string" || value.length === 0 || value.length > maxLength) {
|
||||
throw new PaperclipQuestionValidationError(path, `must be a non-empty string of at most ${maxLength} characters`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function optionalText(
|
||||
value: unknown,
|
||||
path: string,
|
||||
maxLength = 4_000,
|
||||
): string | undefined {
|
||||
function optionalText(value: unknown, path: string, maxLength = 4_000): string | undefined {
|
||||
if (value === undefined) return undefined;
|
||||
if (typeof value !== "string" || value.length > maxLength) {
|
||||
throw new PaperclipQuestionValidationError(
|
||||
path,
|
||||
`must be a string of at most ${maxLength} characters`,
|
||||
);
|
||||
throw new PaperclipQuestionValidationError(path, `must be a string of at most ${maxLength} characters`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function optionalFiniteNumber(
|
||||
value: unknown,
|
||||
path: string,
|
||||
): number | undefined {
|
||||
function optionalFiniteNumber(value: unknown, path: string): number | undefined {
|
||||
if (value === undefined) return undefined;
|
||||
if (typeof value !== "number" || !Number.isFinite(value)) {
|
||||
throw new PaperclipQuestionValidationError(path, "must be a finite number");
|
||||
|
|
@ -152,321 +129,151 @@ function optionalFiniteNumber(
|
|||
}
|
||||
|
||||
/** Parse and sanitize the provider-neutral presentation contract at an adapter boundary. */
|
||||
export function parsePaperclipQuestionSet(
|
||||
value: unknown,
|
||||
): PaperclipQuestionSet {
|
||||
export function parsePaperclipQuestionSet(value: unknown): PaperclipQuestionSet {
|
||||
const candidate = record(value);
|
||||
if (
|
||||
candidate === null ||
|
||||
candidate.schema !== PAPERCLIP_QUESTION_SET_SCHEMA
|
||||
) {
|
||||
throw new PaperclipQuestionValidationError(
|
||||
"/input",
|
||||
`must use ${PAPERCLIP_QUESTION_SET_SCHEMA}`,
|
||||
);
|
||||
if (candidate === null || candidate.schema !== PAPERCLIP_QUESTION_SET_SCHEMA) {
|
||||
throw new PaperclipQuestionValidationError("/input", `must use ${PAPERCLIP_QUESTION_SET_SCHEMA}`);
|
||||
}
|
||||
if (
|
||||
!Array.isArray(candidate.questions) ||
|
||||
candidate.questions.length === 0 ||
|
||||
candidate.questions.length > 64
|
||||
) {
|
||||
throw new PaperclipQuestionValidationError(
|
||||
"/input/questions",
|
||||
"must contain between 1 and 64 questions",
|
||||
);
|
||||
if (!Array.isArray(candidate.questions) || candidate.questions.length === 0 || candidate.questions.length > 64) {
|
||||
throw new PaperclipQuestionValidationError("/input/questions", "must contain between 1 and 64 questions");
|
||||
}
|
||||
const questionIds = new Set<string>();
|
||||
const questions = candidate.questions.map(
|
||||
(rawQuestion, questionIndex): PaperclipQuestion => {
|
||||
const path = `/input/questions/${questionIndex}`;
|
||||
const question = record(rawQuestion);
|
||||
if (question === null)
|
||||
throw new PaperclipQuestionValidationError(path, "must be an object");
|
||||
const id = requiredText(question.id, `${path}/id`, 160);
|
||||
if (questionIds.has(id))
|
||||
throw new PaperclipQuestionValidationError(
|
||||
`${path}/id`,
|
||||
"must be unique",
|
||||
);
|
||||
questionIds.add(id);
|
||||
const answerMode = question.answerMode;
|
||||
if (
|
||||
answerMode !== "single_select" &&
|
||||
answerMode !== "multi_select" &&
|
||||
answerMode !== "text"
|
||||
) {
|
||||
throw new PaperclipQuestionValidationError(
|
||||
`${path}/answerMode`,
|
||||
"must be single_select, multi_select, or text",
|
||||
);
|
||||
}
|
||||
if (typeof question.required !== "boolean") {
|
||||
throw new PaperclipQuestionValidationError(
|
||||
`${path}/required`,
|
||||
"must be boolean",
|
||||
);
|
||||
}
|
||||
if (Array.isArray(question.options) && question.options.length > 128) {
|
||||
throw new PaperclipQuestionValidationError(
|
||||
`${path}/options`,
|
||||
"cannot contain more than 128 options",
|
||||
);
|
||||
}
|
||||
const options: PaperclipQuestionOption[] | undefined = Array.isArray(
|
||||
question.options,
|
||||
)
|
||||
? question.options.map((rawOption, optionIndex) => {
|
||||
const optionPath = `${path}/options/${optionIndex}`;
|
||||
const option = record(rawOption);
|
||||
if (option === null)
|
||||
throw new PaperclipQuestionValidationError(
|
||||
optionPath,
|
||||
"must be an object",
|
||||
);
|
||||
return {
|
||||
id: requiredText(option.id, `${optionPath}/id`, 160),
|
||||
label: requiredText(option.label, `${optionPath}/label`, 1_000),
|
||||
...(optionalText(
|
||||
option.description,
|
||||
`${optionPath}/description`,
|
||||
) !== undefined
|
||||
? {
|
||||
description: optionalText(
|
||||
option.description,
|
||||
`${optionPath}/description`,
|
||||
),
|
||||
}
|
||||
: {}),
|
||||
};
|
||||
})
|
||||
: undefined;
|
||||
if (
|
||||
options !== undefined &&
|
||||
new Set(options.map((option) => option.id)).size !== options.length
|
||||
) {
|
||||
throw new PaperclipQuestionValidationError(
|
||||
`${path}/options`,
|
||||
"option IDs must be unique within a question",
|
||||
);
|
||||
}
|
||||
if (answerMode !== "text" && (!options || options.length === 0)) {
|
||||
throw new PaperclipQuestionValidationError(
|
||||
`${path}/options`,
|
||||
"select questions require at least one option",
|
||||
);
|
||||
}
|
||||
if (
|
||||
answerMode === "text" &&
|
||||
options !== undefined &&
|
||||
options.length > 0
|
||||
) {
|
||||
throw new PaperclipQuestionValidationError(
|
||||
`${path}/options`,
|
||||
"text questions cannot define options",
|
||||
);
|
||||
}
|
||||
const custom = record(question.customAnswer);
|
||||
const customAnswer =
|
||||
custom === null
|
||||
? undefined
|
||||
: {
|
||||
enabled: true as const,
|
||||
...(optionalText(
|
||||
custom.label,
|
||||
`${path}/customAnswer/label`,
|
||||
1_000,
|
||||
) !== undefined
|
||||
? {
|
||||
label: optionalText(
|
||||
custom.label,
|
||||
`${path}/customAnswer/label`,
|
||||
1_000,
|
||||
),
|
||||
}
|
||||
: {}),
|
||||
...(optionalText(
|
||||
custom.placeholder,
|
||||
`${path}/customAnswer/placeholder`,
|
||||
1_000,
|
||||
) !== undefined
|
||||
? {
|
||||
placeholder: optionalText(
|
||||
custom.placeholder,
|
||||
`${path}/customAnswer/placeholder`,
|
||||
1_000,
|
||||
),
|
||||
}
|
||||
: {}),
|
||||
};
|
||||
if (custom !== null && custom.enabled !== true) {
|
||||
throw new PaperclipQuestionValidationError(
|
||||
`${path}/customAnswer/enabled`,
|
||||
"must be true when customAnswer is present",
|
||||
);
|
||||
}
|
||||
if (answerMode === "text" && customAnswer !== undefined) {
|
||||
throw new PaperclipQuestionValidationError(
|
||||
`${path}/customAnswer`,
|
||||
"text questions do not use a separate custom answer",
|
||||
);
|
||||
}
|
||||
const validation = record(question.textValidation);
|
||||
const textValidation: PaperclipQuestionTextValidation | undefined =
|
||||
validation === null
|
||||
? undefined
|
||||
: {
|
||||
...(typeof validation.minLength === "number"
|
||||
? { minLength: validation.minLength }
|
||||
: {}),
|
||||
...(typeof validation.maxLength === "number"
|
||||
? { maxLength: validation.maxLength }
|
||||
: {}),
|
||||
...(optionalText(
|
||||
validation.pattern,
|
||||
`${path}/textValidation/pattern`,
|
||||
1_000,
|
||||
) !== undefined
|
||||
? {
|
||||
pattern: optionalText(
|
||||
validation.pattern,
|
||||
`${path}/textValidation/pattern`,
|
||||
1_000,
|
||||
),
|
||||
}
|
||||
: {}),
|
||||
...(validation.inputType === "number" ||
|
||||
validation.inputType === "integer" ||
|
||||
validation.inputType === "text"
|
||||
? { inputType: validation.inputType }
|
||||
: {}),
|
||||
...(optionalFiniteNumber(
|
||||
validation.minimum,
|
||||
`${path}/textValidation/minimum`,
|
||||
) !== undefined
|
||||
? {
|
||||
minimum: optionalFiniteNumber(
|
||||
validation.minimum,
|
||||
`${path}/textValidation/minimum`,
|
||||
),
|
||||
}
|
||||
: {}),
|
||||
...(optionalFiniteNumber(
|
||||
validation.maximum,
|
||||
`${path}/textValidation/maximum`,
|
||||
) !== undefined
|
||||
? {
|
||||
maximum: optionalFiniteNumber(
|
||||
validation.maximum,
|
||||
`${path}/textValidation/maximum`,
|
||||
),
|
||||
}
|
||||
: {}),
|
||||
};
|
||||
if (validation !== null) {
|
||||
for (const key of ["minLength", "maxLength"] as const) {
|
||||
const raw = validation[key];
|
||||
if (
|
||||
raw !== undefined &&
|
||||
(!Number.isSafeInteger(raw) ||
|
||||
(raw as number) < 0 ||
|
||||
(raw as number) > 100_000)
|
||||
) {
|
||||
throw new PaperclipQuestionValidationError(
|
||||
`${path}/textValidation/${key}`,
|
||||
"must be an integer from 0 through 100000",
|
||||
);
|
||||
}
|
||||
}
|
||||
if (
|
||||
validation.inputType !== undefined &&
|
||||
!["text", "number", "integer"].includes(String(validation.inputType))
|
||||
) {
|
||||
throw new PaperclipQuestionValidationError(
|
||||
`${path}/textValidation/inputType`,
|
||||
"must be text, number, or integer",
|
||||
);
|
||||
}
|
||||
if (
|
||||
textValidation?.minLength !== undefined &&
|
||||
textValidation.maxLength !== undefined &&
|
||||
textValidation.minLength > textValidation.maxLength
|
||||
) {
|
||||
throw new PaperclipQuestionValidationError(
|
||||
`${path}/textValidation`,
|
||||
"minLength cannot exceed maxLength",
|
||||
);
|
||||
}
|
||||
if (
|
||||
textValidation?.minimum !== undefined &&
|
||||
textValidation.maximum !== undefined &&
|
||||
textValidation.minimum > textValidation.maximum
|
||||
) {
|
||||
throw new PaperclipQuestionValidationError(
|
||||
`${path}/textValidation`,
|
||||
"minimum cannot exceed maximum",
|
||||
);
|
||||
}
|
||||
if (textValidation?.pattern !== undefined) {
|
||||
try {
|
||||
new RegExp(textValidation.pattern);
|
||||
} catch {
|
||||
throw new PaperclipQuestionValidationError(
|
||||
`${path}/textValidation/pattern`,
|
||||
"must be a valid regular expression",
|
||||
);
|
||||
const questions = candidate.questions.map((rawQuestion, questionIndex): PaperclipQuestion => {
|
||||
const path = `/input/questions/${questionIndex}`;
|
||||
const question = record(rawQuestion);
|
||||
if (question === null) throw new PaperclipQuestionValidationError(path, "must be an object");
|
||||
const id = requiredText(question.id, `${path}/id`, 160);
|
||||
if (questionIds.has(id)) throw new PaperclipQuestionValidationError(`${path}/id`, "must be unique");
|
||||
questionIds.add(id);
|
||||
const answerMode = question.answerMode;
|
||||
if (answerMode !== "single_select" && answerMode !== "multi_select" && answerMode !== "text") {
|
||||
throw new PaperclipQuestionValidationError(`${path}/answerMode`, "must be single_select, multi_select, or text");
|
||||
}
|
||||
if (typeof question.required !== "boolean") {
|
||||
throw new PaperclipQuestionValidationError(`${path}/required`, "must be boolean");
|
||||
}
|
||||
if (Array.isArray(question.options) && question.options.length > 128) {
|
||||
throw new PaperclipQuestionValidationError(`${path}/options`, "cannot contain more than 128 options");
|
||||
}
|
||||
const options: PaperclipQuestionOption[] | undefined = Array.isArray(question.options)
|
||||
? question.options.map((rawOption, optionIndex) => {
|
||||
const optionPath = `${path}/options/${optionIndex}`;
|
||||
const option = record(rawOption);
|
||||
if (option === null) throw new PaperclipQuestionValidationError(optionPath, "must be an object");
|
||||
if (option.recommended !== undefined && typeof option.recommended !== "boolean") {
|
||||
throw new PaperclipQuestionValidationError(`${optionPath}/recommended`, "must be boolean");
|
||||
}
|
||||
return {
|
||||
id: requiredText(option.id, `${optionPath}/id`, 160),
|
||||
label: requiredText(option.label, `${optionPath}/label`, 1_000),
|
||||
...(optionalText(option.description, `${optionPath}/description`) !== undefined
|
||||
? { description: optionalText(option.description, `${optionPath}/description`) }
|
||||
: {}),
|
||||
...(option.recommended !== undefined ? { recommended: option.recommended } : {}),
|
||||
};
|
||||
})
|
||||
: undefined;
|
||||
if (options !== undefined && new Set(options.map((option) => option.id)).size !== options.length) {
|
||||
throw new PaperclipQuestionValidationError(`${path}/options`, "option IDs must be unique within a question");
|
||||
}
|
||||
if (answerMode !== "text" && (!options || options.length === 0)) {
|
||||
throw new PaperclipQuestionValidationError(`${path}/options`, "select questions require at least one option");
|
||||
}
|
||||
if (answerMode === "text" && options !== undefined && options.length > 0) {
|
||||
throw new PaperclipQuestionValidationError(`${path}/options`, "text questions cannot define options");
|
||||
}
|
||||
const custom = record(question.customAnswer);
|
||||
const customAnswer = custom === null
|
||||
? undefined
|
||||
: {
|
||||
enabled: true as const,
|
||||
...(optionalText(custom.label, `${path}/customAnswer/label`, 1_000) !== undefined
|
||||
? { label: optionalText(custom.label, `${path}/customAnswer/label`, 1_000) }
|
||||
: {}),
|
||||
...(optionalText(custom.placeholder, `${path}/customAnswer/placeholder`, 1_000) !== undefined
|
||||
? { placeholder: optionalText(custom.placeholder, `${path}/customAnswer/placeholder`, 1_000) }
|
||||
: {}),
|
||||
};
|
||||
if (custom !== null && custom.enabled !== true) {
|
||||
throw new PaperclipQuestionValidationError(`${path}/customAnswer/enabled`, "must be true when customAnswer is present");
|
||||
}
|
||||
if (answerMode === "text" && customAnswer !== undefined) {
|
||||
throw new PaperclipQuestionValidationError(`${path}/customAnswer`, "text questions do not use a separate custom answer");
|
||||
}
|
||||
const validation = record(question.textValidation);
|
||||
const textValidation: PaperclipQuestionTextValidation | undefined = validation === null
|
||||
? undefined
|
||||
: {
|
||||
...(typeof validation.minLength === "number" ? { minLength: validation.minLength } : {}),
|
||||
...(typeof validation.maxLength === "number" ? { maxLength: validation.maxLength } : {}),
|
||||
...(optionalText(validation.pattern, `${path}/textValidation/pattern`, 1_000) !== undefined
|
||||
? { pattern: optionalText(validation.pattern, `${path}/textValidation/pattern`, 1_000) }
|
||||
: {}),
|
||||
...(validation.inputType === "number" || validation.inputType === "integer" || validation.inputType === "text"
|
||||
? { inputType: validation.inputType }
|
||||
: {}),
|
||||
...(optionalFiniteNumber(validation.minimum, `${path}/textValidation/minimum`) !== undefined
|
||||
? { minimum: optionalFiniteNumber(validation.minimum, `${path}/textValidation/minimum`) }
|
||||
: {}),
|
||||
...(optionalFiniteNumber(validation.maximum, `${path}/textValidation/maximum`) !== undefined
|
||||
? { maximum: optionalFiniteNumber(validation.maximum, `${path}/textValidation/maximum`) }
|
||||
: {}),
|
||||
};
|
||||
if (validation !== null) {
|
||||
for (const key of ["minLength", "maxLength"] as const) {
|
||||
const raw = validation[key];
|
||||
if (raw !== undefined && (!Number.isSafeInteger(raw) || (raw as number) < 0 || (raw as number) > 100_000)) {
|
||||
throw new PaperclipQuestionValidationError(`${path}/textValidation/${key}`, "must be an integer from 0 through 100000");
|
||||
}
|
||||
}
|
||||
return {
|
||||
id,
|
||||
...(optionalText(question.header, `${path}/header`, 1_000) !== undefined
|
||||
? { header: optionalText(question.header, `${path}/header`, 1_000) }
|
||||
: {}),
|
||||
prompt: requiredText(question.prompt, `${path}/prompt`),
|
||||
...(optionalText(question.helpText, `${path}/helpText`) !== undefined
|
||||
? { helpText: optionalText(question.helpText, `${path}/helpText`) }
|
||||
: {}),
|
||||
required: question.required,
|
||||
answerMode,
|
||||
...(options !== undefined ? { options } : {}),
|
||||
...(customAnswer !== undefined ? { customAnswer } : {}),
|
||||
...(textValidation !== undefined ? { textValidation } : {}),
|
||||
};
|
||||
},
|
||||
);
|
||||
if (validation.inputType !== undefined && !["text", "number", "integer"].includes(String(validation.inputType))) {
|
||||
throw new PaperclipQuestionValidationError(`${path}/textValidation/inputType`, "must be text, number, or integer");
|
||||
}
|
||||
if (textValidation?.minLength !== undefined && textValidation.maxLength !== undefined && textValidation.minLength > textValidation.maxLength) {
|
||||
throw new PaperclipQuestionValidationError(`${path}/textValidation`, "minLength cannot exceed maxLength");
|
||||
}
|
||||
if (textValidation?.minimum !== undefined && textValidation.maximum !== undefined && textValidation.minimum > textValidation.maximum) {
|
||||
throw new PaperclipQuestionValidationError(`${path}/textValidation`, "minimum cannot exceed maximum");
|
||||
}
|
||||
if (textValidation?.pattern !== undefined) {
|
||||
try { new RegExp(textValidation.pattern); } catch {
|
||||
throw new PaperclipQuestionValidationError(`${path}/textValidation/pattern`, "must be a valid regular expression");
|
||||
}
|
||||
}
|
||||
}
|
||||
return {
|
||||
id,
|
||||
...(optionalText(question.header, `${path}/header`, 1_000) !== undefined
|
||||
? { header: optionalText(question.header, `${path}/header`, 1_000) }
|
||||
: {}),
|
||||
prompt: requiredText(question.prompt, `${path}/prompt`),
|
||||
...(optionalText(question.helpText, `${path}/helpText`) !== undefined
|
||||
? { helpText: optionalText(question.helpText, `${path}/helpText`) }
|
||||
: {}),
|
||||
required: question.required,
|
||||
answerMode,
|
||||
...(options !== undefined ? { options } : {}),
|
||||
...(customAnswer !== undefined ? { customAnswer } : {}),
|
||||
...(textValidation !== undefined ? { textValidation } : {}),
|
||||
};
|
||||
});
|
||||
return {
|
||||
schema: PAPERCLIP_QUESTION_SET_SCHEMA,
|
||||
...(optionalText(candidate.title, "/input/title", 1_000) !== undefined
|
||||
? { title: optionalText(candidate.title, "/input/title", 1_000) }
|
||||
: {}),
|
||||
...(optionalText(candidate.description, "/input/description") !== undefined
|
||||
? {
|
||||
description: optionalText(
|
||||
candidate.description,
|
||||
"/input/description",
|
||||
),
|
||||
}
|
||||
? { description: optionalText(candidate.description, "/input/description") }
|
||||
: {}),
|
||||
...(optionalText(candidate.submitLabel, "/input/submitLabel", 200) !==
|
||||
undefined
|
||||
? {
|
||||
submitLabel: optionalText(
|
||||
candidate.submitLabel,
|
||||
"/input/submitLabel",
|
||||
200,
|
||||
),
|
||||
}
|
||||
...(optionalText(candidate.submitLabel, "/input/submitLabel", 200) !== undefined
|
||||
? { submitLabel: optionalText(candidate.submitLabel, "/input/submitLabel", 200) }
|
||||
: {}),
|
||||
questions,
|
||||
};
|
||||
}
|
||||
|
||||
function answerHasValue(answer: PaperclipQuestionAnswer): boolean {
|
||||
return Boolean(
|
||||
answer.text?.trim() ||
|
||||
answer.customText?.trim() ||
|
||||
answer.selectedOptionIds?.length,
|
||||
);
|
||||
return Boolean(answer.text?.trim() || answer.customText?.trim() || answer.selectedOptionIds?.length);
|
||||
}
|
||||
|
||||
/** Revalidate untrusted UI input against the persisted question set. */
|
||||
|
|
@ -476,122 +283,54 @@ export function parsePaperclipQuestionResponse(
|
|||
): PaperclipQuestionResponse {
|
||||
const questionSet = parsePaperclipQuestionSet(questionSetValue);
|
||||
const response = record(responseValue);
|
||||
if (
|
||||
response === null ||
|
||||
response.schema !== PAPERCLIP_QUESTION_RESPONSE_SCHEMA
|
||||
) {
|
||||
throw new PaperclipQuestionValidationError(
|
||||
"/response",
|
||||
`must use ${PAPERCLIP_QUESTION_RESPONSE_SCHEMA}`,
|
||||
);
|
||||
if (response === null || response.schema !== PAPERCLIP_QUESTION_RESPONSE_SCHEMA) {
|
||||
throw new PaperclipQuestionValidationError("/response", `must use ${PAPERCLIP_QUESTION_RESPONSE_SCHEMA}`);
|
||||
}
|
||||
rejectUnknownKeys(response, ["schema", "answers"], "/response");
|
||||
const rawAnswers = record(response.answers);
|
||||
if (rawAnswers === null)
|
||||
throw new PaperclipQuestionValidationError(
|
||||
"/response/answers",
|
||||
"must be an object keyed by question ID",
|
||||
);
|
||||
const questions = new Map(
|
||||
questionSet.questions.map((question) => [question.id, question]),
|
||||
);
|
||||
if (rawAnswers === null) throw new PaperclipQuestionValidationError("/response/answers", "must be an object keyed by question ID");
|
||||
const questions = new Map(questionSet.questions.map((question) => [question.id, question]));
|
||||
for (const questionId of Object.keys(rawAnswers)) {
|
||||
if (!questions.has(questionId))
|
||||
throw new PaperclipQuestionValidationError(
|
||||
`/response/answers/${questionId}`,
|
||||
"does not match a question in the persisted set",
|
||||
);
|
||||
if (!questions.has(questionId)) throw new PaperclipQuestionValidationError(`/response/answers/${questionId}`, "does not match a question in the persisted set");
|
||||
}
|
||||
const answers: Record<string, PaperclipQuestionAnswer> = {};
|
||||
for (const question of questionSet.questions) {
|
||||
const path = `/response/answers/${question.id}`;
|
||||
const raw = rawAnswers[question.id];
|
||||
if (raw === undefined) {
|
||||
if (question.required)
|
||||
throw new PaperclipQuestionValidationError(path, "is required");
|
||||
if (question.required) throw new PaperclipQuestionValidationError(path, "is required");
|
||||
continue;
|
||||
}
|
||||
const answer = record(raw);
|
||||
if (answer === null)
|
||||
throw new PaperclipQuestionValidationError(path, "must be an object");
|
||||
rejectUnknownKeys(
|
||||
answer,
|
||||
["selectedOptionIds", "text", "customText"],
|
||||
path,
|
||||
);
|
||||
const selectedOptionIds =
|
||||
answer.selectedOptionIds === undefined
|
||||
? undefined
|
||||
: Array.isArray(answer.selectedOptionIds) &&
|
||||
answer.selectedOptionIds.every((entry) => typeof entry === "string")
|
||||
? [...answer.selectedOptionIds]
|
||||
: null;
|
||||
if (selectedOptionIds === null)
|
||||
throw new PaperclipQuestionValidationError(
|
||||
`${path}/selectedOptionIds`,
|
||||
"must be an array of strings",
|
||||
);
|
||||
if (
|
||||
selectedOptionIds !== undefined &&
|
||||
new Set(selectedOptionIds).size !== selectedOptionIds.length
|
||||
) {
|
||||
throw new PaperclipQuestionValidationError(
|
||||
`${path}/selectedOptionIds`,
|
||||
"cannot contain duplicates",
|
||||
);
|
||||
if (answer === null) throw new PaperclipQuestionValidationError(path, "must be an object");
|
||||
rejectUnknownKeys(answer, ["selectedOptionIds", "text", "customText"], path);
|
||||
const selectedOptionIds = answer.selectedOptionIds === undefined
|
||||
? undefined
|
||||
: Array.isArray(answer.selectedOptionIds) && answer.selectedOptionIds.every((entry) => typeof entry === "string")
|
||||
? [...answer.selectedOptionIds]
|
||||
: null;
|
||||
if (selectedOptionIds === null) throw new PaperclipQuestionValidationError(`${path}/selectedOptionIds`, "must be an array of strings");
|
||||
if (selectedOptionIds !== undefined && new Set(selectedOptionIds).size !== selectedOptionIds.length) {
|
||||
throw new PaperclipQuestionValidationError(`${path}/selectedOptionIds`, "cannot contain duplicates");
|
||||
}
|
||||
const textValue = optionalText(answer.text, `${path}/text`, 100_000);
|
||||
const customText = optionalText(
|
||||
answer.customText,
|
||||
`${path}/customText`,
|
||||
100_000,
|
||||
);
|
||||
const customText = optionalText(answer.customText, `${path}/customText`, 100_000);
|
||||
if (question.answerMode === "text") {
|
||||
if (selectedOptionIds?.length || customText !== undefined)
|
||||
throw new PaperclipQuestionValidationError(
|
||||
path,
|
||||
"text answers only carry text",
|
||||
);
|
||||
if (selectedOptionIds?.length || customText !== undefined) throw new PaperclipQuestionValidationError(path, "text answers only carry text");
|
||||
} else {
|
||||
if (textValue !== undefined)
|
||||
throw new PaperclipQuestionValidationError(
|
||||
path,
|
||||
"select answers do not carry text",
|
||||
);
|
||||
const allowed = new Set(
|
||||
(question.options ?? []).map((option) => option.id),
|
||||
);
|
||||
if (textValue !== undefined) throw new PaperclipQuestionValidationError(path, "select answers do not carry text");
|
||||
const allowed = new Set((question.options ?? []).map((option) => option.id));
|
||||
for (const optionId of selectedOptionIds ?? []) {
|
||||
if (!allowed.has(optionId))
|
||||
throw new PaperclipQuestionValidationError(
|
||||
`${path}/selectedOptionIds`,
|
||||
`contains unknown option ${optionId}`,
|
||||
);
|
||||
if (!allowed.has(optionId)) throw new PaperclipQuestionValidationError(`${path}/selectedOptionIds`, `contains unknown option ${optionId}`);
|
||||
}
|
||||
if (
|
||||
question.answerMode === "single_select" &&
|
||||
(selectedOptionIds?.length ?? 0) > 1
|
||||
) {
|
||||
throw new PaperclipQuestionValidationError(
|
||||
`${path}/selectedOptionIds`,
|
||||
"single-select answers choose at most one option",
|
||||
);
|
||||
if (question.answerMode === "single_select" && (selectedOptionIds?.length ?? 0) > 1) {
|
||||
throw new PaperclipQuestionValidationError(`${path}/selectedOptionIds`, "single-select answers choose at most one option");
|
||||
}
|
||||
if (customText !== undefined && question.customAnswer?.enabled !== true) {
|
||||
throw new PaperclipQuestionValidationError(
|
||||
`${path}/customText`,
|
||||
"custom answers are not enabled for this question",
|
||||
);
|
||||
throw new PaperclipQuestionValidationError(`${path}/customText`, "custom answers are not enabled for this question");
|
||||
}
|
||||
if (
|
||||
customText?.trim() &&
|
||||
(selectedOptionIds?.length ?? 0) > 0 &&
|
||||
question.answerMode === "single_select"
|
||||
) {
|
||||
throw new PaperclipQuestionValidationError(
|
||||
path,
|
||||
"single-select answers cannot select an option and a custom answer",
|
||||
);
|
||||
if (customText?.trim() && (selectedOptionIds?.length ?? 0) > 0 && question.answerMode === "single_select") {
|
||||
throw new PaperclipQuestionValidationError(path, "single-select answers cannot select an option and a custom answer");
|
||||
}
|
||||
}
|
||||
const parsed: PaperclipQuestionAnswer = {
|
||||
|
|
@ -599,63 +338,26 @@ export function parsePaperclipQuestionResponse(
|
|||
...(textValue !== undefined ? { text: textValue } : {}),
|
||||
...(customText !== undefined ? { customText } : {}),
|
||||
};
|
||||
if (question.required && !answerHasValue(parsed))
|
||||
throw new PaperclipQuestionValidationError(path, "is required");
|
||||
const boundedText =
|
||||
question.answerMode === "text" ? parsed.text : parsed.customText;
|
||||
if (question.required && !answerHasValue(parsed)) throw new PaperclipQuestionValidationError(path, "is required");
|
||||
const boundedText = question.answerMode === "text" ? parsed.text : parsed.customText;
|
||||
if (boundedText !== undefined) {
|
||||
const validation = question.textValidation;
|
||||
if (
|
||||
validation?.minLength !== undefined &&
|
||||
boundedText.length < validation.minLength
|
||||
) {
|
||||
throw new PaperclipQuestionValidationError(
|
||||
path,
|
||||
`must contain at least ${validation.minLength} characters`,
|
||||
);
|
||||
if (validation?.minLength !== undefined && boundedText.length < validation.minLength) {
|
||||
throw new PaperclipQuestionValidationError(path, `must contain at least ${validation.minLength} characters`);
|
||||
}
|
||||
if (
|
||||
validation?.maxLength !== undefined &&
|
||||
boundedText.length > validation.maxLength
|
||||
) {
|
||||
throw new PaperclipQuestionValidationError(
|
||||
path,
|
||||
`must contain at most ${validation.maxLength} characters`,
|
||||
);
|
||||
if (validation?.maxLength !== undefined && boundedText.length > validation.maxLength) {
|
||||
throw new PaperclipQuestionValidationError(path, `must contain at most ${validation.maxLength} characters`);
|
||||
}
|
||||
if (
|
||||
validation?.pattern !== undefined &&
|
||||
!new RegExp(validation.pattern).test(boundedText)
|
||||
) {
|
||||
throw new PaperclipQuestionValidationError(
|
||||
path,
|
||||
"does not match the required format",
|
||||
);
|
||||
if (validation?.pattern !== undefined && !new RegExp(validation.pattern).test(boundedText)) {
|
||||
throw new PaperclipQuestionValidationError(path, "does not match the required format");
|
||||
}
|
||||
if (
|
||||
validation?.inputType === "number" ||
|
||||
validation?.inputType === "integer"
|
||||
) {
|
||||
if (validation?.inputType === "number" || validation?.inputType === "integer") {
|
||||
const numeric = Number(boundedText);
|
||||
if (
|
||||
!Number.isFinite(numeric) ||
|
||||
(validation.inputType === "integer" && !Number.isInteger(numeric))
|
||||
) {
|
||||
throw new PaperclipQuestionValidationError(
|
||||
path,
|
||||
`must be a valid ${validation.inputType}`,
|
||||
);
|
||||
if (!Number.isFinite(numeric) || (validation.inputType === "integer" && !Number.isInteger(numeric))) {
|
||||
throw new PaperclipQuestionValidationError(path, `must be a valid ${validation.inputType}`);
|
||||
}
|
||||
if (validation.minimum !== undefined && numeric < validation.minimum)
|
||||
throw new PaperclipQuestionValidationError(
|
||||
path,
|
||||
`must be at least ${validation.minimum}`,
|
||||
);
|
||||
if (validation.maximum !== undefined && numeric > validation.maximum)
|
||||
throw new PaperclipQuestionValidationError(
|
||||
path,
|
||||
`must be at most ${validation.maximum}`,
|
||||
);
|
||||
if (validation.minimum !== undefined && numeric < validation.minimum) throw new PaperclipQuestionValidationError(path, `must be at least ${validation.minimum}`);
|
||||
if (validation.maximum !== undefined && numeric > validation.maximum) throw new PaperclipQuestionValidationError(path, `must be at most ${validation.maximum}`);
|
||||
}
|
||||
}
|
||||
if (answerHasValue(parsed)) answers[question.id] = parsed;
|
||||
|
|
|
|||
|
|
@ -0,0 +1,146 @@
|
|||
import { createHash } from "node:crypto";
|
||||
|
||||
export const NATIVE_RUNTIME_ASSET_SCHEMA = "paperclip.runtime-asset.v1" as const;
|
||||
export const PAPERCLIP_EXECUTION_PROMPT_REVISION = "paperclip-execution.v1" as const;
|
||||
export const PAPERCLIP_EXECUTION_PROMPT = "You are running as a Paperclip agent. Complete the assigned task in the provided execution environment. Follow the attached agent instructions and use assigned skills and tools when relevant. Use Paperclip tools for coordination. Finish exactly once with `paperclip_finish` or `paperclip_block`." as const;
|
||||
|
||||
export interface NativeRuntimeAssetReference {
|
||||
schema: typeof NATIVE_RUNTIME_ASSET_SCHEMA;
|
||||
digest: string;
|
||||
manifestDigest: string;
|
||||
rootPath: string;
|
||||
fileCount: number;
|
||||
totalBytes: number;
|
||||
}
|
||||
|
||||
export interface NativeRuntimeContextSnapshot {
|
||||
prompt: { revision: typeof PAPERCLIP_EXECUTION_PROMPT_REVISION; text: typeof PAPERCLIP_EXECUTION_PROMPT; digest: string };
|
||||
instructions: { entryPath: string; bundle: NativeRuntimeAssetReference };
|
||||
skills: Array<{ key: string; runtimeName: string; versionId: string | null; bundle: NativeRuntimeAssetReference }>;
|
||||
mcp: { assignmentSetId: string; digest: string; bindingId: string | null };
|
||||
aggregateDigest: string;
|
||||
}
|
||||
|
||||
export class NativeRuntimeContextError extends Error {
|
||||
readonly code = "native_runtime_context_invalid" as const;
|
||||
constructor(message: string) { super(message); this.name = "NativeRuntimeContextError"; }
|
||||
}
|
||||
|
||||
const sha256 = (value: string) => createHash("sha256").update(value).digest("hex");
|
||||
const object = (value: unknown, path: string): Record<string, unknown> => {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) throw new NativeRuntimeContextError(`${path} must be an object`);
|
||||
return value as Record<string, unknown>;
|
||||
};
|
||||
const exact = (value: Record<string, unknown>, keys: string[], path: string) => {
|
||||
const allowed = new Set(keys);
|
||||
const unknown = Object.keys(value).find((key) => !allowed.has(key));
|
||||
if (unknown) throw new NativeRuntimeContextError(`${path} contains unknown field ${unknown}`);
|
||||
};
|
||||
const text = (value: unknown, path: string) => {
|
||||
if (typeof value !== "string" || !value.trim()) throw new NativeRuntimeContextError(`${path} must be a non-empty string`);
|
||||
return value;
|
||||
};
|
||||
const digest = (value: unknown, path: string) => {
|
||||
const result = text(value, path);
|
||||
if (!/^[a-f0-9]{64}$/.test(result)) throw new NativeRuntimeContextError(`${path} must be a sha256 digest`);
|
||||
return result;
|
||||
};
|
||||
const integer = (value: unknown, path: string) => {
|
||||
if (!Number.isSafeInteger(value) || Number(value) < 0) throw new NativeRuntimeContextError(`${path} must be a non-negative integer`);
|
||||
return Number(value);
|
||||
};
|
||||
const safeRelativePath = (value: unknown, path: string) => {
|
||||
const result = text(value, path).replaceAll("\\", "/");
|
||||
if (result.startsWith("/") || result.includes("\0") || result.split("/").some((part) => !part || part === "." || part === "..")) {
|
||||
throw new NativeRuntimeContextError(`${path} must stay within its bundle root`);
|
||||
}
|
||||
return result;
|
||||
};
|
||||
|
||||
function parseAsset(value: unknown, path: string): NativeRuntimeAssetReference {
|
||||
const asset = object(value, path);
|
||||
exact(asset, ["schema", "digest", "manifestDigest", "rootPath", "fileCount", "totalBytes"], path);
|
||||
if (asset.schema !== NATIVE_RUNTIME_ASSET_SCHEMA) throw new NativeRuntimeContextError(`${path}.schema is unsupported`);
|
||||
return {
|
||||
schema: NATIVE_RUNTIME_ASSET_SCHEMA,
|
||||
digest: digest(asset.digest, `${path}.digest`),
|
||||
manifestDigest: digest(asset.manifestDigest, `${path}.manifestDigest`),
|
||||
rootPath: text(asset.rootPath, `${path}.rootPath`),
|
||||
fileCount: integer(asset.fileCount, `${path}.fileCount`),
|
||||
totalBytes: integer(asset.totalBytes, `${path}.totalBytes`),
|
||||
};
|
||||
}
|
||||
|
||||
function aggregatePayload(value: Omit<NativeRuntimeContextSnapshot, "aggregateDigest">) {
|
||||
return {
|
||||
prompt: value.prompt,
|
||||
instructions: { entryPath: value.instructions.entryPath, bundleDigest: value.instructions.bundle.digest },
|
||||
skills: [...value.skills].sort((a, b) => a.key.localeCompare(b.key)).map((skill) => ({
|
||||
key: skill.key, runtimeName: skill.runtimeName, versionId: skill.versionId, bundleDigest: skill.bundle.digest,
|
||||
})),
|
||||
// The binding is deliberately run-scoped. Compatibility is determined by the
|
||||
// assigned access set so a fresh capability can be rebound without forcing a
|
||||
// provider-session rotation when policy has not changed.
|
||||
mcp: { assignmentSetId: value.mcp.assignmentSetId, digest: value.mcp.digest },
|
||||
};
|
||||
}
|
||||
|
||||
export function canonicalNativeRuntimeContextDigest(value: Omit<NativeRuntimeContextSnapshot, "aggregateDigest">): string {
|
||||
return sha256(JSON.stringify(aggregatePayload(value)));
|
||||
}
|
||||
|
||||
export function nativeRuntimePromptDigest(): string { return sha256(PAPERCLIP_EXECUTION_PROMPT); }
|
||||
|
||||
export function parseNativeRuntimeContext(value: unknown): NativeRuntimeContextSnapshot {
|
||||
const context = object(value, "input.runtimeContext");
|
||||
exact(context, ["prompt", "instructions", "skills", "mcp", "aggregateDigest"], "input.runtimeContext");
|
||||
const prompt = object(context.prompt, "input.runtimeContext.prompt");
|
||||
exact(prompt, ["revision", "text", "digest"], "input.runtimeContext.prompt");
|
||||
if (prompt.revision !== PAPERCLIP_EXECUTION_PROMPT_REVISION || prompt.text !== PAPERCLIP_EXECUTION_PROMPT) {
|
||||
throw new NativeRuntimeContextError("input.runtimeContext.prompt must match the fixed Paperclip prompt revision");
|
||||
}
|
||||
if (digest(prompt.digest, "input.runtimeContext.prompt.digest") !== nativeRuntimePromptDigest()) {
|
||||
throw new NativeRuntimeContextError("input.runtimeContext.prompt.digest does not match prompt text");
|
||||
}
|
||||
const instructions = object(context.instructions, "input.runtimeContext.instructions");
|
||||
exact(instructions, ["entryPath", "bundle"], "input.runtimeContext.instructions");
|
||||
if (!Array.isArray(context.skills)) throw new NativeRuntimeContextError("input.runtimeContext.skills must be an array");
|
||||
const skills = context.skills.map((value, index) => {
|
||||
const skill = object(value, `input.runtimeContext.skills[${index}]`);
|
||||
exact(skill, ["key", "runtimeName", "versionId", "bundle"], `input.runtimeContext.skills[${index}]`);
|
||||
return {
|
||||
key: text(skill.key, `input.runtimeContext.skills[${index}].key`),
|
||||
runtimeName: safeRelativePath(skill.runtimeName, `input.runtimeContext.skills[${index}].runtimeName`),
|
||||
versionId: skill.versionId === null ? null : text(skill.versionId, `input.runtimeContext.skills[${index}].versionId`),
|
||||
bundle: parseAsset(skill.bundle, `input.runtimeContext.skills[${index}].bundle`),
|
||||
};
|
||||
});
|
||||
if (new Set(skills.flatMap((skill) => [skill.key, `runtime:${skill.runtimeName}`])).size !== skills.length * 2) {
|
||||
throw new NativeRuntimeContextError("input.runtimeContext.skills contains duplicate identities");
|
||||
}
|
||||
const mcp = object(context.mcp, "input.runtimeContext.mcp");
|
||||
exact(mcp, ["assignmentSetId", "digest", "bindingId"], "input.runtimeContext.mcp");
|
||||
const parsed = {
|
||||
prompt: { revision: PAPERCLIP_EXECUTION_PROMPT_REVISION, text: PAPERCLIP_EXECUTION_PROMPT, digest: nativeRuntimePromptDigest() },
|
||||
instructions: { entryPath: safeRelativePath(instructions.entryPath, "input.runtimeContext.instructions.entryPath"), bundle: parseAsset(instructions.bundle, "input.runtimeContext.instructions.bundle") },
|
||||
skills,
|
||||
mcp: {
|
||||
assignmentSetId: text(mcp.assignmentSetId, "input.runtimeContext.mcp.assignmentSetId"),
|
||||
digest: digest(mcp.digest, "input.runtimeContext.mcp.digest"),
|
||||
bindingId: mcp.bindingId === null ? null : text(mcp.bindingId, "input.runtimeContext.mcp.bindingId"),
|
||||
},
|
||||
} satisfies Omit<NativeRuntimeContextSnapshot, "aggregateDigest">;
|
||||
const aggregateDigest = digest(context.aggregateDigest, "input.runtimeContext.aggregateDigest");
|
||||
if (aggregateDigest !== canonicalNativeRuntimeContextDigest(parsed)) {
|
||||
throw new NativeRuntimeContextError("input.runtimeContext.aggregateDigest does not match the canonical context");
|
||||
}
|
||||
return { ...parsed, aggregateDigest };
|
||||
}
|
||||
|
||||
export function composeNativeSystemInstructions(context: NativeRuntimeContextSnapshot, entryContent: string): string {
|
||||
return [
|
||||
context.prompt.text,
|
||||
entryContent.trim(),
|
||||
`Read-only instruction sibling root: ${context.instructions.bundle.rootPath}`,
|
||||
].filter(Boolean).join("\n\n");
|
||||
}
|
||||
|
|
@ -0,0 +1,49 @@
|
|||
export interface NativeRunIdentity {
|
||||
runId: string;
|
||||
sessionId: string;
|
||||
companyId: string;
|
||||
issueId: string;
|
||||
agentId: string;
|
||||
}
|
||||
|
||||
export type NativeRunEventType = "run.started" | "run.completed";
|
||||
|
||||
export interface NativeRunEvent {
|
||||
eventId: string;
|
||||
runId: string;
|
||||
sequence: number;
|
||||
type: NativeRunEventType;
|
||||
}
|
||||
|
||||
export type NativeRunStatus = "succeeded" | "failed" | "cancelled";
|
||||
|
||||
export interface NativeRunResult {
|
||||
runId: string;
|
||||
status: NativeRunStatus;
|
||||
summary: string;
|
||||
}
|
||||
|
||||
export interface NativeSessionCapabilities {
|
||||
resume: boolean;
|
||||
typedEvents: boolean;
|
||||
typedEventFamilies?: TypedEventFamilyCapability[];
|
||||
steering: boolean;
|
||||
interruption: boolean;
|
||||
structuredResult: boolean;
|
||||
read?: boolean;
|
||||
reconciliation?: boolean;
|
||||
usage?: boolean;
|
||||
dynamicTools?: boolean;
|
||||
runtimeRequestResolution?: boolean;
|
||||
runtimeRequestHandoff?: boolean;
|
||||
goals?: boolean;
|
||||
threadLineage?: boolean;
|
||||
collaborationModes?: Array<"default" | "plan">;
|
||||
unsupported?: string[];
|
||||
}
|
||||
|
||||
export interface NativeUserMessage {
|
||||
role: "user";
|
||||
text: string;
|
||||
}
|
||||
import type { TypedEventFamilyCapability } from "../provider-events.js";
|
||||
|
|
@ -1,6 +1,12 @@
|
|||
export * from "./catalog/index.js";
|
||||
export * from "./contracts/completion-result.js";
|
||||
export * from "./contracts/codex.js";
|
||||
export * from "./contracts/durable-recovery.js";
|
||||
export * from "./contracts/local-runner.js";
|
||||
export * from "./contracts/native-execution.js";
|
||||
export * from "./contracts/question-set.js";
|
||||
export * from "./contracts/runtime-context.js";
|
||||
export * from "./contracts/types.js";
|
||||
export {
|
||||
DurablePrpControlPlane,
|
||||
type DurablePrpControlPlaneOptions,
|
||||
|
|
|
|||
Loading…
Reference in New Issue