feat(claude-local): add Claude Fable 5.1 support (#12730)

## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work.
> - The Claude local adapter lets operators select a Claude model for an
agent.
> - Claude Fable 5.1 was absent from the adapter model lists.
> - The adapter runtime also used a Claude Code build that rejected
Fable 5.1.
> - This pull request adds the direct Anthropic ID and the AWS Bedrock
inference profile ID.
> - It also updates the Claude ACP runtime and keeps the Paperclip usage
and isolation patches.
> - The benefit is that operators can select and run Claude Fable 5.1
through the Claude adapter.

## Linked Issues or Issue Description

Refs #8810. That issue covers related model ID handling. This change
does not change provider-prefixed model IDs.

**Agent or provider**

Claude Code through the built-in `claude_local` adapter. The requested
model is Claude Fable 5.1.

**Why this adapter is useful**

Operators can use Fable 5.1 without entering an undocumented model ID.
The configured model also reaches both supported Claude execution lanes.

**How the agent is invoked**

The CLI lane sends `--model claude-fable-5-1`. The ACP lane sends
`ANTHROPIC_MODEL=claude-fable-5-1` to
`@agentclientprotocol/claude-agent-acp`.

**Are you willing to implement it?**

Yes. This pull request includes the implementation and tests.

**Additional context**

Claude Code 2.1.232 rejected Fable 5.1 and required version 2.1.251 or
newer. ACP package 0.73.0 includes Claude Code 2.1.257. The update keeps
Paperclip's usage metadata and isolated-context behavior.

## What Changed

- Added `claude-fable-5-1` to the direct Claude fallback list.
- Added `us.anthropic.claude-fable-5-1` to the AWS Bedrock list.
- Kept the existing default model at the first position in each list.
- Updated the Claude ACP dependency from 0.70 to 0.73.
- Carried the Paperclip usage and isolated-context changes into the 0.73
patch.
- Added a Claude Code 2.1.251 minimum-version preflight for Fable 5.1
when using the standard `claude` executable, surfaced in both adapter
Test and execution. Explicit custom wrappers retain their existing
compatibility contract.
- Kept local adapter Tests from executing caller-selected binaries: when
runtime `PATH` selects a different Claude executable than the trusted
probe, the Test warns and defers the authoritative version check to
execution instead of approving or rejecting the alternate installation.
- Added tests for model listing, discovery deduplication, Bedrock
filtering, model pass-through in both execution lanes, old-CLI rejection
before launch, custom-wrapper compatibility, and local runtime-PATH
mismatch handling.

## Verification

- `pnpm --filter @paperclipai/adapter-claude-local typecheck`
- `pnpm exec vitest run
packages/adapters/claude-local/src/server/execute.remote.test.ts
packages/adapters/claude-local/src/server/test.remote.test.ts
packages/adapters/claude-local/src/server/test.probe.test.ts
packages/adapters/claude-local/src/server/acp.test.ts
server/src/__tests__/adapter-models.test.ts` (72 tests passed)
- `node --test scripts/acpx-patch-packaging.test.mjs` (13 tests passed)
- `pnpm -r typecheck`
- `pnpm build`
- A local Paperclip agent run completed with `usageJson.model` set to
`claude-fable-5-1` through ACP 0.73.0 and its bundled Claude Code
2.1.257.
- `pnpm test:run` completed 5,638 passing tests and 24 skipped tests. It
also found 24 failures in unrelated workspace-runtime,
path-canonicalization, and runtime-exposure tests on macOS with Node 26.
These failures do not touch this diff. Clean pull request CI is the
final full-suite gate.

## Risks

- The ACP dependency update can change Claude runtime behavior outside
model selection. Focused ACP tests, the full typecheck, the production
build, and a real local Fable run reduce this risk.
- The 0.73 patch must stay aligned with the installed ACP version.
Dependency-resolution CI verifies the manifest and patch pair.
- Fable 5.1 adds a short `claude --version` preflight to standard
CLI-lane Tests and runs. The result is intentionally not cached so an
in-place Claude Code upgrade takes effect without restarting Paperclip.
Explicit custom wrappers are not version-probed because their output and
compatibility contract can differ from the standard executable.
- Local Tests preserve the existing deny-by-default probe boundary and
do not execute a binary selected by caller-controlled `PATH`. A
mismatched runtime binary produces an explicit warning without blocking
an otherwise valid setup; execution independently validates the actual
runtime-selected CLI before launch.
- The AWS Bedrock identifier differs from earlier IDs because Fable 5.1
has no `-v1` suffix. The model-list test locks this exact value.
- There is no schema change or migration.

> For core feature work, check [`ROADMAP.md`](ROADMAP.md) first and
discuss it in `#dev` before opening the PR. Feature PRs that overlap
with planned core work may need to be redirected — check the roadmap
first. See `CONTRIBUTING.md`.

## Model Used

Provider: OpenAI. Model: GPT-5 Codex. The host did not expose a more
specific model ID or context-window size. Capabilities used: agentic
reasoning, repository editing, shell execution, web research, and local
runtime verification.

## 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
- [x] I have run tests locally and they 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:
Michael Nguyen 2026-09-02 11:32:01 -10:00 committed by GitHub
parent 0f94521017
commit dfdfc8664e
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
14 changed files with 531 additions and 12 deletions

View File

@ -102,6 +102,7 @@
"acpx@0.12.0": "patches/acpx@0.12.0.patch",
"acpx@0.13.1": "patches/acpx@0.13.1.patch",
"@agentclientprotocol/claude-agent-acp@0.70.0": "patches/@agentclientprotocol__claude-agent-acp@0.70.0.patch",
"@agentclientprotocol/claude-agent-acp@0.73.0": "patches/@agentclientprotocol__claude-agent-acp@0.73.0.patch",
"@agentclientprotocol/codex-acp@1.6.2": "patches/@agentclientprotocol__codex-acp@1.6.2.patch"
},
"overrides": {

View File

@ -53,7 +53,7 @@
"probe:quota:raw": "pnpm exec tsx src/cli/quota-probe.ts --json --raw-cli"
},
"dependencies": {
"@agentclientprotocol/claude-agent-acp": "^0.70.0",
"@agentclientprotocol/claude-agent-acp": "^0.73.0",
"@anthropic-ai/sdk": "0.121.0",
"@paperclipai/adapter-utils": "workspace:*",
"@paperclipai/shared": "workspace:*",

View File

@ -6,6 +6,7 @@ export const SANDBOX_INSTALL_COMMAND = "npm install -g @anthropic-ai/claude-code
export const models = [
{ id: "claude-opus-4-8", label: "Claude Opus 4.8" },
{ id: "claude-sonnet-5", label: "Claude Sonnet 5" },
{ id: "claude-fable-5-1", label: "Claude Fable 5.1" },
{ id: "claude-fable-5", label: "Claude Fable 5" },
{ id: "claude-mythos-5", label: "Claude Mythos 5" },
{ id: "claude-opus-5", label: "Claude Opus 5" },

View File

@ -514,6 +514,30 @@ describe("claude_local ACP lane", () => {
expect(settings.permissions.allow).toEqual(expect.arrayContaining(["Bash(curl:*)", "Bash(env)"]));
});
it("passes the exact configured Fable 5.1 ID through ANTHROPIC_MODEL on the ACP lane", async () => {
const root = await makeTempRoot("paperclip-claude-acp-fable51-");
const meta: AdapterInvocationMeta[] = [];
const execute = createClaudeAcpExecutor({
createRuntime: (options: FakeRuntimeOptions) => new FakeRuntime(options) as never,
});
const result = await execute(buildContext(root, {
config: {
engine: "acp",
cwd: root,
stateDir: path.join(root, "state"),
model: "claude-fable-5-1",
promptTemplate: "Do the assigned work.",
},
onMeta: async (payload: AdapterInvocationMeta) => {
meta.push(payload);
},
}));
expect(result.exitCode).toBe(0);
expect(meta[0]?.env?.ANTHROPIC_MODEL).toBe("claude-fable-5-1");
});
it("creates the ACP session on the in-sandbox workspace cwd for runner-backed remote runs", async () => {
const root = await makeTempRoot("paperclip-claude-acp-remote-cwd-");
const localCwd = path.join(root, "worktree");

View File

@ -4,6 +4,13 @@ import path from "node:path";
const effortFlagSupportCache = new Map<string, Promise<boolean | null>>();
export const CLAUDE_FABLE_5_1_MIN_CLI_VERSION = "2.1.251";
const CLAUDE_FABLE_5_1_MODEL_IDS = new Set([
"claude-fable-5-1",
"us.anthropic.claude-fable-5-1",
]);
export function claudeCommandLooksLike(command: string, expected = "claude"): boolean {
const base = path.basename(command).toLowerCase();
return base === expected || base === `${expected}.cmd` || base === `${expected}.exe`;
@ -33,6 +40,78 @@ function cacheKeyForTarget(command: string, target: AdapterExecutionTarget | nul
].join(":");
}
export function minimumClaudeCliVersionForModel(model: string): string | null {
return CLAUDE_FABLE_5_1_MODEL_IDS.has(model.trim())
? CLAUDE_FABLE_5_1_MIN_CLI_VERSION
: null;
}
export function parseClaudeCliVersion(output: string): string | null {
return output.match(/\b(\d+)\.(\d+)\.(\d+)\b/)?.[0] ?? null;
}
export function claudeCliVersionAtLeast(version: string, minimum: string): boolean {
const parsedVersion = version.split(".").map(Number);
const parsedMinimum = minimum.split(".").map(Number);
if (
parsedVersion.length !== 3 ||
parsedMinimum.length !== 3 ||
parsedVersion.some((part) => !Number.isInteger(part) || part < 0) ||
parsedMinimum.some((part) => !Number.isInteger(part) || part < 0)
) {
return false;
}
for (let index = 0; index < parsedMinimum.length; index += 1) {
if (parsedVersion[index] !== parsedMinimum[index]) {
return parsedVersion[index] > parsedMinimum[index];
}
}
return true;
}
async function probeClaudeCommandVersion(input: {
runId: string;
command: string;
target: AdapterExecutionTarget | null | undefined;
cwd: string;
env: Record<string, string>;
timeoutSec: number;
graceSec: number;
}): Promise<string | null> {
const version = await runAdapterExecutionTargetProcess(
input.runId,
input.target,
input.command,
["--version"],
{
cwd: input.cwd,
env: input.env,
timeoutSec: Math.max(1, Math.min(input.timeoutSec, 20)),
graceSec: Math.max(1, Math.min(input.graceSec, 5)),
onLog: async () => {},
},
);
if (version.timedOut || version.exitCode !== 0) return null;
return parseClaudeCliVersion(`${version.stdout}\n${version.stderr}`);
}
export async function readClaudeCommandVersion(input: {
runId: string;
command: string;
target: AdapterExecutionTarget | null | undefined;
cwd: string;
env: Record<string, string>;
timeoutSec: number;
graceSec: number;
}): Promise<string | null> {
// Do not cache this probe: an operator may upgrade Claude Code while the
// Paperclip server is running, and the next Test/run should recover without
// requiring a server restart.
return probeClaudeCommandVersion(input).catch(() => null);
}
async function probeClaudeCommandSupportsEffortFlag(input: {
runId: string;
command: string;

View File

@ -13,15 +13,17 @@ const {
syncDirectoryToSsh,
startAdapterExecutionTargetPaperclipBridge,
} = vi.hoisted(() => ({
runChildProcess: vi.fn(async (): Promise<RunProcessResult> => ({
runChildProcess: vi.fn(async (_runId: string, _command: string, args: string[]): Promise<RunProcessResult> => ({
exitCode: 0,
signal: null,
timedOut: false,
stdout: [
JSON.stringify({ type: "system", subtype: "init", session_id: "claude-session-1", model: "claude-sonnet" }),
JSON.stringify({ type: "assistant", session_id: "claude-session-1", message: { content: [{ type: "text", text: "hello" }] } }),
JSON.stringify({ type: "result", session_id: "claude-session-1", result: "hello", usage: { input_tokens: 1, cache_read_input_tokens: 0, output_tokens: 1 } }),
].join("\n"),
stdout: args.includes("--version")
? "2.1.251 (Claude Code)\n"
: [
JSON.stringify({ type: "system", subtype: "init", session_id: "claude-session-1", model: "claude-sonnet" }),
JSON.stringify({ type: "assistant", session_id: "claude-session-1", message: { content: [{ type: "text", text: "hello" }] } }),
JSON.stringify({ type: "result", session_id: "claude-session-1", result: "hello", usage: { input_tokens: 1, cache_read_input_tokens: 0, output_tokens: 1 } }),
].join("\n"),
stderr: "",
pid: 123,
startedAt: new Date().toISOString(),
@ -76,12 +78,14 @@ vi.mock("@paperclipai/adapter-utils/execution-target", async () => {
});
import { execute } from "./execute.js";
import { resetClaudeCliCapabilitiesCacheForTests } from "./cli-capabilities.js";
describe("claude remote execution", () => {
const cleanupDirs: string[] = [];
afterEach(async () => {
vi.clearAllMocks();
resetClaudeCliCapabilitiesCacheForTests();
while (cleanupDirs.length > 0) {
const dir = cleanupDirs.pop();
if (!dir) continue;
@ -403,4 +407,126 @@ describe("claude remote execution", () => {
expect(result.errorCode).toBe("duplex_channel_lost");
});
describe("CLI-lane model pass-through", () => {
async function executeWithModel(prefix: string, config: Record<string, unknown>) {
const rootDir = await mkdtemp(path.join(os.tmpdir(), prefix));
cleanupDirs.push(rootDir);
const workspaceDir = path.join(rootDir, "workspace");
await mkdir(workspaceDir, { recursive: true });
const result = await execute({
runId: "run-model-passthrough",
agent: {
id: "agent-1",
companyId: "company-1",
name: "Claude Coder",
adapterType: "claude_local",
adapterConfig: {},
},
runtime: {
sessionId: null,
sessionParams: null,
sessionDisplayId: null,
taskKey: null,
},
config: {
command: "claude",
...config,
},
context: {
paperclipWorkspace: {
cwd: workspaceDir,
source: "project_primary",
},
},
executionTransport: {
remoteExecution: {
host: "127.0.0.1",
port: 2222,
username: "fixture",
remoteWorkspacePath: "/remote/workspace",
remoteCwd: "/remote/workspace",
privateKey: "PRIVATE KEY",
knownHosts: "[127.0.0.1]:2222 ssh-ed25519 AAAA",
strictHostKeyChecking: true,
},
},
onLog: async () => {},
});
const call = runChildProcess.mock.calls.find((candidate) =>
(candidate[2] as string[]).includes("--print"),
) as unknown as [string, string, string[]] | undefined;
return { args: call?.[2] ?? [], result };
}
it("passes the exact configured Fable 5.1 ID as --model on the CLI lane", async () => {
const { args } = await executeWithModel("paperclip-claude-model-direct-", {
model: "claude-fable-5-1",
});
const modelFlag = args.indexOf("--model");
expect(modelFlag).toBeGreaterThanOrEqual(0);
expect(args[modelFlag + 1]).toBe("claude-fable-5-1");
});
it("passes the Bedrock-native Fable 5.1 ID as --model under Bedrock auth", async () => {
const { args } = await executeWithModel("paperclip-claude-model-bedrock-", {
model: "us.anthropic.claude-fable-5-1",
env: { CLAUDE_CODE_USE_BEDROCK: "1" },
});
const modelFlag = args.indexOf("--model");
expect(modelFlag).toBeGreaterThanOrEqual(0);
expect(args[modelFlag + 1]).toBe("us.anthropic.claude-fable-5-1");
});
it("skips --model for a direct Anthropic ID under Bedrock auth", async () => {
const { args } = await executeWithModel("paperclip-claude-model-bedrock-skip-", {
model: "claude-fable-5-1",
env: { CLAUDE_CODE_USE_BEDROCK: "1" },
});
expect(args).not.toContain("--model");
});
it("rejects Fable 5.1 before launch when the CLI is older than 2.1.251", async () => {
runChildProcess.mockResolvedValueOnce({
exitCode: 0,
signal: null,
timedOut: false,
stdout: "2.1.247 (Claude Code)\n",
stderr: "",
pid: 123,
startedAt: new Date().toISOString(),
});
const { args, result } = await executeWithModel("paperclip-claude-model-old-cli-", {
model: "claude-fable-5-1",
});
expect(args).toEqual([]);
expect(result.errorCode).toBe("claude_cli_version_incompatible");
expect(result.errorMessage).toContain("requires Claude Code 2.1.251 or newer");
expect(result.resultJson).toMatchObject({
requiredClaudeCodeVersion: "2.1.251",
detectedClaudeCodeVersion: "2.1.247",
});
});
it("leaves Fable compatibility to explicitly configured custom CLI wrappers", async () => {
const { args, result } = await executeWithModel("paperclip-claude-model-wrapper-", {
command: "/opt/paperclip/claude-wrapper",
model: "claude-fable-5-1",
});
expect(args).toContain("--model");
expect(args).toContain("claude-fable-5-1");
expect(result.errorCode).not.toBe("claude_cli_version_incompatible");
expect(runChildProcess.mock.calls.some((call) =>
(call[2] as string[]).includes("--version"),
)).toBe(false);
});
});
});

View File

@ -81,7 +81,13 @@ import {
resolveSharedClaudeConfigDir,
writePaperclipClaudeMcpConfig,
} from "./claude-config.js";
import { claudeCommandSupportsEffortFlag } from "./cli-capabilities.js";
import {
claudeCliVersionAtLeast,
claudeCommandLooksLike,
claudeCommandSupportsEffortFlag,
minimumClaudeCliVersionForModel,
readClaudeCommandVersion,
} from "./cli-capabilities.js";
import { resolveClaudeDesiredSkillNames } from "./skills.js";
import { isBedrockModelId } from "./models.js";
import { prepareClaudePromptBundle } from "./prompt-cache.js";
@ -863,6 +869,9 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
taskContextChars: taskContextNote.length,
heartbeatPromptChars: renderedPrompt.length,
};
const passesConfiguredModel = Boolean(
model && (!isBedrockAuth(effectiveEnv) || isBedrockModelId(model)),
);
const buildClaudeArgs = (
resumeSessionId: string | null,
@ -879,7 +888,7 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
// For Bedrock: only pass --model when the ID is a Bedrock-native identifier
// (e.g. "us.anthropic.*" or ARN). Anthropic-style IDs like "claude-opus-4-6" are invalid
// on Bedrock, so skip them and let the CLI use its own configured model.
if (model && (!isBedrockAuth(effectiveEnv) || isBedrockModelId(model))) {
if (passesConfiguredModel) {
args.push("--model", model);
}
if (effectiveEffort) args.push("--effort", effectiveEffort);
@ -1250,6 +1259,49 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
};
try {
const minimumCliVersion = passesConfiguredModel && claudeCommandLooksLike(command, "claude")
? minimumClaudeCliVersionForModel(model)
: null;
if (minimumCliVersion) {
const detectedCliVersion = await readClaudeCommandVersion({
runId,
command,
target: runtimeExecutionTarget,
cwd,
env,
timeoutSec,
graceSec,
});
if (
!detectedCliVersion ||
!claudeCliVersionAtLeast(detectedCliVersion, minimumCliVersion)
) {
const detected = detectedCliVersion
? `detected ${detectedCliVersion}`
: "could not determine the installed version";
const errorMessage =
`Claude Fable 5.1 requires Claude Code ${minimumCliVersion} or newer on the CLI lane; ${detected}. ` +
"Upgrade Claude Code or restore the default ACP lane before retrying.";
await onLog("stderr", `[paperclip] ${errorMessage}\n`);
return {
exitCode: 1,
signal: null,
timedOut: false,
errorMessage,
errorCode: "claude_cli_version_incompatible",
provider: "anthropic",
biller: isBedrockAuth(effectiveEnv) ? "aws_bedrock" : "anthropic",
model,
billingType,
resultJson: {
stopReason: "claude_cli_version_incompatible",
requiredClaudeCodeVersion: minimumCliVersion,
detectedClaudeCodeVersion: detectedCliVersion,
},
};
}
}
const initial = await runAttempt(sessionId ?? null);
const sessionErrorKind =
sessionId &&

View File

@ -10,6 +10,8 @@ const ANTHROPIC_API_VERSION = "2023-06-01";
/** AWS Bedrock model IDs — region-qualified identifiers required by the Bedrock API. */
const BEDROCK_MODELS: AdapterModel[] = [
{ id: "us.anthropic.claude-opus-4-8-v1", label: "Bedrock Opus 4.8" },
// Fable 5.1's documented geo inference ID carries no -v1 suffix, unlike earlier entries.
{ id: "us.anthropic.claude-fable-5-1", label: "Bedrock Fable 5.1" },
{ id: "us.anthropic.claude-fable-5-v1", label: "Bedrock Fable 5" },
{ id: "us.anthropic.claude-opus-4-6-v1", label: "Bedrock Opus 4.6" },
{ id: "us.anthropic.claude-sonnet-4-5-20250929-v2:0", label: "Bedrock Sonnet 4.5" },

View File

@ -629,6 +629,39 @@ describe("claude CLI local hello probe hardening", () => {
expect(JSON.stringify(spawnedEnv)).not.toContain("caller-proxy");
});
it("warns without executing when runtime PATH selects a different local Claude executable", async () => {
const runtimeDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-cli-runtime-path-"));
const runtimeClaudePath = path.join(runtimeDir, "claude");
await writeFile(runtimeClaudePath, "#!/bin/sh\nexit 0\n");
await chmod(runtimeClaudePath, 0o755);
try {
probeResult.value = { exitCode: 0, stdout: "2.1.251 (Claude Code)\n", stderr: "" };
const result = await testEnvironment({
companyId: "company-1",
adapterType: "claude_local",
config: {
engine: "cli",
command: "claude",
model: "claude-fable-5-1",
env: { PATH: runtimeDir },
},
executionTarget: null,
environmentName: null,
});
expect(result.status).toBe("warn");
expect(result.checks).toContainEqual(expect.objectContaining({
code: "claude_cli_version_probe_mismatch",
level: "warn",
}));
expect(runAdapterExecutionTargetProcess).not.toHaveBeenCalled();
} finally {
await rm(runtimeDir, { recursive: true, force: true });
}
});
it("names the local host target on every result", async () => {
probeResult.value = { exitCode: 0, stdout: successStdout, stderr: "" };

View File

@ -49,6 +49,7 @@ vi.mock("@paperclipai/adapter-utils/execution-target", async () => {
import { testEnvironment } from "./test.js";
import { ADAPTER_AUTH_MISSING_CHECK_CODE } from "./auth-check.js";
import { resetClaudeCliCapabilitiesCacheForTests } from "./cli-capabilities.js";
const sandboxTarget: AdapterExecutionTarget = {
kind: "remote",
@ -94,6 +95,7 @@ const loginRequiredStdout = [
afterEach(() => {
vi.clearAllMocks();
resetClaudeCliCapabilitiesCacheForTests();
});
describe("claude sandbox auth-missing check", () => {
@ -137,3 +139,40 @@ describe("claude sandbox auth-missing check", () => {
expect(result.checks.some((check) => check.code === ADAPTER_AUTH_MISSING_CHECK_CODE)).toBe(false);
});
});
describe("claude CLI model compatibility check", () => {
it("fails before the hello probe when Fable 5.1 is configured with an older CLI", async () => {
probeResult.value = {
exitCode: 0,
stdout: "2.1.247 (Claude Code)\n",
stderr: "",
};
const result = await testEnvironment({
companyId: "company-1",
adapterType: "claude_local",
config: {
engine: "cli",
command: "claude",
model: "claude-fable-5-1",
},
executionTarget: sandboxTarget,
environmentName: "Daytona",
});
expect(result.status).toBe("fail");
expect(result.checks).toContainEqual(expect.objectContaining({
code: "claude_cli_version_incompatible",
level: "error",
detail: "Detected Claude Code 2.1.247.",
}));
expect(runAdapterExecutionTargetProcess).toHaveBeenCalledTimes(1);
const versionCall = runAdapterExecutionTargetProcess.mock.calls[0] as unknown as [
string,
AdapterExecutionTarget,
string,
string[],
];
expect(versionCall[3]).toEqual(["--version"]);
});
});

View File

@ -16,6 +16,7 @@ import {
ensureAdapterExecutionTargetDirectory,
runAdapterExecutionTargetProcess,
resolveAdapterExecutionTargetCwd,
resolveAdapterExecutionTargetCommandForLogs,
} from "@paperclipai/adapter-utils/execution-target";
import {
detectClaudeLoginRequired,
@ -23,7 +24,13 @@ import {
isClaudeTransientUpstreamError,
parseClaudeStreamJson,
} from "./parse.js";
import { claudeCommandLooksLike, claudeCommandSupportsEffortFlag } from "./cli-capabilities.js";
import {
claudeCliVersionAtLeast,
claudeCommandLooksLike,
claudeCommandSupportsEffortFlag,
minimumClaudeCliVersionForModel,
readClaudeCommandVersion,
} from "./cli-capabilities.js";
import { isBedrockModelId } from "./models.js";
import { buildClaudeProbePermissionArgs } from "./permissions.js";
import { prepareSandboxClaudeProbeRuntime } from "./claude-config.js";
@ -47,6 +54,14 @@ function isNonEmpty(value: unknown): value is string {
return typeof value === "string" && value.trim().length > 0;
}
function localExecutablesMatch(
trustedCommand: string | null,
runtimeCommand: string | null,
): boolean {
if (!trustedCommand || !runtimeCommand) return false;
return trustedCommand === runtimeCommand;
}
export async function testEnvironment(
ctx: AdapterEnvironmentTestContext,
): Promise<AdapterEnvironmentTestResult> {
@ -130,8 +145,17 @@ export async function testEnvironment(
})),
);
const runtimeEnv = ensurePathInEnv({ ...process.env, ...env });
let localRuntimeCommand: string | null = null;
try {
await ensureAdapterExecutionTargetCommandResolvable(command, target, cwd, runtimeEnv);
if (!targetIsRemote) {
localRuntimeCommand = await resolveAdapterExecutionTargetCommandForLogs(
command,
target,
cwd,
runtimeEnv,
);
}
checks.push({
code: "claude_command_resolvable",
level: "info",
@ -214,7 +238,62 @@ export async function testEnvironment(
check.code !== "claude_command_unresolvable" &&
check.code !== "claude_managed_config_dir_failed",
);
if (canRunProbe) {
let configuredModelIsCompatible = true;
const configuredModel = asString(config.model, "").trim();
const minimumCliVersion =
claudeCommandLooksLike(command, "claude") &&
(!hasBedrock || isBedrockModelId(configuredModel))
? minimumClaudeCliVersionForModel(configuredModel)
: null;
const versionProbeCommand = localProbe?.command ?? (targetIsRemote ? command : null);
const versionProbeMatchesRuntime = targetIsRemote || localExecutablesMatch(
localProbe?.command ?? null,
localRuntimeCommand,
);
if (
canRunProbe &&
minimumCliVersion &&
versionProbeCommand &&
!versionProbeMatchesRuntime
) {
configuredModelIsCompatible = false;
checks.push({
code: "claude_cli_version_probe_mismatch",
level: "warn",
message:
"Skipped Fable 5.1 readiness probing because the runtime PATH selects a different Claude executable than the trusted local Test probe.",
hint:
"Ensure the runtime-selected Claude Code is 2.1.251 or newer. Execution will verify that exact executable before launch.",
});
} else if (canRunProbe && minimumCliVersion && versionProbeCommand) {
const versionProbeEnv = localProbe?.env ?? env;
const detectedCliVersion = await readClaudeCommandVersion({
runId,
command: versionProbeCommand,
target,
cwd,
env: versionProbeEnv,
timeoutSec: 45,
graceSec: 5,
});
if (
!detectedCliVersion ||
!claudeCliVersionAtLeast(detectedCliVersion, minimumCliVersion)
) {
configuredModelIsCompatible = false;
checks.push({
code: "claude_cli_version_incompatible",
level: "error",
message: `Claude Fable 5.1 requires Claude Code ${minimumCliVersion} or newer on the CLI lane.`,
detail: detectedCliVersion
? `Detected Claude Code ${detectedCliVersion}.`
: "Could not determine the installed Claude Code version.",
hint: "Upgrade Claude Code or restore the default ACP lane, then retry the Test.",
});
}
}
if (canRunProbe && configuredModelIsCompatible) {
if (!claudeCommandLooksLike(command, "claude")) {
checks.push({
code: "claude_hello_probe_skipped_custom_command",
@ -233,7 +312,7 @@ export async function testEnvironment(
hint: "Install the `claude` CLI on the Paperclip host, then retry the Test.",
});
} else {
const model = asString(config.model, "").trim();
const model = configuredModel;
const effort = asString(config.effort, "").trim();
const chrome = asBoolean(config.chrome, false);
const maxTurns = asNumber(config.maxTurnsPerRun, 0);

View File

@ -0,0 +1,51 @@
diff --git a/dist/acp-agent.js b/dist/acp-agent.js
--- a/dist/acp-agent.js
+++ b/dist/acp-agent.js
@@ -2917,10 +2917,20 @@
cost: {
amount: message.total_cost_usd,
currency: "USD",
},
- ...(message.origin && {
- _meta: { "_claude/origin": message.origin },
- }),
+ _meta: {
+ ...(message.origin && { "_claude/origin": message.origin }),
+ // ACP's core Usage shape exposes context occupancy and
+ // cost, but not the billable input/output split. Keep the
+ // SDK's bounded aggregate in extension metadata so ACPX can
+ // normalize it without persisting a raw provider message.
+ usage: {
+ input_tokens: message.usage.input_tokens,
+ output_tokens: message.usage.output_tokens,
+ cache_read_input_tokens: message.usage.cache_read_input_tokens,
+ cache_creation_input_tokens: message.usage.cache_creation_input_tokens,
+ },
+ },
},
});
}
@@ -5307,8 +5317,12 @@
const options = {
systemPrompt,
settingSources: ["user", "project", "local"],
...(thinking !== undefined && { thinking }),
...userProvidedOptions,
+ // Paperclip Runner owns the complete session context. Its isolated
+ // user root contains only assigned skills; project/local settings
+ // would reintroduce host prompts, plugins, skills, and MCP servers.
+ ...(process.env.PAPERCLIP_ACPX_ISOLATED_CONTEXT === "1" && { settingSources: ["user"] }),
...(settings && { settings }),
env,
// Override certain fields that must be controlled by ACP
@@ -5317,7 +5331,9 @@
includePartialMessages: true,
forwardSubagentText,
mcpServers: {
- ...(userProvidedOptions?.mcpServers || {}),
+ ...(process.env.PAPERCLIP_ACPX_ISOLATED_CONTEXT === "1"
+ ? {}
+ : (userProvidedOptions?.mcpServers || {})),
...mcpServers,
...(fileChangeAuditSupport
? { [FILE_CHANGE_AUDIT_SERVER_NAME]: fileChangeAuditSupport.mcpServer }

View File

@ -20,4 +20,5 @@ patchedDependencies:
acpx@0.12.0: patches/acpx@0.12.0.patch
acpx@0.13.1: patches/acpx@0.13.1.patch
'@agentclientprotocol/claude-agent-acp@0.70.0': patches/@agentclientprotocol__claude-agent-acp@0.70.0.patch
'@agentclientprotocol/claude-agent-acp@0.73.0': patches/@agentclientprotocol__claude-agent-acp@0.73.0.patch
'@agentclientprotocol/codex-acp@1.6.2': patches/@agentclientprotocol__codex-acp@1.6.2.patch

View File

@ -66,6 +66,7 @@ describe("adapter model listing", () => {
// Newer flagship models are offered, but Opus 4.8 stays the default (first) option.
expect(models[0]?.id).toBe("claude-opus-4-8");
expect(models.some((model) => model.id === "claude-sonnet-5")).toBe(true);
expect(models.some((model) => model.id === "claude-fable-5-1")).toBe(true);
expect(models.some((model) => model.id === "claude-fable-5")).toBe(true);
expect(models.some((model) => model.id === "claude-mythos-5")).toBe(true);
// Opus 5 is a current GA flagship and must be offered even when live discovery is unavailable.
@ -130,6 +131,36 @@ describe("adapter model listing", () => {
expect(models).toEqual(claudeFallbackModels);
});
it("does not duplicate claude-fable-5-1 when discovery returns the identical ID", async () => {
process.env.ANTHROPIC_API_KEY = "sk-ant-test";
vi.spyOn(globalThis, "fetch").mockResolvedValue({
ok: true,
json: async () => ({
data: [{ id: "claude-fable-5-1", display_name: "Claude Fable 5.1" }],
}),
} as Response);
const models = await listAdapterModels("claude_local");
expect(models.filter((model) => model.id === "claude-fable-5-1")).toHaveLength(1);
// Curated fallbacks discovery did not return are still merged in.
expect(models.some((model) => model.id === "claude-fable-5")).toBe(true);
expect(models.some((model) => model.id === "claude-opus-4-8")).toBe(true);
});
it("exposes the Bedrock-native Fable 5.1 ID (never the direct ID) in Bedrock mode", async () => {
process.env.CLAUDE_CODE_USE_BEDROCK = "1";
const fetchSpy = vi.spyOn(globalThis, "fetch");
const models = await listAdapterModels("claude_local");
// The Bedrock default (first entry) is unchanged.
expect(models[0]?.id).toBe("us.anthropic.claude-opus-4-8-v1");
expect(models.some((model) => model.id === "us.anthropic.claude-fable-5-1")).toBe(true);
expect(models.some((model) => model.id === "claude-fable-5-1")).toBe(false);
expect(fetchSpy).not.toHaveBeenCalled();
});
it("loads codex models dynamically and merges fallback options", async () => {
process.env.OPENAI_API_KEY = "sk-test";
const fetchSpy = vi.spyOn(globalThis, "fetch").mockResolvedValue({