mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-29 04:25:52 +01:00
Fix Pi session admission and reconcile production checks
Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
1 parent
167035ac26
commit
8fbfda6756
27 files changed
+959
-730
No files matched your search
@@ -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);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Generated
+771
-643
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();
|
||||
|
||||
@@ -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"))}
|
||||
|
||||
@@ -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");
|
||||
|
||||
Reference in new issue
Block a user