test(runner): add credential-free acceptance foundation (#12652)

## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work.
> - Paperclip supports direct adapters and an experimental native Runner
path.
> - These paths need one stable compatibility matrix.
> - The matrix must not launch providers or load credentials during
normal tests.
> - Result handling must reject incomplete output and sensitive values.
> - This pull request adds a credential-free acceptance foundation.
> - The benefit is a reviewable contract for later end-to-end executors.

## Linked Issues or Issue Description

**What existing behavior does this improve?**

This improves verification for direct adapters and Paperclip Runner
providers.

**Subsystem affected**

Cross-cutting test infrastructure for adapters, the server runtime, and
the task thread.

**Current behavior**

The repository has subsystem tests. It does not have one declarative
matrix for direct and native compatibility.

**Proposed behavior**

Add a pure acceptance catalog, result validator, redaction helpers, and
failure classification. Keep all execution authority outside this
change.

**Reason and benefit**

The matrix makes legacy isolation and native recovery requirements
explicit. The helpers let later executors report safe and complete
results.

**Breaking changes**

None. This change does not alter production runtime selection or start
any provider.

## What Changed

- Add a catalog for built-in direct adapters and qualified native
provider profiles.
- Add compatibility cases for runtime selection, task threads,
questions, and flag-change recovery.
- Add pure redaction and transient-failure classification helpers.
- Add fail-closed Markdown and JUnit report aggregation.
- Add isolated test and type-check commands.
- Document the credential-free boundary and deferred live execution
work.

## Verification

GitHub Actions must run:

- `pnpm test:runner-acceptance`
- `pnpm test:runner-acceptance:typecheck`
- The repository test, type-check, build, policy, and security gates.

No local test command was run. The repository owner requested
GitHub-only verification.

## Risks

Low production risk. The change adds test-only files and root scripts.
The catalog can drift when a built-in adapter changes. Its validation
fails closed on that drift.

## Model Used

OpenAI Codex with the GPT-5 agent model. The work used high reasoning,
repository inspection, tool use, and parallel code review.

## Checklist

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

View File

@ -23,6 +23,8 @@
"test": "pnpm run test:run",
"test:watch": "pnpm run preflight:workspace-links && vitest",
"test:run": "pnpm run preflight:workspace-links && node scripts/run-vitest-stable.mjs",
"test:runner-acceptance": "vitest run --config tests/runner-acceptance/vitest.config.ts",
"test:runner-acceptance:typecheck": "tsc -p tests/runner-acceptance/tsconfig.json",
"test:run:general": "pnpm run preflight:workspace-links && pnpm --filter @paperclipai/plugin-sdk ensure-build-deps && node scripts/run-vitest-stable.mjs --mode general",
"test:run:serialized": "pnpm run preflight:workspace-links && pnpm --filter @paperclipai/plugin-sdk ensure-build-deps && node scripts/run-vitest-stable.mjs --mode serialized",
"db:generate": "pnpm --filter @paperclipai/db generate",

View File

@ -1118,6 +1118,7 @@ describe("Capability live runnerd and Codex session", () => {
]));
await resumedService.shutdown(resumed.id, "test complete");
},
15_000,
// CI exercises two real process generations here and can exceed the unit default under load.
30_000,
);
});

View File

@ -0,0 +1,70 @@
# Credential-free Runner acceptance foundation
This directory defines the reviewable, deterministic foundation for Runner
acceptance checks. It contains no launcher and grants no authority to start
Paperclip, a provider process, a browser, a remote environment, or a billable
model request.
The catalog covers:
- every current built-in direct adapter except the explicitly deferred Pi
adapter;
- the external-plugin direct-adapter compatibility contract;
- Paperclip Runner with Codex;
- Paperclip Runner with the qualified OpenCode model; and
- Paperclip Runner with the qualified ACPX Claude and Codex profiles.
The direct-adapter cells assert the legacy boundary: runnerd does not start,
native records are not created, direct finalization remains authoritative, and
classic task controls remain available. Native cells assert persisted provider
identity, runtime authority, structured-question behavior, and recovery of an
already-recorded run after the rollout flag changes.
Registered compatibility-only adapters, including the retired `acpx_local`
entry, remain in the direct catalog so a future selection check can prove they
never fall into native execution. Their presence is not a claim that the
adapter can start a provider session.
## Commands
Run the isolated unit suite:
```sh
pnpm test:runner-acceptance
```
Check the standalone TypeScript boundary:
```sh
pnpm test:runner-acceptance:typecheck
```
These commands are credential-free. They validate the catalog and pure support
utilities; they do not claim that a provider was contacted.
## Result boundary
Future fixture executors may emit `paperclip.runner-acceptance.result/v1`
objects and pass them to `buildRunnerAcceptanceReport`. Results must name a
catalog cell, report every expected assertion, carry successful redaction, and
contain no sensitive-looking structured values. The aggregator produces only
in-memory normalized data plus optional Markdown or JUnit strings. It does not
write evidence, screenshots, history, or public reports.
## Deliberate exclusions
This foundation does not include:
- Pi or any Pi transitive package;
- Claude Managed or AWS AgentCore;
- Daytona, remote images, live provider execution, or cost accounting;
- provider-key loading, secret references, auth-file discovery, or tracked env
templates;
- screenshots, traces, raw logs, evidence archives, dashboards, history, or
publication workflows; or
- the eval kernel, scenario explorer, browser SDK, or package/runtime
replacement work.
Add an executor only in a later, explicitly authorized change. Keep its launch
authority separate from this catalog, and make any billable or secret-bearing
mode opt-in and independently reviewed.

View File

