mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-04 02:32:07 +01:00
feat: default new Apple Silicon Claude agents to Paperclip Runner (#15993)
## Thinking Path > - Paperclip lets users choose an agent harness and account. > - The server resolves the runner for new agents. > - Claude still needs the same ordinary native-default journey as Codex. > - Existing agents must retain their runner, model and account. > - This PR defaults new Apple Silicon Claude agents to Paperclip Runner with Opus 5.5. > - Users can connect and work without a runner override. ## Linked Issues or Issue Description Refs #15683. This PR depends on #15994 and precedes #15997. Related scope: #15422, #13978 and #15224. ## What Changed - Resolve new Apple Silicon Claude agents to `paperclip_runner`, `provider: acpx`, `acpxAgent: claude`. Keep explicit Legacy, models, provider routing and saved execution. - Use `claude-opus-5-5` for ordinary Claude defaults and retain explicit overrides. - Reuse existing API-key/subscription connections, credential storage and checks. Generalize the shared Advanced runner control with Claude branding. - Carry configured effort into the shared v9 contract. Preserve compatible permissions, instructions, skills, timeouts and session behavior. Require Legacy for unsupported settings. - Qualify a new immutable managed Claude target at explicit Connect through the existing permission-gated probe. Use fresh scoped discovery for test and hire. Discard stale results and require an explicit Connect after settings change. - Retain merged Codex onboarding recovery, auth preparation, import/effort corrections and Grok Bot gates. - Check the Claude first-task wizard's saved default directly. Update configuration/runtime documentation and exact Opus 5.5 accounting. - Align hire fixtures with the merged host-environment restriction. Native agent-authenticated hires cannot inject host env settings; both creation endpoints verify that no extra agent is saved. ## Verification - Updated onto master `9179d8dcc3e908c217fde4f63c9d08ccf8132eeb` through #15994. Native input v9 carries effort while preserving v7 home modes and v8 Muse. Codex defaults remain unchanged. - Focused local verification passed 285 server tests, 323 UI tests, 124 setup/registry tests, and two v9 permission-pin transport cases. Direct server/UI TypeScript checks passed. An initial 30-second hook timeout was resolved by serial verification with a longer hook budget: all 122 credential/permission cases and 232 snapshot/environment cases pass. Direct server TypeScript checking also passes. - Ordinary web creation with the experiment disabled saved native Claude, Opus 5.5 and High effort without runner/model overrides. The actual product task and follow-up both succeeded on integrated source `b2c9fe43f9cd3bc5dd9d22552da0148845ba71a4` with the cloud-built daemon from `04e0202ca81387b17cbe7fd094a8338648356fe9`. The final master reconciliation also changes shared runtime source. Historical proof retains its source identity; a final-source cloud qualification is running before refreshing the preview daemon. - Both actual browser downloads were checked: v1 has three sections/six checkboxes (451 bytes); v2 preserves the original body and adds two Rollback checks (612 bytes). The original attachment stayed byte-identical. The follow-up records session resume and the same provider session. Total estimated API usage: $0.518432800, with no pending/unpriced usage in the successful fixture. - Fixed the live-discovered setup incompatibility: native API-key validation no longer depends on the global Legacy Claude CLI. The existing provider endpoint verifies the key without a paid prompt; subscription/custom routing probes remain intact. - Fixed security review's native-hire credential redirection: agent-supplied URLs, proxy/launch settings and other host environment controls are rejected before inheritance. Native credential references remain supported and partial edits use the saved harness. Added create/hire/update regression coverage. - Current-head cloud CI is green. All three stack PRs have fresh Greptile 5/5 reviews and zero unresolved review threads. Required typecheck, test and build gates ran in cloud CI; Docker work stayed remote. Earlier failed checks remain in their original runs. - Docker and native release builds ran in cloud infrastructure. An inadvertent local Rust prerequisite was stopped; no local Docker build was run. - The final master merge preserves task-loading, permission, skill-retention and OpenRouter changes. Pi uses a fresh combined profile 27, preserving both historical profile-24 declarations; Copilot remains profile 22. Focused protocol checks passed 155 cases (one skipped), the updated Pi attestation check passed 18 (one skipped), and 91 evaluation/catalog checks passed. - Latest-application manual continuation passed in 37.942 seconds on `695e5c005a85bddc8711d3fcddcaf6b7e1fb64d0`, with High effort, native Opus 5.5, Anthropic API attribution, the same resumed Claude session, an independently checked 635-byte file and persisted Done status. It uses the earlier verified Mac daemon; the preceding-source package qualification passed at `dca62c7aeb4b45f0dd91a8e18d386a16a2658ef8`. That package run predates the small TypeScript startup fix and is not relabeled as qualification for it. - Upgrade limitation: changed compiled sidecar identity rejected old durable launch state and recovery hit session-open bounds before eventually resuming. That attempt was operator-cancelled and retained; the successful manual continuation does not qualify seamless upgrades. - Local latency profile, Opus 5.5/High unchanged: three alternating real-ACP loopback samples per variant reduce median provider-ready time from 5.081s to 4.034s. The matched real API task startup improves from 5.533s to 4.348s, but total reply time changes from 17.933s to 20.221s because agent work varies. This establishes a startup saving of about one second, not a consistent total-reply speedup. A real candidate follow-up passed in 20.146s and resumed the same saved session. The loopback fixture makes no external model requests; failed initial probes remain retained. - The startup change passes 107 focused runtime/effort/lease tests, 53 profile checks (one skipped), TypeScript checking and the TypeScript-only runtime build. Native hire fixture checks pass 70 tests; their first local run retained a socket-hangup failure and stale OpenCode expectation, both accounted for before the passing rerun. Broader required checks run in cloud CI. ## Risks - Missing packages, authentication rejection, unsupported settings and unavailable models must fail explicitly. Existing agents retain saved execution; explicit runner changes use existing revision/session invalidation. - Subscription follow-up remains unverified; remaining live acceptance uses API keys per human steering. Historical accounting holds and the separate $50 ceiling remain intact. - The subscription acceptance limit remains explicit for review. No Claude database migration is introduced. ## Model Used OpenAI Codex, GPT-6 family, with reasoning, repository tools, code execution and browser testing. The exact serving model ID and context-window size are not exposed in this session. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have searched GitHub for duplicate or related PRs and linked them above - [x] I have either (a) linked existing issues with `Fixes: #` / `Closes #` / `Refs #` OR (b) described the issue in-PR following the relevant issue template - [x] I have not referenced internal/instance-local Paperclip issues or links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip` URLs) - [x] My branch name describes the change (e.g. `docs/...`, `fix/...`) and contains no internal Paperclip ticket id or instance-derived details - [ ] Focused local tests and final cloud verification pass - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [x] All Paperclip CI gates are green - [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge --------- Co-authored-by: Paperclip <noreply@paperclip.ing>
This commit is contained in:
1 parent
9e8a9f28d5
commit
45b67f1d93
93 files changed
+3718
-587
No files matched your search
@@ -712,7 +712,7 @@ describe("agent routes adapter validation", () => {
|
||||
expect(mockAgentService.create).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rechecks selection policy when a saved native adapter switches its underlying harness", async () => {
|
||||
it("rechecks selection policy when a saved native adapter switches to an experimentally gated harness", async () => {
|
||||
mockAgentService.getById.mockResolvedValue({ ...(await mockAgentService.getById()), adapterType: "paperclip_runner", adapterConfig: { provider: "codex" } });
|
||||
mockInstanceSettingsService.getExperimental.mockResolvedValue({ enableNativeRunner: false });
|
||||
const probe = vi.fn();
|
||||
@@ -722,7 +722,7 @@ describe("agent routes adapter validation", () => {
|
||||
const app = await createApp();
|
||||
const res = await requestApp(app, baseUrl => request(baseUrl)
|
||||
.post("/api/companies/company-1/adapters/paperclip_runner/test-environment")
|
||||
.send({ agentId: "11111111-1111-4111-8111-111111111111", adapterConfig: { provider: "acpx", acpxAgent: "claude" } }));
|
||||
.send({ agentId: "11111111-1111-4111-8111-111111111111", adapterConfig: { provider: "acpx", acpxAgent: "pi" } }));
|
||||
expect(res.status, JSON.stringify(res.body)).toBe(422);
|
||||
expect(res.body.details).toMatchObject({ code: "paperclip_runner_rollout_disabled" });
|
||||
expect(probe).not.toHaveBeenCalled();
|
||||
@@ -762,9 +762,9 @@ describe("agent routes adapter validation", () => {
|
||||
expect(mockAgentService.create).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("keeps non-Codex native provider selection behind its existing flag", async () => {
|
||||
it("keeps other native provider selection behind its existing flag", async () => {
|
||||
const app = await createApp();
|
||||
const response = await requestApp(app, baseUrl => request(baseUrl).post("/api/companies/company-1/agents").send({ name: "Native Claude", adapterType: "paperclip_runner", adapterConfig: { provider: "acpx", acpxAgent: "claude", model: "claude-sonnet-5" } }));
|
||||
const response = await requestApp(app, baseUrl => request(baseUrl).post("/api/companies/company-1/agents").send({ name: "Native OpenCode", adapterType: "paperclip_runner", adapterConfig: { provider: "opencode", model: "openrouter/deepseek/deepseek-v4-flash-0731" } }));
|
||||
expect(response.status).toBe(422);
|
||||
expect(response.body.details).toMatchObject({ code: "paperclip_runner_rollout_disabled" });
|
||||
});
|
||||
|
||||
@@ -134,7 +134,7 @@ describe("agent-created hires use managed AI connections", () => {
|
||||
const f = await fixture("openai");
|
||||
const p = await poolFixture(f);
|
||||
await db.update(agents).set({ runtimeConfig: { aiConnection: p.binding } }).where(eq(agents.id, f.agentId));
|
||||
const response = await request(f.app).post(`/api/companies/${f.companyId}/agent-hires`).send({ name: "Incompatible teammate", role: "engineer", adapterType: "claude_local" });
|
||||
const response = await request(f.app).post(`/api/companies/${f.companyId}/agent-hires`).send({ name: "Incompatible teammate", role: "engineer", adapterType: "claude_local", runner: "legacy" });
|
||||
expect(response.status, JSON.stringify(response.body)).toBe(422);
|
||||
// Fixed bindings contain identity only; compatibility is decided from
|
||||
// authoritative connection metadata when selecting each pool member.
|
||||
@@ -155,7 +155,7 @@ describe("agent-created hires use managed AI connections", () => {
|
||||
["opencode_local", {}, "PAPERCLIP_OPENCODE_PROVIDERS"],
|
||||
["opencode_local", { model: "anthropic/claude-sonnet-5" }, "OPENCODE_AUTH_JSON"],
|
||||
["paperclip_runner", { provider: "opencode", model: "anthropic/claude-sonnet-5" }, "OPENCODE_CONFIG_CONTENT"],
|
||||
] as const)("%s hires with %j keep explicit %s auth outside the managed OpenRouter model catalog", async (adapterType, config, key) => {
|
||||
] as const)("%s hires with %j apply the host boundary to explicit %s auth", async (adapterType, config, key) => {
|
||||
const f = await fixture("openai");
|
||||
const p = await poolFixture(f);
|
||||
await db.update(agents).set({ runtimeConfig: { aiConnection: p.binding } }).where(eq(agents.id, f.agentId));
|
||||
@@ -163,9 +163,17 @@ describe("agent-created hires use managed AI connections", () => {
|
||||
const value = JSON.stringify(key === "PAPERCLIP_OPENCODE_PROVIDERS" ? { anthropic: provider }
|
||||
: key === "OPENCODE_CONFIG_CONTENT" ? { provider: { anthropic: provider } }
|
||||
: { anthropic: { type: "api", key: "fixture-explicit-key" } });
|
||||
const agent = hired(await request(f.app).post(`/api/companies/${f.companyId}/agent-hires`).send({
|
||||
const response = await request(f.app).post(`/api/companies/${f.companyId}/agent-hires`).send({
|
||||
name: "Independent OpenCode teammate", role: "engineer", adapterType, adapterConfig: { ...config, env: { [key]: value } },
|
||||
}));
|
||||
});
|
||||
if (adapterType === "paperclip_runner") {
|
||||
expect(response.status).toBe(403);
|
||||
expect(response.body.error).toContain("host-executed local adapter settings");
|
||||
const savedAgents = await db.select({ id: agents.id }).from(agents).where(eq(agents.companyId, f.companyId));
|
||||
expect(savedAgents.map(agent => agent.id)).toEqual([f.agentId]);
|
||||
return;
|
||||
}
|
||||
const agent = hired(response);
|
||||
expect(agent.runtimeConfig.aiConnection).toBeUndefined();
|
||||
expect(agent.adapterConfig.env[key]).toEqual({ type: "plain", value });
|
||||
expect(await db.select().from(toolConnectionInstalls).where(eq(toolConnectionInstalls.targetId, agent.id))).toEqual([]);
|
||||
@@ -385,19 +393,16 @@ describe("agent-created hires use managed AI connections", () => {
|
||||
["openai", "claude_local", {}, "OPENAI_API_KEY"],
|
||||
["anthropic", "paperclip_runner", { provider: "codex" }, "ANTHROPIC_API_KEY"],
|
||||
["openai", "paperclip_runner", { provider: "acpx", acpxAgent: "claude" }, "OPENAI_API_KEY"],
|
||||
] as const)(`${endpoint}: ignores the %s auth key for a different provider in %s`, async (provider, adapterType, config, key) => {
|
||||
] as const)(`${endpoint}: rejects host env settings for a %s caller hiring %s`, async (provider, adapterType, config, key) => {
|
||||
const f = await fixture(provider);
|
||||
const response = await request(f.app).post(`/api/companies/${f.companyId}/${endpoint}`).send({
|
||||
name: "Cross-provider config", role: "engineer", adapterType,
|
||||
adapterConfig: { ...config, env: { [key]: "leftover-parent-setting" } },
|
||||
});
|
||||
if (adapterType.endsWith("_local")) {
|
||||
expect(response.status).toBe(403);
|
||||
expect(response.body.error).toContain("host-executed local adapter settings");
|
||||
return;
|
||||
}
|
||||
const agent = hired(response);
|
||||
expect(agent.runtimeConfig.aiConnection).toMatchObject({ provider: provider === "anthropic" ? "openai" : "anthropic", mode: "responsible_user" });
|
||||
expect(response.status).toBe(403);
|
||||
expect(response.body.error).toContain("host-executed local adapter settings");
|
||||
const savedAgents = await db.select({ id: agents.id }).from(agents).where(eq(agents.companyId, f.companyId));
|
||||
expect(savedAgents.map(agent => agent.id)).toEqual([f.agentId]);
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -265,6 +265,34 @@ describeEmbeddedPostgres("hired agent provider credential inheritance", () => {
|
||||
expect(childEnvOf(res).XAI_API_KEY).toMatchObject({ type: "secret_ref", secretId: secret.id });
|
||||
});
|
||||
|
||||
it.each(["create", "hire"] as const)("blocks native Claude credential redirection before %s inheritance", async mode => {
|
||||
const companyId = await seedCompany();
|
||||
const secret = await createCompanySecret(companyId, "fixture-parent-credential");
|
||||
const parent = await seedParentAgent(companyId, "paperclip_runner", { ANTHROPIC_API_KEY: secretRef(secret.id) });
|
||||
await db.update(agents).set({ adapterConfig: { provider: "acpx", acpxAgent: "claude", env: { ANTHROPIC_API_KEY: secretRef(secret.id) } } }).where(eq(agents.id, parent.id));
|
||||
const app = createApp(db, agentActor(companyId, parent.id));
|
||||
const res = await request(app).post(`/api/companies/${companyId}/${mode === "hire" ? "agent-hires" : "agents"}`).send({
|
||||
name: "Redirected child", role: "engineer", adapterType: "paperclip_runner",
|
||||
adapterConfig: { provider: "acpx", acpxAgent: "claude", env: { ANTHROPIC_BASE_URL: "https://untrusted.example.test" } },
|
||||
});
|
||||
expect(res.status, JSON.stringify(res.body)).toBe(403);
|
||||
expect(res.body.error).toContain("host-executed local adapter settings");
|
||||
expect(await db.select().from(agents).where(eq(agents.companyId, companyId))).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("still inherits Claude credential references for an ordinary native hire", async () => {
|
||||
const companyId = await seedCompany();
|
||||
const secret = await createCompanySecret(companyId, "fixture-parent-credential");
|
||||
const parent = await seedParentAgent(companyId, "claude_local", { ANTHROPIC_API_KEY: secretRef(secret.id) });
|
||||
const res = await hire(agentActor(companyId, parent.id), companyId, {
|
||||
name: "Native child", role: "engineer", adapterType: "paperclip_runner",
|
||||
adapterConfig: { provider: "acpx", acpxAgent: "claude" },
|
||||
});
|
||||
expect(res.status, JSON.stringify(res.body)).toBe(201);
|
||||
expect(res.body.agent.adapterType).toBe("paperclip_runner");
|
||||
expect(childEnvOf(res).ANTHROPIC_API_KEY).toMatchObject({ type: "secret_ref", secretId: secret.id });
|
||||
});
|
||||
|
||||
it("inherits a claude_local parent's ANTHROPIC_API_KEY reference", async () => {
|
||||
const companyId = await seedCompany();
|
||||
const secret = await createCompanySecret(companyId, "ant-parent-key");
|
||||
@@ -499,13 +527,13 @@ describeEmbeddedPostgres("hired agent provider credential inheritance", () => {
|
||||
const parent = await seedParentAgent(companyId, "paperclip_runner", {});
|
||||
await db.update(agents).set({ adapterConfig: {
|
||||
provider: "acpx", acpxAgent: "claude", model: "claude-sonnet-5", acpxPermissionMode: "approve-all",
|
||||
env: { ANTHROPIC_API_KEY: secretRef("parent-secret") }, cwd: "/private/parent",
|
||||
env: { PRIVATE_PARENT_TOKEN: secretRef("parent-secret") }, cwd: "/private/parent",
|
||||
invocationLimits: { maxIterations: 3, credentials: "never-copy" },
|
||||
} }).where(eq(agents.id, parent.id));
|
||||
const res = await hire(agentActor(companyId, parent.id), companyId, {
|
||||
name: "Approval Snapshot Child", role: "engineer", adapterType: "paperclip_runner", inheritRuntimeFrom: "caller",
|
||||
});
|
||||
expect(res.status).toBe(201);
|
||||
expect(res.status, JSON.stringify(res.body)).toBe(201);
|
||||
expect(res.body.agent.status).toBe("pending_approval");
|
||||
expect(res.body.approval.payload.inheritRuntimeFrom).toBeUndefined();
|
||||
expect(res.body.approval.payload.adapterConfig).toMatchObject({ provider: "acpx", acpxAgent: "claude", acpxPermissionMode: "approve-all" });
|
||||
|
||||
@@ -1027,6 +1027,26 @@ describe("agent permission routes", () => {
|
||||
expect(mockAgentService.update).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it.each(["ANTHROPIC_BASE_URL", "HTTP_PROXY", "NODE_OPTIONS"])("blocks agent-supplied %s on native Claude updates", async key => {
|
||||
mockAgentService.getById.mockResolvedValue({ ...baseAgent, adapterType: "paperclip_runner",
|
||||
adapterConfig: { provider: "acpx", acpxAgent: "claude" } });
|
||||
const app = await createApp({ type: "agent", agentId: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa", companyId, source: "agent_key", runId: "run-1" });
|
||||
const res = await requestApp(app, baseUrl => request(baseUrl).patch(`/api/agents/${agentId}`)
|
||||
.send({ adapterConfig: { env: { [key]: "untrusted-setting" } } }));
|
||||
expect(res.status).toBe(403);
|
||||
expect(mockAgentService.update).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("allows native Claude credential references in partial updates using the saved harness", async () => {
|
||||
mockAgentService.getById.mockResolvedValue({ ...baseAgent, adapterType: "paperclip_runner",
|
||||
adapterConfig: { provider: "acpx", acpxAgent: "claude" } });
|
||||
const app = await createApp({ type: "agent", agentId: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa", companyId, source: "agent_key", runId: "run-1" });
|
||||
const res = await requestApp(app, baseUrl => request(baseUrl).patch(`/api/agents/${agentId}`)
|
||||
.send({ adapterConfig: { env: { ANTHROPIC_API_KEY: { type: "secret_ref", secretId: "33333333-3333-4333-8333-333333333333" } } } }));
|
||||
expect(res.status, JSON.stringify(res.body)).toBe(200);
|
||||
expect(mockAgentService.update).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("blocks an agent from activating inherited host settings by switching onto a local adapter", async () => {
|
||||
mockAgentService.getById.mockResolvedValue({
|
||||
...baseAgent,
|
||||
@@ -1853,8 +1873,8 @@ describe("agent permission routes", () => {
|
||||
}),
|
||||
{ runnerResolved: true, aiConnectionInstall: undefined, createdByUserId: "board-user", responsibleUserId: "board-user", claudeLogin: { storedSessionId: null, ownerUserId: "board-user", applyExistingWithoutClaim: false } },
|
||||
);
|
||||
if (adapterCase.adapterType === "codex_local") {
|
||||
expect(mockAgentService.create.mock.calls[0][1].adapterConfig).toMatchObject({ provider: "codex" });
|
||||
if (["codex_local", "claude_local"].includes(adapterCase.adapterType)) {
|
||||
if (adapterCase.adapterType === "codex_local") expect(mockAgentService.create.mock.calls[0][1].adapterConfig).toMatchObject({ provider: "codex" });
|
||||
const { AGENT_RUNNER_SSH_PLATFORM_COMMAND } = await import("../services/agent-runner-selection.js");
|
||||
expect(mockRunSshCommand).toHaveBeenCalledWith(expect.objectContaining({ host: "qa.example.invalid" }), AGENT_RUNNER_SSH_PLATFORM_COMMAND, { timeoutMs: 10_000 });
|
||||
} else {
|
||||
|
||||
@@ -909,6 +909,7 @@ describe("agent skill routes", () => {
|
||||
name: "QA Agent",
|
||||
role: "engineer",
|
||||
adapterType: "claude_local",
|
||||
runner: "legacy",
|
||||
desiredSkills: ["paperclip"],
|
||||
adapterConfig: {},
|
||||
}));
|
||||
@@ -942,6 +943,7 @@ describe("agent skill routes", () => {
|
||||
name: "QA Agent",
|
||||
role: "engineer",
|
||||
adapterType: "claude_local",
|
||||
runner: "legacy",
|
||||
desiredSkills: [{
|
||||
key: "paperclipai/paperclip/paperclip",
|
||||
versionId: "22222222-2222-4222-8222-222222222222",
|
||||
@@ -961,6 +963,7 @@ describe("agent skill routes", () => {
|
||||
name: "Security Engineer",
|
||||
role: "security",
|
||||
adapterType: "claude_local",
|
||||
runner: "legacy",
|
||||
adapterConfig: {},
|
||||
}));
|
||||
|
||||
@@ -992,6 +995,7 @@ describe("agent skill routes", () => {
|
||||
name: "QA Agent",
|
||||
role: "engineer",
|
||||
adapterType: "claude_local",
|
||||
runner: "legacy",
|
||||
adapterConfig: {},
|
||||
instructionsBundle: {
|
||||
files: {
|
||||
@@ -1028,6 +1032,7 @@ describe("agent skill routes", () => {
|
||||
name: "QA Agent",
|
||||
role: "engineer",
|
||||
adapterType: "claude_local",
|
||||
runner: "legacy",
|
||||
adapterConfig: {
|
||||
instructionsFilePath: "/tmp/existing/AGENTS.md",
|
||||
promptTemplate: "You are QA.",
|
||||
@@ -1054,6 +1059,7 @@ describe("agent skill routes", () => {
|
||||
name: "CEO",
|
||||
role: "ceo",
|
||||
adapterType,
|
||||
...(adapterType === "claude_local" ? { runner: "legacy" } : {}),
|
||||
adapterConfig: adapterType === "paperclip_runner" ? { provider: "codex" } : {},
|
||||
}));
|
||||
|
||||
@@ -1084,6 +1090,7 @@ describe("agent skill routes", () => {
|
||||
name: "Research Lead",
|
||||
role: "ceo",
|
||||
adapterType,
|
||||
...(adapterType === "claude_local" ? { runner: "legacy" } : {}),
|
||||
adapterConfig: adapterType === "paperclip_runner" ? { provider: "codex" } : {},
|
||||
instructionsBundle: { entryFile: "AGENTS.md", files: customFiles },
|
||||
}));
|
||||
@@ -1110,6 +1117,7 @@ describe("agent skill routes", () => {
|
||||
name: "Engineer",
|
||||
role: "engineer",
|
||||
adapterType: "claude_local",
|
||||
runner: "legacy",
|
||||
adapterConfig: {},
|
||||
}));
|
||||
|
||||
@@ -1137,6 +1145,7 @@ describe("agent skill routes", () => {
|
||||
name: "Ada",
|
||||
role: "general",
|
||||
adapterType: "claude_local",
|
||||
runner: "legacy",
|
||||
adapterConfig: {},
|
||||
onboardingFirstAgent: true,
|
||||
}));
|
||||
@@ -1168,7 +1177,12 @@ describe("agent skill routes", () => {
|
||||
{ adapterType: "codex_local", runner: "legacy", platform: "darwin", expectedType: "codex_local" },
|
||||
{ adapterType: "codex_local", runner: "auto", platform: "win32", expectedType: "codex_local" },
|
||||
];
|
||||
it.each([...codexRunnerCases, { adapterType: "claude_local", runner: "auto", platform: "linux", expectedType: "claude_local" }].flatMap((runnerCase) => [
|
||||
const creationRunnerCases = [...codexRunnerCases,
|
||||
{ adapterType: "claude_local", runner: "auto", platform: "linux", expectedType: "claude_local" },
|
||||
{ adapterType: "claude_local", runner: "auto", platform: "darwin", expectedType: "paperclip_runner" },
|
||||
{ adapterType: "claude_local", runner: "legacy", platform: "darwin", expectedType: "claude_local" },
|
||||
];
|
||||
it.each(creationRunnerCases.flatMap((runnerCase) => [
|
||||
["agents", "paperclipai/paperclip/paperclip-create-agent"],
|
||||
["agent-hires", "paperclipai/paperclip/paperclip-create-agent"],
|
||||
["agents", "paperclip"],
|
||||
@@ -1192,7 +1206,7 @@ describe("agent skill routes", () => {
|
||||
expect(input.role).toBe("general");
|
||||
expect(input.adapterType).toBe(expectedType);
|
||||
const native = expectedType === "paperclip_runner";
|
||||
if (native) expect(input.adapterConfig.provider).toBe("codex");
|
||||
if (native) expect(input.adapterConfig).toMatchObject(adapterType === "claude_local" ? { provider: "acpx", acpxAgent: "claude", model: "claude-opus-5-5" } : { provider: "codex" });
|
||||
const canonicalKey = skill === "paperclip" ? "paperclipai/paperclip/paperclip" : skill;
|
||||
const expected = ["paperclip", "paperclip-board", "paperclip-converting-plans-to-tasks", "paperclip-create-agent", "para-memory-files", "first-task"]
|
||||
.filter((name) => !native || name !== "paperclip")
|
||||
@@ -1211,17 +1225,17 @@ describe("agent skill routes", () => {
|
||||
expect(mockAgentService.create.mock.calls[0][1].adapterConfig.paperclipSkillSync).toBeUndefined();
|
||||
});
|
||||
|
||||
it.each(codexRunnerCases.flatMap((runnerCase) => ["agents", "agent-hires"].map((route) => ({ ...runnerCase, route }))))("assigns core skills without first-task to ordinary CEOs via $route using $runner on $platform", async ({ route, runner, platform, expectedType }) => {
|
||||
it.each(creationRunnerCases.flatMap((runnerCase) => ["agents", "agent-hires"].map((route) => ({ ...runnerCase, route }))))("assigns core skills without first-task to ordinary CEOs via $route using $runner on $platform", async ({ route, runner, platform, expectedType, adapterType }) => {
|
||||
runnerTarget.platform = platform;
|
||||
runnerTarget.architecture = platform === "darwin" ? "arm64" : "x64";
|
||||
const res = await request(await createApp())
|
||||
.post(`/api/companies/company-1/${route}`)
|
||||
.send({ name: "CEO", role: "ceo", adapterType: "codex_local", ...(runner === "auto" ? {} : { runner }) });
|
||||
.send({ name: "CEO", role: "ceo", adapterType, ...(runner === "auto" ? {} : { runner }) });
|
||||
expect(res.status, JSON.stringify(res.body)).toBe(201);
|
||||
const input = mockAgentService.create.mock.calls[0][1];
|
||||
expect(input.adapterType).toBe(expectedType);
|
||||
const native = expectedType === "paperclip_runner";
|
||||
if (native) expect(input.adapterConfig.provider).toBe("codex");
|
||||
if (native) expect(input.adapterConfig).toMatchObject(adapterType === "claude_local" ? { provider: "acpx", acpxAgent: "claude", model: "claude-opus-5-5" } : { provider: "codex" });
|
||||
const desiredSkills = input.adapterConfig.paperclipSkillSync.desiredSkills;
|
||||
const expected = ["paperclip", "paperclip-board", "paperclip-converting-plans-to-tasks", "paperclip-create-agent", "para-memory-files"]
|
||||
.filter((name) => !native || name !== "paperclip")
|
||||
@@ -1237,7 +1251,8 @@ describe("agent skill routes", () => {
|
||||
type: "agent", agentId: "11111111-1111-4111-8111-111111111111", companyId: "company-1",
|
||||
}))
|
||||
.post("/api/companies/company-1/agent-hires")
|
||||
.send({ name: "Biff", role: "general", adapterType: "claude_local", onboardingFirstAgent: true });
|
||||
.send({ name: "Biff", role: "general", adapterType: "claude_local",
|
||||
runner: "legacy", onboardingFirstAgent: true });
|
||||
expect(res.status, JSON.stringify(res.body)).toBe(201);
|
||||
expect(mockAgentService.create.mock.calls[0][1].adapterConfig.paperclipSkillSync).toBeUndefined();
|
||||
await vi.waitFor(() => expect(mockAgentInstructionsService.materializeManagedBundle).toHaveBeenCalled());
|
||||
@@ -1272,6 +1287,7 @@ describe("agent skill routes", () => {
|
||||
name: "First Lead",
|
||||
role: "ceo",
|
||||
adapterType: "claude_local",
|
||||
runner: "legacy",
|
||||
adapterConfig: {},
|
||||
});
|
||||
|
||||
@@ -1329,6 +1345,7 @@ describe("agent skill routes", () => {
|
||||
name: "First Lead",
|
||||
role: "ceo",
|
||||
adapterType: "claude_local",
|
||||
runner: "legacy",
|
||||
desiredSkills: ["paperclip"],
|
||||
adapterConfig: {},
|
||||
});
|
||||
@@ -1350,6 +1367,7 @@ describe("agent skill routes", () => {
|
||||
name: "QA Agent",
|
||||
role: "engineer",
|
||||
adapterType: "claude_local",
|
||||
runner: "legacy",
|
||||
adapterConfig: {},
|
||||
});
|
||||
|
||||
@@ -1367,6 +1385,7 @@ describe("agent skill routes", () => {
|
||||
name: "QA Agent",
|
||||
role: "engineer",
|
||||
adapterType: "claude_local",
|
||||
runner: "legacy",
|
||||
desiredSkills: [{
|
||||
key: "paperclipai/paperclip/paperclip",
|
||||
versionId: "22222222-2222-4222-8222-222222222222",
|
||||
@@ -1391,6 +1410,7 @@ describe("agent skill routes", () => {
|
||||
role: "engineer",
|
||||
icon: "crown",
|
||||
adapterType: "claude_local",
|
||||
runner: "legacy",
|
||||
desiredSkills: ["paperclip"],
|
||||
adapterConfig: {},
|
||||
sourceIssueId,
|
||||
@@ -1445,6 +1465,7 @@ describe("agent skill routes", () => {
|
||||
name: "QA Agent",
|
||||
role: "engineer",
|
||||
adapterType: "claude_local",
|
||||
runner: "legacy",
|
||||
adapterConfig: {},
|
||||
instructionsBundle: {
|
||||
files: {
|
||||
@@ -1481,6 +1502,7 @@ describe("agent skill routes", () => {
|
||||
name: "QA Agent",
|
||||
role: "engineer",
|
||||
adapterType: "claude_local",
|
||||
runner: "legacy",
|
||||
adapterConfig: {
|
||||
instructionsFilePath: "/tmp/existing/AGENTS.md",
|
||||
promptTemplate: "You are QA.",
|
||||
|
||||
@@ -48,6 +48,16 @@ const withEnv = (env: Record<string, unknown>) => ({ env });
|
||||
// --- The pure server-enforced binding invariant (no database) ----------------
|
||||
|
||||
describe("assertClaudeOAuthBindingInvariant", () => {
|
||||
it.each([undefined, "claude"])("enforces the fixed binding for native Claude (agent: %s)", acpxAgent => {
|
||||
const nextConfig = { provider: "acpx", ...(acpxAgent && { acpxAgent }), ...withEnv({ CLAUDE_CODE_OAUTH_TOKEN: FIXED_BINDING }) };
|
||||
expect(assertClaudeOAuthBindingInvariant({ adapterType: "paperclip_runner", nextConfig }))
|
||||
.toEqual({ introducesBinding: true, keepsBinding: false });
|
||||
expect(() => assertClaudeOAuthBindingInvariant({ adapterType: "paperclip_runner", nextConfig: {
|
||||
...nextConfig, env: { ...nextConfig.env, ANTHROPIC_API_KEY: "fixture-api-key" },
|
||||
} })).toThrow(CLAUDE_OAUTH_CREDENTIAL_CONFLICT);
|
||||
expect(assertClaudeOAuthBindingInvariant({ adapterType: "paperclip_runner", nextConfig: { ...nextConfig, acpxAgent: "codex" } }))
|
||||
.toEqual({ introducesBinding: false, keepsBinding: false });
|
||||
});
|
||||
it("reports that a create introduces the fixed binding", () => {
|
||||
const decision = assertClaudeOAuthBindingInvariant({
|
||||
adapterType: "claude_local",
|
||||
@@ -312,6 +322,7 @@ describeEmbeddedPostgres("agent service Claude OAuth binding claim", () => {
|
||||
role: "engineer",
|
||||
status: "idle" as const,
|
||||
adapterType: "claude_local",
|
||||
runner: "legacy" as const,
|
||||
defaultEnvironmentId: scope.environmentId,
|
||||
adapterConfig: { env: { CLAUDE_CODE_OAUTH_TOKEN: { ...FIXED_BINDING }, ...extraEnv } },
|
||||
runtimeConfig: {},
|
||||
@@ -359,11 +370,13 @@ describeEmbeddedPostgres("agent service Claude OAuth binding claim", () => {
|
||||
return rows.length;
|
||||
}
|
||||
|
||||
it("inserts the fixed binding and consumes a valid claim exactly once", async () => {
|
||||
it.each([false, true])("inserts the fixed binding and consumes a valid claim exactly once (native: %s)", async native => {
|
||||
const scope = await seedScope();
|
||||
const sessionId = await seedStoredClaim(scope, Date.now() + 60_000);
|
||||
const legacyInput = createInput(scope);
|
||||
const input = native ? { ...legacyInput, runner: undefined, adapterType: "paperclip_runner", adapterConfig: { provider: "acpx", acpxAgent: "claude", ...legacyInput.adapterConfig } } : legacyInput;
|
||||
|
||||
const created = await createAgentLifecycle(db).requestHire(scope.companyId, createInput(scope), {
|
||||
const created = await createAgentLifecycle(db).requestHire(scope.companyId, input, {
|
||||
claudeLogin: { storedSessionId: sessionId, ownerUserId: scope.ownerUserId },
|
||||
});
|
||||
|
||||
@@ -377,7 +390,7 @@ describeEmbeddedPostgres("agent service Claude OAuth binding claim", () => {
|
||||
|
||||
// A replay of the same claim inserts no second agent.
|
||||
await expect(
|
||||
createAgentLifecycle(db).requestHire(scope.companyId, createInput(scope), {
|
||||
createAgentLifecycle(db).requestHire(scope.companyId, input, {
|
||||
claudeLogin: { storedSessionId: sessionId, ownerUserId: scope.ownerUserId },
|
||||
}),
|
||||
).rejects.toMatchObject({ message: CLAUDE_OAUTH_CLAIM_REJECTED });
|
||||
@@ -616,6 +629,7 @@ describeEmbeddedPostgres("agent service Claude OAuth binding claim", () => {
|
||||
role: "engineer",
|
||||
status: "idle",
|
||||
adapterType: "claude_local",
|
||||
runner: "legacy",
|
||||
defaultEnvironmentId: scope.environmentId,
|
||||
adapterConfig: { env: {} },
|
||||
runtimeConfig: {},
|
||||
@@ -680,6 +694,7 @@ describeEmbeddedPostgres("agent service Claude OAuth binding claim", () => {
|
||||
role: "engineer",
|
||||
status: "idle",
|
||||
adapterType: "claude_local",
|
||||
runner: "legacy",
|
||||
defaultEnvironmentId: scope.environmentId,
|
||||
adapterConfig: { env: {} },
|
||||
runtimeConfig: {},
|
||||
@@ -755,6 +770,7 @@ describeEmbeddedPostgres("agent service Claude OAuth binding claim", () => {
|
||||
role: "engineer",
|
||||
status: "idle",
|
||||
adapterType: "claude_local",
|
||||
runner: "legacy",
|
||||
defaultEnvironmentId: scope.environmentId,
|
||||
adapterConfig: { env: {} },
|
||||
runtimeConfig: {},
|
||||
|
||||
@@ -202,6 +202,9 @@ describe("durable, authorized connection routing", () => {
|
||||
expect(changed.binding).toEqual(first.binding); expect(changed.runtimeConfig).toMatchObject({ provider: "codex", model: "gpt-5.6-sol", modelReasoningEffort: "high" }); expect(changed.notes).toHaveLength(1);
|
||||
const claude = poolMemberRuntimeConfig(members[1]!, "paperclip_runner", { model: "gpt-5.6-sol", modelReasoningEffort: "ultra" });
|
||||
expect(claude.config).toMatchObject({ provider: "acpx", acpxAgent: "claude", model: "claude-sonnet-5" }); expect(claude.notes).toHaveLength(2);
|
||||
const claudeEffort = poolMemberRuntimeConfig(members[1]!, "paperclip_runner", { modelReasoningEffort: "xhigh" });
|
||||
expect(claudeEffort.config).toMatchObject({ provider: "acpx", acpxAgent: "claude", modelReasoningEffort: "xhigh" });
|
||||
expect(claudeEffort.notes).toEqual([]);
|
||||
const overrides = applyAiConnectionRouterTaskSettings({ model: "claude-sonnet-5", effort: "invalid", cwd: "/fixture" }, changed);
|
||||
const native = resolvePaperclipRunnerNativeProviderInput({ backend: "codex_app_server", adapterConfig: projectPaperclipRunnerTaskConfig("codex_app_server", changed.runtimeConfig, overrides) });
|
||||
expect(native).toMatchObject({ provider: "codex", model: "gpt-5.6-sol", codexReasoningEffort: "high" });
|
||||
|
||||
@@ -48,6 +48,7 @@ afterEach(() => {
|
||||
|
||||
async function writeHelpWithoutEffortClaudeCommand(commandPath: string): Promise<void> {
|
||||
const script = `#!/usr/bin/env node
|
||||
if (process.argv.includes("--version")) { console.log("2.1.286 (Claude Code)"); process.exit(0); }
|
||||
const argv = process.argv.slice(2);
|
||||
if (argv.includes("--help")) {
|
||||
process.stdout.write("Usage: claude [options]\\n --print\\n --model <id>\\n");
|
||||
@@ -306,6 +307,7 @@ describe("claude_local environment diagnostics", () => {
|
||||
adapterType: "claude_local",
|
||||
config: {
|
||||
engine: "cli",
|
||||
model: "claude-opus-5",
|
||||
command: "claude",
|
||||
},
|
||||
executionTarget: {
|
||||
@@ -379,6 +381,7 @@ describe("claude_local environment diagnostics", () => {
|
||||
await fs.writeFile(path.join(sourceConfigDir, "credentials.json"), JSON.stringify({ token: "local" }), "utf8");
|
||||
await fs.writeFile(path.join(remoteHome, ".claude", ".credentials.json"), JSON.stringify({ token: "remote" }), "utf8");
|
||||
await fs.writeFile(commandPath, `#!/usr/bin/env node
|
||||
if (process.argv.includes("--version")) { console.log("2.1.286 (Claude Code)"); process.exit(0); }
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
const configDir = process.env.CLAUDE_CONFIG_DIR || "";
|
||||
|
||||
@@ -20,6 +20,7 @@ async function writeFailingClaudeCommand(
|
||||
const payload = JSON.stringify(options.resultEvent);
|
||||
const exit = options.exitCode ?? 1;
|
||||
const script = `#!/usr/bin/env node
|
||||
if (process.argv.includes("--version")) { console.log("2.1.286 (Claude Code)"); process.exit(0); }
|
||||
console.log(${JSON.stringify(payload)});
|
||||
process.exit(${exit});
|
||||
`;
|
||||
@@ -33,6 +34,7 @@ async function writeTextFailingClaudeCommand(
|
||||
): Promise<void> {
|
||||
const exit = options.exitCode ?? 1;
|
||||
const script = `#!/usr/bin/env node
|
||||
if (process.argv.includes("--version")) { console.log("2.1.286 (Claude Code)"); process.exit(0); }
|
||||
if (${JSON.stringify(options.stdout ?? "")}) {
|
||||
process.stdout.write(${JSON.stringify(options.stdout ?? "")});
|
||||
}
|
||||
@@ -47,6 +49,7 @@ process.exit(${exit});
|
||||
|
||||
async function writeFakeClaudeCommand(commandPath: string): Promise<void> {
|
||||
const script = `#!/usr/bin/env node
|
||||
if (process.argv.includes("--version")) { console.log("2.1.286 (Claude Code)"); process.exit(0); }
|
||||
const fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
|
||||
@@ -88,6 +91,7 @@ console.log(JSON.stringify({ type: "result", session_id: "11111111-1111-4111-811
|
||||
|
||||
async function writeHelpWithoutEffortClaudeCommand(commandPath: string): Promise<void> {
|
||||
const script = `#!/usr/bin/env node
|
||||
if (process.argv.includes("--version")) { console.log("2.1.286 (Claude Code)"); process.exit(0); }
|
||||
const fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
|
||||
@@ -126,6 +130,7 @@ console.log(JSON.stringify({ type: "result", session_id: "33333333-3333-4333-833
|
||||
|
||||
async function writeHelpWithEffortClaudeCommand(commandPath: string): Promise<void> {
|
||||
const script = `#!/usr/bin/env node
|
||||
if (process.argv.includes("--version")) { console.log("2.1.286 (Claude Code)"); process.exit(0); }
|
||||
const fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
|
||||
@@ -185,6 +190,7 @@ afterEach(() => {
|
||||
|
||||
async function writePoisonedMessageIdClaudeCommand(commandPath: string): Promise<void> {
|
||||
const script = `#!/usr/bin/env node
|
||||
if (process.argv.includes("--version")) { console.log("2.1.286 (Claude Code)"); process.exit(0); }
|
||||
const fs = require("node:fs");
|
||||
|
||||
const capturePath = process.env.PAPERCLIP_TEST_CAPTURE_PATH;
|
||||
@@ -221,6 +227,7 @@ console.log(JSON.stringify({ type: "result", session_id: "bbbbbbbb-bbbb-4bbb-8bb
|
||||
|
||||
async function writeAlwaysPoisonedMessageIdClaudeCommand(commandPath: string): Promise<void> {
|
||||
const script = `#!/usr/bin/env node
|
||||
if (process.argv.includes("--version")) { console.log("2.1.286 (Claude Code)"); process.exit(0); }
|
||||
const fs = require("node:fs");
|
||||
|
||||
const capturePath = process.env.PAPERCLIP_TEST_CAPTURE_PATH;
|
||||
@@ -251,6 +258,7 @@ process.exit(1);
|
||||
|
||||
async function writeRetryThenSucceedClaudeCommand(commandPath: string): Promise<void> {
|
||||
const script = `#!/usr/bin/env node
|
||||
if (process.argv.includes("--version")) { console.log("2.1.286 (Claude Code)"); process.exit(0); }
|
||||
const fs = require("node:fs");
|
||||
|
||||
const capturePath = process.env.PAPERCLIP_TEST_CAPTURE_PATH;
|
||||
@@ -410,9 +418,9 @@ describe("claude execute", () => {
|
||||
});
|
||||
|
||||
it.each([
|
||||
[undefined, "claude-opus-5"],
|
||||
["", "claude-opus-5"],
|
||||
[" ", "claude-opus-5"],
|
||||
[undefined, "claude-opus-5-5"],
|
||||
["", "claude-opus-5-5"],
|
||||
[" ", "claude-opus-5-5"],
|
||||
["claude-sonnet-4-5", "claude-sonnet-4-5"],
|
||||
])("passes the resolved model to the CLI for %j", async (model, expected) => {
|
||||
const root = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-claude-default-"));
|
||||
|
||||
@@ -6095,7 +6095,7 @@ describe("company portability", () => {
|
||||
await expect(portability.importBundle(input, "user-1")).rejects.toMatchObject({ status: 422, details: { code: "paperclip_runner_dot_disabled" } });
|
||||
expect(agentSvc.create).not.toHaveBeenCalled();
|
||||
instanceSettingsSvc.getExperimental.mockResolvedValue({ enableNativeRunner: false, enableOpenAiDot: true });
|
||||
await expect(portability.importBundle({ ...input, adapterOverrides: { claudecoder: { adapterType: "paperclip_runner", adapterConfig: { provider: "acpx", acpxAgent: "claude", model: "claude-sonnet-4-6" } } } }, "user-1"))
|
||||
await expect(portability.importBundle({ ...input, adapterOverrides: { claudecoder: { adapterType: "paperclip_runner", adapterConfig: { provider: "acpx", acpxAgent: "grok", model: "grok-build-1.0.13" } } } }, "user-1"))
|
||||
.rejects.toMatchObject({ status: 422, details: { code: "paperclip_runner_rollout_disabled" } });
|
||||
expect(agentSvc.create).not.toHaveBeenCalled();
|
||||
await portability.importBundle(input, "user-1");
|
||||
@@ -6129,7 +6129,7 @@ describe("company portability", () => {
|
||||
adapterOverrides: {
|
||||
claudecoder: {
|
||||
adapterType: "paperclip_runner",
|
||||
adapterConfig: { provider: "acpx", acpxAgent: "claude", model: "claude-sonnet-4-6" },
|
||||
adapterConfig: { provider: "opencode", model: "openrouter/deepseek/deepseek-v4-flash-0731" },
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -6161,7 +6161,7 @@ describe("company portability", () => {
|
||||
await portability.importBundle(request, "user-1");
|
||||
expect(agentSvc.create).toHaveBeenCalledWith("company-1", expect.objectContaining({
|
||||
adapterType: "paperclip_runner",
|
||||
adapterConfig: expect.objectContaining({ provider: "acpx", acpxAgent: "claude" }),
|
||||
adapterConfig: expect.objectContaining({ provider: "opencode" }),
|
||||
}), { createdByUserId: "user-1" });
|
||||
|
||||
await portability.importBundle({
|
||||
|
||||
@@ -311,6 +311,7 @@ describe("redaction", () => {
|
||||
providerValue: input.nested.providerValue,
|
||||
},
|
||||
});
|
||||
expect(sanitizeRecord({ schema: "paperclip.native-execution-input.v9" })).toEqual({ schema: "paperclip.native-execution-input.v9" });
|
||||
expect(sanitizeRecord({ schema: "paperclip.native-execution-input.v7", runtimeSchema: "paperclip.native-execution-input.v8" })).toEqual({
|
||||
schema: "paperclip.native-execution-input.v7",
|
||||
runtimeSchema: "paperclip.native-execution-input.v8",
|
||||
|
||||
@@ -2,7 +2,7 @@ import { randomUUID } from "node:crypto";
|
||||
import { promises as fs } from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
||||
import { afterAll, beforeAll, describe, expect, it, vi } from "vitest";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { agents, companies, createDb } from "@paperclipai/db";
|
||||
import {
|
||||
@@ -11,6 +11,12 @@ import {
|
||||
} from "./helpers/embedded-postgres.js";
|
||||
import { teamsCatalogService } from "../services/teams-catalog.js";
|
||||
|
||||
vi.mock("../services/agent-runner-selection.js", async importOriginal => {
|
||||
const actual = await importOriginal<typeof import("../services/agent-runner-selection.js")>();
|
||||
return { ...actual, resolveNewAgentRunnerForCompany: async (_db: unknown, _companyId: string, input: Parameters<typeof actual.resolveNewAgentRunnerForCompany>[2]) =>
|
||||
actual.resolveNewAgentRunner({ ...input, target: { driver: "local", platform: "linux", architecture: "x64", libc: "glibc", glibcVersion: "2.39" } }) };
|
||||
});
|
||||
|
||||
const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport();
|
||||
const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip;
|
||||
|
||||
|
||||
@@ -4,11 +4,13 @@ import { listServerAdapters, requireServerAdapter } from "./registry.js";
|
||||
import * as executionTarget from "@paperclipai/adapter-utils/execution-target";
|
||||
import { BUILTIN_ADAPTER_TYPES } from "./builtin-adapter-types.js";
|
||||
|
||||
const { probeInstallation, probeGrokInstallation, probePiInstallation } = vi.hoisted(() => ({
|
||||
const { probeInstallation, probeGrokInstallation, probePiInstallation, verifyApiKey } = vi.hoisted(() => ({
|
||||
verifyApiKey: vi.fn(),
|
||||
probeInstallation: vi.fn(),
|
||||
probeGrokInstallation: vi.fn(),
|
||||
probePiInstallation: vi.fn(),
|
||||
}));
|
||||
vi.mock("../services/ai-api-key-test.js", () => ({ validateAiApiKey: verifyApiKey }));
|
||||
vi.mock("../vendor/paperclip-runner/live/index.js", () => ({
|
||||
probeAcpxClaudeInstallation: probeInstallation,
|
||||
probeAcpxGrokInstallation: probeGrokInstallation,
|
||||
@@ -96,6 +98,7 @@ describe("built-in runtime connection tool delivery", () => {
|
||||
|
||||
describe("native ACPX environment checks", () => {
|
||||
beforeEach(() => {
|
||||
verifyApiKey.mockReset().mockResolvedValue(undefined);
|
||||
probeInstallation.mockReset().mockResolvedValue(undefined);
|
||||
probeGrokInstallation.mockReset().mockResolvedValue(undefined);
|
||||
probePiInstallation.mockReset().mockResolvedValue(undefined);
|
||||
@@ -108,6 +111,32 @@ describe("native ACPX environment checks", () => {
|
||||
config: { provider: "acpx", acpxAgent: "claude", model: "claude-sonnet-5" },
|
||||
};
|
||||
|
||||
it("verifies a native Claude key without the global CLI or a model prompt", async () => {
|
||||
const result = await requireServerAdapter("paperclip_runner").testEnvironment!({ ...context,
|
||||
config: { ...context.config, env: { ANTHROPIC_API_KEY: "test-key" } },
|
||||
});
|
||||
expect(result.status).toBe("pass");
|
||||
expect(verifyApiKey).toHaveBeenCalledExactlyOnceWith("anthropic", "test-key");
|
||||
expect(result.checks.map(check => check.code)).toEqual(["acpx_runtime_ready", "ai_connection_api_key_reverified"]);
|
||||
});
|
||||
|
||||
it.each(["ai_connection_api_key_rejected", "ai_connection_verification_failed"])("fails setup on %s", async code => {
|
||||
verifyApiKey.mockRejectedValue({ details: { code } });
|
||||
const result = await requireServerAdapter("paperclip_runner").testEnvironment!({ ...context,
|
||||
config: { ...context.config, env: { ANTHROPIC_API_KEY: "test-key" } },
|
||||
});
|
||||
expect(result.status).toBe("fail");
|
||||
expect(result.checks.at(-1)).toMatchObject({ code, level: "error" });
|
||||
});
|
||||
|
||||
it.each(["ANTHROPIC_BASE_URL", "ANTHROPIC_AUTH_TOKEN", "CLAUDE_CODE_USE_BEDROCK", "CLAUDE_CODE_USE_VERTEX", "CLAUDE_CODE_USE_FOUNDRY"])("preserves the supplementary probe for custom routing via %s", async name => {
|
||||
const result = await requireServerAdapter("paperclip_runner").testEnvironment!({ ...context,
|
||||
config: { ...context.config, env: { ANTHROPIC_API_KEY: "test-key", [name]: "configured" } },
|
||||
});
|
||||
expect(verifyApiKey).not.toHaveBeenCalled();
|
||||
expect(result.checks.some(check => check.code === "ai_connection_api_key_reverified")).toBe(false);
|
||||
});
|
||||
|
||||
it("reports unsupported local platforms before a successful CLI login can mask them", async () => {
|
||||
probeInstallation.mockRejectedValue(new Error("ACPX Claude requires a supported runtime platform"));
|
||||
const result = await requireServerAdapter("paperclip_runner").testEnvironment!(context);
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import type { AdapterRuntimeCommandSpec, ServerAdapterModule } from "./types.js";
|
||||
import { parseAdapterModelsEnv } from "../services/adapter-models-env.js";
|
||||
import { validateAiApiKey } from "../services/ai-api-key-test.js";
|
||||
import type { AdapterEnvironmentTestResult } from "@paperclipai/shared";
|
||||
import { stampClaudeAgentIdHeader } from "./claude-agent-id-header.js";
|
||||
import {
|
||||
buildSandboxNpmInstallCommand,
|
||||
@@ -143,6 +145,33 @@ import {
|
||||
resolvePaperclipRunnerProviderProfile,
|
||||
} from "../services/native-runtime/provider-profile.js";
|
||||
|
||||
/** Reuse the existing provider key check without requiring a global Claude CLI.
|
||||
* Readiness and account access are separate checks; model access is proven by a task.
|
||||
*/
|
||||
async function verifyNativeClaudeApiKey(
|
||||
config: Record<string, unknown>,
|
||||
result: AdapterEnvironmentTestResult,
|
||||
): Promise<AdapterEnvironmentTestResult> {
|
||||
const env = config.env as Record<string, unknown> | undefined;
|
||||
if (config.acpxAgent !== "claude" || !env || typeof env.ANTHROPIC_API_KEY !== "string"
|
||||
|| !env.ANTHROPIC_API_KEY || config.managedAiRouting || config.managedAiConnection
|
||||
|| ["ANTHROPIC_BASE_URL", "ANTHROPIC_AUTH_TOKEN", "CLAUDE_CODE_USE_BEDROCK", "CLAUDE_CODE_USE_VERTEX", "CLAUDE_CODE_USE_FOUNDRY"].some(key => env[key])) return result;
|
||||
try {
|
||||
await validateAiApiKey("anthropic", env.ANTHROPIC_API_KEY);
|
||||
return { ...result, checks: [...result.checks, {
|
||||
code: "ai_connection_api_key_reverified", level: "info",
|
||||
message: "Anthropic verified this API key. Model access is checked when the agent runs.",
|
||||
}] };
|
||||
} catch (error) {
|
||||
const details = error && typeof error === "object" && "details" in error
|
||||
? error.details as { code?: string } | undefined : undefined;
|
||||
return { ...result, status: "fail", checks: [...result.checks, {
|
||||
code: details?.code === "ai_connection_api_key_rejected" ? details.code : "ai_connection_verification_failed",
|
||||
level: "error", message: "Could not verify the Anthropic API key. Check the connection and try again.",
|
||||
}] };
|
||||
}
|
||||
}
|
||||
|
||||
function readConfiguredCommand(config: Record<string, unknown>, fallback: string): string {
|
||||
const value = typeof config.command === "string" ? config.command.trim() : "";
|
||||
return value.length > 0 ? value : fallback;
|
||||
@@ -436,18 +465,18 @@ const paperclipRunnerAdapter: ServerAdapterModule = {
|
||||
if (!((os === "Linux" && arch === "x86_64") || (os === "Darwin" && (arch === "arm64" || (profile.acpxAgent !== "grok" && arch === "x86_64"))))) {
|
||||
throw new Error(`ACPX ${profile.acpxAgent} requires a qualified Linux x64 or macOS architecture.`);
|
||||
}
|
||||
return {
|
||||
return verifyNativeClaudeApiKey(context.config, {
|
||||
adapterType: "paperclip_runner", status: "warn" as const, testedAt: new Date().toISOString(),
|
||||
checks: [{ code: "acpx_remote_runtime_unverified", level: "warn" as const,
|
||||
message: "The remote platform is supported. Runtime package integrity and readiness must still be verified by the remote runner before launch." }],
|
||||
};
|
||||
});
|
||||
}
|
||||
const { probeAcpxClaudeInstallation, probeAcpxGrokInstallation, probeAcpxCursorInstallation, probeAcpxPiInstallation } = await import("../vendor/paperclip-runner/live/index.js");
|
||||
await (profile.acpxAgent === "pi" ? probeAcpxPiInstallation : profile.acpxAgent === "grok" ? probeAcpxGrokInstallation : profile.acpxAgent === "cursor" ? probeAcpxCursorInstallation : probeAcpxClaudeInstallation)(profile.model);
|
||||
return {
|
||||
return verifyNativeClaudeApiKey(context.config, {
|
||||
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.` }],
|
||||
};
|
||||
});
|
||||
} catch (error) {
|
||||
return {
|
||||
adapterType: "paperclip_runner", status: "fail" as const, testedAt: new Date().toISOString(),
|
||||
@@ -505,19 +534,19 @@ const paperclipRunnerAdapter: ServerAdapterModule = {
|
||||
models: [
|
||||
...codexModels,
|
||||
{ id: DEFAULT_OPENCODE_RUNNER_MODEL, label: "OpenRouter · DeepSeek V4 Flash 0731" },
|
||||
{ id: DEFAULT_ACPX_RUNNER_MODELS.claude, label: "Claude Sonnet 5" },
|
||||
{ id: DEFAULT_ACPX_RUNNER_MODELS.claude, label: "Claude Opus 5.5" },
|
||||
{ id: "global.anthropic.claude-sonnet-4-6", label: "Amazon Bedrock · Claude Sonnet 4.6 (global)" },
|
||||
],
|
||||
listModels: async () => [
|
||||
...await listCodexModels(),
|
||||
{ id: DEFAULT_OPENCODE_RUNNER_MODEL, label: "OpenRouter · DeepSeek V4 Flash 0731" },
|
||||
{ id: DEFAULT_ACPX_RUNNER_MODELS.claude, label: "Claude Sonnet 5" },
|
||||
{ id: DEFAULT_ACPX_RUNNER_MODELS.claude, label: "Claude Opus 5.5" },
|
||||
{ id: "global.anthropic.claude-sonnet-4-6", label: "Amazon Bedrock · Claude Sonnet 4.6 (global)" },
|
||||
],
|
||||
refreshModels: async () => [
|
||||
...await refreshCodexModels(),
|
||||
{ id: DEFAULT_OPENCODE_RUNNER_MODEL, label: "OpenRouter · DeepSeek V4 Flash 0731" },
|
||||
{ id: DEFAULT_ACPX_RUNNER_MODELS.claude, label: "Claude Sonnet 5" },
|
||||
{ id: DEFAULT_ACPX_RUNNER_MODELS.claude, label: "Claude Opus 5.5" },
|
||||
{ id: "global.anthropic.claude-sonnet-4-6", label: "Amazon Bedrock · Claude Sonnet 4.6 (global)" },
|
||||
],
|
||||
supportsLocalAgentJwt: false,
|
||||
|
||||
@@ -136,7 +136,7 @@ const support = await getEmbeddedPostgresTestSupport();
|
||||
const probe = vi.spyOn(adapter, "testEnvironment").mockResolvedValue({ adapterType, status: "warn",
|
||||
testedAt: new Date().toISOString(), checks: [{ code: `${adapterType.split("_")[0]}_hello_probe_auth_required`, level: "warn", message: "Login required" }] });
|
||||
try {
|
||||
const agent = await createAgentLifecycle(db).requestHire(companyId, { name: "Login test", adapterType });
|
||||
const agent = await createAgentLifecycle(db).requestHire(companyId, { name: "Login test", adapterType, runner: "legacy" });
|
||||
const work = worker(createLifecycleDriver(db, {} as never).runHost);
|
||||
await work.process(agent.id);
|
||||
expect(probe).toHaveBeenCalled();
|
||||
|
||||
@@ -92,6 +92,7 @@ export const PAPERCLIP_PUBLIC_SCHEMA_IDS = new Set([
|
||||
"paperclip.native-execution-input.v5",
|
||||
"paperclip.native-execution-input.v7",
|
||||
"paperclip.native-execution-input.v8",
|
||||
"paperclip.native-execution-input.v9",
|
||||
"paperclip.native-finalization.v1",
|
||||
"paperclip.native-finalization.v2",
|
||||
"paperclip.native-harness-backup-stamp.v1",
|
||||
|
||||
@@ -1849,7 +1849,7 @@ export function agentRoutes(
|
||||
if (experimental.enableMuse !== true || experimental.enableNativeRunner !== true) throw unprocessable("Muse is experimental and disabled on this instance.", { code: "paperclip_runner_muse_disabled" });
|
||||
} else if (asRecord(config)?.provider === "grok_bot" && (experimental.enableGrokBot !== true || experimental.enablePublicMcp !== true || experimental.enableNativeRunner !== true)) {
|
||||
throw unprocessable("Enable Grok Bot, Assistant connections (MCP), and Paperclip Runner before connecting this agent.", { code: "paperclip_runner_grok_disabled" });
|
||||
} else if (!([undefined, "codex"].includes(asRecord(config)?.provider as string | undefined)) && experimental.enableNativeRunner !== true) {
|
||||
} else if (!([undefined, "codex"].includes(asRecord(config)?.provider as string | undefined)) && agentHarnessType("paperclip_runner", asRecord(config) ?? {}) !== "claude_local" && experimental.enableNativeRunner !== true) {
|
||||
throw unprocessable(
|
||||
"Paperclip Runner is experimental and disabled on this instance.",
|
||||
{ code: "paperclip_runner_rollout_disabled" },
|
||||
@@ -2255,12 +2255,12 @@ export function agentRoutes(
|
||||
const noInheritance = { adapterConfig, inheritedFixedClaudeOAuthBinding: false };
|
||||
if (asRecord(runtimeConfig)?.aiConnection) return noInheritance;
|
||||
if (req.actor.type !== "agent" || !req.actor.agentId) return noInheritance;
|
||||
const harness = agentHarnessType(adapterType ?? "", adapterConfig) === "codex_local" ? "codex_local" : adapterType ?? "";
|
||||
const harness = agentHarnessType(adapterType ?? "", adapterConfig);
|
||||
const credentialKeys = INHERITABLE_AGENT_CREDENTIAL_ENV_KEYS[harness];
|
||||
if (!credentialKeys) return noInheritance;
|
||||
|
||||
const parent = await svc.getById(req.actor.agentId);
|
||||
if (!parent || parent.companyId !== companyId || (harness === "codex_local" ? agentHarnessType(parent.adapterType, parent.adapterConfig) !== harness : parent.adapterType !== adapterType)) return noInheritance;
|
||||
if (!parent || parent.companyId !== companyId || agentHarnessType(parent.adapterType, parent.adapterConfig) !== harness) return noInheritance;
|
||||
// Managed parents must not pass stale legacy credential references to hires.
|
||||
if (aiRuntimeConnectionBindingSchema.safeParse(parent.runtimeConfig.aiConnection).success) return noInheritance;
|
||||
const parentEnv = asRecord(asRecord(parent.adapterConfig)?.env);
|
||||
@@ -2269,7 +2269,7 @@ export function agentRoutes(
|
||||
const existingEnv = asRecord(adapterConfig.env);
|
||||
const claudeCredentialKeys = INHERITABLE_AGENT_CREDENTIAL_ENV_KEYS.claude_local;
|
||||
const childHasClaudeCredential =
|
||||
adapterType === "claude_local" &&
|
||||
harness === "claude_local" &&
|
||||
existingEnv !== null &&
|
||||
claudeCredentialKeys.some((key) => existingEnv[key] !== undefined);
|
||||
if (childHasClaudeCredential) return noInheritance;
|
||||
@@ -2630,11 +2630,13 @@ export function agentRoutes(
|
||||
adapterType: string,
|
||||
adapterConfig: Record<string, unknown>,
|
||||
allowPlainCredentials = false,
|
||||
harnessConfig = adapterConfig,
|
||||
) {
|
||||
if (req.actor.type !== "agent" || !adapterType.endsWith("_local")) return;
|
||||
if (req.actor.type !== "agent" || (!adapterType.endsWith("_local") && adapterType !== "paperclip_runner")) return;
|
||||
const harness = agentHarnessType(adapterType, harnessConfig);
|
||||
const changedKeys = LOCAL_ADAPTER_HOST_COMMAND_KEYS.filter((key) =>
|
||||
adapterConfig[key] !== undefined &&
|
||||
(key !== "env" || !containsOnlyLocalAdapterCredentialRefs(adapterType, adapterConfig.env, allowPlainCredentials)),
|
||||
(key !== "env" || !containsOnlyLocalAdapterCredentialRefs(harness, adapterConfig.env, allowPlainCredentials)),
|
||||
);
|
||||
if (changedKeys.length === 0) return;
|
||||
throw forbidden(
|
||||
@@ -5329,6 +5331,8 @@ export function agentRoutes(
|
||||
req,
|
||||
requestedAdapterType,
|
||||
changingAdapterType ? rawEffectiveAdapterConfig : (requestedAdapterConfig ?? {}),
|
||||
false,
|
||||
rawEffectiveAdapterConfig,
|
||||
);
|
||||
}
|
||||
const existingRunnerProvider =
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { eq } from "drizzle-orm";
|
||||
import { agents, type Db } from "@paperclipai/db";
|
||||
import { agentHarnessType } from "@paperclipai/shared";
|
||||
import { collectSecretRefs, collectUserSecretRefs, syncAgentAdapterEnvBindings } from "./agent-secret-bindings.js";
|
||||
import { logActivity } from "./activity-log.js";
|
||||
import { claudeOAuthBindingsMatchExactly, claudeOAuthClaimRejectedError,
|
||||
@@ -166,7 +167,7 @@ export function agentCredentialService(db: Db) {
|
||||
if (
|
||||
!parent ||
|
||||
parent.companyId !== input.companyId ||
|
||||
parent.adapterType !== CLAUDE_LOCAL_ADAPTER_TYPE ||
|
||||
agentHarnessType(parent.adapterType, parent.adapterConfig as Record<string, unknown>) !== CLAUDE_LOCAL_ADAPTER_TYPE ||
|
||||
!claudeOAuthBindingsMatchExactly(parentBinding, childBinding)
|
||||
) {
|
||||
throw claudeOAuthClaimRejectedError();
|
||||
|
||||
@@ -378,7 +378,8 @@ describe("server-owned Codex runner selection", () => {
|
||||
describe("Codex creation runner contract", () => {
|
||||
it("graduates only Codex and retains saved provider identity", () => {
|
||||
expect(paperclipRunnerProfileForHarness("codex_local")).toEqual({ provider: "codex" });
|
||||
for (const harness of ["claude_local", "opencode_local", "grok_local", "cursor", "pi_local", "process"]) expect(paperclipRunnerProfileForHarness(harness)).toBeUndefined();
|
||||
expect(paperclipRunnerProfileForHarness("claude_local")).toEqual({ provider: "acpx", acpxAgent: "claude" });
|
||||
for (const harness of ["opencode_local", "grok_local", "cursor", "pi_local", "process"]) expect(paperclipRunnerProfileForHarness(harness)).toBeUndefined();
|
||||
expect(agentHarnessType("paperclip_runner", { provider: "codex" })).toBe("codex_local");
|
||||
expect(agentHarnessType("paperclip_runner", { provider: "acpx", acpxAgent: "claude" })).toBe("claude_local");
|
||||
expect(agentHarnessType("paperclip_runner", { provider: "unknown" })).toBe("unknown");
|
||||
@@ -394,7 +395,7 @@ describe("Codex creation runner contract", () => {
|
||||
expect(paperclipRunnerSupportsPlatform("codex_local", "linux", architecture, "unknown")).toBe(false);
|
||||
}
|
||||
for (const [platform, architecture] of [["darwin", "ia32"], ["linux", "arm"], ["win32", "x64"], ["win32", "arm64"], ["freebsd", "x64"]]) expect(paperclipRunnerSupportsPlatform("codex_local", platform, architecture)).toBe(false);
|
||||
expect(paperclipRunnerSupportsPlatform("claude_local", "darwin", "arm64")).toBe(false);
|
||||
expect(paperclipRunnerSupportsPlatform("claude_local", "darwin", "arm64")).toBe(true);
|
||||
});
|
||||
it("keeps auto out of saved update defaults and validates request intent", () => {
|
||||
expect(createAgentSchema.parse({ name: "Codex", adapterType: "codex_local" }).runner).toBeUndefined();
|
||||
@@ -403,3 +404,82 @@ describe("Codex creation runner contract", () => {
|
||||
expect(createAgentSchema.safeParse({ name: "Codex", adapterType: "codex_local", runner: "other" }).success).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
describe("Claude runner defaults", () => {
|
||||
const mac = { driver: "local", platform: "darwin", architecture: "arm64" };
|
||||
beforeEach(() => { state.disabled = []; state.overridden.clear(); });
|
||||
it.each(["arm64"])("keeps Darwin SSH %s automatic selection Legacy while preserving explicit assets", architecture => {
|
||||
const target = { driver: "ssh", platform: "darwin", architecture };
|
||||
expect(agentRunnerAvailability("claude_local", target)).toEqual({ supportedRunners: ["paperclip", "legacy"], defaultRunner: "legacy" });
|
||||
const config = { model: "custom-model", dangerouslySkipPermissions: false };
|
||||
expect(resolveNewAgentRunner({ adapterType: "claude_local", target, adapterConfig: config })).toEqual({ adapterType: "claude_local", adapterConfig: config });
|
||||
const assets = { runnerRemoteProviderPackPath: "/operator/mac-pack", runnerRemoteBinaryPath: "/operator/mac-daemon" };
|
||||
expect(resolveNewAgentRunner({ adapterType: "claude_local", target, runner: "paperclip", adapterConfig: assets }))
|
||||
.toMatchObject({ adapterType: "paperclip_runner", adapterConfig: { ...assets, provider: "acpx", acpxAgent: "claude" } });
|
||||
const saved = { adapterType: "paperclip_runner", adapterConfig: { ...assets, provider: "acpx", acpxAgent: "claude" }, target };
|
||||
expect(resolveNewAgentRunner(saved)).toEqual({ adapterType: saved.adapterType, adapterConfig: saved.adapterConfig });
|
||||
});
|
||||
it("saves native Claude with Opus 5.5, preserving credentials and shared settings", () => {
|
||||
const config = { env: { ANTHROPIC_API_KEY: { type: "secret_ref", secretId: "key" } }, instructionsFilePath: "/workspace/AGENTS.md", timeoutSec: 180, dangerouslySkipPermissions: true };
|
||||
const result = resolveNewAgentRunner({ adapterType: "claude_local", target: mac, adapterConfig: config });
|
||||
expect(result).toEqual({ adapterType: "paperclip_runner", adapterConfig: { env: config.env, instructionsFilePath: config.instructionsFilePath, timeoutSec: 180, provider: "acpx", acpxAgent: "claude", model: "claude-opus-5-5", acpxPermissionMode: "approve-all", lifecycleMode: "per_turn" } });
|
||||
expect(resolveNewAgentRunner(result)).toEqual(result);
|
||||
expect(config.dangerouslySkipPermissions).toBe(true);
|
||||
});
|
||||
it("preserves explicit models and Legacy choices", () => {
|
||||
const legacy = { adapterType: "claude_local", target: mac, runner: "legacy" as const, adapterConfig: { model: "custom", chrome: true } };
|
||||
expect(resolveNewAgentRunner(legacy)).toEqual({ adapterType: legacy.adapterType, adapterConfig: legacy.adapterConfig });
|
||||
expect(resolveNewAgentRunner({ adapterType: "claude_local", target: mac, adapterConfig: { model: "custom" } }).adapterConfig.model).toBe("custom");
|
||||
});
|
||||
it.each(["claude-sonnet-5-5", "custom-model"])("preserves the plain environment model binding %s", model => {
|
||||
const env = { ANTHROPIC_MODEL: { type: "plain", value: model } };
|
||||
const native = resolveNewAgentRunner({ adapterType: "claude_local", target: mac, adapterConfig: { env } });
|
||||
expect(native.adapterConfig.model).toBe(model);
|
||||
expect(native.adapterConfig.env).toEqual(env);
|
||||
});
|
||||
it.each(["ANTHROPIC_MODEL", "CLAUDE_CODE_USE_BEDROCK", "CLAUDE_CODE_USE_VERTEX", "ANTHROPIC_BEDROCK_BASE_URL"])("rejects unresolved %s before choosing a default", key => {
|
||||
const env = { [key]: { type: "secret_ref", secretId: "override" } };
|
||||
expect(() => resolveNewAgentRunner({ adapterType: "claude_local", target: mac, adapterConfig: { env } })).toThrow(`env.${key}`);
|
||||
expect(resolveNewAgentRunner({ adapterType: "claude_local", target: mac, adapterConfig: { env, model: "custom-model" } }).adapterConfig.model).toBe("custom-model");
|
||||
});
|
||||
it("does not pin the ordinary model over a plain provider-routing binding", () => {
|
||||
expect(() => resolveNewAgentRunner({ adapterType: "claude_local", target: mac,
|
||||
adapterConfig: { env: { CLAUDE_CODE_USE_BEDROCK: { type: "plain", value: "1" } } } })).toThrow("Legacy runner");
|
||||
});
|
||||
it.each(["permissionMode", "acpPermissionMode"])("translates the legacy Claude %s without granting broader permissions", key => {
|
||||
for (const mode of ["deny-all", "approve-reads", "approve-all"]) {
|
||||
const native = resolveNewAgentRunner({ adapterType: "claude_local", target: mac, adapterConfig: { engine: "acp", [key]: mode } });
|
||||
expect(native.adapterConfig.acpxPermissionMode).toBe(mode);
|
||||
expect(native.adapterConfig[key]).toBeUndefined();
|
||||
}
|
||||
expect(() => resolveNewAgentRunner({ adapterType: "claude_local", target: mac, adapterConfig: { [key]: "deny-all", acpxPermissionMode: "approve-all" } })).toThrow("cannot honor");
|
||||
expect(() => resolveNewAgentRunner({ adapterType: "claude_local", target: mac, adapterConfig: { [key]: "unrecognized" } })).toThrow("Legacy runner");
|
||||
});
|
||||
it("retains saved native Claude and allows an explicit compatible Legacy switch", () => {
|
||||
const saved = { adapterType: "paperclip_runner", adapterConfig: { provider: "acpx", acpxAgent: "claude", model: "custom", acpxPermissionMode: "approve-all", lifecycleMode: "per_turn" } };
|
||||
expect(resolveNewAgentRunner(saved)).toEqual(saved);
|
||||
expect(resolveNewAgentRunner({ ...saved, runner: "legacy" })).toEqual({ adapterType: "claude_local", adapterConfig: { model: "custom", dangerouslySkipPermissions: true } });
|
||||
expect(() => resolveNewAgentRunner({ ...saved, runner: "legacy", adapterConfig: { ...saved.adapterConfig, acpxPermissionMode: "deny-all" } })).toThrow("cannot honor");
|
||||
});
|
||||
it.each(["chrome", "agentCommand", "maxTurnsPerRun", "networkScope"])("rejects unsupported %s without fallback", key => {
|
||||
expect(() => resolveNewAgentRunner({ adapterType: "claude_local", target: mac, adapterConfig: { [key]: "configured" } })).toThrow("Legacy runner");
|
||||
});
|
||||
it.each(["low", "medium", "high", "xhigh", "max"])("translates Claude effort %s and preserves it on an explicit Legacy switch", effort => {
|
||||
const native = resolveNewAgentRunner({ adapterType: "claude_local", target: mac, adapterConfig: { effort } });
|
||||
expect(native.adapterConfig.modelReasoningEffort).toBe(effort);
|
||||
expect(native.adapterConfig.effort).toBeUndefined();
|
||||
expect(resolveNewAgentRunner({ ...native, runner: "legacy" }).adapterConfig).toMatchObject({ effort });
|
||||
});
|
||||
it("rejects invalid effort and unknown platform defaults", () => {
|
||||
expect(() => resolveNewAgentRunner({ adapterType: "claude_local", target: mac, adapterConfig: { effort: "ultra" } })).toThrow("Claude effort");
|
||||
expect(agentRunnerAvailability("claude_local", { driver: "sandbox" }).defaultRunner).toBe("legacy");
|
||||
});
|
||||
it("keeps unqualified targets and overrides legacy", () => {
|
||||
expect(agentRunnerAvailability("claude_local", target).defaultRunner).toBe("legacy");
|
||||
state.overridden.add("claude_local");
|
||||
expect(agentRunnerAvailability("claude_local", mac).defaultRunner).toBe("legacy");
|
||||
state.overridden.clear(); state.disabled = ["claude_local"];
|
||||
expect(() => resolveNewAgentRunner({ adapterType: "claude_local", target: mac })).toThrow("Claude is disabled");
|
||||
});
|
||||
});
|
||||
@@ -1,9 +1,10 @@
|
||||
import type { Db } from "@paperclipai/db";
|
||||
import { agentHarnessType, isEnvironmentDriverSupportedForAdapter, paperclipRunnerSupportsPlatform, type AgentRunnerChoice, type AgentRunnerAvailability } from "@paperclipai/shared";
|
||||
import { normalizeLegacyRunnerProvider, PAPERCLIP_RUNNER_DEFAULT_MODELS } from "@paperclipai/adapter-utils";
|
||||
import { resolveClaudeModel } from "@paperclipai/adapter-claude-local";
|
||||
import { findActiveServerAdapter, hasActiveAdapterOverride, waitForExternalAdapters } from "../adapters/registry.js";
|
||||
import { getDisabledAdapterTypes } from "./adapter-plugin-store.js";
|
||||
import { PaperclipRunnerProviderProfileError, resolvePaperclipRunnerNativeProviderInput } from "./native-runtime/provider-profile.js";
|
||||
import { claudeRunnerUnsupportedSettings, PaperclipRunnerProviderProfileError, resolvePaperclipRunnerNativeProviderInput } from "./native-runtime/provider-profile.js";
|
||||
import { forbidden, unprocessable } from "../errors.js";
|
||||
import { runSshCommand } from "@paperclipai/adapter-utils/ssh";
|
||||
import { resolveEnvironmentDriverConfigForRuntime } from "./environment-config.js";
|
||||
@@ -18,34 +19,84 @@ export interface AgentRunnerSelection {
|
||||
target?: RunnerTarget;
|
||||
}
|
||||
|
||||
export function agentRunnerAvailability(harness: string, target: RunnerTarget = localAgentRunnerTarget()): AgentRunnerAvailability {
|
||||
function runnerAdaptersAvailable(harness: string, driver: string): boolean {
|
||||
const disabled = new Set(getDisabledAdapterTypes());
|
||||
const supported = harness === "codex_local"
|
||||
return ["codex_local", "claude_local"].includes(harness)
|
||||
&& !disabled.has(harness) && !disabled.has("paperclip_runner")
|
||||
&& !!findActiveServerAdapter(harness) && !!findActiveServerAdapter("paperclip_runner")
|
||||
&& !hasActiveAdapterOverride(harness) && !hasActiveAdapterOverride("paperclip_runner")
|
||||
&& isEnvironmentDriverSupportedForAdapter("paperclip_runner", target.driver)
|
||||
&& isEnvironmentDriverSupportedForAdapter("paperclip_runner", driver);
|
||||
}
|
||||
|
||||
export function agentRunnerAvailability(harness: string, target: RunnerTarget = localAgentRunnerTarget()): AgentRunnerAvailability {
|
||||
const supported = runnerAdaptersAvailable(harness, target.driver)
|
||||
&& (target.driver !== "sandbox" || Boolean(target.platform && target.architecture
|
||||
&& (target.platform !== "linux" || (target.libc === "glibc" && target.glibcVersion))))
|
||||
&& (!target.platform || paperclipRunnerSupportsPlatform(harness, target.platform, target.architecture ?? "", target.libc, target.glibcVersion));
|
||||
return { supportedRunners: supported ? ["paperclip", "legacy"] : ["legacy"], defaultRunner: supported ? "paperclip" : "legacy" };
|
||||
&& (harness === "claude_local"
|
||||
? !!target.platform && paperclipRunnerSupportsPlatform(harness, target.platform, target.architecture ?? "", target.libc, target.glibcVersion)
|
||||
: !target.platform || paperclipRunnerSupportsPlatform(harness, target.platform, target.architecture ?? "", target.libc, target.glibcVersion));
|
||||
const automatic = supported && !(harness === "claude_local" && target.driver === "ssh" && target.platform === "darwin");
|
||||
return { supportedRunners: supported ? ["paperclip", "legacy"] : ["legacy"], defaultRunner: automatic ? "paperclip" : "legacy" };
|
||||
}
|
||||
|
||||
function incompatible(fields: string[]): never {
|
||||
throw unprocessable(`Paperclip Runner cannot honor these settings: ${fields.join(", ")}. Remove them or select Legacy runner in Advanced.`, { code: "agent_runner_config_incompatible", fields });
|
||||
}
|
||||
|
||||
function resolveClaudeRunnerConfig(config: Record<string, unknown>): Record<string, unknown> {
|
||||
const translated = { ...config };
|
||||
const legacyPermission = [config.permissionMode, config.acpPermissionMode].find(value => typeof value === "string" && value.trim()) as string | undefined;
|
||||
if (legacyPermission !== undefined) {
|
||||
const mode = legacyPermission.trim();
|
||||
if (!["approve-all", "approve-reads", "deny-all"].includes(mode)
|
||||
|| (config.acpxPermissionMode !== undefined && config.acpxPermissionMode !== mode)) incompatible(["permissionMode", "acpPermissionMode"]);
|
||||
translated.acpxPermissionMode = mode;
|
||||
}
|
||||
for (const key of ["permissionMode", "acpPermissionMode"]) {
|
||||
if (config[key] !== undefined && typeof config[key] !== "string") incompatible([key]);
|
||||
delete translated[key];
|
||||
}
|
||||
const unsupported = claudeRunnerUnsupportedSettings(translated);
|
||||
const effort = config.modelReasoningEffort ?? config.reasoningEffort ?? config.effort;
|
||||
if (config.model !== undefined && typeof config.model !== "string") unsupported.push("model");
|
||||
if (unsupported.length) incompatible(unsupported);
|
||||
for (const key of ["engine", "command", "mode", "acpMode", "nonInteractivePermissions", "acpNonInteractivePermissions", "dangerouslySkipPermissions", "chrome", "maxTurnsPerRun", "effort", "reasoningEffort"]) delete translated[key];
|
||||
const env = config.env && typeof config.env === "object" ? config.env as Record<string, unknown> : {};
|
||||
const modelEnv = { ...env };
|
||||
if (!(typeof config.model === "string" && config.model.trim())) {
|
||||
for (const key of ["ANTHROPIC_MODEL", "CLAUDE_CODE_USE_BEDROCK", "CLAUDE_CODE_USE_VERTEX", "ANTHROPIC_BEDROCK_BASE_URL"]) {
|
||||
const binding = env[key];
|
||||
if (binding === undefined || typeof binding === "string") continue;
|
||||
if (binding && typeof binding === "object" && "type" in binding && binding.type === "plain"
|
||||
&& "value" in binding && typeof binding.value === "string") modelEnv[key] = binding.value;
|
||||
else incompatible([`env.${key}`]);
|
||||
}
|
||||
}
|
||||
const model = resolveClaudeModel(config.model, modelEnv);
|
||||
if (!model) incompatible(["model"]);
|
||||
return { ...translated, ...(effort === undefined || effort === "" ? {} : { modelReasoningEffort: effort }), provider: "acpx", acpxAgent: "claude", model, acpxPermissionMode: translated.acpxPermissionMode ?? "approve-all", lifecycleMode: config.lifecycleMode ?? "per_turn" };
|
||||
}
|
||||
|
||||
export function resolveNewAgentRunner(input: AgentRunnerSelection): { adapterType: string; adapterConfig: Record<string, unknown> } {
|
||||
const adapterType = input.adapterType ?? "process";
|
||||
let config = { ...input.adapterConfig };
|
||||
const harness = agentHarnessType(adapterType, config);
|
||||
const native = adapterType === "paperclip_runner";
|
||||
if (adapterType === "codex_local" && getDisabledAdapterTypes().includes(adapterType)) throw unprocessable("Codex is disabled on this instance. Enable its adapter or choose another harness.", { code: "agent_runner_unavailable" });
|
||||
if (["codex_local", "claude_local"].includes(adapterType) && getDisabledAdapterTypes().includes(adapterType)) throw unprocessable(`${adapterType === "claude_local" ? "Claude" : "Codex"} is disabled on this instance. Enable its adapter or choose another harness.`, { code: "agent_runner_unavailable" });
|
||||
if (native && (input.runner === undefined || input.runner === "paperclip" || (harness !== "codex_local" && input.runner !== "legacy"))) return { adapterType, adapterConfig: normalizeLegacyRunnerProvider(config) };
|
||||
const availability = agentRunnerAvailability(harness, input.target);
|
||||
const choice = input.runner ?? "auto";
|
||||
const useNative = choice === "paperclip" || (choice === "auto" && availability.defaultRunner === "paperclip");
|
||||
if (!useNative) {
|
||||
if (harness === "claude_local") {
|
||||
if (!native) return { adapterType, adapterConfig: config };
|
||||
if (config.acpxPermissionMode !== undefined && config.acpxPermissionMode !== "approve-all") incompatible(["acpxPermissionMode"]);
|
||||
if (config.lifecycleMode !== undefined && config.lifecycleMode !== "per_turn") incompatible(["lifecycleMode"]);
|
||||
const effort = config.modelReasoningEffort ?? config.reasoningEffort ?? config.effort;
|
||||
for (const key of ["provider", "acpxAgent", "acpxPermissionMode", "lifecycleMode", "idleTimeoutMs", "modelReasoningEffort", "reasoningEffort"]) delete config[key];
|
||||
if (effort !== undefined) config.effort = effort;
|
||||
return { adapterType: "claude_local", adapterConfig: { ...config, dangerouslySkipPermissions: config.dangerouslySkipPermissions ?? true } };
|
||||
}
|
||||
if (!native && harness !== "codex_local") return { adapterType, adapterConfig: config };
|
||||
if (!native && !["codexPermissionMode", "lifecycleMode", "idleTimeoutMs"].some(key => config[key] !== undefined)) return { adapterType, adapterConfig: config };
|
||||
if (harness !== "codex_local") throw unprocessable("This native harness does not have a runner override in this release.", { code: "agent_runner_unavailable" });
|
||||
@@ -55,10 +106,19 @@ export function resolveNewAgentRunner(input: AgentRunnerSelection): { adapterTyp
|
||||
return { adapterType: "codex_local", adapterConfig: { ...config, dangerouslyBypassApprovalsAndSandbox: config.dangerouslyBypassApprovalsAndSandbox ?? true } };
|
||||
}
|
||||
if (native && harness !== "codex_local" && choice === "paperclip") return { adapterType, adapterConfig: normalizeLegacyRunnerProvider(config) };
|
||||
if (availability.defaultRunner !== "paperclip") throw unprocessable(input.target?.driver === "sandbox"
|
||||
if (!availability.supportedRunners.includes("paperclip")) throw unprocessable(input.target?.driver === "sandbox"
|
||||
? "Paperclip Runner is unavailable for this sandbox. Choose an immutable OCI image digest and run its environment connection probe to qualify the OS, architecture and libc, or select Legacy runner in Advanced."
|
||||
: "Paperclip Runner is unavailable for this harness and environment. Select Legacy runner in Advanced.", { code: "agent_runner_unavailable" });
|
||||
if (native) return { adapterType, adapterConfig: normalizeLegacyRunnerProvider(config) };
|
||||
if (harness === "claude_local") {
|
||||
config = resolveClaudeRunnerConfig(config);
|
||||
try { resolvePaperclipRunnerNativeProviderInput({ backend: "acpx_runtime", adapterConfig: config }); }
|
||||
catch (error) {
|
||||
if (error instanceof PaperclipRunnerProviderProfileError) throw unprocessable(error.message, { code: error.code });
|
||||
throw error;
|
||||
}
|
||||
return { adapterType: "paperclip_runner", adapterConfig: config };
|
||||
}
|
||||
const aliasSources = new Map<string, string>();
|
||||
for (const [canonical, alias] of [
|
||||
["agentCommand", "acpAgentCommand"], ["stateDir", "acpStateDir"],
|
||||
@@ -114,7 +174,7 @@ export function resolveNewAgentRunner(input: AgentRunnerSelection): { adapterTyp
|
||||
}
|
||||
|
||||
export async function resolveNewAgentRunnerForCompany(db: Db, companyId: string, input: AgentRunnerSelection & { defaultEnvironmentId?: string | null }) {
|
||||
if (agentHarnessType(input.adapterType ?? "process", input.adapterConfig) !== "codex_local"
|
||||
if (!["codex_local", "claude_local"].includes(agentHarnessType(input.adapterType ?? "process", input.adapterConfig))
|
||||
|| input.runner === "legacy" || (input.adapterType === "paperclip_runner" && (input.runner === undefined || input.runner === "paperclip"))) return resolveNewAgentRunner(input);
|
||||
const target = await resolveAgentRunnerTargetForCompany(db, companyId, input.defaultEnvironmentId);
|
||||
return resolveNewAgentRunner({ ...input, target });
|
||||
@@ -151,7 +211,7 @@ export async function resolveAgentRunnerTargetForCompany(db: Db, companyId: stri
|
||||
const qualified = await readSandboxPlatformQualification(db, environment, companyId);
|
||||
if (qualified) target = { driver, ...qualified };
|
||||
}
|
||||
if (driver === "ssh" && environment && agentRunnerAvailability("codex_local", target).defaultRunner === "paperclip") {
|
||||
if (driver === "ssh" && environment && ["codex_local", "claude_local"].some(harness => runnerAdaptersAvailable(harness, driver))) {
|
||||
const parsed = await resolveEnvironmentDriverConfigForRuntime(db, companyId, environment);
|
||||
if (parsed.driver !== "ssh") throw unprocessable("The selected SSH environment is unavailable.");
|
||||
let output: string;
|
||||
|
||||
@@ -55,8 +55,7 @@ export function poolMemberRuntimeConfig(member: AiConnectionPoolMember, adapterT
|
||||
const efforts = provider === "openai" ? codexLocalReasoningEffortsForModel(model)
|
||||
: provider === "anthropic" ? claudeLocalReasoningEffortsForModel(model)
|
||||
: provider === "xai" ? grokLocalReasoningEffortsForModel(model) : [];
|
||||
// ACPX currently has no qualified effort override; keep that host limitation explicit.
|
||||
const effortKey = adapterType === "paperclip_runner" ? profile.provider === "codex" ? "modelReasoningEffort" : null
|
||||
const effortKey = adapterType === "paperclip_runner" ? profile.provider === "codex" || (profile.provider === "acpx" && profile.acpxAgent === "claude") ? "modelReasoningEffort" : null
|
||||
: provider === "anthropic" ? "effort" : provider === "xai" ? "reasoningEffort" : provider === "openai" ? "modelReasoningEffort" : "variant";
|
||||
const requested = overrides.modelReasoningEffort ?? overrides.reasoningEffort ?? overrides.effort ?? overrides.variant;
|
||||
const effort = typeof requested === "string" && efforts.includes(requested) && effortKey ? requested
|
||||
|
||||
@@ -6,6 +6,12 @@ const receipt: AdapterUsageCheckpoint = { complete: true, usageBasis: "per_run",
|
||||
billingType: "metered_api", model: "claude-sonnet-5", costUsd: null, costStatus: "unpriced",
|
||||
usage: { inputTokens: 22462, cachedInputTokens: 208218, cacheWriteTokens: 22450, outputTokens: 2450 } };
|
||||
describe("complete Anthropic receipt pricing", () => {
|
||||
it("prices Opus 5.5 cache writes at the one-hour upper rate", () => {
|
||||
const priced = priceAnthropicReceipt({ ...receipt, model: "claude-opus-5-5" });
|
||||
expect(priced.costUsdExact).toBe("0.270291600");
|
||||
expect(priced.pricingProvenance).toMatchObject({ version: "anthropic-standard-2026-10-12", inputCentsPerMillion: "400", cachedInputCentsPerMillion: "20", cacheWriteCentsPerMillion: "800", outputCentsPerMillion: "2000" });
|
||||
expect(priced.costStatus).toBe("estimated");
|
||||
});
|
||||
it("prices complete counters conservatively and labels their provenance as an estimate", () => {
|
||||
const priced = priceAnthropicReceipt(receipt);
|
||||
expect(priced.costUsdExact).toBe("0.155967600");
|
||||
@@ -17,7 +23,7 @@ describe("complete Anthropic receipt pricing", () => {
|
||||
});
|
||||
it.each([
|
||||
{ complete: false }, { billingType: "unknown" }, { billingType: "subscription_included" },
|
||||
{ provider: "unknown" }, { biller: "aws_bedrock" }, { model: "claude-sonnet-latest" },
|
||||
{ provider: "unknown" }, { biller: "aws_bedrock" }, { model: "claude-sonnet-latest" }, { model: "constructor" }, { model: "toString" }, { model: "__proto__" },
|
||||
{ usageBasis: "session_cumulative" }, { costUsd: 0.12 }, { costUsdExact: "0.12" }, { cacheAdjustedCostUsd: 0.12 },
|
||||
{ pricingContext: { serviceTier: "priority" } }, { usageByModel: [{ model: "other", usage: { inputTokens: 1, outputTokens: 1 }, costUsd: 1 }] },
|
||||
{ usage: undefined }, { usage: { inputTokens: 1, outputTokens: 1 } },
|
||||
|
||||
@@ -1,31 +1,35 @@
|
||||
import { centsToUsd, unitsToCents, usdToUnits } from "@paperclipai/shared";
|
||||
import type { AdapterUsageCheckpoint } from "@paperclipai/adapter-utils";
|
||||
|
||||
// Direct Anthropic API list prices, verified 2026-10-07:
|
||||
// https://platform.claude.com/docs/en/about-claude/pricing
|
||||
// Keep this bounded to the qualified model; aliases and other billers stay unknown.
|
||||
const RATES = ["2", "0.2", "4", "10"] as const;
|
||||
const RATE_CARDS: Record<string, { rates: readonly string[]; version: string }> = {
|
||||
"claude-sonnet-5": { rates: ["2", "0.2", "4", "10"], version: "anthropic-standard-2026-10-07" },
|
||||
"claude-opus-5-5": { rates: ["4", "0.2", "8", "20"], version: "anthropic-standard-2026-10-12" },
|
||||
};
|
||||
|
||||
/** A complete token receipt can receive an explicit estimate, never an invoice
|
||||
* claim. ACP aggregates cache writes without TTL; conservatively price them at
|
||||
* the documented one-hour rate rather than silently assume five-minute writes. */
|
||||
export function priceAnthropicReceipt(receipt: AdapterUsageCheckpoint): AdapterUsageCheckpoint {
|
||||
const card = receipt.model && Object.hasOwn(RATE_CARDS, receipt.model) ? RATE_CARDS[receipt.model] : undefined;
|
||||
if (!receipt.complete || !receipt.usage || receipt.usageBasis !== "per_run"
|
||||
|| receipt.provider !== "anthropic" || receipt.biller !== "anthropic"
|
||||
|| !["api", "metered_api"].includes(receipt.billingType ?? "")
|
||||
|| receipt.model !== "claude-sonnet-5" || receipt.usageByModel?.length
|
||||
|| !card || receipt.usageByModel?.length
|
||||
|| receipt.costUsd != null || receipt.costUsdExact != null || receipt.cacheAdjustedCostUsd != null
|
||||
|| (receipt.pricingContext?.serviceTier && !["standard", "default"].includes(receipt.pricingContext.serviceTier))) return receipt;
|
||||
const usage = receipt.usage;
|
||||
if (usage.cacheWriteTokens === undefined || usage.cachedInputTokens === undefined) return receipt;
|
||||
const counts = [usage.inputTokens - usage.cacheWriteTokens, usage.cachedInputTokens, usage.cacheWriteTokens, usage.outputTokens];
|
||||
if (counts.some(count => !Number.isSafeInteger(count) || count < 0)) return receipt;
|
||||
const numerator = counts.reduce((total, count, index) => total + BigInt(count) * usdToUnits(RATES[index]), 0n);
|
||||
const numerator = counts.reduce((total, count, index) => total + BigInt(count) * usdToUnits(card.rates[index]), 0n);
|
||||
return { ...receipt, costUsdExact: centsToUsd(unitsToCents((numerator + 500_000n) / 1_000_000n)), costStatus: "estimated",
|
||||
pricingProvenance: {
|
||||
source: "rate_card", version: "anthropic-standard-2026-10-07",
|
||||
source: "rate_card", version: card.version,
|
||||
evidence: "https://platform.claude.com/docs/en/about-claude/pricing; standard global API pricing assumed; cache-write TTL unavailable, one-hour upper rate used; not an invoice",
|
||||
inputCentsPerMillion: "200", cachedInputCentsPerMillion: "20", cacheWriteCentsPerMillion: "400", outputCentsPerMillion: "1000", serviceTier: "standard",
|
||||
inputCentsPerMillion: (usdToUnits(card.rates[0]) / 10_000_000n).toString(),
|
||||
cachedInputCentsPerMillion: (usdToUnits(card.rates[1]) / 10_000_000n).toString(),
|
||||
cacheWriteCentsPerMillion: (usdToUnits(card.rates[2]) / 10_000_000n).toString(),
|
||||
outputCentsPerMillion: (usdToUnits(card.rates[3]) / 10_000_000n).toString(), serviceTier: "standard",
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -1761,11 +1761,12 @@ export function builtInAgentService(db: Db) {
|
||||
const adapterType = resolvedInput.adapterType ?? existing.adapterType;
|
||||
const adapterConfig = resolvedInput.adapterConfig ?? existing.adapterConfig;
|
||||
assertAdapterAllowed(definition, adapterType, adapterConfig);
|
||||
const sameCodexHarness = agentHarnessType(adapterType, adapterConfig) === "codex_local"
|
||||
&& agentHarnessType(existing.adapterType, existing.adapterConfig) === "codex_local";
|
||||
const runner = resolvedInput.runner ?? (sameCodexHarness && existing.metadata?.paperclipBuiltInSetupRequired !== true
|
||||
const selectedHarness = agentHarnessType(adapterType, adapterConfig);
|
||||
const sameQualifiedHarness = ["codex_local", "claude_local"].includes(selectedHarness)
|
||||
&& agentHarnessType(existing.adapterType, existing.adapterConfig) === selectedHarness;
|
||||
const runner = resolvedInput.runner ?? (sameQualifiedHarness && existing.metadata?.paperclipBuiltInSetupRequired !== true
|
||||
? agentRunner(existing.adapterType) : undefined);
|
||||
const retainedNative = sameCodexHarness && runner === "paperclip" && existing.adapterType === "paperclip_runner";
|
||||
const retainedNative = sameQualifiedHarness && runner === "paperclip" && existing.adapterType === "paperclip_runner";
|
||||
const resolvedAdapterConfig = { ...adapterConfig };
|
||||
if (retainedNative) {
|
||||
for (const field of ["provider", "acpxAgent", "acpxMode"]) {
|
||||
|
||||
@@ -5307,7 +5307,7 @@ export function companyPortabilityService(db: Db, storage?: StorageService) {
|
||||
"OpenAI Dot is experimental and disabled on this instance.",
|
||||
{ code: "paperclip_runner_dot_disabled" },
|
||||
);
|
||||
} else if (config.provider !== "codex" && config.provider !== undefined && experimental.enableNativeRunner !== true) {
|
||||
} else if (config.provider !== "codex" && config.provider !== undefined && agentHarnessType("paperclip_runner", config) !== "claude_local" && experimental.enableNativeRunner !== true) {
|
||||
throw unprocessable(
|
||||
"Paperclip Runner is experimental and disabled on this instance.",
|
||||
{ code: "paperclip_runner_rollout_disabled" },
|
||||
@@ -5651,7 +5651,7 @@ export function companyPortabilityService(db: Db, storage?: StorageService) {
|
||||
runner = agentRunner(existingImportAgent.adapterType);
|
||||
}
|
||||
}
|
||||
const retainedNative = runner === "paperclip" && selectedHarness === "codex_local"
|
||||
const retainedNative = runner === "paperclip" && ["codex_local", "claude_local"].includes(selectedHarness)
|
||||
? [manifestAgent, existingImportAgent].find((agent) => agent?.adapterType === "paperclip_runner"
|
||||
&& agentHarnessType(agent.adapterType, agent.adapterConfig) === selectedHarness)
|
||||
: undefined;
|
||||
|
||||
@@ -117,6 +117,16 @@ describe("Paperclip Runner native provider configuration", () => {
|
||||
.toMatchObject({ provider: "codex", model: "gpt-6-astra", modelReasoningEffort: "high" });
|
||||
});
|
||||
|
||||
it.each([true, false])("carries Claude task effort while retaining provider and permissions (explicit agent: %s)", explicitAgent => {
|
||||
const base = { provider: "acpx", ...(explicitAgent ? { acpxAgent: "claude" } : {}), model: "claude-opus-5-5", modelReasoningEffort: "medium", acpxPermissionMode: "deny-all" };
|
||||
const projected = projectPaperclipRunnerTaskConfig("acpx_runtime", base, { effort: "xhigh", provider: "codex", acpxAgent: "pi", acpxPermissionMode: "approve-all" });
|
||||
expect(projected).toEqual({ ...base, modelReasoningEffort: "xhigh" });
|
||||
expect(resolvePaperclipRunnerNativeProviderInput({ backend: "acpx_runtime", adapterConfig: projected })).toMatchObject({ provider: "acpx", acpxAgent: "claude", claudeEffort: "xhigh", acpxPermissionMode: "deny-all" });
|
||||
const changedModel = projectPaperclipRunnerTaskConfig("acpx_runtime", base, { model: "claude-sonnet-5" });
|
||||
expect(changedModel).not.toHaveProperty("modelReasoningEffort");
|
||||
expect(() => resolvePaperclipRunnerNativeProviderInput({ backend: "acpx_runtime", adapterConfig: { ...base, chrome: true } })).toThrow("Legacy runner");
|
||||
});
|
||||
|
||||
it("uses an OpenCode task model without changing the Runner provider", () => {
|
||||
const projected = projectPaperclipRunnerTaskConfig(
|
||||
"opencode_server",
|
||||
|
||||
@@ -21,7 +21,7 @@ import type { agentInstructionWorkingCopyService } from "../agent-instruction-wo
|
||||
import { createHash, randomUUID } from "node:crypto";
|
||||
import { and, eq, inArray, sql } from "drizzle-orm";
|
||||
import type { Db } from "@paperclipai/db";
|
||||
import { CHAT_PROVIDERS } from "@paperclipai/shared";
|
||||
import { agentHarnessType, CHAT_PROVIDERS } from "@paperclipai/shared";
|
||||
import {
|
||||
type agents,
|
||||
completionContracts,
|
||||
@@ -716,6 +716,7 @@ export async function selectHeartbeatRuntime(db: Db, input: HeartbeatRuntimeSele
|
||||
});
|
||||
getNativeFreshSessionHandoff = nativeReviewRequest ? undefined : getFreshSessionHandoff;
|
||||
const nativeProviderConfig = nativeRuntimeResolution.profile.backend === "codex_app_server"
|
||||
|| (nativeRuntimeResolution.profile.backend === "acpx_runtime" && agentHarnessType(agent.adapterType, parseObject(agent.adapterConfig)) === "claude_local")
|
||||
|| nativeRuntimeResolution.profile.backend === "opencode_server"
|
||||
? projectPaperclipRunnerTaskConfig(
|
||||
nativeRuntimeResolution.profile.backend,
|
||||
|
||||
@@ -72,7 +72,7 @@ describe("host ACPX qualification admission", () => {
|
||||
}
|
||||
});
|
||||
it("preserves product defaults separately from qualification examples", () => {
|
||||
for (const [acpxAgent, model] of [["claude", "claude-sonnet-5"], ["grok", "grok-4.7"]]) {
|
||||
for (const [acpxAgent, model] of [["claude", "claude-opus-5-5"], ["grok", "grok-4.7"]]) {
|
||||
expect(resolvePaperclipRunnerProviderProfile({ provider: "acpx", acpxAgent }))
|
||||
.toMatchObject({ acpxAgent, model });
|
||||
}
|
||||
|
||||
@@ -664,3 +664,27 @@ describe("Pi thinking native execution projection", () => {
|
||||
expect(() => buildNativeExecutionInput({ ...fixture("low"), acpxAgent: "copilot" })).toThrow("only for Pi");
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
describe("Claude effort and master home-mode compatibility", () => {
|
||||
it.each([
|
||||
["isolated", undefined, "v5"], ["preconfigured", undefined, "v7"],
|
||||
["isolated", "high", "v9"], ["preconfigured", "high", "v9"],
|
||||
] as const)("retains %s home mode with effort %s in %s", (homeMode, claudeEffort, version) => {
|
||||
const value = buildNativeExecutionInput({
|
||||
companyId: "company", runId: "run", agentId: "agent",
|
||||
issue: { id: "issue", identifier: "CLA-1", title: "Useful output", description: null, workMode: "standard" },
|
||||
taskPrompt: "Create the requested file.",
|
||||
workspace: { id: "workspace", cwd: "/workspace", repoUrl: null, repoRef: null, branchName: null },
|
||||
normalizedSessionId: null, provider: "acpx", acpxAgent: "claude", model: "claude-opus-5-5",
|
||||
acpxPermissionMode: "approve-all", homeMode, claudeEffort,
|
||||
completionContract: { id: "contract", sha256: `sha256:${"a".repeat(64)}`, schemaVersion: "paperclip.run-result.v1",
|
||||
contract: { revision: "1", objective: "Create a file", criteria: [{ id: "output", requirement: "Create the file" }] } },
|
||||
runtimeContext: nativeRuntimeContextFixture(),
|
||||
});
|
||||
expect(value.schema).toBe(`paperclip.native-execution-input.${version}`);
|
||||
if (version !== "v5") expect(value).toHaveProperty("homeMode", homeMode);
|
||||
if (claudeEffort) expect(value.provider).toHaveProperty("claudeEffort", claudeEffort);
|
||||
expect(JSON.stringify(buildNativeModelEnvelope(value))).not.toContain("claudeEffort");
|
||||
});
|
||||
});
|
||||
@@ -9,6 +9,7 @@ import type {
|
||||
NativeExecutionInputV5,
|
||||
NativeExecutionInputV6,
|
||||
NativeExecutionInputV8,
|
||||
NativeExecutionInputV9,
|
||||
MuseBindingSnapshot,
|
||||
DotBindingSnapshot,
|
||||
NativeExecutionInputV7,
|
||||
@@ -85,6 +86,7 @@ export interface BuildNativeExecutionInput {
|
||||
acpxPermissionMode?: NativeAcpxPermissionMode;
|
||||
acpxSessionMode?: "agent" | "plan" | "ask";
|
||||
piThinkingLevel?: "off" | "low" | "high" | "max";
|
||||
claudeEffort?: "low" | "medium" | "high" | "xhigh" | "max";
|
||||
model?: string | null;
|
||||
managedProfile?: Extract<
|
||||
NativeExecutionInputV5["provider"],
|
||||
@@ -115,9 +117,9 @@ export interface BuildNativeExecutionInput {
|
||||
}
|
||||
export function buildNativeExecutionInput(input: BuildNativeExecutionInput & { provider: "muse"; museBinding: MuseBindingSnapshot }): NativeExecutionInputV8;
|
||||
export function buildNativeExecutionInput(input: BuildNativeExecutionInput & { provider: "openai_dot" | "grok_bot"; dotBinding: DotBindingSnapshot }): NativeExecutionInputV6;
|
||||
export function buildNativeExecutionInput(input: BuildNativeExecutionInput & { provider?: Exclude<BuildNativeExecutionInput["provider"], "openai_dot" | "grok_bot" | "muse"> }): NativeExecutionInputV5 | NativeExecutionInputV7;
|
||||
export function buildNativeExecutionInput(input: BuildNativeExecutionInput): NativeExecutionInputV5 | NativeExecutionInputV6 | NativeExecutionInputV7 | NativeExecutionInputV8;
|
||||
export function buildNativeExecutionInput(input: BuildNativeExecutionInput): NativeExecutionInputV5 | NativeExecutionInputV6 | NativeExecutionInputV7 | NativeExecutionInputV8 {
|
||||
export function buildNativeExecutionInput(input: BuildNativeExecutionInput & { provider?: Exclude<BuildNativeExecutionInput["provider"], "openai_dot" | "grok_bot" | "muse"> }): NativeExecutionInputV5 | NativeExecutionInputV7 | NativeExecutionInputV9;
|
||||
export function buildNativeExecutionInput(input: BuildNativeExecutionInput): NativeExecutionInputV5 | NativeExecutionInputV6 | NativeExecutionInputV7 | NativeExecutionInputV8 | NativeExecutionInputV9;
|
||||
export function buildNativeExecutionInput(input: BuildNativeExecutionInput): NativeExecutionInputV5 | NativeExecutionInputV6 | NativeExecutionInputV7 | NativeExecutionInputV8 | NativeExecutionInputV9 {
|
||||
if (input.issue.workMode !== "standard" && input.issue.workMode !== "planning" && input.issue.workMode !== "ask") {
|
||||
throw new Error("native_execution_input_invalid: issue work mode must be standard, planning, or ask");
|
||||
}
|
||||
@@ -217,8 +219,9 @@ export function buildNativeExecutionInput(input: BuildNativeExecutionInput): Nat
|
||||
? verifiedCompletionSources(input.turnContext, input.completionContract.sources ?? [])
|
||||
: [];
|
||||
const prepared = {
|
||||
schema: input.homeMode === "preconfigured" ? "paperclip.native-execution-input.v7" : "paperclip.native-execution-input.v5",
|
||||
...(input.homeMode === "preconfigured" ? { homeMode: input.homeMode } : {}),
|
||||
schema: input.claudeEffort !== undefined ? "paperclip.native-execution-input.v9"
|
||||
: input.homeMode === "preconfigured" ? "paperclip.native-execution-input.v7" : "paperclip.native-execution-input.v5",
|
||||
...(input.claudeEffort !== undefined || input.homeMode === "preconfigured" ? { homeMode: input.homeMode ?? "isolated" } : {}),
|
||||
...((input.initialCommunicationGuidance || input.freshSessionHandoff) ? {
|
||||
initialCommunicationGuidance: [input.initialCommunicationGuidance, input.freshSessionHandoff].filter(Boolean).join("\n\n"),
|
||||
} : {}),
|
||||
@@ -297,6 +300,7 @@ export function buildNativeExecutionInput(input: BuildNativeExecutionInput): Nat
|
||||
permissionMode: input.acpxPermissionMode ?? "approve-all",
|
||||
...(mode === undefined ? {} : { mode }),
|
||||
...(piThinkingLevel === undefined ? {} : { piThinkingLevel }),
|
||||
...(input.claudeEffort === undefined ? {} : { claudeEffort: input.claudeEffort }),
|
||||
profile: {
|
||||
driverKind: acpxProfile!.driverKind,
|
||||
protocolVersion: acpxProfile!.protocolVersion,
|
||||
@@ -345,7 +349,7 @@ export function buildNativeExecutionInput(input: BuildNativeExecutionInput): Nat
|
||||
schema: "paperclip.native-execution-input.v6", provider: { kind: input.provider, model: null, binding: input.dotBinding },
|
||||
workspace: { access: "none", cwd: null, repoUrl: null, repoRef: null, branchName: null },
|
||||
session: { ...prepared.session, driverKind: input.provider === "grok_bot" ? "grok_bot_mcp" : "openai_dot_mcp", lifecyclePolicy: { mode: "per_turn", idleTimeoutMs: null } }
|
||||
} : prepared) as NativeExecutionInputV5 | NativeExecutionInputV6 | NativeExecutionInputV7 | NativeExecutionInputV8;
|
||||
} : prepared) as NativeExecutionInputV5 | NativeExecutionInputV6 | NativeExecutionInputV7 | NativeExecutionInputV8 | NativeExecutionInputV9;
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -10235,6 +10235,20 @@ describe("runnerd provider runtime wiring", () => {
|
||||
await expect(readFile(authPath, "utf8")).resolves.toBe(auth);
|
||||
});
|
||||
|
||||
it.each(["isolated", "preconfigured"] as const)("pins Claude v9 permissions and effort at daemon startup in %s mode", async homeMode => {
|
||||
const current = { ...execution, schema: "paperclip.native-execution-input.v9", homeMode,
|
||||
provider: { kind: "acpx", agent: "claude", model: "claude-opus-5-5", permissionMode: "approve-all", claudeEffort: "high" },
|
||||
session: { ...execution.session, driverKind: "acpx_runtime" },
|
||||
} as unknown as NativeExecutionInput;
|
||||
await createRunnerdBackend({ db: leaseDb(current), execution: current,
|
||||
runnerInstanceId: `claude-v9-pinned-${homeMode}` });
|
||||
state.createBackend.mock.calls.at(-1)![1].codexTransportFactory!();
|
||||
expect(state.createTransport.mock.calls.at(-1)![0]).toMatchObject({
|
||||
provider: "acpx", acpxAgent: "claude", acpxPermissionMode: "approve-all",
|
||||
acpxPermissionModePinned: true, claudeEffort: "high", homeMode,
|
||||
});
|
||||
});
|
||||
|
||||
it("copies back and removes Grok credentials from the exact ACPX launch home", async () => {
|
||||
const priorHome = process.env.PAPERCLIP_HOME;
|
||||
const privateRoot = await realpath(isolatedStateDirectory);
|
||||
|
||||
@@ -5638,6 +5638,7 @@ export function providerSessionIdentityFromDurableProviderState(input: {
|
||||
identity.permissionMode !== input.execution.provider.permissionMode ||
|
||||
!acpxRecoveryModeMatches(input.execution.provider, descriptor.mode, identity.mode) ||
|
||||
!acpxRecoveryPiThinkingMatches(input.execution.provider, descriptor.piThinkingLevel, identity.piThinkingLevel) ||
|
||||
!acpxRecoveryClaudeEffortMatches(input.execution.provider, descriptor.claudeEffort, identity.claudeEffort) ||
|
||||
!["approve-all", "approve-paperclip", "approve-reads", "deny-all"].includes(
|
||||
String(identity.permissionMode),
|
||||
) ||
|
||||
@@ -5745,6 +5746,13 @@ function acpxRecoveryModeMatches(
|
||||
&& observedModes.every(mode => mode === expected);
|
||||
}
|
||||
|
||||
function acpxRecoveryClaudeEffortMatches(provider: unknown, projected: unknown, observed: unknown): boolean {
|
||||
const config = record(provider);
|
||||
const expected = config.claudeEffort;
|
||||
if (config.kind !== "acpx" || config.agent !== "claude") return expected === undefined && projected === undefined && observed === undefined;
|
||||
return expected === projected && expected === observed && (expected === undefined || ["low", "medium", "high", "xhigh", "max"].includes(String(expected)));
|
||||
}
|
||||
|
||||
function acpxRecoveryPiThinkingMatches(provider: NativeExecutionInput["provider"], ...observed: unknown[]): boolean {
|
||||
const expected = record(provider).piThinkingLevel;
|
||||
if (provider.kind === "acpx" && provider.agent === "pi") return ["off", "low", "high", "max"].includes(String(expected)) && observed.every(value => value === expected);
|
||||
@@ -5762,6 +5770,7 @@ export function providerSessionIdentityTransitionIsAllowed(input: {
|
||||
record(record(input.current).providerSessionIdentity).mode,
|
||||
)) return false;
|
||||
if (!acpxRecoveryPiThinkingMatches(input.execution.provider, record(record(input.previous).providerSessionIdentity).piThinkingLevel, record(record(input.current).providerSessionIdentity).piThinkingLevel)) return false;
|
||||
if (!acpxRecoveryClaudeEffortMatches(input.execution.provider, record(record(input.previous).providerSessionIdentity).claudeEffort, record(record(input.current).providerSessionIdentity).claudeEffort)) return false;
|
||||
if (canonicalJson(input.previous) === canonicalJson(input.current)) {
|
||||
return true;
|
||||
}
|
||||
@@ -13001,10 +13010,12 @@ async function createRunnerdBackendWithinSessionClaim(
|
||||
acpxPermissionMode: input.execution.provider.permissionMode,
|
||||
acpxMode: input.execution.provider.mode,
|
||||
piThinkingLevel: input.execution.provider.piThinkingLevel,
|
||||
claudeEffort: "claudeEffort" in input.execution.provider ? input.execution.provider.claudeEffort : undefined,
|
||||
acpxPermissionModePinned:
|
||||
input.execution.schema === "paperclip.native-execution-input.v4" ||
|
||||
input.execution.schema === "paperclip.native-execution-input.v5" ||
|
||||
input.execution.schema === "paperclip.native-execution-input.v7",
|
||||
input.execution.schema === "paperclip.native-execution-input.v7" ||
|
||||
input.execution.schema === "paperclip.native-execution-input.v9",
|
||||
acpxRuntimeDirectory: remoteRunnerFilesystemRoot
|
||||
? posix.join(remoteRunnerFilesystemRoot, "acpx")
|
||||
: resolve(
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { resolveQualifiedAcpxProfile } from "../../vendor/paperclip-runner/index.js";
|
||||
import { agentHarnessType } from "@paperclipai/shared";
|
||||
import { resolveClaudeEffort, resolveQualifiedAcpxProfile } from "../../vendor/paperclip-runner/index.js";
|
||||
import {
|
||||
isPaperclipRunnerProvider,
|
||||
PAPERCLIP_RUNNER_PERMISSION_CAPABILITIES,
|
||||
@@ -26,7 +27,7 @@ export const CLAUDE_MANAGED_BETA_VERSION = "managed-agents-2026-04-01" as const;
|
||||
|
||||
export const DEFAULT_ACPX_RUNNER_MODELS = {
|
||||
grok: "grok-4.7",
|
||||
claude: "claude-sonnet-5",
|
||||
claude: "claude-opus-5-5",
|
||||
// These profiles require explicit configuration. Admission belongs to the runner.
|
||||
codex: null,
|
||||
cursor: null,
|
||||
@@ -136,6 +137,7 @@ export type PaperclipRunnerNativeProviderInput =
|
||||
acpxPermissionMode: "approve-all" | "approve-paperclip" | "approve-reads" | "deny-all";
|
||||
acpxSessionMode?: "agent" | "plan" | "ask";
|
||||
piThinkingLevel?: "off" | "low" | "high" | "max";
|
||||
claudeEffort?: "low" | "medium" | "high" | "xhigh" | "max";
|
||||
};
|
||||
|
||||
export class PaperclipRunnerProviderProfileError extends Error {
|
||||
@@ -195,7 +197,7 @@ export function validatePaperclipRunnerGrokConfig(config: Record<string, unknown
|
||||
|
||||
/** A task may change a local Runner model (and Codex effort), but not provider identity. */
|
||||
export function projectPaperclipRunnerTaskConfig(
|
||||
backend: "codex_app_server" | "opencode_server",
|
||||
backend: "codex_app_server" | "opencode_server" | "acpx_runtime",
|
||||
agentConfig: unknown,
|
||||
taskOverrides: unknown,
|
||||
managedModel?: string,
|
||||
@@ -206,10 +208,11 @@ export function projectPaperclipRunnerTaskConfig(
|
||||
// The server resolves a connection's model namespace after task overrides.
|
||||
// Carry that model into durable input without allowing routing to change the harness.
|
||||
const model = optionalString(managedModel) ?? optionalString(task.model);
|
||||
const effortKey = backend === "codex_app_server"
|
||||
const supportsEffort = backend === "codex_app_server" || (backend === "acpx_runtime" && agentHarnessType("paperclip_runner", base) === "claude_local");
|
||||
const effortKey = supportsEffort
|
||||
? ["modelReasoningEffort", "reasoningEffort", "effort"].find((key) => key in task)
|
||||
: undefined;
|
||||
if (backend === "codex_app_server" && ((model !== null && model !== optionalString(base.model)) || effortKey !== undefined)) {
|
||||
if (supportsEffort && ((model !== null && model !== optionalString(base.model)) || effortKey !== undefined)) {
|
||||
delete config.modelReasoningEffort;
|
||||
delete config.reasoningEffort;
|
||||
delete config.effort;
|
||||
@@ -380,6 +383,19 @@ export function assertAgentCoreProfileRecoveryBinding(input: {
|
||||
}
|
||||
}
|
||||
|
||||
export function claudeRunnerUnsupportedSettings(config: Record<string, unknown>): string[] {
|
||||
const unsupported = ["chrome", "agentCommand", "acpAgentCommand", "stateDir", "acpStateDir", "warmHandleIdleMs", "acpWarmHandleIdleMs", "extraArgs", "args", "filesystemScope", "filesystemExtraPaths", "filesystemSandboxCommand", "networkScope", "networkAllowlist", "maxTurnsPerRun", "permissionMode", "acpPermissionMode"]
|
||||
.filter(key => config[key] !== undefined && config[key] !== false && config[key] !== "" && config[key] !== 0 && !(Array.isArray(config[key]) && config[key].length === 0));
|
||||
if (config.command && config.command !== "claude") unsupported.push("command");
|
||||
if (config.mode && config.mode !== "persistent") unsupported.push("mode");
|
||||
if (config.acpMode && config.acpMode !== "persistent") unsupported.push("acpMode");
|
||||
if (config.engine && !["auto", "cli", "acp"].includes(String(config.engine))) unsupported.push("engine");
|
||||
if (config.nonInteractivePermissions && config.nonInteractivePermissions !== "deny") unsupported.push("nonInteractivePermissions");
|
||||
if (config.acpNonInteractivePermissions && config.acpNonInteractivePermissions !== "deny") unsupported.push("acpNonInteractivePermissions");
|
||||
if (config.dangerouslySkipPermissions === false) unsupported.push("dangerouslySkipPermissions");
|
||||
return unsupported;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the immutable provider identity used for a fresh Paperclip Runner
|
||||
* selection. The persisted adapterConfig is the authority; runtimeConfig is
|
||||
@@ -409,6 +425,11 @@ export function resolvePaperclipRunnerProviderProfile(
|
||||
const bindingId = validatePaperclipRunnerDotConfig(config)!;
|
||||
return { provider: "openai_dot", backend: "openai_dot_mcp", model: null, dotBindingId: bindingId };
|
||||
}
|
||||
if (candidate === "acpx" && agentHarnessType("paperclip_runner", config) === "claude_local") {
|
||||
const unsupported = claudeRunnerUnsupportedSettings(config);
|
||||
if (unsupported.length) throw new PaperclipRunnerProviderProfileError("agent_runner_config_incompatible",
|
||||
`Paperclip Runner cannot honor these settings: ${unsupported.join(", ")}. Remove them or select Legacy runner in Advanced.`);
|
||||
}
|
||||
assertPermissionMode(candidate, config);
|
||||
try {
|
||||
resolvePaperclipRunnerCursorMode(candidate, config.acpxAgent, config.acpxSessionMode);
|
||||
@@ -418,6 +439,9 @@ export function resolvePaperclipRunnerProviderProfile(
|
||||
error instanceof Error ? error.message : "Invalid Cursor session mode",
|
||||
);
|
||||
}
|
||||
try { if (candidate === "acpx" && agentHarnessType("paperclip_runner", config) === "claude_local") resolveClaudeEffort("claude", config.modelReasoningEffort ?? config.reasoningEffort ?? config.effort); } catch (error) {
|
||||
throw new PaperclipRunnerProviderProfileError("paperclip_runner_claude_effort_invalid", error instanceof Error ? error.message : "Invalid Claude effort");
|
||||
}
|
||||
try { resolvePaperclipRunnerPiThinkingLevel(candidate, config.acpxAgent, config.piThinkingLevel); } catch (error) {
|
||||
throw new PaperclipRunnerProviderProfileError("paperclip_runner_pi_thinking_invalid", error instanceof Error ? error.message : "Invalid Pi thinking level");
|
||||
}
|
||||
@@ -630,6 +654,7 @@ export function resolvePaperclipRunnerNativeProviderInput(input: {
|
||||
provider: "acpx",
|
||||
model: profile.model,
|
||||
acpxAgent: profile.acpxAgent,
|
||||
...(profile.acpxAgent === "claude" ? { claudeEffort: resolveClaudeEffort("claude", config.modelReasoningEffort ?? config.reasoningEffort ?? config.effort) } : {}),
|
||||
...(profile.acpxAgent === "pi" ? { piThinkingLevel: resolvePaperclipRunnerPiThinkingLevel("acpx", "pi", config.piThinkingLevel) } : {}),
|
||||
...(profile.acpxAgent === "cursor" ? {
|
||||
acpxSessionMode: resolvePaperclipRunnerCursorMode("acpx", "cursor", config.acpxSessionMode),
|
||||
|
||||
@@ -501,6 +501,10 @@ describe("resolveHeartbeatRuntimeMode compatibility", () => {
|
||||
adapterConfig: { provider: "opencode", model: "opencode/gpt-5" },
|
||||
})).toThrow(NativeRunnerSelectionError);
|
||||
});
|
||||
it("admits native Claude without enabling other ACPX providers", () => {
|
||||
expect(resolveNativeRuntimeMode({ ...eligible, enabled: false, adapterConfig: { provider: "acpx", acpxAgent: "claude" } })).toMatchObject({ kind: "native", profile: { backend: "acpx_runtime" } });
|
||||
expect(() => resolveNativeRuntimeMode({ ...eligible, enabled: false, adapterConfig: { provider: "acpx", acpxAgent: "grok" } })).toThrow(expect.objectContaining({ code: "paperclip_runner_rollout_disabled" }));
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
|
||||
@@ -138,7 +138,7 @@ export function resolveNativeRuntimeMode(input: {
|
||||
if (input.dotEnabled !== true) throw ineligible("paperclip_runner_dot_disabled", "Enable OpenAI Dot and Assistant connections (MCP) in experimental settings before assigning new work.");
|
||||
} else if (runnerProfile.provider === "grok_bot" && (input.grokEnabled !== true || !input.enabled)) {
|
||||
throw ineligible("paperclip_runner_grok_disabled", "Enable Grok Bot, Paperclip Runner, and Assistant connections (MCP) before assigning new work.");
|
||||
} else if (runnerProfile.provider !== "codex" && !input.enabled) {
|
||||
} else if (runnerProfile.provider !== "codex" && !(runnerProfile.provider === "acpx" && runnerProfile.acpxAgent === "claude") && !input.enabled) {
|
||||
throw ineligible(
|
||||
"paperclip_runner_rollout_disabled",
|
||||
"Paperclip Runner is experimental and disabled on this instance.",
|
||||
|
||||
@@ -36,6 +36,7 @@ import type {
|
||||
SecretVersionSelector,
|
||||
} from "@paperclipai/shared";
|
||||
import {
|
||||
agentHarnessType,
|
||||
CLASS3_STATIC_LEASE_ALLOWLIST,
|
||||
createSecretProviderConfigSchema,
|
||||
deriveProjectUrlKey,
|
||||
@@ -279,7 +280,7 @@ export interface ClaudeOAuthBindingInvariantDecision {
|
||||
export function assertClaudeOAuthBindingInvariant(
|
||||
input: ClaudeOAuthBindingInvariantInput,
|
||||
): ClaudeOAuthBindingInvariantDecision {
|
||||
const isClaudeLocal = input.adapterType === CLAUDE_LOCAL_ADAPTER_TYPE;
|
||||
const isClaudeLocal = agentHarnessType(input.adapterType ?? "", asRecord(input.nextConfig) ?? {}) === CLAUDE_LOCAL_ADAPTER_TYPE;
|
||||
const nextIsFixed = hasFixedClaudeOAuthBinding(input.nextConfig);
|
||||
const priorIsFixed = hasFixedClaudeOAuthBinding(input.priorConfig);
|
||||
|
||||
|
||||
+2
@@ -37,6 +37,7 @@ export type {
|
||||
NativeExecutionInputV5,
|
||||
NativeExecutionInputV6,
|
||||
NativeExecutionInputV8,
|
||||
NativeExecutionInputV9,
|
||||
MuseBindingSnapshot,
|
||||
DotBindingSnapshot,
|
||||
ExternalProviderPort,
|
||||
@@ -137,6 +138,7 @@ export const parsePaperclipQuestionSet = runner.parsePaperclipQuestionSet;
|
||||
export const parsePaperclipQuestionResponse =
|
||||
runner.parsePaperclipQuestionResponse;
|
||||
export const resolveQualifiedAcpxProfile = runner.resolveQualifiedAcpxProfile;
|
||||
export const resolveClaudeEffort = runner.resolveClaudeEffort;
|
||||
export const QUALIFIED_ACPX_PROFILES = runner.QUALIFIED_ACPX_PROFILES;
|
||||
export const QUALIFIED_ACPX_VERSION = runner.QUALIFIED_ACPX_VERSION;
|
||||
export const CURSOR_DISTRIBUTION_PINS = runner.CURSOR_DISTRIBUTION_PINS;
|
||||
|
||||
Reference in new issue
Block a user