diff --git a/packages/adapter-utils/src/paperclip-runner-permissions.ts b/packages/adapter-utils/src/paperclip-runner-permissions.ts index 2fa8c9730a9..e82a2cbd68b 100644 --- a/packages/adapter-utils/src/paperclip-runner-permissions.ts +++ b/packages/adapter-utils/src/paperclip-runner-permissions.ts @@ -243,7 +243,7 @@ export function normalizeLegacyRunnerProvider( export const PAPERCLIP_RUNNER_ACPX_PROFILES = Object.freeze([ { value: "grok", label: "Grok Build", qualified: true, credentialEnvironment: ["XAI_API_KEY"] }, { value: "claude", label: "Claude", qualified: true, credentialEnvironment: ["ANTHROPIC_API_KEY", "CLAUDE_CODE_OAUTH_TOKEN"] }, - { value: "cursor", label: "Cursor", qualified: false, credentialEnvironment: ["CURSOR_API_KEY", "CURSOR_AUTH_TOKEN"] }, + { value: "cursor", label: "Cursor", qualified: true, credentialEnvironment: ["CURSOR_API_KEY", "CURSOR_AUTH_TOKEN"] }, { value: "copilot", label: "GitHub Copilot", qualified: false, credentialEnvironment: ["COPILOT_GITHUB_TOKEN"] }, { value: "pi", label: "Pi", qualified: false, credentialEnvironment: ["OPENROUTER_API_KEY"] }, ] as const); diff --git a/packages/adapters/codex-local/src/ui/build-config.ts b/packages/adapters/codex-local/src/ui/build-config.ts index 60c5414b4e1..c0a73d83ac3 100644 --- a/packages/adapters/codex-local/src/ui/build-config.ts +++ b/packages/adapters/codex-local/src/ui/build-config.ts @@ -98,7 +98,7 @@ export function buildPaperclipRunnerConfig(v: CreateConfigValues): Record profile.value === schemaValues.acpxAgent); - if (provider === "acpx" && selectedAcpxProfile && !selectedAcpxProfile.qualified && selectedAcpxProfile.value !== "cursor") { + if (provider === "acpx" && selectedAcpxProfile && !selectedAcpxProfile.qualified) { throw new Error(`${selectedAcpxProfile.label} is not enabled for production`); } const acpxAgent = selectedAcpxProfile?.value ?? "claude"; diff --git a/packages/paperclip-runner/scripts/build-provider-pack.mjs b/packages/paperclip-runner/scripts/build-provider-pack.mjs index 8cba2fca015..e5544a31a73 100644 --- a/packages/paperclip-runner/scripts/build-provider-pack.mjs +++ b/packages/paperclip-runner/scripts/build-provider-pack.mjs @@ -180,7 +180,7 @@ try { || !/^sha256:[a-f0-9]{64}$/.test(metadata.profileDigest) || !/^sha256:[a-f0-9]{64}$/.test(metadata.closureDigest)) throw new Error("Candidate builder omitted its pinned identity"); candidateProviders[provider] = { version: metadata.version, profileDigest: metadata.profileDigest, - closureDigest: metadata.closureDigest, qualification: "pending", path: assetPath, + closureDigest: metadata.closureDigest, qualification: provider === "cursor" ? "qualified" : "pending", path: assetPath, sha256: sha256Tree(join(temporaryRoot, assetPath)) }; } diff --git a/packages/paperclip-runner/scripts/stage-release-runner-binaries.mjs b/packages/paperclip-runner/scripts/stage-release-runner-binaries.mjs index ba134a5460f..5a43989db60 100644 --- a/packages/paperclip-runner/scripts/stage-release-runner-binaries.mjs +++ b/packages/paperclip-runner/scripts/stage-release-runner-binaries.mjs @@ -19,6 +19,16 @@ const verified = required.map(target => { if (sha256 !== artifact.sha256 || runnerBinaryTarget(bytes) !== target) throw Error(`Release daemon ${target} digest or architecture mismatch`); return { target, source: artifact.path, sha256 }; }); +let remoteProviderPack = null; +if (manifest.remoteProviderPack) { + const entry = manifest.remoteProviderPack; + if (!isAbsolute(entry.path) || !/^sha256:[a-f0-9]{64}$/.test(entry.sha256)) throw Error("Invalid remote provider-pack release artifact"); + const bytes = readFileSync(entry.path); + if (bytes.length > 512 * 1024 || "sha256:" + createHash("sha256").update(bytes).digest("hex") !== entry.sha256) throw Error("Remote provider-pack release manifest digest mismatch"); + const identity = JSON.parse(bytes); + if (identity.schema !== "paperclip-runner/remote-provider-pack/v1" || identity.payload?.target?.platform !== "linux" || identity.payload?.target?.architecture !== "x64" || !/^sha256:[a-f0-9]{64}$/.test(identity.digest ?? "")) throw Error("Remote provider-pack release must bind a Linux x64 image"); + remoteProviderPack = { bytes, identity }; +} const platforms = {}; for (const artifact of verified) { const relative = `${artifact.target}/paperclip-runnerd`, destination = join(root, "dist", "bin", relative); @@ -31,5 +41,11 @@ for (const artifact of verified) { const sha256 = "sha256:" + createHash("sha256").update(readFileSync(destination)).digest("hex"); platforms[artifact.target] = { path: relative, sha256, sourceSha256: artifact.sha256 }; } -writeFileSync(join(root, "dist", "bin", "release-manifest.json"), JSON.stringify({schema:"paperclip.runner.release-binaries.v1",sourceRevision:manifest.sourceRevision,platforms},null,2)+"\n"); +if (remoteProviderPack) { + const destination = join(root, "dist", "remote-provider-packs", "linux-x64", "provider-pack.json"); + mkdirSync(dirname(destination), { recursive: true }); + writeFileSync(destination, remoteProviderPack.bytes, { mode: 0o444 }); +} +writeFileSync(join(root, "dist", "bin", "release-manifest.json"), JSON.stringify({schema:"paperclip.runner.release-binaries.v1",sourceRevision:manifest.sourceRevision,platforms, + ...(remoteProviderPack ? { remoteProviderPack: { target: "linux-x64", digest: remoteProviderPack.identity.digest, sourceRevision: remoteProviderPack.identity.payload.runnerSourceRevision } } : {})},null,2)+"\n"); console.log("Staged verified release daemons for " + required.join(", ")); diff --git a/packages/paperclip-runner/src/backends/native-backend-factory.test.ts b/packages/paperclip-runner/src/backends/native-backend-factory.test.ts index 2aa2addcd52..21f33394862 100644 --- a/packages/paperclip-runner/src/backends/native-backend-factory.test.ts +++ b/packages/paperclip-runner/src/backends/native-backend-factory.test.ts @@ -529,7 +529,7 @@ describe("native backend factory", () => { }, ); - it.each(["pi", "cursor", "copilot"] as const)("rejects unqualified %s direct execution even with an exact persisted profile", agent => { + it.each(["pi", "copilot"] as const)("rejects unqualified %s direct execution even with an exact persisted profile", agent => { const input = acpxExecution(); if (input.provider.kind !== "acpx") throw new Error("invalid fixture"); const model = agent === "pi" ? QUALIFIED_ACPX_PROFILES.pi.qualificationModel : "explicit-fixture-model"; @@ -540,6 +540,15 @@ describe("native backend factory", () => { })).toThrow("ACPX candidate direct execution requires completed qualification"); }); + it("constructs the qualified Cursor backend without candidate admission", async () => { + const input = acpxExecution(); + if (input.provider.kind !== "acpx") throw new Error("invalid fixture"); + const model = "explicit-cursor-model"; + Object.assign(input.provider, { agent: "cursor", model, cursorMode: "agent", profile: resolveQualifiedAcpxProfile("cursor", model) }); + const backend = createNativeSessionBackend(input, { acpxRuntimeDirectory: "/runtime", acpxEnvironment: { CURSOR_API_KEY: "explicit-fixture" } }); + await expect(backend.descriptor()).resolves.toMatchObject({ name: "acpx_runtime", version: "0.13.1" }); + }); + it("rejects a Codex ACPX snapshot that drifts from its qualified profile", () => { const input = acpxExecution(); if (input.provider.kind !== "acpx") throw new Error("invalid fixture"); diff --git a/packages/paperclip-runner/src/cli/acpx-runtime-sidecar.test.ts b/packages/paperclip-runner/src/cli/acpx-runtime-sidecar.test.ts index 5c853813416..75a098ce4a2 100644 --- a/packages/paperclip-runner/src/cli/acpx-runtime-sidecar.test.ts +++ b/packages/paperclip-runner/src/cli/acpx-runtime-sidecar.test.ts @@ -558,7 +558,7 @@ describe("qualified ACPX runtime sidecar", () => { ["pi", "openrouter/deepseek/deepseek-v4-flash-0731"], ["cursor", "explicit-cursor-model"], ["copilot", "explicit-copilot-model"], - ] as const)("initializes the declared %s candidate without promoting its profile", async (agent, model) => { + ] as const)("initializes the declared %s profile with its current admission status", async (agent, model) => { const sidecar = startSidecar(); sidecar.write(initializeRequest(1, agent, model)); const frame = await sidecar.next((value) => value.id === 1); @@ -566,7 +566,7 @@ describe("qualified ACPX runtime sidecar", () => { const result = frame.result as Record; expect(result.profile).toEqual(resolveQualifiedAcpxProfile(agent, model)); expect(result.profile).toMatchObject({ reportedModelId: model }); - expect(ACPX_CAPABILITY_PROFILES[agent].qualification).toBe("pending"); + expect(ACPX_CAPABILITY_PROFILES[agent].qualification).toBe(agent === "cursor" ? "qualified" : "pending"); }); it("fails closed after an unsupported provider bootstrap", async () => { diff --git a/packages/paperclip-runner/src/cli/eval-session-contract.test.ts b/packages/paperclip-runner/src/cli/eval-session-contract.test.ts index 774b243f922..31a09c845d7 100644 --- a/packages/paperclip-runner/src/cli/eval-session-contract.test.ts +++ b/packages/paperclip-runner/src/cli/eval-session-contract.test.ts @@ -67,7 +67,7 @@ function agentCoreProfile(overrides: Record = {}) { } describe("eval-session request contract", () => { - it.each(["pi", "cursor", "copilot"] as const)("admits %s only with the matching CLI diagnostic opt-in", (agent) => { + it.each(["pi", "copilot"] as const)("admits %s only with the matching CLI diagnostic opt-in", (agent) => { const value = request({ provider: "acpx", acpxAgent: agent, model: "explicit-provider-model" }); expect(() => parseEvalSessionRequest(value)).toThrow("--candidate-profile"); expect(parseEvalSessionRequest(value, { candidateProfile: agent })).toMatchObject({ acpxAgent: agent, model: "explicit-provider-model" }); @@ -77,6 +77,11 @@ describe("eval-session request contract", () => { expect(() => parseEvalSessionRequest(request({ provider: "acpx", acpxAgent: agent, candidateProfile: agent }))).toThrow("--candidate-profile"); }); + it("admits qualified Cursor without a diagnostic flag and preserves its explicit model", () => { + expect(parseEvalSessionRequest(request({ provider: "acpx", acpxAgent: "cursor", model: "exact-cursor-model" }))) + .toMatchObject({ acpxAgent: "cursor", model: "exact-cursor-model" }); + }); + it("accepts only known diagnostic flags and rejects ambiguous repeated arguments", () => { const args = ["--request", "/tmp/request.json", "--output", "/tmp/result.json"]; const expected = resolveQualifiedAcpxProfile("pi", "openrouter/deepseek/deepseek-v4-flash-0731"); diff --git a/packages/paperclip-runner/src/cli/eval-session-contract.ts b/packages/paperclip-runner/src/cli/eval-session-contract.ts index d1446cb4ac0..3e4c229cb35 100644 --- a/packages/paperclip-runner/src/cli/eval-session-contract.ts +++ b/packages/paperclip-runner/src/cli/eval-session-contract.ts @@ -262,7 +262,7 @@ export function parseEvalSessionRequest( if (options.candidateProfile !== undefined && (provider !== "acpx" || acpxAgent !== options.candidateProfile || !candidate)) { throw new Error("--candidate-profile must match the request's registered candidate ACPX agent"); } - if (candidate && options.candidateProfile !== acpxAgent) { + if (candidate && acpxAgent !== "cursor" && options.candidateProfile !== acpxAgent) { throw new Error("Candidate ACPX profiles require an explicit matching --candidate-profile diagnostic flag"); } const managedProfileInput = input.managedProfile === null diff --git a/packages/paperclip-runner/src/drivers/acpx/capability-profiles.ts b/packages/paperclip-runner/src/drivers/acpx/capability-profiles.ts index a5739b23c65..90811445937 100644 --- a/packages/paperclip-runner/src/drivers/acpx/capability-profiles.ts +++ b/packages/paperclip-runner/src/drivers/acpx/capability-profiles.ts @@ -42,7 +42,7 @@ export const ACPX_CAPABILITY_PROFILES: Readonly { + const installation = await verifyAcpxProfileInstallation(resolveQualifiedAcpxProfile("cursor", model)); + await (await installation.openCommand()).close(); +} diff --git a/packages/paperclip-runner/src/drivers/acpx/qualified-profiles.ts b/packages/paperclip-runner/src/drivers/acpx/qualified-profiles.ts index 7eeebe2a438..e1b12866bb4 100644 --- a/packages/paperclip-runner/src/drivers/acpx/qualified-profiles.ts +++ b/packages/paperclip-runner/src/drivers/acpx/qualified-profiles.ts @@ -64,10 +64,10 @@ export const QUALIFIED_ACPX_PROFILES: Readonly< agentServerPackage: "cursor-agent", agentServerVersion: "2026.09.26-dd393fe", agentRuntimePackage: null, agentRuntimeVersion: null, commandDigest: "sha256:2feb50c7b0a317dff454c00115a5bbe4d5c757189691586577be9c80234d477e", - // Authenticated discovery has not established a qualification model. Never - // turn this empty declaration into a default; callers must select an ID. + // Every caller selects a model explicitly; the native provider must verify + // that exact ID. Qualification never turns Luna into a default model. qualificationModel: "", reportedModelId: "", permissionPolicy: "interactive", - modelPolicy: "explicit-provider-verified", qualificationStatus: "pending", + modelPolicy: "explicit-provider-verified", }, copilot: { driverKind: ACPX_DRIVER_KIND, protocolVersion: ACPX_DRIVER_PROTOCOL_VERSION, diff --git a/packages/paperclip-runner/src/index.ts b/packages/paperclip-runner/src/index.ts index 6e4472b4f24..9bc7f2af132 100644 --- a/packages/paperclip-runner/src/index.ts +++ b/packages/paperclip-runner/src/index.ts @@ -78,3 +78,5 @@ export * from "./generated/capability-contract.js"; export * from "./semantic-tools/index.js"; export * as acceptedCapabilitySemanticTools from "./semantic-tools/index.js"; export * from "./compatibility.js"; + +export { bundledRemoteProviderPackManifestPath, bundledRemoteRunnerBinary } from "./live/bundled-remote-provider-pack.js"; diff --git a/packages/paperclip-runner/src/live/bundled-remote-provider-pack.test.ts b/packages/paperclip-runner/src/live/bundled-remote-provider-pack.test.ts new file mode 100644 index 00000000000..f63f8001e24 --- /dev/null +++ b/packages/paperclip-runner/src/live/bundled-remote-provider-pack.test.ts @@ -0,0 +1,39 @@ +import { createHash } from "node:crypto"; +import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { pathToFileURL } from "node:url"; +import { afterEach, expect, it } from "vitest"; +import { bundledRemoteProviderPackManifestPath, bundledRemoteRunnerBinary } from "./bundled-remote-provider-pack.js"; +const roots: string[] = []; +afterEach(() => roots.splice(0).forEach(root => rmSync(root, { recursive: true, force: true }))); +function release() { + const root = mkdtempSync(join(tmpdir(), "paperclip-remote-release-")); roots.push(root); + const output = join(root, "dist/vendor/paperclip-runner"), binary = join(output, "bin/linux-x64/paperclip-runnerd"); + mkdirSync(join(output, "bin/linux-x64"), { recursive: true }); + const bytes = Buffer.alloc(64); Buffer.from([0x7f, 0x45, 0x4c, 0x46, 2, 1]).copy(bytes); bytes.writeUInt16LE(62, 18); + writeFileSync(binary, bytes); + const manifest = { schema: "paperclip.runner.release-binaries.v1", sourceRevision: "a".repeat(40), platforms: { "linux-x64": { path: "linux-x64/paperclip-runnerd", sha256: "sha256:" + createHash("sha256").update(bytes).digest("hex") } } }; + const save = () => writeFileSync(join(output, "bin/release-manifest.json"), JSON.stringify(manifest)); save(); + return { output, binary, bytes, manifest, save, url: pathToFileURL(join(output, "live/bundled-remote-provider-pack.js")).href }; +} +it("selects the packaged Linux daemon even when the controller is macOS", () => { + const r = release(); + expect(bundledRemoteRunnerBinary(r.url)).toBe(r.binary); + expect(bundledRemoteProviderPackManifestPath(r.url)).toBe(join(r.output, "remote-provider-packs/linux-x64/provider-pack.json")); +}); +it.each(["bytes", "architecture", "digest", "path"])("rejects a mismatched packaged remote daemon: %s", reason => { + const r = release(); + if (reason === "bytes") writeFileSync(r.binary, Buffer.alloc(64)); + if (reason === "architecture") { r.bytes.writeUInt16LE(183, 18); writeFileSync(r.binary, r.bytes); } + if (reason === "digest") r.manifest.platforms["linux-x64"].sha256 = "sha256:" + "b".repeat(64); + if (reason === "path") r.manifest.platforms["linux-x64"].path = "../../foreign-daemon"; + r.save(); expect(() => bundledRemoteRunnerBinary(r.url)).toThrow(/runner_remote_artifact_(incompatible|unavailable)/); +}); +it("resolves source development through built package assets without a runtime override", () => { + expect(bundledRemoteProviderPackManifestPath("file:///repo/packages/paperclip-runner/src/live/bundled-remote-provider-pack.ts")) + .toBe("/repo/packages/paperclip-runner/dist/remote-provider-packs/linux-x64/provider-pack.json"); + for (const url of ["https://example.com/dist/live/bundled-remote-provider-pack.js", "file:///tmp/foreign.js", "file:///repo/dist/live/bundled-remote-provider-pack.js?override=1"]) { + expect(() => bundledRemoteProviderPackManifestPath(url)).toThrow("package file layout"); + } +}); diff --git a/packages/paperclip-runner/src/live/bundled-remote-provider-pack.ts b/packages/paperclip-runner/src/live/bundled-remote-provider-pack.ts new file mode 100644 index 00000000000..29b4c9885db --- /dev/null +++ b/packages/paperclip-runner/src/live/bundled-remote-provider-pack.ts @@ -0,0 +1,36 @@ +import { fileURLToPath } from "node:url"; +import { createHash } from "node:crypto"; +import { readFileSync } from "node:fs"; +import { dirname, join, resolve } from "node:path"; +import { runnerBinaryTarget } from "./runner-binary.js"; + +/** Release-owned identity for the pack already installed in the Daytona image. */ +export function bundledRemoteProviderPackManifestPath(moduleUrl = import.meta.url): string { + const url = new URL(moduleUrl); + if (url.protocol !== "file:" || url.search || url.hash) throw new Error("Bundled remote provider pack requires a package file layout"); + if (/\/src\/live\/bundled-remote-provider-pack\.ts$/.test(url.pathname)) { + return fileURLToPath(new URL("../../dist/remote-provider-packs/linux-x64/provider-pack.json", url)); + } + if (/\/dist\/(?:vendor\/paperclip-runner\/)?live\/bundled-remote-provider-pack\.js$/.test(url.pathname)) { + return fileURLToPath(new URL("../remote-provider-packs/linux-x64/provider-pack.json", url)); + } + throw new Error("Bundled remote provider pack requires a package file layout"); +} + +/** A macOS controller must authorize Linux bytes for its Daytona daemon. */ +export function bundledRemoteRunnerBinary(moduleUrl = import.meta.url): string { + const manifestPath = bundledRemoteProviderPackManifestPath(moduleUrl); + const outputRoot = resolve(dirname(manifestPath), "../.."); + const release = JSON.parse(readFileSync(join(outputRoot, "bin/release-manifest.json"), "utf8")); + const artifact = release?.platforms?.["linux-x64"]; + if (release?.schema !== "paperclip.runner.release-binaries.v1" || !/^[a-f0-9]{40}$/.test(release.sourceRevision ?? "") || + artifact?.path !== "linux-x64/paperclip-runnerd" || !/^sha256:[a-f0-9]{64}$/.test(artifact.sha256 ?? "")) { + throw new Error("runner_remote_artifact_unavailable: install the complete Paperclip release with its Linux daemon"); + } + const binary = join(outputRoot, "bin", artifact.path); + const bytes = readFileSync(binary); + if (runnerBinaryTarget(bytes) !== "linux-x64" || "sha256:" + createHash("sha256").update(bytes).digest("hex") !== artifact.sha256) { + throw new Error("runner_remote_artifact_incompatible: packaged Linux daemon identity mismatch"); + } + return binary; +} diff --git a/packages/paperclip-runner/src/live/index.ts b/packages/paperclip-runner/src/live/index.ts index 22b2bc5e375..15737c6481b 100644 --- a/packages/paperclip-runner/src/live/index.ts +++ b/packages/paperclip-runner/src/live/index.ts @@ -6,3 +6,7 @@ export * from "./runnerd-codex-transport.js"; export * from "./turn-stream.js"; export { probeAcpxClaudeInstallation, probeAcpxGrokInstallation } from "../drivers/acpx/installation-integrity.js"; + +export { probeAcpxCursorInstallation } from "../drivers/acpx/profile-installation.js"; + +export { bundledRemoteProviderPackManifestPath, bundledRemoteRunnerBinary } from "./bundled-remote-provider-pack.js"; diff --git a/packages/paperclip-runner/src/live/live-session.test.ts b/packages/paperclip-runner/src/live/live-session.test.ts index e99da299374..851df4a097a 100644 --- a/packages/paperclip-runner/src/live/live-session.test.ts +++ b/packages/paperclip-runner/src/live/live-session.test.ts @@ -26,7 +26,7 @@ import { defaultCapabilityRunnerdBinary } from "./runnerd-codex-transport.js"; import { captureTurnRejection } from "../../test/capture-turn-rejection.js"; import * as workspaceDiff from "./workspace-diff.js"; -it.each(["pi", "cursor", "copilot"] as const)("requires separately bound evaluation opt-in for %s", async (acpxAgent) => { +it.each(["pi", "copilot"] as const)("requires separately bound evaluation opt-in for %s", async (acpxAgent) => { const service = new CapabilityLiveSessionService(); await expect(service.create({ provider: "acpx", acpxAgent, requestedModel: "explicit-model" })) .rejects.toThrow("explicit evaluation opt-in"); diff --git a/packages/paperclip-runner/src/live/live-session.ts b/packages/paperclip-runner/src/live/live-session.ts index a89183ded92..62358441eed 100644 --- a/packages/paperclip-runner/src/live/live-session.ts +++ b/packages/paperclip-runner/src/live/live-session.ts @@ -902,7 +902,7 @@ export class CapabilityLiveSessionService { async create(input: CreateCapabilityLiveSessionInput = {}): Promise { if (input.provider === "acpx" && input.acpxAgent !== undefined - && ["pi", "cursor", "copilot"].includes(input.acpxAgent) + && ["pi", "copilot"].includes(input.acpxAgent) && this.#transportOptions.acpxCandidateProfile !== input.acpxAgent) { throw new Error("The candidate ACPX profile requires explicit evaluation opt-in"); } @@ -1829,7 +1829,7 @@ export class CapabilityLiveSession { const candidate = this.#config.acpxAgent; if (missingTokens && this.#config.provider === "acpx" && (candidate === "pi" || candidate === "cursor" || candidate === "copilot") - && this.#transportOptions.acpxCandidateProfile === candidate) { + && (candidate === "cursor" || this.#transportOptions.acpxCandidateProfile === candidate)) { // Native candidate wrappers can complete a turn without a usage receipt, // including entitlement-denied turns. Retain the actual result for the // oracle without inventing tokens, charges, or a successful model call. diff --git a/packages/paperclip-runner/src/live/runnerd-codex-transport.ts b/packages/paperclip-runner/src/live/runnerd-codex-transport.ts index dff47409af2..65f7c19a478 100644 --- a/packages/paperclip-runner/src/live/runnerd-codex-transport.ts +++ b/packages/paperclip-runner/src/live/runnerd-codex-transport.ts @@ -3519,7 +3519,7 @@ class DurablePrpCodexTransport implements CodexAppServerTransport { throw new Error("native_adopted_runner_state_directory_required"); } if (options.provider === "acpx" && options.acpxAgent !== undefined - && ["pi", "cursor", "copilot"].includes(options.acpxAgent) + && ["pi", "copilot"].includes(options.acpxAgent) && options.acpxCandidateProfile !== options.acpxAgent) { throw new Error("The candidate ACPX profile requires explicit evaluation opt-in"); } diff --git a/scripts/verify-cursor-npm-install.mjs b/scripts/verify-cursor-npm-install.mjs index b532091088b..7f3ff06bb14 100644 --- a/scripts/verify-cursor-npm-install.mjs +++ b/scripts/verify-cursor-npm-install.mjs @@ -20,10 +20,11 @@ const releaseBinaries = JSON.parse(readFileSync(join(repo, 'server/dist/vendor/p assert.equal(releaseBinaries.schema, 'paperclip.runner.release-binaries.v1'); assert.match(releaseBinaries.sourceRevision, /^[a-f0-9]{40}$/); run('git', ['merge-base', '--is-ancestor', releaseBinaries.sourceRevision, sourceRevision], repo); -// Controller/fixture repairs may follow a frozen runtime. Its exact binary -// provenance stays unchanged; require the Runner source to remain identical. -run('git', ['diff', '--quiet', releaseBinaries.sourceRevision, sourceRevision, '--', 'packages/paperclip-runner'], repo); +// Controller and TypeScript admission may follow a frozen daemon. Keep its +// exact provenance and require the Rust sources and protocol to remain identical. +run('git', ['diff', '--quiet', releaseBinaries.sourceRevision, sourceRevision, '--', 'packages/paperclip-runner/runner', 'packages/paperclip-runner/protocol'], repo); assert.deepEqual(Object.keys(releaseBinaries.platforms).sort(), ['darwin-arm64', 'darwin-x64', 'linux-x64']); +assert.equal(releaseBinaries.remoteProviderPack?.target, 'linux-x64', 'Release assembly must include the expected ordinary Daytona image pack'); const releaseVersion = `0.0.0-cursor-verify.${sourceRevision.slice(0, 12)}`; const listing = run(process.execPath, [join(repo, 'scripts/release-package-map.mjs'), 'list'], repo).toString().trim().split('\n').map(line => line.split('\t')); const packages = new Map(listing.map(([dir, name]) => [name, {dir, manifest: JSON.parse(readFileSync(join(repo, dir, 'package.json')))}])); @@ -86,6 +87,7 @@ import { execFileSync } from 'node:child_process'; import { defaultCapabilityRunnerdBinary } from '/consumer/node_modules/@paperclipai/server/dist/vendor/paperclip-runner/live/runnerd-codex-transport.js'; import { resolvePaperclipRunnerBinary } from '/consumer/node_modules/@paperclipai/server/dist/services/native-runtime/native-codex-runner.js'; import { runnerBinaryTarget } from '/consumer/node_modules/@paperclipai/server/dist/vendor/paperclip-runner/live/runner-binary.js'; +import { bundledRemoteRunnerBinary, bundledRemoteProviderPackManifestPath } from '/consumer/node_modules/@paperclipai/server/dist/vendor/paperclip-runner/live/bundled-remote-provider-pack.js'; import { verifyAcpxProfileInstallation } from '/consumer/node_modules/@paperclipai/server/dist/vendor/paperclip-runner/drivers/acpx/profile-installation.js'; import { resolveQualifiedAcpxProfile } from '/consumer/node_modules/@paperclipai/server/dist/vendor/paperclip-runner/drivers/acpx/qualified-profiles.js'; const profile = resolveQualifiedAcpxProfile('cursor', 'gpt-5.6-luna[context=272k,reasoning=medium,fast=false]'); @@ -107,6 +109,10 @@ for (const [target, artifact] of Object.entries(manifest.platforms)) { const binary = defaultCapabilityRunnerdBinary(); assert.equal(binary, binaryRoot + '/linux-x64/paperclip-runnerd'); assert.equal(resolvePaperclipRunnerBinary(), binary, 'The installed server must use the same verified platform daemon'); +assert.equal(bundledRemoteRunnerBinary(), binary); +const remotePack = JSON.parse(readFileSync(bundledRemoteProviderPackManifestPath(), 'utf8')); +assert.equal(remotePack.digest, manifest.remoteProviderPack.digest); +assert.equal(remotePack.payload.providers.cursor.qualification, 'qualified'); const metadata = JSON.parse(execFileSync(binary, ['--build-metadata'], { encoding: 'utf8' })); assert.equal(metadata.schema, 'paperclip-runner/runnerd-build-metadata/v1'); assert.equal(metadata.binaryContractVersion, 2); diff --git a/server/src/__tests__/adapter-registry.test.ts b/server/src/__tests__/adapter-registry.test.ts index f7e162a3731..a31acc6dc10 100644 --- a/server/src/__tests__/adapter-registry.test.ts +++ b/server/src/__tests__/adapter-registry.test.ts @@ -1,4 +1,4 @@ -import { probeAcpxClaudeInstallation } from "@paperclipai/paperclip-runner/live"; +import { probeAcpxClaudeInstallation, probeAcpxCursorInstallation } from "@paperclipai/paperclip-runner/live"; import { describe, expect, it, beforeEach, afterEach, vi } from "vitest"; import { buildSandboxNpmInstallCommand } from "@paperclipai/adapter-utils"; import type { ServerAdapterModule } from "../adapters/index.js"; @@ -20,6 +20,7 @@ import { vi.mock("@paperclipai/paperclip-runner/live", () => ({ probeAcpxClaudeInstallation: vi.fn(async () => undefined), probeAcpxGrokInstallation: vi.fn(async () => undefined), + probeAcpxCursorInstallation: vi.fn(async () => undefined), })); it("advertises tool-refresh recovery for the selected legacy harness", () => { @@ -326,6 +327,22 @@ describe("server adapter registry", () => { }); }); + it.each([true, false])("checks ordinary Cursor runtime readiness (%s)", async (ready) => { + const probe = vi.mocked(probeAcpxCursorInstallation); + if (ready) probe.mockResolvedValueOnce(undefined); + else probe.mockRejectedValueOnce(new Error("Run paperclipai runtime setup cursor")); + const model = "gpt-5.6-luna[context=272k,reasoning=medium,fast=false]"; + const result = await requireServerAdapter("paperclip_runner").testEnvironment({ + companyId: "company-1", adapterType: "paperclip_runner", + config: { provider: "acpx", acpxAgent: "cursor", model }, + }); + expect(probe).toHaveBeenLastCalledWith(model); + expect(result).toMatchObject({ + status: ready ? "pass" : "fail", + checks: [expect.objectContaining({ code: ready ? "acpx_runtime_ready" : "acpx_runtime_unavailable" })], + }); + }); + it("keeps the ACPX Pi profile unavailable", async () => { const result = await requireServerAdapter("paperclip_runner").testEnvironment({ companyId: "company-1", @@ -345,11 +362,11 @@ describe("server adapter registry", () => { it("reports qualification-only readiness for an exact host-authorized candidate", async () => { const key = "PAPERCLIP_RUNNER_ACPX_QUALIFICATION"; const previous = process.env[key]; - process.env[key] = JSON.stringify([{ agent: "cursor", model: "exact-model" }]); + process.env[key] = JSON.stringify([{ agent: "copilot", model: "exact-model" }]); try { const result = await requireServerAdapter("paperclip_runner").testEnvironment({ companyId: "company-1", adapterType: "paperclip_runner", - config: { provider: "acpx", acpxAgent: "cursor", model: "exact-model" }, + config: { provider: "acpx", acpxAgent: "copilot", model: "exact-model" }, }); expect(result).toMatchObject({ status: "warn", checks: [{ code: "acpx_candidate_qualification_only" }] }); } finally { diff --git a/server/src/adapters/registry.ts b/server/src/adapters/registry.ts index 996ad3e465f..b4bc5973f5a 100644 --- a/server/src/adapters/registry.ts +++ b/server/src/adapters/registry.ts @@ -406,7 +406,7 @@ const paperclipRunnerAdapter: ServerAdapterModule = { }; } if (profile.provider === "acpx") { - if (["cursor", "copilot", "pi"].includes(profile.acpxAgent)) { + if (["copilot", "pi"].includes(profile.acpxAgent)) { // The profile resolver already validated the isolated host's exact // qualification pair. Do not report a production readiness pass. return { @@ -416,7 +416,7 @@ const paperclipRunnerAdapter: ServerAdapterModule = { }; } try { - if (profile.acpxAgent !== "claude" && profile.acpxAgent !== "grok") throw new Error("Select Codex to use the native Codex runner."); + if (profile.acpxAgent !== "claude" && profile.acpxAgent !== "grok" && profile.acpxAgent !== "cursor") throw new Error("Select Codex to use the native Codex runner."); const target = context.executionTarget; if (target?.kind === "remote") { const probe = await runAdapterExecutionTargetShellCommand( @@ -434,8 +434,8 @@ const paperclipRunnerAdapter: ServerAdapterModule = { message: "The remote platform is supported. Runtime package integrity and readiness must still be verified by the remote runner before launch." }], }; } - const { probeAcpxClaudeInstallation, probeAcpxGrokInstallation } = await import("@paperclipai/paperclip-runner/live"); - await (profile.acpxAgent === "grok" ? probeAcpxGrokInstallation : probeAcpxClaudeInstallation)(profile.model); + const { probeAcpxClaudeInstallation, probeAcpxGrokInstallation, probeAcpxCursorInstallation } = await import("@paperclipai/paperclip-runner/live"); + await (profile.acpxAgent === "grok" ? probeAcpxGrokInstallation : profile.acpxAgent === "cursor" ? probeAcpxCursorInstallation : probeAcpxClaudeInstallation)(profile.model); return { adapterType: "paperclip_runner", status: "pass" as const, testedAt: new Date().toISOString(), checks: [{ code: "acpx_runtime_ready", level: "info" as const, message: `ACPX ${profile.acpxAgent} runtime is installed and verified. Model access is checked when it runs.` }], @@ -521,7 +521,7 @@ const paperclipRunnerAdapter: ServerAdapterModule = { ) : buildNpmRuntimeCommandSpec(config, "codex", "@openai/codex@0.156.0"), agentConfigurationDoc: - "# Paperclip Runner\n\nAdapter: paperclip_runner\n\nRuns Codex, OpenCode, Claude Managed, AWS AgentCore, or ACPX Claude/Grok Build through the Rust Paperclip runner and authenticated PRP transport. Cursor, GitHub Copilot, and Pi are awaiting local and Daytona qualification and are not enabled for production runs. Managed providers use company-scoped qualified profiles, explicit retention acknowledgement, and spend limits.\n", + "# Paperclip Runner\n\nAdapter: paperclip_runner\n\nRuns Codex, OpenCode, Claude Managed, AWS AgentCore, or ACPX Claude/Grok Build/Cursor through the Rust Paperclip runner and authenticated PRP transport. GitHub Copilot and Pi are awaiting local and Daytona qualification and are not enabled for production runs. Managed providers use company-scoped qualified profiles, explicit retention acknowledgement, and spend limits.\n", getConfigSchema: () => ({ fields: [ { diff --git a/server/src/services/native-runtime/acpx-qualification.test.ts b/server/src/services/native-runtime/acpx-qualification.test.ts index e8be873d29e..051e1033898 100644 --- a/server/src/services/native-runtime/acpx-qualification.test.ts +++ b/server/src/services/native-runtime/acpx-qualification.test.ts @@ -4,11 +4,11 @@ import { ACPX_QUALIFICATION_ENV, resolveAcpxQualification } from "./acpx-qualifi import type { NativeExecutionInput } from "../../vendor/paperclip-runner/index.js"; import { resolvePaperclipRunnerProviderProfile, resolvePaperclipRunnerNativeProviderInput } from "./provider-profile.js"; -const provider = { kind: "acpx", agent: "cursor", model: "exact-model", permissionMode: "approve-all" } as NativeExecutionInput["provider"]; +const provider = { kind: "acpx", agent: "copilot", model: "exact-model", permissionMode: "approve-all" } as NativeExecutionInput["provider"]; const authorize = (value: unknown) => ({ [ACPX_QUALIFICATION_ENV]: JSON.stringify(value) }); describe("host ACPX qualification admission", () => { afterEach(() => vi.unstubAllEnvs()); - it.each(["cursor", "copilot", "pi"])("admits %s through agent validation and native input only for the exact host pair", (agent) => { + it.each(["copilot", "pi"])("admits %s through agent validation and native input only for the exact host pair", (agent) => { const config = { provider: "acpx", acpxAgent: agent, model: "exact-model" }; vi.stubEnv(ACPX_QUALIFICATION_ENV, undefined); expect(() => resolvePaperclipRunnerProviderProfile(config)).toThrow(expect.objectContaining({ code: "paperclip_runner_acpx_agent_unavailable" })); @@ -21,8 +21,8 @@ describe("host ACPX qualification admission", () => { }); it("keeps normal candidate execution closed and admits only the exact operator pair", () => { expect(resolveAcpxQualification(provider, {})).toBeUndefined(); - expect(resolveAcpxQualification(provider, authorize([{ agent: "cursor", model: "exact-model" }]))).toBe("cursor"); - for (const entries of [[{ agent: "copilot", model: "exact-model" }], [{ agent: "cursor", model: "other-model" }]]) { + expect(resolveAcpxQualification(provider, authorize([{ agent: "copilot", model: "exact-model" }]))).toBe("copilot"); + for (const entries of [[{ agent: "pi", model: "exact-model" }], [{ agent: "copilot", model: "other-model" }]]) { expect(() => resolveAcpxQualification(provider, authorize(entries))).toThrow("exact model"); } }); @@ -32,6 +32,14 @@ describe("host ACPX qualification admission", () => { ])("rejects malformed or broad authorization %j", entries => { expect(() => resolveAcpxQualification(provider, authorize(entries))).toThrow("Invalid ACPX"); }); + it("admits Cursor through ordinary configuration and ignores unrelated qualification authority", () => { + const config = { provider: "acpx", acpxAgent: "cursor", model: "exact-cursor-model" }; + vi.stubEnv(ACPX_QUALIFICATION_ENV, JSON.stringify([{ agent: "pi", model: "pi-model" }])); + expect(resolvePaperclipRunnerProviderProfile(config)).toMatchObject({ acpxAgent: "cursor", model: config.model }); + expect(resolvePaperclipRunnerNativeProviderInput({ backend: "acpx_runtime", adapterConfig: config })).toMatchObject({ acpxAgent: "cursor", model: config.model }); + expect(resolveAcpxQualification({ ...provider, agent: "cursor" } as typeof provider, authorize([]))).toBeUndefined(); + expect(() => resolvePaperclipRunnerProviderProfile({ ...config, model: "" })).toThrow(expect.objectContaining({ code: "paperclip_runner_acpx_model_required" })); + }); it("does not alter existing qualified providers", () => { expect(resolveAcpxQualification({ ...provider, agent: "codex" } as typeof provider, authorize([]))).toBeUndefined(); }); diff --git a/server/src/services/native-runtime/acpx-qualification.ts b/server/src/services/native-runtime/acpx-qualification.ts index 76eb3cdd36f..80b939e1514 100644 --- a/server/src/services/native-runtime/acpx-qualification.ts +++ b/server/src/services/native-runtime/acpx-qualification.ts @@ -8,7 +8,7 @@ export function resolveAcpxQualification( provider: NativeExecutionInput["provider"] | { kind: "acpx"; agent: string; model: string }, hostEnvironment: NodeJS.ProcessEnv, ): AcpxQualificationCandidate | undefined { - if (provider.kind !== "acpx" || !["cursor", "copilot", "pi"].includes(provider.agent)) return undefined; + if (provider.kind !== "acpx" || !["copilot", "pi"].includes(provider.agent)) return undefined; const encoded = hostEnvironment[ACPX_QUALIFICATION_ENV]; if (!encoded) return undefined; let entries: unknown; diff --git a/server/src/services/native-runtime/native-session-executor.test.ts b/server/src/services/native-runtime/native-session-executor.test.ts index 8200e0657d2..a77e23f6050 100644 --- a/server/src/services/native-runtime/native-session-executor.test.ts +++ b/server/src/services/native-runtime/native-session-executor.test.ts @@ -310,6 +310,7 @@ import { REMOTE_RUNNER_CHILD_LAUNCH_SCRIPT, verifyRemoteRunnerReattachment, readRemoteProviderPackManifest, + readBundledRemoteProviderPackManifest, providerSessionIdentityFromDurableProviderState, durableProviderCheckpointFailureReason, providerSessionIdentityTransitionIsAllowed, @@ -1156,10 +1157,22 @@ describe("remote provider pack manifest", () => { await mkdir(join(root, cursorPath), { recursive: true }); await writeFile(join(root, cursorPath, "runtime"), "pinned Cursor runtime"); Object.assign(payload, { providers: { cursor: { version: "2026.09.26-dd393fe", profileDigest: digest("cursor-profile"), - closureDigest: digest("cursor-closure"), qualification: "pending", path: cursorPath, + closureDigest: digest("cursor-closure"), qualification: "qualified", path: cursorPath, sha256: sha256DirectoryTree(join(root, cursorPath)) } } }); await writeManifest(); expect(readRemoteProviderPackManifest(root).payload.providers?.cursor?.version).toBe("2026.09.26-dd393fe"); + const releaseMetadata = await mkdtemp(join(tmpdir(), "paperclip-image-identity-")); + const manifestPath = join(releaseMetadata, "provider-pack.json"); + await cp(join(root, "provider-pack.json"), manifestPath); + expect(readBundledRemoteProviderPackManifest(manifestPath).digest).toBe(readRemoteProviderPackManifest(root).digest); + // A metadata-only release identity is never accepted as a host asset tree. + expect(() => readRemoteProviderPackManifest(releaseMetadata)).toThrow(); + const originalTarget = payload.target; + payload.target = { platform: "darwin", architecture: "arm64" }; + await writeManifest(); await cp(join(root, "provider-pack.json"), manifestPath); + expect(() => readBundledRemoteProviderPackManifest(manifestPath)).toThrow(); + payload.target = originalTarget; await writeManifest(); + await rm(releaseMetadata, { recursive: true, force: true }); await writeFile(join(root, cursorPath, "runtime"), "substitute Cursor runtime"); expect(() => readRemoteProviderPackManifest(root)).toThrow("asset tree digest mismatch"); await writeFile(join(root, cursorPath, "runtime"), "pinned Cursor runtime"); @@ -8234,7 +8247,7 @@ describe("native process ownership", () => { }, ); - it.each(["pi", "cursor", "copilot"])("rejects ACPX candidate %s without host authorization before constructing a backend", async (agent) => { + it.each(["pi", "copilot"])("rejects ACPX candidate %s without host authorization before constructing a backend", async (agent) => { const piExecution = { ...execution, binding: { ...execution.binding, runId: "run-acpx-pi-rejected" }, diff --git a/server/src/services/native-runtime/native-session-executor.ts b/server/src/services/native-runtime/native-session-executor.ts index a78fe8610f2..e61cf36342b 100644 --- a/server/src/services/native-runtime/native-session-executor.ts +++ b/server/src/services/native-runtime/native-session-executor.ts @@ -1,3 +1,4 @@ +import { bundledRemoteProviderPackManifestPath, bundledRemoteRunnerBinary } from "../../vendor/paperclip-runner/index.js"; import { nativeRetryCancellationEligible, rethrowNativeCancellationLockConflict, assertCancellationRequest, cancellationIntentId as callerCancellationIntentId, cancellationRequestId } from "./native-cancellation-request.js"; import { readNativeCursorPlanWait } from "./native-cursor-plan-wait.js"; import { NativeCursorPermissionDeclinedError, readCompletedCursorPermissionDecline } from "./native-cursor-permission-decline.js"; @@ -7502,7 +7503,7 @@ async function executePaperclipNativeSessionWithinScope( } if ( input.execution.provider.kind === "acpx" && - ["pi", "cursor", "copilot"].includes(input.execution.provider.agent) && + ["pi", "copilot"].includes(input.execution.provider.agent) && !resolveAcpxQualification(input.execution.provider, process.env) ) { throw new Error( @@ -9511,11 +9512,11 @@ type RemoteProviderPackManifest = { bridgeDigest: string; acpxProfileDigests: typeof REMOTE_PROVIDER_PACK_PROFILE_DIGESTS; providers?: Partial>; candidateProviders?: Partial>; artifacts: { @@ -9579,9 +9580,19 @@ function providerPackRelativePath(value: unknown, field: string): string { return value; } -export function readRemoteProviderPackManifest( - packRoot: string, -): RemoteProviderPackManifest { +export function readRemoteProviderPackManifest(packRoot: string): RemoteProviderPackManifest { + return readRemoteProviderPackIdentity(packRoot, true); +} + +export function readBundledRemoteProviderPackManifest(manifestPath = bundledRemoteProviderPackManifestPath()): RemoteProviderPackManifest { + const manifest = readRemoteProviderPackIdentity(dirname(manifestPath), false); + if (manifest.payload.target.platform !== "linux" || manifest.payload.target.architecture !== "x64") { + throw new Error("runner_remote_provider_artifact_incompatible: bundled image pack must target Linux x64"); + } + return manifest; +} + +function readRemoteProviderPackIdentity(packRoot: string, verifyControllerFiles: boolean): RemoteProviderPackManifest { let manifest: RemoteProviderPackManifest; try { manifest = JSON.parse( @@ -9669,15 +9680,15 @@ export function readRemoteProviderPackManifest( ); } if ( - typeof artifact?.sha256 !== "string" || - sha256File(resolve(packRoot, artifactPath)) !== artifact.sha256 + !/^sha256:[a-f0-9]{64}$/.test(artifact?.sha256 ?? "") || + (verifyControllerFiles && sha256File(resolve(packRoot, artifactPath)) !== artifact.sha256) ) { throw new Error( `runner_remote_provider_artifact_incompatible: ${label} digest mismatch`, ); } } - if (sha256DirectoryTree(resolve(packRoot, "dist")) !== payload.distDigest) { + if (verifyControllerFiles && sha256DirectoryTree(resolve(packRoot, "dist")) !== payload.distDigest) { throw new Error( "runner_remote_provider_artifact_incompatible: provider dist tree digest mismatch", ); @@ -9691,7 +9702,7 @@ export function readRemoteProviderPackManifest( const expectedPath = `provider-assets/${provider}/${payload.target.platform}-${payload.target.architecture}`; if (!(inventory === "providers" ? ["cursor"] : ["cursor", "copilot", "pi"]).includes(provider) || !candidate || Object.keys(candidate).some(key => !["version", "profileDigest", "closureDigest", "qualification", "path", "sha256"].includes(key)) - || candidate.qualification !== "pending" || candidate.path !== expectedPath + || candidate.qualification !== (provider === "cursor" ? "qualified" : "pending") || candidate.path !== expectedPath || typeof candidate.version !== "string" || !candidate.version || candidate.version.length > 120 || !/^sha256:[a-f0-9]{64}$/.test(candidate.profileDigest) || !/^sha256:[a-f0-9]{64}$/.test(candidate.closureDigest) @@ -9699,7 +9710,7 @@ export function readRemoteProviderPackManifest( throw new Error("runner_remote_provider_artifact_incompatible: invalid candidate identity"); } const candidatePath = providerPackRelativePath(candidate.path, "candidate assets"); - if (sha256DirectoryTree(resolve(packRoot, candidatePath)) !== candidate.sha256) { + if (verifyControllerFiles && sha256DirectoryTree(resolve(packRoot, candidatePath)) !== candidate.sha256) { throw new Error("runner_remote_provider_artifact_incompatible: candidate asset tree digest mismatch"); } } @@ -10915,7 +10926,11 @@ async function createRunnerdBackendWithinSessionClaim( const configuredProviderPackRoot = input.runnerRemoteProviderPackPath?.trim() || null; let expectedProviderPackManifest: RemoteProviderPackManifest | null = null; - if (requiresRemoteProviderPack) { + const useBundledCursorImageAssets = requiresRemoteProviderPack && !configuredProviderPackRoot && + input.execution.provider.kind === "acpx" && input.execution.provider.agent === "cursor"; + if (useBundledCursorImageAssets) { + expectedProviderPackManifest = readBundledRemoteProviderPackManifest(); + } else if (requiresRemoteProviderPack) { if ( !configuredProviderPackRoot || !existsSync(configuredProviderPackRoot) || @@ -10942,7 +10957,7 @@ async function createRunnerdBackendWithinSessionClaim( // When an explicit remote artifact is configured, prepareRemoteRunner stages // these exact bytes at remoteBinary before launch. const controllerRunnerBinary = remoteTarget - ? input.runnerRemoteBinaryPath?.trim() || resolvePaperclipRunnerBinary() + ? input.runnerRemoteBinaryPath?.trim() || (useBundledCursorImageAssets ? bundledRemoteRunnerBinary() : resolvePaperclipRunnerBinary()) : resolvePaperclipRunnerBinary(); const explicitRemoteCodex = input.runnerRemoteCodexPath?.trim() || null; const remoteCodexNpmSpec = input.runnerRemoteCodexNpmSpec?.trim() || null; @@ -11470,7 +11485,7 @@ async function createRunnerdBackendWithinSessionClaim( } if ( requiresRemoteProviderPack && - configuredProviderPackRoot && + expectedProviderPackManifest && stagedRemoteProviderPackRoot ) { const packSource = await prepareVerifiedRemoteProviderPack({ @@ -11526,6 +11541,9 @@ async function createRunnerdBackendWithinSessionClaim( return preinstalledProviderPack !== null; }, stageAndVerify: async () => { + if (!configuredProviderPackRoot) { + throw new Error("runner_remote_provider_artifact_incompatible: install the matching Paperclip package and Daytona image; the image provider pack did not match the bundled release identity"); + } if (!remoteCommandRunner.syncIn) { throw new Error( "runner_remote_provider_artifact_incompatible: this remote transport cannot stage a provider pack; preinstall the exact manifest-matched pack", diff --git a/server/src/services/native-runtime/provider-profile.ts b/server/src/services/native-runtime/provider-profile.ts index d001bba8f6c..196f0a6d17f 100644 --- a/server/src/services/native-runtime/provider-profile.ts +++ b/server/src/services/native-runtime/provider-profile.ts @@ -25,6 +25,8 @@ export const QUALIFIED_ACPX_RUNNER_MODELS = { grok: "grok-4.7", claude: "claude-sonnet-5", codex: "gpt-5.6-sol", + // Cursor models are explicit and provider-verified; there is no default. + cursor: "", } as const; export type QualifiedPaperclipRunnerAcpxAgent = @@ -462,7 +464,10 @@ export function resolvePaperclipRunnerProviderProfile( } throw new PaperclipRunnerProviderProfileError("paperclip_runner_acpx_agent_unavailable", `${pendingAcpxProfile.label} is awaiting local and Daytona qualification. Its profile is not enabled for production runs.`); } - if (acpxAgent !== "claude" && acpxAgent !== "codex" && acpxAgent !== "grok") { + if (acpxAgent === "cursor" && !model) { + throw new PaperclipRunnerProviderProfileError("paperclip_runner_acpx_model_required", "Cursor requires an explicit model ID; there is no default model."); + } + if (acpxAgent !== "claude" && acpxAgent !== "codex" && acpxAgent !== "grok" && acpxAgent !== "cursor") { throw new PaperclipRunnerProviderProfileError( "paperclip_runner_acpx_agent_unavailable", "Paperclip Runner ACPX requires a qualified agent profile.", diff --git a/server/src/vendor/paperclip-runner/index.ts b/server/src/vendor/paperclip-runner/index.ts index 80f98ee6f0f..5679d345b8d 100644 --- a/server/src/vendor/paperclip-runner/index.ts +++ b/server/src/vendor/paperclip-runner/index.ts @@ -131,3 +131,6 @@ export const NativeProviderTerminalFailure = runner.NativeProviderTerminalFailur export const completeTerminatedRemoteNativeSessionCleanup = runner.completeTerminatedRemoteNativeSessionCleanup; export const completeTerminatedLocalNativeSessionCleanup = runner.completeTerminatedLocalNativeSessionCleanup; + +export const bundledRemoteProviderPackManifestPath = runner.bundledRemoteProviderPackManifestPath; +export const bundledRemoteRunnerBinary = runner.bundledRemoteRunnerBinary; diff --git a/tests/runner-e2e/catalog.ts b/tests/runner-e2e/catalog.ts index 944914cc9dd..14038cbd4dc 100644 --- a/tests/runner-e2e/catalog.ts +++ b/tests/runner-e2e/catalog.ts @@ -325,13 +325,13 @@ export const runnerProfiles: readonly RunnerProfileFixture[] = [ ] as const; // Explicit qualification choices from authenticated model discovery, not shipped -// defaults or claims that these candidates have passed inference qualification. +// defaults. Cursor has local/Daytona proof; Copilot and Pi remain pending. export const extendedHarnessProfiles: readonly RunnerProfileFixture[] = [ nativeProfile({ - id: "runner-acpx-cursor", label: "Runner Cursor (candidate)", provider: "acpx", acpxAgent: "cursor", + id: "runner-acpx-cursor", label: "Runner Cursor", provider: "acpx", acpxAgent: "cursor", qualificationCandidate: "cursor", credential: "CURSOR_AUTH_TOKEN", model: "gpt-5.6-luna[context=272k,reasoning=medium,fast=false]", - modelQualification: { source: "candidate_runner_profile", qualificationId: "cursor:2026.09.26-dd393fe:discovery-2026-09-28" }, + modelQualification: { source: "qualified_runner_profile", qualificationId: "cursor:2026.09.26-dd393fe:v11:luna:local-daytona-2026-10-04" }, }), nativeProfile({ id: "runner-acpx-copilot", label: "Runner Copilot (candidate)", provider: "acpx", acpxAgent: "copilot", diff --git a/tests/runner-e2e/extended-harnesses.test.ts b/tests/runner-e2e/extended-harnesses.test.ts index a4b66575426..73abc204be3 100644 --- a/tests/runner-e2e/extended-harnesses.test.ts +++ b/tests/runner-e2e/extended-harnesses.test.ts @@ -16,12 +16,12 @@ describe("extended ACP harness qualification", () => { expect(all.some(cell => cell.suite.id === "extended-harnesses")).toBe(false); expect(runnerSuites.find(suite => suite.id === "extended-harnesses")?.manualOnly).toBe(true); }); - it("uses exact discovered models, encrypted credential references and pending qualification metadata", () => { + it("uses exact discovered models, encrypted credential references and current qualification metadata", () => { expect(extendedHarnessProfiles.map(profile => profile.model)).toEqual([ "gpt-5.6-luna[context=272k,reasoning=medium,fast=false]", "gpt-5.6-luna", "openrouter/deepseek/deepseek-v4-flash-0731", ]); for (const profile of extendedHarnessProfiles) { - expect(profile.modelQualification.source).toBe("candidate_runner_profile"); + expect(profile.modelQualification.source).toBe(profile.qualificationCandidate === "cursor" ? "qualified_runner_profile" : "candidate_runner_profile"); const secretRef = { type: "secret_ref" as const, secretId: "11111111-1111-4111-8111-111111111111", version: "latest" as const }; const payload = profile.buildAgent({ executionId: "fixture", environmentId: "local", environmentFixtureId: "local", workspacePath: "/tmp/workspace", secretRefs: { [profile.credential]: secretRef } }); expect(payload.adapterConfig).toMatchObject({ provider: "acpx", model: profile.model, timeoutSec: 120, acpxAgent: profile.qualificationCandidate, env: { [profile.credential]: secretRef } }); @@ -31,11 +31,12 @@ describe("extended ACP harness qualification", () => { const cell = selected[0]!; const ambient = { PAPERCLIP_RUNNER_ACPX_QUALIFICATION: "ambient", CURSOR_AUTH_TOKEN: "cursor", CURSOR_API_KEY: "cursor-key", COPILOT_GITHUB_TOKEN: "copilot", GH_TOKEN: "github", GITHUB_TOKEN: "github" }; const env = buildRunnerE2EProcessEnvironment(ambient, [cell]); - expect(JSON.parse(env.PAPERCLIP_RUNNER_ACPX_QUALIFICATION!)).toEqual([{ agent: "cursor", model: cell.profile.model }]); + expect(env.PAPERCLIP_RUNNER_ACPX_QUALIFICATION).toBeUndefined(); const server = buildPaperclipServerEnvironment(env); for (const name of Object.keys(ambient).filter(name => name !== "PAPERCLIP_RUNNER_ACPX_QUALIFICATION")) expect(server[name]).toBeUndefined(); expect(buildRunnerE2EProcessEnvironment(ambient, []).PAPERCLIP_RUNNER_ACPX_QUALIFICATION).toBeUndefined(); - expect(() => buildRunnerE2EProcessEnvironment({}, [{ ...cell, suite: { ...cell.suite, manualOnly: false } }])).toThrow("explicit"); + const pending = selected.find(value => value.profile.qualificationCandidate === "copilot")!; + expect(() => buildRunnerE2EProcessEnvironment({}, [{ ...pending, suite: { ...pending.suite, manualOnly: false } }])).toThrow("explicit"); }); it("grades the actual file independently of the model's validation claim", () => { const cell = selected.find(cell => cell.task.id === "file-edit-validate")!; diff --git a/tests/runner-e2e/harness-env.ts b/tests/runner-e2e/harness-env.ts index 4e18e3154f1..7fe62c200f4 100644 --- a/tests/runner-e2e/harness-env.ts +++ b/tests/runner-e2e/harness-env.ts @@ -114,7 +114,7 @@ export function buildRunnerE2EProcessEnvironment( const candidates = new Map(); for (const execution of executions) { const agent = execution.profile.qualificationCandidate; - if (!agent) continue; + if (!agent || agent === "cursor") continue; const admittedSuite = execution.suite.id === "extended-harnesses" || execution.suite.id === "rich-acp-warm-continuity" || (execution.suite.id === "cursor-native" && agent === "cursor") diff --git a/tests/runner-e2e/native-provider-loss.test.ts b/tests/runner-e2e/native-provider-loss.test.ts index f90660c6f27..51541bf65b7 100644 --- a/tests/runner-e2e/native-provider-loss.test.ts +++ b/tests/runner-e2e/native-provider-loss.test.ts @@ -5,10 +5,10 @@ import type { ActiveStopPending } from "./native-active-stop-evidence.js"; import { buildRunnerE2EProcessEnvironment } from "./harness-env.js"; import { runnerMatrix } from "./catalog.js"; -it("admits only explicit Cursor loss cells through the qualification harness", () => { +it("runs qualified Cursor loss cells without qualification overrides", () => { const execution = runnerMatrix.find(row => row.suite.id === "native-provider-loss" && row.environment.id === "local")!; - expect(JSON.parse(buildRunnerE2EProcessEnvironment({}, [execution]).PAPERCLIP_RUNNER_ACPX_QUALIFICATION!)) - .toEqual([{ agent: "cursor", model: execution.profile.model }]); + expect(buildRunnerE2EProcessEnvironment({}, [execution]).PAPERCLIP_RUNNER_ACPX_QUALIFICATION) + .toBeUndefined(); expect(() => buildRunnerE2EProcessEnvironment({}, [{ ...execution, profile: { ...execution.profile, qualificationCandidate: "copilot" } }])).toThrow("explicit provider qualification suite"); }); diff --git a/ui/src/adapters/codex-local/config-fields.test.tsx b/ui/src/adapters/codex-local/config-fields.test.tsx index 261b194aa32..259cdf05043 100644 --- a/ui/src/adapters/codex-local/config-fields.test.tsx +++ b/ui/src/adapters/codex-local/config-fields.test.tsx @@ -76,7 +76,7 @@ describe("Paperclip Runner Codex configuration", () => { expect(html).toContain(''); expect(html).toContain("ACP agent"); expect(html).toContain(''); - expect(html).toContain(''); + expect(html).toContain(''); expect(html).toContain(''); expect(html).toContain(''); expect(html).not.toContain("Codex via ACPX"); diff --git a/ui/src/adapters/codex-local/config-fields.tsx b/ui/src/adapters/codex-local/config-fields.tsx index b1c21dfef1c..df02cb47d6d 100644 --- a/ui/src/adapters/codex-local/config-fields.tsx +++ b/ui/src/adapters/codex-local/config-fields.tsx @@ -242,7 +242,7 @@ export function CodexLocalConfigFields({ )} {runnerManaged && runnerProvider === "acpx" && runnerSchemaValue("acpxAgent", "claude") !== "grok" && ( - +