@ -0,0 +1,64 @@
import { describe, expect, it } from "vitest";
import { QUALIFIED_ACPX_PROFILES } from "../../packages/paperclip-runner/src/drivers/acpx/qualified-profiles.js";
import { QUALIFIED_OPENCODE_MODEL } from "../../packages/paperclip-runner/src/drivers/opencode/opencode-server-driver.js";
import {
directAcceptanceProfiles,
nativeAcceptanceProfiles,
runnerAcceptanceCases,
runnerAcceptanceMatrix,
validateRunnerAcceptanceCatalog,
} from "./catalog.js";
describe("credential-free Runner acceptance catalog", () => {
it("covers direct adapters without activating the native runtime", () => {
expect(directAcceptanceProfiles.length).toBeGreaterThan(10);
expect(directAcceptanceProfiles.every((profile) =>
profile.generation === "direct"
&& profile.expectedRuntimeMode === "legacy"
&& profile.invariants.includes("runnerd.start_count=0")
&& profile.invariants.includes("native_record.count=0"))).toBe(true);
expect(directAcceptanceProfiles.some(({ adapterType }) => adapterType === "process")).toBe(true);
expect(directAcceptanceProfiles.some(({ adapterType }) => adapterType === "http")).toBe(true);
expect(directAcceptanceProfiles.some(({ adapterScope }) =>
adapterScope === "external_plugin_contract")).toBe(true);
});
it("contains only the approved native providers and ACPX profiles", () => {
expect(nativeAcceptanceProfiles.map(({ id }) => id)).toEqual([
"runner-codex",
"runner-opencode",
"runner-acpx-claude",
"runner-acpx-codex",
]);
expect(nativeAcceptanceProfiles.find(({ id }) => id === "runner-opencode")?.model)
.toBe(QUALIFIED_OPENCODE_MODEL);
expect(nativeAcceptanceProfiles.find(({ id }) => id === "runner-acpx-claude")?.model)
.toBe(QUALIFIED_ACPX_PROFILES.claude.qualificationModel);
expect(nativeAcceptanceProfiles.find(({ id }) => id === "runner-acpx-codex")?.model)
.toBe(QUALIFIED_ACPX_PROFILES.codex.qualificationModel);
expect(nativeAcceptanceProfiles.some(({ adapterConfig }) =>
adapterConfig.acpxAgent === "pi")).toBe(false);
expect(directAcceptanceProfiles.some(({ adapterType }) =>
adapterType === "pi_local")).toBe(false);
});
it("contains no launch authority, provider credentials, or managed providers", () => {
const serialized = JSON.stringify({
profiles: [...directAcceptanceProfiles, ...nativeAcceptanceProfiles],
cases: runnerAcceptanceCases,
});
expect(serialized).not.toMatch(/claude_managed|aws_agentcore|daytona/i);
expect(serialized).not.toMatch(/api.?key|secret_ref|authorization/i);
});
it("builds a stable, unique matrix and applies recovery only to native profiles", () => {
expect(validateRunnerAcceptanceCatalog()).toEqual(runnerAcceptanceMatrix);
expect(new Set(runnerAcceptanceMatrix.map(({ id }) => id)).size)
.toBe(runnerAcceptanceMatrix.length);
expect(runnerAcceptanceMatrix
.filter(({ acceptanceCase }) => acceptanceCase.id === "flagged-native-recovery")
.every(({ profile }) => profile.generation === "native"))
.toBe(true);
});
});

View File

