diff --git a/packages/paperclip-runner/src/contracts/codex.ts b/packages/paperclip-runner/src/contracts/codex.ts new file mode 100644 index 0000000000..0ff690189a --- /dev/null +++ b/packages/paperclip-runner/src/contracts/codex.ts @@ -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"') + ); +} diff --git a/packages/paperclip-runner/src/contracts/completion-result.test.ts b/packages/paperclip-runner/src/contracts/completion-result.test.ts index 321f0789bb..9ae9b6a76c 100644 --- a/packages/paperclip-runner/src/contracts/completion-result.test.ts +++ b/packages/paperclip-runner/src/contracts/completion-result.test.ts @@ -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; 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); }); }); diff --git a/packages/paperclip-runner/src/contracts/completion-result.ts b/packages/paperclip-runner/src/contracts/completion-result.ts index 11be498be4..6c18cdbf47 100644 --- a/packages/paperclip-runner/src/contracts/completion-result.ts +++ b/packages/paperclip-runner/src/contracts/completion-result.ts @@ -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" }, diff --git a/packages/paperclip-runner/src/contracts/durable-recovery.ts b/packages/paperclip-runner/src/contracts/durable-recovery.ts new file mode 100644 index 0000000000..5cd271b687 --- /dev/null +++ b/packages/paperclip-runner/src/contracts/durable-recovery.ts @@ -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; + byteSize: number; +} + +export interface DurableRecoveryProcessedCommand { + commandId: string; + controllerSeq: number; + commandDigest: string; + status: "completed" | "failed" | "rejected"; + logicalEffectCount: number; + result: Record; +} + +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; + 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; + status: "pending" | "completed" | "failed" | "rejected"; + result: Record | null; +} + +export interface DurableRecoveryCommittedEvent { + sourceSeq: number; + sourceEventId: string; + eventType: string; + priority: 0 | 1 | 2; + envelope: Record; + 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; + }; +} diff --git a/packages/paperclip-runner/src/contracts/local-runner.ts b/packages/paperclip-runner/src/contracts/local-runner.ts new file mode 100644 index 0000000000..ae878c2827 --- /dev/null +++ b/packages/paperclip-runner/src/contracts/local-runner.ts @@ -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 { + 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[]; +} diff --git a/packages/paperclip-runner/src/contracts/native-execution.test.ts b/packages/paperclip-runner/src/contracts/native-execution.test.ts new file mode 100644 index 0000000000..980ae6e3a9 --- /dev/null +++ b/packages/paperclip-runner/src/contracts/native-execution.test.ts @@ -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; + 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; + delete (legacy.session as Record).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"); + }); +}); diff --git a/packages/paperclip-runner/src/contracts/native-execution.ts b/packages/paperclip-runner/src/contracts/native-execution.ts new file mode 100644 index 0000000000..9e51fbcc71 --- /dev/null +++ b/packages/paperclip-runner/src/contracts/native-execution.ts @@ -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; +} + +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; +} + +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 + | { + 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 { + schema: typeof NATIVE_EXECUTION_INPUT_SCHEMA_V2; + executionMode: NativeExecutionMode; + task: Omit & { + workMode: "standard" | "planning" | "ask"; + }; + planningContext: NativePlanningContext | null; +} + +export interface NativeExecutionInputV3 extends Omit { + schema: typeof NATIVE_EXECUTION_INPUT_SCHEMA_V3; + runtimeContext: NativeRuntimeContextSnapshot; +} + +export interface NativeExecutionInputV4 extends Omit { + 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 | null; + completionContract: StrictCompletionContractInput; + interactionResponses: NativeInteractionResponseEnvelope[]; +} + +export interface NativeModelEnvelopeV2 { + schema: typeof NATIVE_MODEL_ENVELOPE_SCHEMA; + task: NativeExecutionInputV2["task"]; + executionMode: NativeExecutionMode; + planningContext: NativePlanningContext | null; + workspace: Pick | 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 | 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 { + if (typeof value !== "object" || value === null || Array.isArray(value)) { + throw new NativeExecutionInputError(`${path} must be an object`); + } + return value as Record; +} + +function exactKeys(value: Record, 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), + }; +} diff --git a/packages/paperclip-runner/src/contracts/question-set.test.ts b/packages/paperclip-runner/src/contracts/question-set.test.ts index 04644da0aa..7cd873afe3 100644 --- a/packages/paperclip-runner/src/contracts/question-set.test.ts +++ b/packages/paperclip-runner/src/contracts/question-set.test.ts @@ -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/); }); }); diff --git a/packages/paperclip-runner/src/contracts/question-set.ts b/packages/paperclip-runner/src/contracts/question-set.ts index a0a0323fea..34156a87cf 100644 --- a/packages/paperclip-runner/src/contracts/question-set.ts +++ b/packages/paperclip-runner/src/contracts/question-set.ts @@ -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(); - 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 = {}; 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; diff --git a/packages/paperclip-runner/src/contracts/runtime-context.ts b/packages/paperclip-runner/src/contracts/runtime-context.ts new file mode 100644 index 0000000000..8832457945 --- /dev/null +++ b/packages/paperclip-runner/src/contracts/runtime-context.ts @@ -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 => { + if (!value || typeof value !== "object" || Array.isArray(value)) throw new NativeRuntimeContextError(`${path} must be an object`); + return value as Record; +}; +const exact = (value: Record, 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) { + 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): 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; + 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"); +} diff --git a/packages/paperclip-runner/src/contracts/types.ts b/packages/paperclip-runner/src/contracts/types.ts new file mode 100644 index 0000000000..811e922aa1 --- /dev/null +++ b/packages/paperclip-runner/src/contracts/types.ts @@ -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"; diff --git a/packages/paperclip-runner/src/index.ts b/packages/paperclip-runner/src/index.ts index 4a01c2340d..02210787d2 100644 --- a/packages/paperclip-runner/src/index.ts +++ b/packages/paperclip-runner/src/index.ts @@ -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,