Fix Pi session admission and reconcile production checks

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
DottaandPaperclip committed 2026-10-07 06:34:13 -05:00
1 parent 167035ac26
commit 8fbfda6756
27 files changed
+959 -730

No files matched your search

+2 -2
View File
@@ -26,9 +26,9 @@ it("rejects foreign package identity and a setup entrypoint outside that package
await rm(join(f.cli, "provision-pi.cjs")); await symlink(join(other.cli, "provision-pi.cjs"), join(f.cli, "provision-pi.cjs"));
await expect(resolvePiProvisioner(f.url)).rejects.toThrow("escapes");
});
it("provides an explicit Pi-only operator command and rejects other providers before resolution", async () => {
it("rejects unsupported runtime setup providers before resolution", async () => {
const program = new Command(); registerRuntimeCommands(program);
await expect(program.parseAsync(["node", "paperclipai", "runtime", "setup", "untrusted"])).rejects.toThrow("Supported explicit runtime setup");
await expect(program.parseAsync(["node", "paperclipai", "runtime", "setup", "untrusted"])).rejects.toThrow("Supported runtime setup: paperclipai runtime setup pi or cursor");
});
it("resolves the companion importer only inside the actual public server tar layout", async () => {
@@ -5,6 +5,28 @@ set of release gates. Pi remains a candidate until every required gate passes.
Keep behavioral qualification on the accepted fixtures. Do not expand it to
widgets, images, Cursor or Copilot qualification. The existing draft stack must be reviewed in dependency order.
## Startup and merge repairs — 2026-10-07
The real sidecar process now accepts all four explicit Pi thinking levels in
`session.open`; invalid levels, foreign-provider levels and unknown fields still
fail before launch. Runtime identity verification independently checks thinking
level and retires rejected admissions without prompting. Regression tests run
with no ambient provider credentials and a caller-selected custom model.
The empty ACPX patch hunk that broke GNU patch and Linux release packaging is
removed. Current profile 16 identities and Cursor's patch declaration are
regenerated; historical identities remain unchanged. Master-merge regressions
in historical plan receipts, duplicated Cursor controls and outdated tests are
reconciled. The PR's lockfile matches its dependency branch; CI resolves the
updated manifests under the repository's lockfile policy.
The startup/host suites pass 136 tests, the targeted provider/receipt/environment
suites pass 187, the configuration/permission suites pass 71, the native Runner
unit suite passes 342, and packaging checks pass 16. These are free regression
checks. Production remains held until current-source CI, normal Linux package
installation, a real Pi task and the accepted live cases have passed. Mac work
remains deferred and no prior-source live result transfers to this candidate.
## Master integration and model selection — 2026-10-06
This branch merges `origin/master` through `a9a20fb5c6c080884ced7ee0a4e3b04f9cbf3a6e`.
@@ -12,10 +12,10 @@ import {
} from "./paperclip-runner-permissions.js";
describe("Paperclip Runner permission defaults", () => {
it("admits Pi while keeping sibling ACP profiles pending", () => {
it("admits Pi with provider credentials while keeping Copilot pending", () => {
expect(PAPERCLIP_RUNNER_ACPX_PROFILES.find(profile => profile.value === "pi"))
.toMatchObject({ qualified: true, credentialEnvironment: ["OPENROUTER_API_KEY"] });
for (const agent of ["cursor", "copilot"]) {
.toMatchObject({ qualified: true, credentialEnvironment: expect.arrayContaining(["OPENROUTER_API_KEY", "OPENAI_API_KEY", "ANTHROPIC_API_KEY", "GEMINI_API_KEY"]) });
for (const agent of ["copilot"]) {
expect(PAPERCLIP_RUNNER_ACPX_PROFILES.find(profile => profile.value === agent)?.qualified).toBe(false);
}
});
+3 -3
View File
@@ -19,7 +19,7 @@
"agentServerVersion": "0.0.33",
"agentRuntimePackage": "@earendil-works/pi-coding-agent",
"agentRuntimeVersion": "1.0.0",
"commandDigest": "sha256:5f9b802c3105ab60d80b97b993e2806f608cd659de269685c1d1ac433fc93100",
"commandDigest": "sha256:28eefeccb2556d2668e20aee2f12a90d1fef50b9be954d5f6dd97c757e2e945e",
"requiresProviderPolicy": true
},
"cursor": {
@@ -28,7 +28,7 @@
"agentServerVersion": "2026.09.26-dd393fe",
"agentRuntimePackage": null,
"agentRuntimeVersion": null,
"commandDigest": "sha256:ea3d2cffb0ccc978377cef2671a65c1377212cb812cfb1f609ccaaebcb714445",
"commandDigest": "sha256:ac8092119542c8fbe95dae18ba5ef4d3689803fec56b7d2735fa193eea42f59b",
"requiresProviderPolicy": true
},
"copilot": {
@@ -37,7 +37,7 @@
"agentServerVersion": "1.0.88",
"agentRuntimePackage": null,
"agentRuntimeVersion": null,
"commandDigest": "sha256:8070042cc503a6752aaa6deb2e1501a81bc2b8e2beb087fbe86bae950f369eeb",
"commandDigest": "sha256:8591f9a78a16aac4cf558733cd512f09def483fc11c673504bf93be7476d994c",
"requiresProviderPolicy": true,
"qualificationStatus": "pending"
},
@@ -5,7 +5,7 @@
"base": "dd868ed125cd709506dd9b29fca640a44d580501",
"vendor": "2026.09.26-dd393fe",
"nativePatch": "paperclip-cursor-usage-v4",
"acpxPatchSha256": "edddf1ac33106800169a37c6d46f4b08d25162968147c8a3910855075e6bbef5",
"acpxPatchSha256": "00d6af17216cb0cad902b48633eb19bf0ec6b05837a984cb24661fca593f09c1",
"publicSetup": "paperclipai runtime setup cursor",
"assets": "public-server-and-provider-pack",
"modes": [
@@ -19,5 +19,5 @@
"planAcceptance": "successful-run-open-task-explicit-continuation",
"otherProviders": "mainline-gated",
"modelSelection": "explicit-provider-verified-startup-and-reconnect",
"commandDigest": "sha256:ea3d2cffb0ccc978377cef2671a65c1377212cb812cfb1f609ccaaebcb714445"
"commandDigest": "sha256:ac8092119542c8fbe95dae18ba5ef4d3689803fec56b7d2735fa193eea42f59b"
}
@@ -1790,6 +1790,11 @@ mod mode_tests {
let mut config = config();
for agent in ["claude", "codex", "pi", "grok", "cursor", "copilot"] {
config.agent = agent.to_owned();
config.pi_thinking_level = if agent == "pi" {
Some(PiThinkingLevel::Low)
} else {
None
};
config.model = "custom/model[context=272k,reasoning=medium]".to_owned();
config.validate().unwrap();
assert_eq!(
@@ -29,7 +29,7 @@ pub(crate) fn acpx_release_profile(agent: &str) -> Option<AcpxReleaseProfile> {
agent_runtime_package: Some("@earendil-works/pi-coding-agent"),
agent_runtime_version: Some("1.0.0"),
command_digest:
"sha256:5f9b802c3105ab60d80b97b993e2806f608cd659de269685c1d1ac433fc93100",
"sha256:28eefeccb2556d2668e20aee2f12a90d1fef50b9be954d5f6dd97c757e2e945e",
requires_provider_policy: true,
},
"cursor" => AcpxReleaseProfile {
@@ -38,7 +38,7 @@ pub(crate) fn acpx_release_profile(agent: &str) -> Option<AcpxReleaseProfile> {
agent_runtime_package: None,
agent_runtime_version: None,
command_digest:
"sha256:ea3d2cffb0ccc978377cef2671a65c1377212cb812cfb1f609ccaaebcb714445",
"sha256:ac8092119542c8fbe95dae18ba5ef4d3689803fec56b7d2735fa193eea42f59b",
requires_provider_policy: true,
},
"copilot" => AcpxReleaseProfile {
@@ -47,7 +47,7 @@ pub(crate) fn acpx_release_profile(agent: &str) -> Option<AcpxReleaseProfile> {
agent_runtime_package: None,
agent_runtime_version: None,
command_digest:
"sha256:8070042cc503a6752aaa6deb2e1501a81bc2b8e2beb087fbe86bae950f369eeb",
"sha256:8591f9a78a16aac4cf558733cd512f09def483fc11c673504bf93be7476d994c",
requires_provider_policy: true,
},
"claude" => AcpxReleaseProfile {
@@ -1063,6 +1063,46 @@ describe("qualified ACPX runtime sidecar", () => {
},
);
it.each(["off", "low", "high", "max"])(
"accepts Pi thinking level %s through the actual session.open dispatcher",
async (piThinkingLevel) => {
const sidecar = startSidecar();
const model = "custom-provider/caller-selected-model";
sidecar.write(initializeRequest(1, "pi", model));
expect(await sidecar.next(frame => frame.id === 1)).toMatchObject({ ok: true });
sidecar.write({
protocolVersion: ACPX_SIDECAR_PROTOCOL_VERSION, id: 2, command: "session.open",
params: { runtimeDirectory: "/unused", workingDirectory: "/unused", normalizedSessionId: "pi-open",
agent: "pi", model, permissionMode: "deny-all", piThinkingLevel, systemInstructions: "Test instructions", tools: [] },
});
// The host's policy gate runs after parsing and before installation or
// credentials. This proves the real process crosses the open boundary
// without starting a provider or making a model request.
expect(await sidecar.next(frame => frame.id === 2)).toMatchObject({
ok: false, error: { message: "Pi admission requires an explicit task execution policy" },
});
},
);
it.each([
["pi", { piThinkingLevel: "medium" }, "Pi thinking level"],
["codex", { piThinkingLevel: "low" }, "only supported by Pi"],
["pi", { piThinkingLevel: "low", unknownOption: true }, "unsupported field"],
])("rejects invalid %s session.open fields before launch", async (agent, fields, message) => {
const sidecar = startSidecar();
const model = "caller-selected-model";
sidecar.write(initializeRequest(1, agent, model));
expect(await sidecar.next(frame => frame.id === 1)).toMatchObject({ ok: true });
sidecar.write({
protocolVersion: ACPX_SIDECAR_PROTOCOL_VERSION, id: 2, command: "session.open",
params: { runtimeDirectory: "/unused", workingDirectory: "/unused", normalizedSessionId: "invalid-open",
agent, model, permissionMode: "deny-all", tools: [], ...fields },
});
expect(await sidecar.next(frame => frame.id === 2)).toMatchObject({
ok: false, error: { message: expect.stringContaining(message) },
});
});
it.each([
["cursor", "explicit-cursor-model"],
["copilot", "explicit-copilot-model"],
@@ -1146,7 +1186,7 @@ class SidecarProcess {
this.#child = spawn(
fileURLToPath(new URL("../../node_modules/.bin/tsx", import.meta.url)),
[fileURLToPath(new URL("./acpx-runtime-sidecar.ts", import.meta.url))],
{ stdio: ["pipe", "pipe", "pipe"] },
{ stdio: ["pipe", "pipe", "pipe"], env: { PATH: process.env.PATH, LANG: "C.UTF-8" } },
);
let stdout = "";
this.#child.stdout.setEncoding("utf8");
@@ -1209,7 +1209,7 @@ function safeOutput(value: unknown): Record<string, unknown> {
function parseOpenParams(
value: Record<string, unknown>,
): AcpxSidecarOpenParams {
const fields = new Set(["runtimeDirectory", "normalizedSessionId", "workingDirectory", "agent", "model", "permissionMode", "mode", "permissionModePinned", "providerPolicy", "systemInstructions", "runtimeContext", "tools", "providerSessionKey", "expectedIdentity"]);
const fields = new Set(["runtimeDirectory", "normalizedSessionId", "workingDirectory", "agent", "model", "permissionMode", "mode", "piThinkingLevel", "permissionModePinned", "providerPolicy", "systemInstructions", "runtimeContext", "tools", "providerSessionKey", "expectedIdentity"]);
if (Object.keys(value).some(key => !fields.has(key))) throw new Error("ACPX open parameters include an unsupported field");
const agent = requireQualifiedAgent(value.agent);
const model = requiredText(value.model, "model");
@@ -27,7 +27,7 @@ export const QUALIFIED_ACPX_PROFILE_DATA = {
"agentServerVersion": "0.0.33",
"agentRuntimePackage": "@earendil-works/pi-coding-agent",
"agentRuntimeVersion": "1.0.0",
"commandDigest": "sha256:5f9b802c3105ab60d80b97b993e2806f608cd659de269685c1d1ac433fc93100",
"commandDigest": "sha256:28eefeccb2556d2668e20aee2f12a90d1fef50b9be954d5f6dd97c757e2e945e",
"permissionPolicy": "interactive"
},
"cursor": {
@@ -40,7 +40,7 @@ export const QUALIFIED_ACPX_PROFILE_DATA = {
"agentServerVersion": "2026.09.26-dd393fe",
"agentRuntimePackage": null,
"agentRuntimeVersion": null,
"commandDigest": "sha256:ea3d2cffb0ccc978377cef2671a65c1377212cb812cfb1f609ccaaebcb714445",
"commandDigest": "sha256:ac8092119542c8fbe95dae18ba5ef4d3689803fec56b7d2735fa193eea42f59b",
"permissionPolicy": "interactive"
},
"copilot": {
@@ -53,7 +53,7 @@ export const QUALIFIED_ACPX_PROFILE_DATA = {
"agentServerVersion": "1.0.88",
"agentRuntimePackage": null,
"agentRuntimeVersion": null,
"commandDigest": "sha256:8070042cc503a6752aaa6deb2e1501a81bc2b8e2beb087fbe86bae950f369eeb",
"commandDigest": "sha256:8591f9a78a16aac4cf558733cd512f09def483fc11c673504bf93be7476d994c",
"qualificationStatus": "pending",
"permissionPolicy": "interactive"
},
@@ -884,6 +884,23 @@ describe("ACPX runtime host", () => {
expect(openRuntime).toHaveBeenCalledOnce();
});
it.each([undefined, "high"] as const)("retires Pi admission when the runtime reports thinking level %s", async piThinkingLevel => {
const fixture = await hostFixture();
const model = "custom-provider/caller-selected-model";
const runtime = runtimePort({
getStatus: async () => ({ models: { currentModelId: model } }),
identity: async () => ({ acpxRecordId: "record-1", backendSessionId: "backend-1", agentSessionId: "agent-1",
...(piThinkingLevel === undefined ? {} : { piThinkingLevel }) }),
});
await expect(AcpxRuntimeHost.open({
...fixture.options, agent: "pi", model, piThinkingLevel: "low", permissionMode: "deny-all",
providerPolicy: { readOnly: false },
}, fixture.dependencies({ openRuntime: async () => runtime }))).rejects.toThrow(/thinking level/);
expect(runtime.startTurn).not.toHaveBeenCalled();
expect(runtime.close).toHaveBeenCalledOnce();
expect(fixture.commandClose).toHaveBeenCalledOnce();
});
it.each(["cursor", "copilot", "pi"] as const)("binds %s agent files from each registered run copy, never ambient roots", async (agent) => {
const fixture = await hostFixture();
const copies = await mkdtemp(join(tmpdir(), "paperclip-agent-copies-"));
@@ -640,7 +640,9 @@ export class AcpxRuntimeHost {
);
if (runtimeIdentity.mode !== binding.mode) {
throw new Error("ACPX runtime Provider mode does not match the admitted session configuration");
if (runtimeIdentity.piThinkingLevel !== binding.piThinkingLevel) throw new Error("ACPX runtime Pi thinking level conflicts with session binding");
}
if (runtimeIdentity.piThinkingLevel !== binding.piThinkingLevel) {
throw new Error("ACPX runtime Pi thinking level conflicts with session binding");
}
const observedIdentity: AcpxExpectedSessionIdentity = {
kind: "acpx",
@@ -1,5 +1,5 @@
{
"commandDigest": "sha256:5f9b802c3105ab60d80b97b993e2806f608cd659de269685c1d1ac433fc93100",
"commandDigest": "sha256:28eefeccb2556d2668e20aee2f12a90d1fef50b9be954d5f6dd97c757e2e945e",
"declaration": {
"schema": "paperclip.acpx_profile_declaration.v1",
"agent": "pi",
@@ -20,7 +20,7 @@
"nativeQuestions": "select-confirm-input-editor-v3-method-specific-fields",
"pathPolicy": "sdk-normalization-v1",
"mcpToolNames": "provider-safe-exact-source-v1",
"acpxPatchSha256": "edddf1ac33106800169a37c6d46f4b08d25162968147c8a3910855075e6bbef5",
"acpxPatchSha256": "00d6af17216cb0cad902b48633eb19bf0ec6b05837a984cb24661fca593f09c1",
"nativeAssistantMessages": "pi1-rpc-deltas-v3-bound-message-and-tool-receipts",
"nativeNotices": "session-extension-v2-bounded-runtime-failure",
"messageProjectionSha256": "17d1e91b948dfa6a56e1905645add52b502041ad843c8d50a13b07956f22aadd",
@@ -1,5 +1,5 @@
{
"commandDigest": "sha256:8070042cc503a6752aaa6deb2e1501a81bc2b8e2beb087fbe86bae950f369eeb",
"commandDigest": "sha256:8591f9a78a16aac4cf558733cd512f09def483fc11c673504bf93be7476d994c",
"declaration": {
"schema": "paperclip.acpx_profile_declaration.v1",
"agent": "copilot",
@@ -7,7 +7,7 @@
"acpxVersion": "0.13.1",
"agentServerVersion": "1.0.88",
"protocolPolicyRevision": "copilot-agent-manual-v1",
"acpxPatchSha256": "edddf1ac33106800169a37c6d46f4b08d25162968147c8a3910855075e6bbef5",
"acpxPatchSha256": "00d6af17216cb0cad902b48633eb19bf0ec6b05837a984cb24661fca593f09c1",
"policySha256": "a4be69be43db84052560420a3ca546a665c9165573c24d543eb33d144ba138a3",
"distributionSourceSha256": "c0a65e247fc47b263bd369a3d5ab505c4b323d4e6493be8a9ecbeb0e9dbb9107",
"agentFilesBinding": "registered-runtime-context-v1",
@@ -30,7 +30,7 @@
"semanticBridgeSourceSha256": "cf136da18e2cdbefae8e01d23d2c93f64eb7aebccaec938e84c2887c888f87ee",
"toolEvidenceSourceSha256": "49d7d09eb6957e596a173b0afd763544afe582f0c1f05cc0992fe51916a8fdb3",
"semanticNormalizedInputContract": "validated-forwarded-input-commit-v1",
"semanticSidecarSourceSha256": "1c18ebc5a1274aa888d6922e927289d15efabbf62b0ba9fce9d5299618719939",
"semanticSidecarSourceSha256": "91629734a80f0d32295cbce2c573b32af9e345f45302ff4525c269f66811aa06",
"semanticDirectDriverSourceSha256": "2b7b7eaadee658312816ecdcbcb311ce491521fedf39e2ecb571036c36ff7b8e",
"semanticValidationSourceSha256": "039fca52266fe21ba74ca64660d21228314d8ed737b19ba67941bfb25086e4d5",
"semanticNormalizationSourceSha256": "8b628b82e8eacc91cdcc6fdca2210ba4cf282f01e9c6f74e95ff271655b5047e",
-1
View File
@@ -600,7 +600,6 @@ diff --git a/dist/live-checkpoint-BSIrfgVo.js b/dist/live-checkpoint-BSIrfgVo.js
return response;
} catch (error) {
return this.throwSessionModelError("session/set_config_option", modelId, error);
@@ -4864,0 +5087,0 @@
@@ -4864,7 +5087,7 @@
}
selectAuthMethod(methods) {
+771 -643
View File
File diff suppressed because it is too large. Load diff
@@ -1,4 +1,4 @@
import { probeAcpxClaudeInstallation, probeAcpxPiInstallation } from "../vendor/paperclip-runner/index.js";
import { probeAcpxClaudeInstallation, probeAcpxPiInstallation } from "../vendor/paperclip-runner/live/index.js";
import { describe, expect, it, beforeEach, afterEach, vi } from "vitest";
import { buildSandboxNpmInstallCommand } from "@paperclipai/adapter-utils";
import type { ServerAdapterModule } from "../adapters/index.js";
@@ -25,6 +25,14 @@ vi.mock("../vendor/paperclip-runner/index.js", async (importOriginal) => ({
probeAcpxCursorInstallation: vi.fn(async () => undefined),
}));
vi.mock("../vendor/paperclip-runner/live/index.js", async importOriginal => ({
...await importOriginal<typeof import("../vendor/paperclip-runner/live/index.js")>(),
probeAcpxClaudeInstallation: vi.fn(async () => undefined),
probeAcpxGrokInstallation: vi.fn(async () => undefined),
probeAcpxPiInstallation: vi.fn(async () => undefined),
probeAcpxCursorInstallation: vi.fn(async () => undefined),
}));
it("advertises tool-refresh recovery for the selected legacy harness", () => {
for (const type of ["claude_local", "codex_local", "grok_local", "gemini_local", "kimi_local", "cursor", "opencode_local", "pi_local"]) {
expect(requireServerAdapter(type).supportsToolRefreshOnResume).toBe(true);
@@ -300,6 +300,10 @@ describe("registered run instruction copies", () => {
expect((await revisions.readCurrent(target(), board()))?.content).toBe(initial);
expect((await copies.list(companyId, agentId, board()))[0]).toMatchObject({ state: "pending_collection", content: null });
await appendHeartbeatRunEvent(db, { ...target(), runId: copy.runId, eventType: "native.local_process_stopped", stream: "system" });
// Recovery scheduled a retry while stop authority was missing. Make that
// retry due before asking the restarted controller to collect the copy.
await db.update(agentInstructionWorkingCopies).set({ nextAttemptAt: new Date(0) })
.where(eq(agentInstructionWorkingCopies.runId, copy.runId));
await copies.recoverStopped();
expect((await revisions.readCurrent(target(), board()))?.content).toBe("edit before controller restart");
});
@@ -78,7 +78,7 @@ async function createApp(
return app;
}
describe.sequential("GET /invites/:token/test-resolution", () => {
describe("GET /invites/:token/test-resolution", { sequential: true }, () => {
beforeAll(async () => {
// Route transformation is fixture setup, not part of the network assertions.
await loadAppModules();
+10 -1
View File
@@ -9,7 +9,16 @@ const { probeInstallation, probeGrokInstallation, probePiInstallation } = vi.hoi
probeGrokInstallation: vi.fn(),
probePiInstallation: vi.fn(),
}));
vi.mock("../vendor/paperclip-runner/index.js", () => ({
vi.mock("../vendor/paperclip-runner/index.js", async importOriginal => ({
...await importOriginal<typeof import("../vendor/paperclip-runner/index.js")>(),
probeAcpxClaudeInstallation: probeInstallation,
probeAcpxGrokInstallation: probeGrokInstallation,
probeAcpxPiInstallation: probePiInstallation,
probeAcpxCursorInstallation: vi.fn(async () => undefined),
}));
vi.mock("../vendor/paperclip-runner/live/index.js", async importOriginal => ({
...await importOriginal<typeof import("../vendor/paperclip-runner/live/index.js")>(),
probeAcpxClaudeInstallation: probeInstallation,
probeAcpxGrokInstallation: probeGrokInstallation,
probeAcpxPiInstallation: probePiInstallation,
@@ -10,10 +10,10 @@ import {
describe("Paperclip Runner native provider configuration", () => {
afterEach(() => vi.unstubAllEnvs());
it.each([undefined, "agent", "plan", "ask"])("passes Cursor mode %s only after exact candidate admission", acpxSessionMode => {
it.each([undefined, "agent", "plan", "ask"])("passes qualified Cursor mode %s without a model roster", acpxSessionMode => {
const adapterConfig = { provider: "acpx", acpxAgent: "cursor", model: "exact-cursor-model", acpxSessionMode };
vi.stubEnv("PAPERCLIP_RUNNER_ACPX_QUALIFICATION", "");
expect(() => resolvePaperclipRunnerNativeProviderInput({ backend: "acpx_runtime", adapterConfig })).toThrow("awaiting local and Daytona qualification");
expect(resolvePaperclipRunnerNativeProviderInput({ backend: "acpx_runtime", adapterConfig })).toMatchObject({ acpxAgent: "cursor", model: "exact-cursor-model" });
vi.stubEnv("PAPERCLIP_RUNNER_ACPX_QUALIFICATION", JSON.stringify([{ agent: "cursor", model: "exact-cursor-model" }]));
expect(resolvePaperclipRunnerNativeProviderInput({ backend: "acpx_runtime", adapterConfig })).toMatchObject({
provider: "acpx", acpxAgent: "cursor", model: "exact-cursor-model", acpxSessionMode: acpxSessionMode ?? "agent", acpxPermissionMode: "approve-all",
@@ -27,7 +27,7 @@ describe("Paperclip Runner native provider configuration", () => {
{ provider: "codex", acpxSessionMode: "plan" },
])("rejects invalid or foreign mode before provider admission: %j", adapterConfig => {
expect(() => resolvePaperclipRunnerNativeProviderInput({ backend: "acpx_runtime", adapterConfig }))
.toThrowError(expect.objectContaining({ code: "paperclip_runner_cursor_mode_invalid" }));
.toThrowError(expect.objectContaining({ code: "paperclip_runner_mode_invalid" }));
});
it.each([undefined, "approve-all", "approve-paperclip", "approve-reads", "deny-all"])(
@@ -456,12 +456,12 @@ describe("Paperclip Runner native provider configuration", () => {
).toThrow("provider changed after this run selected its native backend");
});
it("rejects an unqualified Pi model before a native descriptor is persisted", () => {
expect(() =>
it("preserves a caller-selected Pi model in the native descriptor", () => {
expect(
resolvePaperclipRunnerNativeProviderInput({
backend: "acpx_runtime",
adapterConfig: { provider: "acpx", acpxAgent: "pi", model: "pi-model" },
}),
).toThrow("requires exact model openrouter/deepseek/deepseek-v4-flash-0731");
).toMatchObject({ provider: "acpx", acpxAgent: "pi", model: "pi-model" });
});
});
@@ -95,7 +95,7 @@ describe("accepted Cursor plan passive-wait authority", () => {
expect(await hasCommittedNativePlanWait(committedDb(f, proof.source, changed), f.binding)).toBe(false);
// A new receipt cannot opt into a historical admission by renaming a field.
const provider = (f.run.runnerProfileJson as any).nativeExecutionInput.provider;
provider.mode = provider.mode; delete provider.mode;
provider.cursorMode = provider.mode; delete provider.mode;
expect(await hasCommittedNativePlanWait(committedDb(f, proof.source, proof.result), f.binding)).toBe(false);
});
it("keeps lifecycle proof generic when an adapter supplies different native semantics", () => {
@@ -126,7 +126,7 @@ describe("accepted Cursor plan passive-wait authority", () => {
const proof = nativePlanWaitFromFacts(f)!;
const admission = (f.run.runnerProfileJson as any).nativeExecutionInput;
Object.assign(admission.provider.profile, { agentProfileVersion: 6, commandDigest: "sha256:377dcea64a727ce799cc112458d4b40ba4bc6574cd6c6f7233b6efd5917a6c4b" });
admission.provider.mode = "plan"; delete admission.provider.mode;
admission.provider.cursorMode = "plan"; delete admission.provider.mode;
f.events = f.events.filter(e => !e.eventType.startsWith("tool.execution."));
const payload = (type: string) => (f.events.find(e => e.eventType === type)!.payload as any).prpEvent;
const { interaction, delivery } = f.interactions[0]!;
@@ -179,7 +179,7 @@ describe("accepted Cursor plan passive-wait authority", () => {
f.run.status = "succeeded";
const proof = nativePlanWaitFromFacts(f)!;
const admission = (f.run.runnerProfileJson as any).nativeExecutionInput;
admission.provider.mode = admission.provider.mode;
admission.provider.cursorMode = admission.provider.mode;
delete admission.provider.mode;
expect(nativePlanWaitFromFacts(f)).toBeNull();
const payload = (type: string) => (f.events.find(e => e.eventType === type)!.payload as any).prpEvent;
@@ -207,9 +207,9 @@ describe("accepted Cursor plan passive-wait authority", () => {
expect(await hasCommittedNativePlanWait(db(), f.binding)).toBe(true);
// History is immutable: neither changing the old mode nor rewriting its
// field name may preserve the original acceptance authority.
admission.provider.mode = "agent";
admission.provider.cursorMode = "agent";
expect(await hasCommittedNativePlanWait(db(), f.binding)).toBe(false);
delete admission.provider.mode;
delete admission.provider.cursorMode;
admission.provider.mode = "plan";
expect(await hasCommittedNativePlanWait(db(), f.binding)).toBe(false);
});
@@ -15,8 +15,8 @@ export const cursorLifecycleAdapter: NativeProviderLifecycleAdapter = {
if (provider.kind !== "acpx" || provider.agent !== "cursor"
|| typeof provider.model !== "string" || !provider.model.trim()) return false;
if (provider.mode !== undefined) {
if (provider.mode !== "plan" || provider.mode !== undefined) return false;
} else if (!history.committed || !history.legacy || provider.mode !== "plan") return false;
if (provider.mode !== "plan" || provider.cursorMode !== undefined) return false;
} else if (!history.committed || !history.legacy || provider.cursorMode !== "plan") return false;
// A committed receipt is replayed against its original, hashed admission.
// New waits must use the currently qualified provider profile.
if (history.committed) return true;
@@ -130,7 +130,7 @@ describe("resolveNativeRuntimeMode", () => {
})).toThrow(expect.objectContaining({
code: "paperclip_runner_opencode_model_invalid",
}));
for (const acpxAgent of ["cursor", "copilot"]) {
for (const acpxAgent of ["copilot"]) {
expect(() => resolveNativeRuntimeMode({
...eligible,
adapterConfig: { provider: "acpx", acpxAgent, model: "explicit-provider-model" },
@@ -11,7 +11,7 @@ import { CodexLocalConfigFields } from "./config-fields";
beforeAll(() => { Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true }); });
async function renderMarkup(node: ReactNode, expand?: string): Promise<string> {
async function renderMarkup(node: ReactNode, expand?: string, inspect?: (container: HTMLElement) => void): Promise<string> {
const container = document.createElement("div");
document.body.appendChild(container);
const root = createRoot(container);
@@ -19,13 +19,14 @@ async function renderMarkup(node: ReactNode, expand?: string): Promise<string> {
if (expand) await act(async () => {
container.querySelector(`[aria-label="${expand}"]`)?.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter", bubbles: true }));
});
const html = document.body.innerHTML;
inspect?.(container);
const html = container.innerHTML + Array.from(document.body.children).filter(child => child !== container).map(child => child.outerHTML).join("");
await act(async () => root.unmount());
container.remove();
return html;
}
async function renderRunner(config: Record<string, unknown>, expand?: string): Promise<string> {
async function renderRunner(config: Record<string, unknown>, expand?: string, inspect?: (container: HTMLElement) => void): Promise<string> {
return renderMarkup(
<TooltipProvider>
<CodexLocalConfigFields
@@ -41,7 +42,7 @@ async function renderRunner(config: Record<string, unknown>, expand?: string): P
hideInstructionsFile
/>
</TooltipProvider>,
expand,
expand, inspect,
);
}
@@ -87,8 +88,8 @@ describe("Paperclip Runner Codex configuration", () => {
expect(html).not.toContain("Ask for untrusted operations");
});
it("offers qualified Claude and Pi and keeps sibling ACP candidates visibly disabled", () => {
const html = renderRunner({
it("offers qualified Claude, Cursor and Pi while keeping Copilot disabled", async () => {
const html = await renderRunner({
provider: "acpx",
acpxAgent: "claude",
acpxPermissionMode: "approve-reads",
@@ -96,38 +97,42 @@ describe("Paperclip Runner Codex configuration", () => {
expect(html).toContain('ACP agents');
expect(html).toContain("ACP agent");
expect(html).toContain('<option value="claude" selected="">Claude</option>');
expect(html).toContain('<option value="cursor" disabled="">Cursor — qualification pending</option>');
expect(html).toContain('<option value="copilot" disabled="">GitHub Copilot — qualification pending</option>');
expect(html).toContain('<option value="pi">Pi</option>');
expect(html).not.toContain('<option value="pi" disabled="">');
const options = new DOMParser().parseFromString(html, "text/html").querySelectorAll('[role="option"]');
expect(Array.from(options, option => ({ label: option.textContent?.trim(), disabled: option.hasAttribute("data-disabled") })))
.toEqual(expect.arrayContaining([
{ label: "Claude", disabled: false }, { label: "Cursor", disabled: false },
{ label: "Pi", disabled: false }, { label: "GitHub Copilot — qualification pending", disabled: true },
]));
expect(html).not.toContain("Codex via ACPX");
expect(html).not.toContain("ACPX Codex");
expect(html).not.toContain("Pi via ACPX");
expect(html).toContain("Allow Paperclip reads");
});
it.each([undefined, "agent", "plan", "ask"])("displays saved Cursor mode %s", acpxSessionMode => {
const html = renderRunner({ provider: "acpx", acpxAgent: "cursor", acpxSessionMode });
it.each([undefined, "agent", "plan", "ask"])("displays saved Cursor mode %s", async acpxSessionMode => {
const selected = acpxSessionMode ?? "agent";
expect(html).toContain('aria-label="Cursor mode"');
expect(html).toContain(`<option value="${selected}" selected="">`);
await renderRunner({ provider: "acpx", acpxAgent: "cursor", acpxSessionMode }, undefined, container => {
const modes = container.querySelectorAll('[aria-label="Cursor mode"]');
expect(modes).toHaveLength(1);
expect((modes[0] as HTMLSelectElement).value).toBe(selected);
});
});
it.each([undefined, "off", "low", "high", "max"])("displays saved Pi thinking level %s", piThinkingLevel => {
const html = renderRunner({ provider: "acpx", acpxAgent: "pi", piThinkingLevel });
expect(html).toContain('aria-label="Pi thinking level"');
expect(html).toContain(`<option value="${piThinkingLevel ?? "low"}" selected="">`);
it.each([undefined, "off", "low", "high", "max"])("displays saved Pi thinking level %s", async piThinkingLevel => {
await renderRunner({ provider: "acpx", acpxAgent: "pi", piThinkingLevel }, undefined, container => {
const mode = container.querySelector('[aria-label="Pi thinking level"]');
expect((mode as HTMLSelectElement).value).toBe(piThinkingLevel ?? "low");
});
});
it("shows unsupported saved Pi level without aliasing it", () => {
expect(renderRunner({ provider: "acpx", acpxAgent: "pi", piThinkingLevel: "medium" })).toContain("Unsupported saved thinking level");
it("shows unsupported saved Pi level without aliasing it", async () => {
expect(await renderRunner({ provider: "acpx", acpxAgent: "pi", piThinkingLevel: "medium" })).toContain("Unsupported saved thinking level");
});
it.each(["claude", "copilot", "pi"])("does not expose Cursor mode for %s", acpxAgent => {
expect(renderRunner({ provider: "acpx", acpxAgent })).not.toContain('aria-label="Cursor mode"');
it.each(["claude", "copilot", "pi"])("does not expose Cursor mode for %s", async acpxAgent => {
expect(await renderRunner({ provider: "acpx", acpxAgent })).not.toContain('aria-label="Cursor mode"');
});
it("falls back to the fail-closed Codex permission mode", () => {
const html = renderRunner({ codexPermissionMode: "unrestricted" });
it("falls back to the fail-closed Codex permission mode", async () => {
const html = await renderRunner({ codexPermissionMode: "unrestricted" });
expect(html).toContain("Unsupported saved mode — select a qualified mode");
expect(html).toContain("cannot start or recover a Paperclip Runner run");
@@ -293,20 +293,6 @@ export function CodexLocalConfigFields({
</select>
</Field>
)}
{runnerManaged && runnerProvider === "acpx" && runnerSchemaValue("acpxAgent", "claude") === "cursor" && (
<Field configSection="adapter" label="Cursor mode" hint="Select Cursor's session mode. Permissions and company approval rules still apply.">
<select className={inputClass} aria-label="Cursor mode"
value={String(runnerSchemaValue("acpxSessionMode", "agent"))}
onChange={(event) => updateRunnerSchemaValue("acpxSessionMode", event.target.value)}>
{!["agent", "plan", "ask"].includes(String(runnerSchemaValue("acpxSessionMode", "agent"))) && (
<option value={String(runnerSchemaValue("acpxSessionMode", "agent"))} disabled>Unsupported saved mode — select Agent, Plan, or Ask</option>
)}
<option value="agent">Agent</option>
<option value="plan">Plan</option>
<option value="ask">Ask</option>
</select>
</Field>
)}
{runnerManaged && runnerProvider === "acpx" && runnerSchemaValue("acpxAgent", "claude") === "pi" && (
<Field configSection="adapter" label="Pi thinking level" hint="The runner verifies this exact level before each session can prompt. Changing it starts a new session.">
<select className={inputClass} aria-label="Pi thinking level" value={String(runnerSchemaValue("piThinkingLevel", "low"))}
+10 -6
View File
@@ -24,7 +24,7 @@ async function renderMarkup(node: ReactNode, expand?: string): Promise<string> {
if (expand) await act(async () => {
container.querySelector(`[aria-label="${expand}"]`)?.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter", bubbles: true }));
});
const html = container.innerHTML;
const html = container.innerHTML + Array.from(document.body.children).filter(child => child !== container).map(child => child.outerHTML).join("");
await act(async () => root.unmount());
container.remove();
return html;
@@ -35,6 +35,7 @@ async function renderSection(
adapterType: string,
section: AdapterConfigSection,
config: Record<string, unknown> = {},
expand?: string,
) {
return renderMarkup(
<TooltipProvider>
@@ -52,6 +53,7 @@ async function renderSection(
hideInstructionsFile
/>
</TooltipProvider>,
expand,
);
}
@@ -91,13 +93,15 @@ describe("adapter configuration sections", () => {
it("keeps ACP agent admission choices in the adapter section", async () => {
const config = { provider: "acpx", acpxAgent: "claude" };
const adapter = await renderSection(CodexLocalConfigFields, "paperclip_runner", "adapter", config);
const adapter = await renderSection(CodexLocalConfigFields, "paperclip_runner", "adapter", config, "ACP agent");
const policy = await renderSection(CodexLocalConfigFields, "paperclip_runner", "runPolicy", config);
expect(adapter).toContain('<option value="claude" selected="">Claude</option>');
expect(adapter).toContain('<option value="pi">Pi</option>');
expect(adapter).toContain('<option value="cursor" disabled="">Cursor — qualification pending</option>');
expect(adapter).toContain('<option value="copilot" disabled="">GitHub Copilot — qualification pending</option>');
const options = new DOMParser().parseFromString(adapter, "text/html").querySelectorAll('[role="option"]');
expect(Array.from(options, option => ({ label: option.textContent?.trim(), disabled: option.hasAttribute("data-disabled") })))
.toEqual(expect.arrayContaining([
{ label: "Claude", disabled: false }, { label: "Pi", disabled: false },
{ label: "Cursor", disabled: false }, { label: "GitHub Copilot — qualification pending", disabled: true },
]));
expect(adapter).not.toContain("Runner lifecycle");
expect(policy).toContain("Runner lifecycle");
expect(policy).not.toContain("ACP agent");