@ -0,0 +1,328 @@
import { createHash } from "node:crypto";
import { DEFAULT_CODEX_LOCAL_MODEL } from "../../packages/adapters/codex-local/src/index.js";
import { QUALIFIED_ACPX_PROFILES } from "../../packages/paperclip-runner/src/drivers/acpx/qualified-profiles.js";
import { QUALIFIED_OPENCODE_MODEL } from "../../packages/paperclip-runner/src/drivers/opencode/opencode-server-driver.js";
import { BUILTIN_ADAPTER_TYPES } from "../../server/src/adapters/builtin-adapter-types.js";
import { findSensitiveValue } from "./redaction.js";
import type {
JsonValue,
RunnerAcceptanceCase,
RunnerAcceptanceCell,
RunnerAcceptanceProfile,
} from "./types.js";
const excludedBuiltInAdapterTypes = new Set(["paperclip_runner", "pi_local"]);
const directBuiltInAdapterTypes = [
"acpx_local",
"claude_local",
"codex_local",
"cursor_cloud",
"cursor",
"gemini_local",
"grok_local",
"hermes_gateway",
"hermes_local",
"kimi_local",
"openclaw_gateway",
"opencode_local",
"process",
"http",
] as const;
function directProfile(adapterType: string): RunnerAcceptanceProfile {
return {
id: `direct-${adapterType.replaceAll("_", "-")}`,
label: `Direct ${adapterType}`,
generation: "direct",
adapterScope: "built_in",
adapterType,
expectedRuntimeMode: "legacy",
provider: null,
model: null,
adapterConfig: {},
invariants: [
"runtime.mode=legacy",
"runnerd.start_count=0",
"native_record.count=0",
"finalization.path=direct_adapter",
"task_controls.mode=classic",
],
};
}
function nativeProfile(input: {
id: string;
label: string;
provider: "codex" | "opencode" | "acpx";
model: string;
adapterConfig: Record<string, JsonValue>;
}): RunnerAcceptanceProfile {
return {
...input,
generation: "native",
adapterScope: "built_in",
adapterType: "paperclip_runner",
expectedRuntimeMode: "native",
invariants: [
"runtime.mode=native",
"runnerd.start_count=1",
"native_record.count>=1",
"finalization.path=native",
"provider.persisted=true",
],
};
}
export const directAcceptanceProfiles: readonly RunnerAcceptanceProfile[] = [
...directBuiltInAdapterTypes.map(directProfile),
{
id: "direct-external-plugin-contract",
label: "Direct external plugin contract",
generation: "direct",
adapterScope: "external_plugin_contract",
adapterType: "external_plugin:*",
expectedRuntimeMode: "legacy",
provider: null,
model: null,
adapterConfig: {},
invariants: [
"runtime.mode=legacy",
"runnerd.start_count=0",
"native_record.count=0",
"finalization.path=plugin_adapter",
"task_controls.mode=classic",
],
},
] as const;
export const nativeAcceptanceProfiles: readonly RunnerAcceptanceProfile[] = [
nativeProfile({
id: "runner-codex",
label: "Paperclip Runner Codex",
provider: "codex",
model: DEFAULT_CODEX_LOCAL_MODEL,
adapterConfig: {
provider: "codex",
codexPermissionMode: "untrusted",
lifecycleMode: "per_turn",
},
}),
nativeProfile({
id: "runner-opencode",
label: "Paperclip Runner OpenCode",
provider: "opencode",
model: QUALIFIED_OPENCODE_MODEL,
adapterConfig: {
provider: "opencode",
model: QUALIFIED_OPENCODE_MODEL,
opencodePermissionMode: "ask",
lifecycleMode: "per_turn",
},
}),
nativeProfile({
id: "runner-acpx-claude",
label: "Paperclip Runner ACPX Claude",
provider: "acpx",
model: QUALIFIED_ACPX_PROFILES.claude.qualificationModel,
adapterConfig: {
provider: "acpx",
acpxAgent: "claude",
model: QUALIFIED_ACPX_PROFILES.claude.qualificationModel,
acpxPermissionMode: "approve-reads",
lifecycleMode: "per_turn",
},
}),
nativeProfile({
id: "runner-acpx-codex",
label: "Paperclip Runner ACPX Codex",
provider: "acpx",
model: QUALIFIED_ACPX_PROFILES.codex.qualificationModel,
adapterConfig: {
provider: "acpx",
acpxAgent: "codex",
model: QUALIFIED_ACPX_PROFILES.codex.qualificationModel,
acpxPermissionMode: "approve-reads",
lifecycleMode: "per_turn",
},
}),
] as const;
export const runnerAcceptanceProfiles: readonly RunnerAcceptanceProfile[] = [
...directAcceptanceProfiles,
...nativeAcceptanceProfiles,
];
export const runnerAcceptanceCases: readonly RunnerAcceptanceCase[] = [
{
id: "runtime-selection",
label: "Runtime selection remains isolated",
revision: 1,
appliesTo: "all",
assertions: [
"runtime_selection.is_stable",
"status_authority.matches_runtime",
"execution_occurs_once",
],
},
{
id: "task-thread-baseline",
label: "Task thread preserves baseline behavior",
revision: 1,
appliesTo: "all",
assertions: [
"task_thread.active_state",
"task_thread.settled_state",
"task_thread.empty_transcript_state",
"task_thread.classic_interface_state",
],
},
{
id: "question-round-trip",
label: "Structured question round trip",
revision: 1,
appliesTo: "all",
assertions: [
"question.rendered_once",
"question.response_validated",
"question.resume_occurs_once",
"finalization.byte_stable",
],
},
{
id: "flagged-native-recovery",
label: "Persisted native recovery after flag change",
revision: 1,
appliesTo: "native",
assertions: [
"fresh_start.flag_required",
"persisted_run.readable_when_disabled",
"persisted_run.recoverable_when_disabled",
"provider.identity_does_not_drift",
],
},
] as const;
function suiteDefinitionHash() {
return createHash("sha256")
.update(JSON.stringify({
profiles: runnerAcceptanceProfiles.map((profile) => ({
id: profile.id,
adapterType: profile.adapterType,
generation: profile.generation,
provider: profile.provider,
model: profile.model,
adapterConfig: profile.adapterConfig,
invariants: profile.invariants,
})),
cases: runnerAcceptanceCases.map((acceptanceCase) => ({
id: acceptanceCase.id,
revision: acceptanceCase.revision,
appliesTo: acceptanceCase.appliesTo,
assertions: acceptanceCase.assertions,
})),
}))
.digest("hex");
}
export const runnerAcceptanceSuiteDefinitionHash = suiteDefinitionHash();
export function buildRunnerAcceptanceMatrix(): RunnerAcceptanceCell[] {
return runnerAcceptanceProfiles.flatMap((profile) =>
runnerAcceptanceCases
.filter((acceptanceCase) =>
acceptanceCase.appliesTo === "all"
|| acceptanceCase.appliesTo === profile.generation)
.map((acceptanceCase) => ({
id: `${profile.id}.${acceptanceCase.id}`,
suiteId: "runner-compatibility" as const,
suiteDefinitionHash: runnerAcceptanceSuiteDefinitionHash,
profile,
acceptanceCase,
assertions: [...new Set([
...profile.invariants,
...acceptanceCase.assertions,
])],
})),
);
}
function duplicates(values: readonly string[]) {
const seen = new Set<string>();
return values.filter((value) => {
if (seen.has(value)) return true;
seen.add(value);
return false;
});
}
function assertSafeConfig(value: JsonValue, path = "adapterConfig") {
if (typeof value === "string") {
const leak = findSensitiveValue(value);
if (leak) throw new Error(`Acceptance catalog contains ${leak} at ${path}`);
return;
}
if (Array.isArray(value)) {
value.forEach((entry, index) => assertSafeConfig(entry, `${path}[${index}]`));
return;
}
if (!value || typeof value !== "object") return;
for (const [key, entry] of Object.entries(value)) {
if (/(?:authorization|api.?key|token|password|secret|credential|env)/i.test(key)) {
throw new Error(`Acceptance catalog contains prohibited field ${path}.${key}`);
}
assertSafeConfig(entry, `${path}.${key}`);
}
}
export function validateRunnerAcceptanceCatalog() {
const duplicateProfileIds = duplicates(runnerAcceptanceProfiles.map(({ id }) => id));
if (duplicateProfileIds.length > 0) {
throw new Error(`Duplicate runner acceptance profile ids: ${duplicateProfileIds.join(", ")}`);
}
const duplicateCaseIds = duplicates(runnerAcceptanceCases.map(({ id }) => id));
if (duplicateCaseIds.length > 0) {
throw new Error(`Duplicate runner acceptance case ids: ${duplicateCaseIds.join(", ")}`);
}
const expectedDirectBuiltIns = new Set(
[...BUILTIN_ADAPTER_TYPES].filter((type) => !excludedBuiltInAdapterTypes.has(type)),
);
const catalogedDirectBuiltIns = new Set(
directAcceptanceProfiles
.filter(({ adapterScope }) => adapterScope === "built_in")
.map(({ adapterType }) => adapterType),
);
const missing = [...expectedDirectBuiltIns].filter((type) => !catalogedDirectBuiltIns.has(type));
const unexpected = [...catalogedDirectBuiltIns].filter((type) => !expectedDirectBuiltIns.has(type));
if (missing.length > 0 || unexpected.length > 0) {
throw new Error(
`Direct adapter catalog drift; missing=${missing.join(",") || "none"}; unexpected=${unexpected.join(",") || "none"}`,
);
}
for (const profile of runnerAcceptanceProfiles) {
assertSafeConfig(profile.adapterConfig);
if (profile.adapterType === "pi_local") {
throw new Error("Pi is outside the acceptance foundation boundary");
}
if (
profile.provider === "acpx"
&& profile.adapterConfig.acpxAgent !== "claude"
&& profile.adapterConfig.acpxAgent !== "codex"
) {
throw new Error(`Unqualified ACPX profile ${profile.id}`);
}
}
const matrix = buildRunnerAcceptanceMatrix();
const duplicateCellIds = duplicates(matrix.map(({ id }) => id));
if (duplicateCellIds.length > 0) {
throw new Error(`Duplicate runner acceptance cell ids: ${duplicateCellIds.join(", ")}`);
}
return matrix;
}
export const runnerAcceptanceMatrix = validateRunnerAcceptanceCatalog();

