mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-06 21:05:21 +02:00
## 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
329 lines
9.8 KiB
TypeScript
329 lines
9.8 KiB
TypeScript
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();
|