From ac17430b014b1663ec7c91a9a922fe9edb5cd492 Mon Sep 17 00:00:00 2001 From: Dotta Date: Mon, 28 Sep 2026 14:10:10 -0500 Subject: [PATCH] Fix candidate ACPX initialization and symlinked eval entrypoint Co-Authored-By: Paperclip --- .../src/cli/acpx-runtime-sidecar.test.ts | 21 +++++++++++-- .../src/cli/acpx-runtime-sidecar.ts | 4 +-- .../src/cli/eval-session-entrypoint.test.ts | 31 +++++++++++++++++++ .../paperclip-runner/src/cli/eval-session.ts | 19 +++++++++--- 4 files changed, 67 insertions(+), 8 deletions(-) create mode 100644 packages/paperclip-runner/src/cli/eval-session-entrypoint.test.ts 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 631f08eac9..cf276bfa91 100644 --- a/packages/paperclip-runner/src/cli/acpx-runtime-sidecar.test.ts +++ b/packages/paperclip-runner/src/cli/acpx-runtime-sidecar.test.ts @@ -5,6 +5,8 @@ import { fileURLToPath } from "node:url"; import { afterEach, describe, expect, it, vi } from "vitest"; import { normalizeAcpxPermission } from "../drivers/acpx/acp-permission-adapter.js"; +import { ACPX_CAPABILITY_PROFILES } from "../drivers/acpx/capability-profiles.js"; +import { resolveQualifiedAcpxProfile } from "../drivers/acpx/qualified-profiles.js"; import { ACPX_SIDECAR_PROTOCOL_VERSION } from "../drivers/acpx/sidecar-protocol.js"; import { canonicalProviderEventsFromAcpxRuntimeEvent } from "../provider-events.js"; import { @@ -552,12 +554,27 @@ describe("qualified ACPX runtime sidecar", () => { }, ); + it.each([ + ["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) => { + const sidecar = startSidecar(); + sidecar.write(initializeRequest(1, agent, model)); + const frame = await sidecar.next((value) => value.id === 1); + expect(frame).toMatchObject({ id: 1, ok: true }); + 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"); + }); + it("fails closed after an unsupported provider bootstrap", async () => { const sidecar = startSidecar(); sidecar.write( initializeRequest( 1, - "pi", + "unknown-provider", "openrouter/deepseek/deepseek-v4-flash-0731", ), ); @@ -569,7 +586,7 @@ describe("qualified ACPX runtime sidecar", () => { ok: false, error: { code: "acpx_sidecar_command_failed", - message: "ACPX agent must be claude or codex", + message: "ACPX agent must be claude, codex, cursor, copilot, or pi", retryable: false, }, }); diff --git a/packages/paperclip-runner/src/cli/acpx-runtime-sidecar.ts b/packages/paperclip-runner/src/cli/acpx-runtime-sidecar.ts index 7e0fd1f082..f40c8f4777 100644 --- a/packages/paperclip-runner/src/cli/acpx-runtime-sidecar.ts +++ b/packages/paperclip-runner/src/cli/acpx-runtime-sidecar.ts @@ -1321,8 +1321,8 @@ function requireHost( } function requireQualifiedAgent(value: unknown): QualifiedAcpxAgent { - if (value !== "codex" && value !== "claude") { - throw new Error("ACPX agent must be claude or codex"); + if (value !== "codex" && value !== "claude" && value !== "pi" && value !== "cursor" && value !== "copilot") { + throw new Error("ACPX agent must be claude, codex, cursor, copilot, or pi"); } return value; } diff --git a/packages/paperclip-runner/src/cli/eval-session-entrypoint.test.ts b/packages/paperclip-runner/src/cli/eval-session-entrypoint.test.ts new file mode 100644 index 0000000000..cbc7f5dc3d --- /dev/null +++ b/packages/paperclip-runner/src/cli/eval-session-entrypoint.test.ts @@ -0,0 +1,31 @@ +import { execFile } from "node:child_process"; +import { mkdtemp, realpath, rm, symlink } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { promisify } from "node:util"; + +import { expect, it } from "vitest"; + +const execute = promisify(execFile); +const tsx = fileURLToPath(new URL("../../node_modules/.bin/tsx", import.meta.url)); + +it("runs the eval CLI parser through both canonical and symlinked paths", async () => { + const cli = await realpath(fileURLToPath(new URL("./eval-session.ts", import.meta.url))); + const root = await mkdtemp(join(tmpdir(), "paperclip-eval-cli-entrypoint-")); + try { + const alias = join(root, "cli-alias"); + await symlink(dirname(cli), alias, "dir"); + for (const path of [cli, join(alias, "eval-session.ts")]) { + // A skipped main guard silently exits zero. Rejecting the invalid option + // proves actual CLI execution without loading credentials or a provider. + await expect(execute(tsx, [path, "--unknown", "value"], { timeout: 15_000 })) + .rejects.toMatchObject({ code: 1, stderr: "unknown argument: --unknown\n" }); + } + const imported = await execute(tsx, ["--eval", `import(${JSON.stringify(cli)}).then(() => console.log("imported"))`], { timeout: 15_000 }); + expect(imported.stdout.trim()).toBe("imported"); + expect(imported.stderr).toBe(""); + } finally { + await rm(root, { recursive: true, force: true }); + } +}, 45_000); diff --git a/packages/paperclip-runner/src/cli/eval-session.ts b/packages/paperclip-runner/src/cli/eval-session.ts index 57e888f01b..9eee120d76 100644 --- a/packages/paperclip-runner/src/cli/eval-session.ts +++ b/packages/paperclip-runner/src/cli/eval-session.ts @@ -1,5 +1,6 @@ #!/usr/bin/env node import { createHash } from "node:crypto"; +import { realpathSync } from "node:fs"; import { chmod, mkdir, mkdtemp, readFile, writeFile } from "node:fs/promises"; import { resolve } from "node:path"; import { fileURLToPath } from "node:url"; @@ -254,12 +255,14 @@ export function boundedEvalSessionUsage( throw new Error("agent turn limit exceeded"); } if ( + usage.estimatedCostNanodollars !== null && usage.estimatedCostNanodollars > request.limits.maxEstimatedCostNanodollars ) { throw new Error("estimated cost limit exceeded"); } if ( + usage.providerReportedCostNanodollars !== null && usage.providerReportedCostNanodollars > request.limits.maxEstimatedCostNanodollars ) { @@ -467,10 +470,18 @@ export async function runEvalSessionCli( } } -if ( - process.argv[1] !== undefined && - resolve(process.argv[1]) === resolve(fileURLToPath(import.meta.url)) -) { +function isEvalSessionEntrypoint(): boolean { + if (process.argv[1] === undefined) return false; + try { + // Node resolves module URLs through symlinks, including /tmp on macOS and + // package-manager bin links. Compare the same physical paths on both sides. + return realpathSync(process.argv[1]) === realpathSync(fileURLToPath(import.meta.url)); + } catch { + return false; + } +} + +if (isEvalSessionEntrypoint()) { void runEvalSessionCli(process.argv.slice(2)) .then((code) => { process.exitCode = code;