View File

@ -0,0 +1,28 @@
import type { FailureClass } from "./types.js";
const TRANSIENT =
/(?:\b429\b|\b5\d\d\b|rate.?limit|ECONN(?:RESET|REFUSED)|socket hang up|network (?:error|interruption|timeout)|service unavailable|(?:provider|server|bootstrap|browser|webserver|health|connection|harness).*(?:temporar|timed? out|timeout|closed|failed|unavailable|interrupt|reset|refused|start|connect)|(?:timed? out|timeout).*(?:provider|server|bootstrap|browser|webserver|health|connection|harness))/i;
const PERMANENT =
/(?:unauthorized|forbidden|qualification|model.*(?:unsupported|incompatible)|artifact.*incompatible|runner_remote_.*(?:incompatible|unavailable)|provider.*unsupported)/i;
const CANDIDATE =
/(?:assertion|matcher|expected.*observed|marker|issue status|run status|runtime mode|wrong output|missing output)/i;
export function classifyFailure(error: unknown): FailureClass {
const message = error instanceof Error
? `${error.name}: ${error.message}`
: String(error);
if (/secret.*(?:leak|plaintext|redaction)/i.test(message)) return "secret_leak";
if (/cleanup|teardown|lease.*release/i.test(message)) {
return TRANSIENT.test(message)
? "transient_infrastructure"
: "cleanup_failure";
}
if (PERMANENT.test(message)) return "permanent_infrastructure";
if (TRANSIENT.test(message)) return "transient_infrastructure";
if (CANDIDATE.test(message)) return "candidate_failure";
return "candidate_failure";
}
export function shouldRetryFailure(failureClass: FailureClass) {
return failureClass === "transient_infrastructure";
}

View File

@ -0,0 +1,118 @@
import { redactDiagnosticText } from "../../packages/adapter-utils/src/command-redaction.js";
const SECRET_SHAPES = [
/\bsk-ant-[A-Za-z0-9_-]{16,}\b/g,
/\bsk-(?:proj-)?[A-Za-z0-9_-]{16,}\b/g,
/\bopenrouter[-_]?(?:api)?[-_]?key["'=:\s]+[A-Za-z0-9._-]{12,}\b/gi,
] as const;
const SENSITIVE_KEY = /(?:authorization|api.?key|token|password|secret|credential|private.?key)/i;
export function normalizedSensitiveValues(
values: readonly (string | undefined)[],
) {
return [...new Set(
values
.map((value) => value?.trim())
.filter((value): value is string => Boolean(value)),
)].sort((left, right) => right.length - left.length);
}
function redactKnownValuesAndShapes(
value: string,
sensitiveValues: readonly string[],
) {
let redacted = value;
for (const sensitiveValue of normalizedSensitiveValues(sensitiveValues)) {
redacted = redacted.split(sensitiveValue).join("[REDACTED]");
}
for (const pattern of SECRET_SHAPES) {
pattern.lastIndex = 0;
redacted = redacted.replace(pattern, "[REDACTED_SECRET_SHAPE]");
}
return redacted;
}
export function redactText(
value: string,
sensitiveValues: readonly string[] = [],
) {
return redactKnownValuesAndShapes(
redactDiagnosticText(value, "[REDACTED]"),
sensitiveValues,
);
}
export function findSensitiveValue(
value: string | Buffer,
sensitiveValues: readonly string[] = [],
): string | null {
const text = Buffer.isBuffer(value) ? value.toString("utf8") : value;
for (const sensitiveValue of normalizedSensitiveValues(sensitiveValues)) {
if (text.includes(sensitiveValue)) return "exact sensitive value";
}
for (const pattern of SECRET_SHAPES) {
pattern.lastIndex = 0;
if (pattern.test(text)) return "secret-shaped value";
}
return null;
}
export function findSensitiveJsonValue(
value: unknown,
sensitiveValues: readonly string[] = [],
): string | null {
if (typeof value === "string") return findSensitiveValue(value, sensitiveValues);
if (Array.isArray(value)) {
for (const entry of value) {
const leak = findSensitiveJsonValue(entry, sensitiveValues);
if (leak) return leak;
}
return null;
}
if (value && typeof value === "object") {
for (const [key, entry] of Object.entries(value)) {
if (
SENSITIVE_KEY.test(key)
&& entry !== null
&& entry !== undefined
&& entry !== "[REDACTED]"
&& entry !== "[REDACTED_SECRET_SHAPE]"
) {
return `sensitive field ${key}`;
}
const leak = findSensitiveJsonValue(entry, sensitiveValues);
if (leak) return leak;
}
}
return null;
}
export function sanitizeJson(
value: unknown,
sensitiveValues: readonly string[] = [],
): unknown {
if (typeof value === "string") return redactText(value, sensitiveValues);
if (Array.isArray(value)) {
return value.map((entry) => sanitizeJson(entry, sensitiveValues));
}
if (value && typeof value === "object") {
return Object.fromEntries(
Object.entries(value).map(([key, entry]) => [
key,
SENSITIVE_KEY.test(key) && entry !== null && entry !== undefined
? "[REDACTED]"
: sanitizeJson(entry, sensitiveValues),
]),
);
}
return value;
}
export function assertSensitiveValueFree(
value: string | Buffer,
sensitiveValues: readonly string[],
label: string,
) {
const leak = findSensitiveValue(value, sensitiveValues);
if (leak) throw new Error(`Secret leak in ${label}: ${leak}`);
}

View File

@ -0,0 +1,189 @@
import { describe, expect, it } from "vitest";
import { runnerAcceptanceMatrix } from "./catalog.js";
import {
buildRunnerAcceptanceReport,
renderRunnerAcceptanceJUnit,
renderRunnerAcceptanceMarkdown,
} from "./report.js";
import type { RunnerAcceptanceCell, RunnerAcceptanceResult } from "./types.js";
function passingResult(
cell: RunnerAcceptanceCell,
attempt = 1,
): RunnerAcceptanceResult {
return {
schema: "paperclip.runner-acceptance.result/v1",
cellId: cell.id,
attempt,
status: "passed",
startedAt: "2026-09-01T00:00:00.000Z",
finishedAt: `2026-09-01T00:00:0${attempt}.000Z`,
durationMs: attempt * 1_000,
redaction: "passed",
assertions: cell.assertions.map((id) => ({ id, passed: true })),
};
}
describe("Runner acceptance report", () => {
const cells = runnerAcceptanceMatrix.slice(0, 2);
it("selects the newest attempt and reports missing cells without publishing evidence", () => {
const retry = passingResult(cells[0]!, 2);
const report = buildRunnerAcceptanceReport({
cells,
generatedAt: "2026-09-01T00:01:00.000Z",
results: [
{
...passingResult(cells[0]!),
status: "failed",
failureClass: "transient_infrastructure",
error: "connection timed out",
},
retry,
],
});
expect(report).toMatchObject({
selected: 2,
passed: 1,
failed: 1,
retries: 1,
});
expect(report.results[0]).toMatchObject({ attempt: 2, valid: true });
expect(report.results[1]).toMatchObject({
attempt: 0,
valid: false,
error: "No result was supplied",
});
});
it("fails closed on incomplete assertions or unsafe structured values", () => {
const cell = cells[0]!;
const incomplete = passingResult(cell);
const report = buildRunnerAcceptanceReport({
cells: [cell],
results: [{
...incomplete,
error: "redacted diagnostic",
assertions: incomplete.assertions.slice(1),
}],
});
expect(report.failed).toBe(1);
expect(report.results[0]?.validationErrors).toContain(
`missing assertion ${cell.assertions[0]}`,
);
const unsafeResult = {
...passingResult(cell),
diagnostic: { accessToken: "not-a-real-sensitive-value" },
} as unknown as RunnerAcceptanceResult;
const unsafe = buildRunnerAcceptanceReport({
cells: [cell],
results: [unsafeResult],
});
expect(unsafe.failed).toBe(1);
expect(unsafe.results[0]?.validationErrors).toContain(
"unsafe result payload: sensitive field accessToken",
);
expect(JSON.stringify(unsafe.results[0])).not.toContain(
"not-a-real-sensitive-value",
);
expect(unsafe.results[0]).not.toHaveProperty("diagnostic");
});
it("fails closed when assertions are missing or are not an array", () => {
const cell = cells[0]!;
const withoutAssertions = { ...passingResult(cell) } as Record<string, unknown>;
delete withoutAssertions.assertions;
const missing = buildRunnerAcceptanceReport({
cells: [cell],
results: [withoutAssertions],
});
const nonArray = buildRunnerAcceptanceReport({
cells: [cell],
results: [{ ...passingResult(cell), assertions: { passed: true } }],
});
for (const report of [missing, nonArray]) {
expect(report).toMatchObject({ selected: 1, passed: 0, failed: 1 });
expect(report.results[0]).toMatchObject({
valid: false,
assertions: [],
});
expect(report.results[0]?.validationErrors).toEqual(expect.arrayContaining([
"assertions must be an array",
`missing assertion ${cell.assertions[0]}`,
]));
}
});
it("normalizes invalid scalar and assertion types without throwing", () => {
const cell = cells[0]!;
const report = buildRunnerAcceptanceReport({
cells: [cell],
results: [{
schema: 1,
cellId: cell.id,
attempt: "1",
status: true,
failureClass: 42,
error: { message: "failed" },
startedAt: 1,
finishedAt: null,
durationMs: "1000",
redaction: false,
assertions: [
null,
{ id: cell.assertions[0], passed: "yes", detail: 42 },
],
}],
});
expect(report).toMatchObject({ selected: 1, passed: 0, failed: 1 });
expect(report.results[0]).toMatchObject({
attempt: 0,
status: "failed",
startedAt: "",
finishedAt: "",
durationMs: 0,
redaction: "failed",
valid: false,
});
expect(report.results[0]?.validationErrors).toEqual(expect.arrayContaining([
"unsupported result schema",
"attempt must be a positive safe integer",
"duration must be a non-negative finite number",
"timestamps must be valid ISO-compatible values",
"status must be passed or failed",
"failureClass is unsupported",
"error must be a string when present",
"redaction did not pass",
"assertion 0 must be an object",
`assertion ${cell.assertions[0]} passed must be a boolean`,
`assertion ${cell.assertions[0]} detail must be a string when present`,
]));
});
it("renders deterministic Markdown and JUnit summaries", () => {
const report = buildRunnerAcceptanceReport({
cells,
results: cells.map((cell) => passingResult(cell)),
generatedAt: "2026-09-01T00:01:00.000Z",
});
const markdown = renderRunnerAcceptanceMarkdown(report);
const junit = renderRunnerAcceptanceJUnit(report);
expect(markdown).toContain("Passed: 2/2");
expect(markdown).toContain(cells[0]!.id);
expect(junit).toContain('tests="2" failures="0"');
expect(junit).toContain('classname="runner-acceptance"');
});
it("rejects results for cells outside the declared catalog", () => {
expect(() => buildRunnerAcceptanceReport({
cells,
results: [{ ...passingResult(cells[0]!), cellId: "unknown.cell" }],
})).toThrow("unknown acceptance cells");
});
});

View File

@ -0,0 +1,316 @@
import { runnerAcceptanceMatrix } from "./catalog.js";
import { findSensitiveJsonValue, redactText } from "./redaction.js";
import type {
AggregatedAcceptanceResult,
FailureClass,
RunnerAcceptanceCell,
RunnerAcceptanceReport,
RunnerAcceptanceResult,
} from "./types.js";
const RESULT_SCHEMA = "paperclip.runner-acceptance.result/v1" as const;
const ALLOWED_FAILURE_CLASSES = new Set<FailureClass>([
"candidate_failure",
"transient_infrastructure",
"permanent_infrastructure",
"secret_leak",
"cleanup_failure",
]);
function record(value: unknown): Record<string, unknown> | null {
return value !== null && typeof value === "object" && !Array.isArray(value)
? value as Record<string, unknown>
: null;
}
function field(value: Record<string, unknown>, key: string): unknown {
try {
return value[key];
} catch {
return undefined;
}
}
function textField(value: Record<string, unknown>, key: string): string | null {
const candidate = field(value, key);
return typeof candidate === "string" ? candidate : null;
}
function numericField(value: Record<string, unknown>, key: string): number | null {
const candidate = field(value, key);
return typeof candidate === "number" && Number.isFinite(candidate)
? candidate
: null;
}
function safeSensitivePayloadError(value: unknown): string | null {
try {
const leak = findSensitiveJsonValue(value);
return leak ? `unsafe result payload: ${leak}` : null;
} catch {
return "result payload could not be inspected safely";
}
}
function validationErrors(
value: unknown,
cell: RunnerAcceptanceCell,
) {
const errors: string[] = [];
const result = record(value);
if (!result) return ["result must be an object"];
if (field(result, "schema") !== RESULT_SCHEMA) {
errors.push("unsupported result schema");
}
if (textField(result, "cellId") !== cell.id) {
errors.push("result cellId does not match the acceptance cell");
}
const attempt = numericField(result, "attempt");
if (attempt === null || !Number.isSafeInteger(attempt) || attempt < 1) {
errors.push("attempt must be a positive safe integer");
}
const durationMs = numericField(result, "durationMs");
if (durationMs === null || durationMs < 0) {
errors.push("duration must be a non-negative finite number");
}
const startedAt = textField(result, "startedAt");
const finishedAt = textField(result, "finishedAt");
if (
startedAt === null
|| finishedAt === null
|| !Number.isFinite(Date.parse(startedAt))
|| !Number.isFinite(Date.parse(finishedAt))
) {
errors.push("timestamps must be valid ISO-compatible values");
}
const status = field(result, "status");
if (status !== "passed" && status !== "failed") {
errors.push("status must be passed or failed");
}
const failureClass = field(result, "failureClass");
if (failureClass !== undefined && !ALLOWED_FAILURE_CLASSES.has(failureClass as FailureClass)) {
errors.push("failureClass is unsupported");
}
const error = field(result, "error");
if (error !== undefined && typeof error !== "string") {
errors.push("error must be a string when present");
}
if (field(result, "redaction") !== "passed") errors.push("redaction did not pass");
const sensitivePayloadError = safeSensitivePayloadError(value);
if (sensitivePayloadError) errors.push(sensitivePayloadError);
const rawAssertions = field(result, "assertions");
const assertions = Array.isArray(rawAssertions) ? rawAssertions : [];
if (!Array.isArray(rawAssertions)) errors.push("assertions must be an array");
const outcomes = new Map<string, { passed: boolean }>();
let duplicateAssertionId = false;
for (const [index, rawAssertion] of assertions.entries()) {
const assertion = record(rawAssertion);
if (!assertion) {
errors.push(`assertion ${index} must be an object`);
continue;
}
const id = textField(assertion, "id");
if (!id || id.trim().length === 0) {
errors.push(`assertion ${index} id must be a non-empty string`);
continue;
}
const passed = field(assertion, "passed");
if (typeof passed !== "boolean") {
errors.push(`assertion ${id} passed must be a boolean`);
}
const detail = field(assertion, "detail");
if (detail !== undefined && typeof detail !== "string") {
errors.push(`assertion ${id} detail must be a string when present`);
}
if (outcomes.has(id)) duplicateAssertionId = true;
else outcomes.set(id, { passed: passed === true });
}
if (duplicateAssertionId) errors.push("duplicate assertion ids");
for (const assertionId of cell.assertions) {
const outcome = outcomes.get(assertionId);
if (!outcome) errors.push(`missing assertion ${assertionId}`);
else if (!outcome.passed) errors.push(`failed assertion ${assertionId}`);
}
const unknownAssertions = [...outcomes.keys()]
.filter((id) => !cell.assertions.includes(id));
if (unknownAssertions.length > 0) {
errors.push(`unknown assertions: ${redactText(unknownAssertions.join(", "))}`);
}
if (status !== "passed") {
errors.push(
typeof error === "string" && error.length > 0
? redactText(error)
: typeof failureClass === "string" && ALLOWED_FAILURE_CLASSES.has(failureClass as FailureClass)
? failureClass
: "acceptance cell failed",
);
}
return errors;
}
function normalizedResult(
value: unknown,
cell: RunnerAcceptanceCell,
): RunnerAcceptanceResult {
const result = record(value) ?? {};
const attempt = numericField(result, "attempt");
const durationMs = numericField(result, "durationMs");
const status = field(result, "status");
const failureClass = field(result, "failureClass");
const error = textField(result, "error");
const startedAt = textField(result, "startedAt");
const finishedAt = textField(result, "finishedAt");
const rawAssertions = field(result, "assertions");
const assertions = Array.isArray(rawAssertions) ? rawAssertions : [];
return {
schema: RESULT_SCHEMA,
cellId: cell.id,
attempt: attempt !== null && Number.isSafeInteger(attempt) && attempt >= 1
? attempt
: 0,
status: status === "passed" ? "passed" : "failed",
...(typeof failureClass === "string" && ALLOWED_FAILURE_CLASSES.has(failureClass as FailureClass)
? { failureClass: failureClass as FailureClass }
: {}),
...(error ? { error: redactText(error) } : {}),
startedAt: startedAt === null ? "" : redactText(startedAt),
finishedAt: finishedAt === null ? "" : redactText(finishedAt),
durationMs: durationMs !== null && durationMs >= 0
? durationMs
: 0,
redaction: field(result, "redaction") === "passed" ? "passed" : "failed",
assertions: cell.assertions.flatMap((id) => {
const assertion = assertions
.map(record)
.find((candidate) => candidate && textField(candidate, "id") === id);
return assertion
? [{
id,
passed: field(assertion, "passed") === true,
...(typeof field(assertion, "detail") === "string"
? { detail: redactText(field(assertion, "detail") as string) }
: {}),
}]
: [];
}),
};
}
function missingResult(cell: RunnerAcceptanceCell, generatedAt: string): AggregatedAcceptanceResult {
return {
schema: "paperclip.runner-acceptance.result/v1",
cellId: cell.id,
attempt: 0,
status: "failed",
failureClass: "permanent_infrastructure",
error: "No result was supplied",
startedAt: generatedAt,
finishedAt: generatedAt,
durationMs: 0,
redaction: "passed",
assertions: [],
valid: false,
validationErrors: ["No result was supplied"],
};
}
export function buildRunnerAcceptanceReport(input: {
results: readonly unknown[];
cells?: readonly RunnerAcceptanceCell[];
generatedAt?: string;
}): RunnerAcceptanceReport {
const cells = input.cells ?? runnerAcceptanceMatrix;
const generatedAt = input.generatedAt ?? new Date().toISOString();
const candidates = new Map<string, Array<{ index: number; value: unknown }>>();
for (const [index, value] of input.results.entries()) {
const result = record(value);
const cellId = result ? textField(result, "cellId") : null;
const candidateCellId = cellId ?? `malformed-result-${index + 1}`;
candidates.set(candidateCellId, [
...(candidates.get(candidateCellId) ?? []),
{ index, value },
]);
}
const selected = cells.map((cell) => {
const attempts = (candidates.get(cell.id) ?? []).sort((left, right) => {
const leftRecord = record(left.value) ?? {};
const rightRecord = record(right.value) ?? {};
const leftAttempt = numericField(leftRecord, "attempt") ?? 0;
const rightAttempt = numericField(rightRecord, "attempt") ?? 0;
const leftFinishedAt = textField(leftRecord, "finishedAt");
const rightFinishedAt = textField(rightRecord, "finishedAt");
const leftFinishedTime = leftFinishedAt === null ? 0 : Date.parse(leftFinishedAt) || 0;
const rightFinishedTime = rightFinishedAt === null ? 0 : Date.parse(rightFinishedAt) || 0;
return rightAttempt - leftAttempt
|| rightFinishedTime - leftFinishedTime
|| left.index - right.index;
});
const candidate = attempts[0];
if (!candidate) return missingResult(cell, generatedAt);
const errors = validationErrors(candidate.value, cell);
return {
...normalizedResult(candidate.value, cell),
valid: errors.length === 0,
validationErrors: errors,
} satisfies AggregatedAcceptanceResult;
});
const knownCellIds = new Set(cells.map(({ id }) => id));
const unknownCellIds = [...candidates.keys()].filter((id) => !knownCellIds.has(id));
if (unknownCellIds.length > 0) {
throw new Error(`Results reference unknown acceptance cells: ${unknownCellIds.join(", ")}`);
}
return {
schema: "paperclip.runner-acceptance.report/v1",
generatedAt,
suiteDefinitionHash: cells[0]?.suiteDefinitionHash ?? "empty",
selected: selected.length,
passed: selected.filter(({ valid }) => valid).length,
failed: selected.filter(({ valid }) => !valid).length,
retries: selected.reduce((sum, result) => sum + Math.max(0, result.attempt - 1), 0),
results: selected,
};
}
function escapeMarkdown(value: string) {
return value.replaceAll("|", "\\|").replaceAll("\n", " ");
}
export function renderRunnerAcceptanceMarkdown(report: RunnerAcceptanceReport) {
return [
"# Runner acceptance",
"",
`Passed: ${report.passed}/${report.selected}`,
"",
"| Cell | Attempt | Result | Duration | Detail |",
"|---|---:|---|---:|---|",
...report.results.map((result) =>
`| ${escapeMarkdown(result.cellId)} | ${result.attempt} | ${result.valid ? "pass" : "fail"} | ${Math.round(result.durationMs / 1000)}s | ${escapeMarkdown(result.validationErrors.join("; ") || "ok")} |`),
"",
].join("\n");
}
function xml(value: unknown) {
return String(value ?? "")
.replaceAll("&", "&amp;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll('"', "&quot;")
.replaceAll("'", "&apos;");
}
export function renderRunnerAcceptanceJUnit(report: RunnerAcceptanceReport) {
const cases = report.results.map((result) => {
const failure = result.valid
? ""
: `<failure message="${xml(result.validationErrors.join("; "))}"/>`;
return `<testcase classname="runner-acceptance" name="${xml(result.cellId)}" time="${result.durationMs / 1000}">${failure}</testcase>`;
}).join("");
return `<?xml version="1.0" encoding="UTF-8"?><testsuite name="Runner acceptance" tests="${report.selected}" failures="${report.failed}">${cases}</testsuite>\n`;
}

View File

@ -0,0 +1,62 @@
import { describe, expect, it } from "vitest";
import { classifyFailure, shouldRetryFailure } from "./failure-classifier.js";
import {
findSensitiveJsonValue,
findSensitiveValue,
normalizedSensitiveValues,
redactText,
sanitizeJson,
} from "./redaction.js";
describe("Runner acceptance failure classification", () => {
it.each([
["assertion runtime mode expected legacy observed native", "candidate_failure"],
["provider connection timed out", "transient_infrastructure"],
["provider unsupported by this runner", "permanent_infrastructure"],
["secret redaction leak in result", "secret_leak"],
["teardown left a process running", "cleanup_failure"],
] as const)("classifies %s", (message, expected) => {
expect(classifyFailure(new Error(message))).toBe(expected);
});
it("retries only transient infrastructure failures", () => {
expect(shouldRetryFailure("transient_infrastructure")).toBe(true);
expect(shouldRetryFailure("candidate_failure")).toBe(false);
expect(shouldRetryFailure("secret_leak")).toBe(false);
});
});
describe("Runner acceptance redaction", () => {
const fakeSensitiveValue = ["fixture", "sensitive", "value"].join("-");
const secretShapedValue = ["sk", "proj", "fixturevalue1234567890"].join("-");
it("deduplicates and orders known sensitive values longest first", () => {
expect(normalizedSensitiveValues([" short ", undefined, "long-value", "short"]))
.toEqual(["long-value", "short"]);
});
it("redacts exact values, secret shapes, and structured sensitive fields", () => {
expect(redactText(
`value=${fakeSensitiveValue} shaped=${secretShapedValue}`,
[fakeSensitiveValue],
)).toBe("value=[REDACTED] shaped=[REDACTED]");
expect(sanitizeJson({
nested: { accessToken: fakeSensitiveValue },
detail: `received ${secretShapedValue}`,
}, [fakeSensitiveValue])).toEqual({
nested: { accessToken: "[REDACTED]" },
detail: "received [REDACTED]",
});
});
it("detects exact, shaped, and sensitive-key leaks without scanning files", () => {
expect(findSensitiveValue(fakeSensitiveValue, [fakeSensitiveValue]))
.toBe("exact sensitive value");
expect(findSensitiveValue(secretShapedValue)).toBe("secret-shaped value");
expect(findSensitiveJsonValue({ password: "fixture-password" }))
.toBe("sensitive field password");
expect(findSensitiveJsonValue({ schema: "paperclip.runner-acceptance.result/v1" }))
.toBeNull();
});
});

View File

@ -0,0 +1,13 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"noEmit": true,
"rootDir": "../..",
"types": ["node"],
"typeRoots": [
"../../cli/node_modules/@types",
"../../server/node_modules/@types"
]
},
"include": ["./**/*.ts"]
}

View File

@ -0,0 +1,85 @@
export type JsonValue =
| null
| boolean
| number
| string
| JsonValue[]
| { [key: string]: JsonValue };
export type RunnerGeneration = "direct" | "native";
export type ExpectedRuntimeMode = "legacy" | "native";
export type NativeProvider = "codex" | "opencode" | "acpx";
export type QualifiedAcpxAgent = "claude" | "codex";
export interface RunnerAcceptanceProfile {
id: string;
label: string;
generation: RunnerGeneration;
adapterScope: "built_in" | "external_plugin_contract";
adapterType: string;
expectedRuntimeMode: ExpectedRuntimeMode;
provider: NativeProvider | null;
model: string | null;
adapterConfig: Readonly<Record<string, JsonValue>>;
invariants: readonly string[];
}
export interface RunnerAcceptanceCase {
id: string;
label: string;
revision: number;
appliesTo: "all" | RunnerGeneration;
assertions: readonly string[];
}
export interface RunnerAcceptanceCell {
id: string;
suiteId: "runner-compatibility";
suiteDefinitionHash: string;
profile: RunnerAcceptanceProfile;
acceptanceCase: RunnerAcceptanceCase;
assertions: readonly string[];
}
export type FailureClass =
| "candidate_failure"
| "transient_infrastructure"
| "permanent_infrastructure"
| "secret_leak"
| "cleanup_failure";
export interface AcceptanceAssertionResult {
id: string;
passed: boolean;
detail?: string;
}
export interface RunnerAcceptanceResult {
schema: "paperclip.runner-acceptance.result/v1";
cellId: string;
attempt: number;
status: "passed" | "failed";
failureClass?: FailureClass;
error?: string;
startedAt: string;
finishedAt: string;
durationMs: number;
redaction: "passed" | "failed";
assertions: readonly AcceptanceAssertionResult[];
}
export interface AggregatedAcceptanceResult extends RunnerAcceptanceResult {
valid: boolean;
validationErrors: readonly string[];
}
export interface RunnerAcceptanceReport {
schema: "paperclip.runner-acceptance.report/v1";
generatedAt: string;
suiteDefinitionHash: string;
selected: number;
passed: number;
failed: number;
retries: number;
results: readonly AggregatedAcceptanceResult[];
}

View File

@ -0,0 +1,10 @@
import { defineConfig } from "vitest/config";
export default defineConfig({
root: import.meta.dirname,
test: {
name: "runner-acceptance",
include: ["**/*.test.ts"],
environment: "node",
},
});