refactor(llm): bring-your-own-binary for the Claude Agent provider

The @anthropic-ai/claude-agent-sdk pulls a ~250 MB per-platform native
binary as an optionalDependency, so the prototype's plain dependency put
that weight into every server install and Docker layer whether or not
anyone uses the subscription provider. This switches to
bring-your-own-binary: keep the ~4 MB SDK JS wrapper, strip the bundled
binary, and drive the user's own installed Claude Code CLI.

- .pnpmfile.cjs readPackage hook removes the
  @anthropic-ai/claude-agent-sdk-<platform> optionalDependencies at
  install time (node_modules/@anthropic-ai/claude-agent-sdk-linux-x64:
  248 MB -> gone; SDK JS retained; lockfile no longer references them).
- claude_binary.ts resolves the CLI (TRILIUM_CLAUDE_CODE_PATH override,
  else `claude` on PATH) and probes `--version` once so a missing/broken
  install fails with a clear, actionable message instead of an opaque
  mid-chat spawn error. The provider passes it via
  pathToClaudeCodeExecutable.
- This also sidesteps the SDK's bundled glibc ELF needing a nix-ld shim:
  the user's platform binary (e.g. the self-contained nixpkgs wrapper)
  is used instead.

Verified: binary stripped from node_modules + lockfile, SDK JS still
imports, resolver finds and probes the host `claude`. Provider suite 24
tests; full LLM suite 235.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Elian Doran 2026-07-13 22:37:40 +03:00
parent bfe071d278
commit 4e960bf4da
No known key found for this signature in database
6 changed files with 153 additions and 78 deletions

30
.pnpmfile.cjs Normal file
View File

@ -0,0 +1,30 @@
/**
* pnpm install hook.
*
* Strips the per-platform native binary packages bundled by agent SDKs whose
* providers run in "bring-your-own-binary" mode. Trilium's Claude Agent
* provider (apps/server) drives the SDK's JS wrapper but points it at the
* user's own installed CLI via `pathToClaudeCodeExecutable`, so the ~250 MB
* bundled binary is never used and must not be downloaded into every server
* install and Docker layer. The tiny JS wrapper is kept; only the
* `@anthropic-ai/claude-agent-sdk-<platform>` optionalDependencies are removed.
*/
/** SDK package name → prefix of the platform-binary optionalDependencies to drop. */
const BYO_BINARY_SDKS = {
"@anthropic-ai/claude-agent-sdk": "@anthropic-ai/claude-agent-sdk-"
};
function readPackage(pkg) {
const prefix = BYO_BINARY_SDKS[pkg.name];
if (prefix && pkg.optionalDependencies) {
for (const dep of Object.keys(pkg.optionalDependencies)) {
if (dep.startsWith(prefix)) {
delete pkg.optionalDependencies[dep];
}
}
}
return pkg;
}
module.exports = { hooks: { readPackage } };

View File

@ -3081,7 +3081,7 @@
"base_url": "Base URL",
"base_url_description": "Optional. Override the default API endpoint — useful for self-hosted models (Ollama, LM Studio, vLLM) or proxies.",
"base_url_invalid": "Base URL must be a valid http:// or https:// URL",
"claude_agent_description": "Uses your Claude Pro/Max subscription through Claude Code — no API key needed. Sign in once by running \"claude /login\" on the machine where the Trilium server runs. To let the AI access your notes, also enable the MCP server below.",
"claude_agent_description": "Uses your Claude Pro/Max subscription through Claude Code — no API key needed. Claude Code must be installed on the machine running the Trilium server; sign in once by running \"claude /login\" there. To let the AI access your notes, also enable the MCP server below.",
"cancel": "Cancel",
"mcp_title": "MCP (Model Context Protocol)",
"mcp_enabled": "MCP server",

View File

@ -27,6 +27,12 @@ vi.mock("../../port.js", () => ({ default: 8080 }));
const buildNoteHintMock = vi.hoisted(() => vi.fn((noteId: string) => `NOTE_META(${noteId})`));
vi.mock("./note_hint.js", () => ({ buildNoteHint: buildNoteHintMock }));
// BYO binary resolution shells out to the user's `claude`; stub it so tests
// don't depend on a real install. Returns a path by default; can be made to
// throw (missing binary) per-test.
const resolveClaudeBinaryMock = vi.hoisted(() => vi.fn(() => "/usr/bin/claude"));
vi.mock("./claude_binary.js", () => ({ resolveClaudeBinaryPath: resolveClaudeBinaryMock }));
// Attachment resolution reads bytes out of Becca, which the core mock above
// omits — stub it so the multimodal tests drive block construction directly.
const resolveAttachmentPartMock = vi.hoisted(() => vi.fn());
@ -331,6 +337,28 @@ describe("ClaudeAgentProvider.chatChunks", () => {
expect(options.settingSources).toEqual([]);
});
it("drives the user's resolved claude binary (bring-your-own-binary)", async () => {
resolveClaudeBinaryMock.mockReturnValueOnce("/opt/homebrew/bin/claude");
scriptAgent([successResult()]);
const provider = new ClaudeAgentProvider();
await collect(provider.chatChunks([{ role: "user", content: "hi" }], {}));
expect(queryMock.mock.calls[0][0].options.pathToClaudeCodeExecutable).toBe("/opt/homebrew/bin/claude");
});
it("surfaces a friendly error (and never spawns) when Claude Code isn't installed", async () => {
resolveClaudeBinaryMock.mockImplementationOnce(() => {
throw new Error("Claude Code CLI not found. Install it and run `claude /login`...");
});
const provider = new ClaudeAgentProvider();
const chunks = await collect(provider.chatChunks([{ role: "user", content: "hi" }], {}));
expect(queryMock).not.toHaveBeenCalled();
const errors = chunks.filter(c => c.type === "error");
expect(errors).toHaveLength(1);
expect(errors[0].error).toContain("Claude Code CLI not found");
});
it("uses Trilium's shared system prompt (skill/link/markdown guidance) when note tools are live", async () => {
scriptAgent([successResult()]);
const provider = new ClaudeAgentProvider();

View File

@ -5,6 +5,10 @@
* authentication is owned entirely by Claude Code (`claude /login` once on the
* machine running the server), and billing goes to the subscription.
*
* Bring-your-own-binary: the SDK's ~250 MB bundled native binary is stripped at
* install time (root .pnpmfile.cjs); the provider drives the user's own
* installed `claude` CLI (see claude_binary.ts), keeping the server lean.
*
* Unlike the AI-SDK providers, the Agent SDK runs its own agentic loop and is
* session-based (it owns conversation history). This provider therefore:
* - implements `chatChunks()` (chunk-native streaming) instead of `chat()`,
@ -29,6 +33,7 @@ import port from "../../port.js";
import type { LlmProvider, LlmProviderConfig, ModelInfo, ModelPricing, StreamResult } from "../types.js";
import { resolveAttachmentPart } from "./attachment_content.js";
import { buildModelList } from "./base_provider.js";
import { resolveClaudeBinaryPath } from "./claude_binary.js";
import { buildNoteHint } from "./note_hint.js";
import { buildSystemPrompt } from "./system_prompt.js";
@ -371,6 +376,9 @@ export class ClaudeAgentProvider implements LlmProvider {
const allowedTools = [...builtinTools];
const options: AgentOptions = {
// Bring-your-own-binary: the SDK's bundled native binary is stripped
// at install time; drive the user's own installed Claude Code CLI.
pathToClaudeCodeExecutable: resolveClaudeBinaryPath(),
cwd: getAgentCwd(),
tools: builtinTools,
settingSources: [],

View File

@ -0,0 +1,84 @@
/**
* Resolves the Claude Code CLI binary the Claude Agent provider drives.
*
* The provider runs in "bring-your-own-binary" mode: the ~250 MB native binary
* that the SDK would otherwise bundle is stripped at install time (see the
* root .pnpmfile.cjs), so we point the SDK at the user's own installed CLI via
* `pathToClaudeCodeExecutable`. This keeps the server install lean and lets each
* platform provide a binary that actually runs there (e.g. the nixpkgs wrapper
* on NixOS, which needs no glibc/nix-ld shim unlike the SDK's bundled ELF).
*
* Resolution order: the TRILIUM_CLAUDE_CODE_PATH override, then `claude` on
* PATH. The resolved binary is probed with `--version` once so a broken/absent
* install surfaces as a clear, actionable error instead of an opaque spawn
* failure mid-chat.
*/
import { getLog } from "@triliumnext/core";
import { execFileSync } from "child_process";
import { existsSync } from "fs";
import path from "path";
/** Cached only on success, so a later install is picked up without a restart. */
let cachedPath: string | undefined;
export function resolveClaudeBinaryPath(): string {
if (cachedPath) {
return cachedPath;
}
const binary = locateBinary();
// Probe once: confirms the binary actually runs on this host (catches a
// wrong-arch/broken install) and records the version for diagnostics.
let version: string;
try {
version = execFileSync(binary, ["--version"], { timeout: 15000, encoding: "utf8" }).trim();
} catch (err) {
const detail = err instanceof Error ? err.message : String(err);
throw new Error(`Found Claude Code at "${binary}" but it failed to run (${detail}). Ensure it is installed correctly and that you've run \`claude /login\` on the machine running the Trilium server.`);
}
getLog().info(`Claude Agent provider: using Claude Code at ${binary} (${version})`);
cachedPath = binary;
return binary;
}
/** For tests: forget the probed binary so the next call re-resolves. */
export function resetClaudeBinaryCache(): void {
cachedPath = undefined;
}
function locateBinary(): string {
const override = process.env.TRILIUM_CLAUDE_CODE_PATH?.trim();
if (override) {
if (!existsSync(override)) {
throw new Error(`TRILIUM_CLAUDE_CODE_PATH is set to "${override}", but no file exists there.`);
}
return override;
}
const onPath = findOnPath("claude");
if (onPath) {
return onPath;
}
throw new Error("Claude Code CLI not found. Install it (`npm install -g @anthropic-ai/claude-code`) and run `claude /login` on the machine running the Trilium server, or set the TRILIUM_CLAUDE_CODE_PATH environment variable to its location.");
}
function findOnPath(binary: string): string | undefined {
// Windows resolves executables via PATHEXT; on POSIX the bare name suffices.
const extensions = process.platform === "win32" ? ["", ".cmd", ".exe", ".bat"] : [""];
for (const dir of (process.env.PATH ?? "").split(path.delimiter)) {
if (!dir) {
continue;
}
for (const ext of extensions) {
const candidate = path.join(dir, binary + ext);
if (existsSync(candidate)) {
return candidate;
}
}
}
return undefined;
}

View File

@ -81,6 +81,8 @@ overrides:
basic-ftp@=5.2.0: '>=5.2.1'
extract-zip>yauzl: 3.4.0
pnpmfileChecksum: sha256-Y+CmGTGmFhNZN6QTL8GJnY/EuBOU2KPY66OgOmzHWqw=
patchedDependencies:
'@ckeditor/ckeditor5-code-block': 1530be090e4f5235e197fb6208a8e8230e3355d5633fc7cad2b2e892659e9c04
'@ckeditor/ckeditor5-mention': 70377e48a04e2f45abba79aa35f3d5527ec7aa7ffce44056f9957a49a24a4a60
@ -1811,50 +1813,6 @@ packages:
'@antfu/install-pkg@1.1.0':
resolution: {integrity: sha512-MGQsmw10ZyI+EJo45CdSER4zEb+p31LpDAFp2Z3gkSd1yqVZGi0Ebx++YTEMonJy4oChEMLsxZ64j8FH6sSqtQ==}
'@anthropic-ai/claude-agent-sdk-darwin-arm64@0.3.207':
resolution: {integrity: sha512-08xSo1FDx8h0aLhL5tvcRxa2SMmcUV3aDWeZiEJVTclyiDAs61BgTjAxCg+SZcu1CndjJO8cfO0yM5dhamxz3g==}
cpu: [arm64]
os: [darwin]
'@anthropic-ai/claude-agent-sdk-darwin-x64@0.3.207':
resolution: {integrity: sha512-1o7K4EYqyCixZ/oeOZSh7AzSy6TM86xoOuf4VuORjPSS31hBnoqY0NGZd27+2VDs9LGtsdksmsTqcNGx9xd1hA==}
cpu: [x64]
os: [darwin]
'@anthropic-ai/claude-agent-sdk-linux-arm64-musl@0.3.207':
resolution: {integrity: sha512-oPj+g2DslhH4Y9nCTs7al4t9wZv78FZwLFQwOCg99BXuz1o0ZOpKmxyvR7J9eBR+GPszeMMS8gYplQTiZC9o2w==}
cpu: [arm64]
os: [linux]
libc: [musl]
'@anthropic-ai/claude-agent-sdk-linux-arm64@0.3.207':
resolution: {integrity: sha512-X4uezYOifDiNTTmmugfRCdg3nNamrr1LFRY9hg30vWYTShL+bbN+nfC3KaFfSYCl4GTtsEEUbYdOTC2F3bBpcA==}
cpu: [arm64]
os: [linux]
libc: [glibc]
'@anthropic-ai/claude-agent-sdk-linux-x64-musl@0.3.207':
resolution: {integrity: sha512-uRv+D5oG/7EYr41FAJ9IPo2pZYBe2ZMaA6nSHCeizsgPxCSMtl5bNppmU21+jZJvo4hivObEgkGFERAhdGqygg==}
cpu: [x64]
os: [linux]
libc: [musl]
'@anthropic-ai/claude-agent-sdk-linux-x64@0.3.207':
resolution: {integrity: sha512-Kg6BPH8Ee0ny/oEUWJmvT1jCRBne4jVpRSOMsJcYp1Fav1rMEgpU219oJJs+LWwx4ifuuLtNWedqJNnVw7mnKg==}
cpu: [x64]
os: [linux]
libc: [glibc]
'@anthropic-ai/claude-agent-sdk-win32-arm64@0.3.207':
resolution: {integrity: sha512-9fWpUzfkXlPAg2tf8JpQe7w9avFaomAUbfAwyAmykQgSIf66LwaJjvI5hNqhNqczRKyfsXPn3ei2S5HKlmFP+Q==}
cpu: [arm64]
os: [win32]
'@anthropic-ai/claude-agent-sdk-win32-x64@0.3.207':
resolution: {integrity: sha512-YPjVT0q6aXEM2MgN4CI6/9fqiTXwETji+4NoPOzCYuqAkhXZqp30Jsk7/NHqYGNNSfURKrsuAoliKB0rsbpbjg==}
cpu: [x64]
os: [win32]
'@anthropic-ai/claude-agent-sdk@0.3.207':
resolution: {integrity: sha512-y0PkQRmQBi96MHiN5Xzfq+GaddxCZCqI/cXEQBLYBLXGa4i1nDSlulQqkMBj2RorrrSGQJ6Wdw+uhu6OfHNPzA==}
engines: {node: '>=18.0.0'}
@ -14003,44 +13961,11 @@ snapshots:
package-manager-detector: 1.6.0
tinyexec: 1.1.2
'@anthropic-ai/claude-agent-sdk-darwin-arm64@0.3.207':
optional: true
'@anthropic-ai/claude-agent-sdk-darwin-x64@0.3.207':
optional: true
'@anthropic-ai/claude-agent-sdk-linux-arm64-musl@0.3.207':
optional: true
'@anthropic-ai/claude-agent-sdk-linux-arm64@0.3.207':
optional: true
'@anthropic-ai/claude-agent-sdk-linux-x64-musl@0.3.207':
optional: true
'@anthropic-ai/claude-agent-sdk-linux-x64@0.3.207':
optional: true
'@anthropic-ai/claude-agent-sdk-win32-arm64@0.3.207':
optional: true
'@anthropic-ai/claude-agent-sdk-win32-x64@0.3.207':
optional: true
'@anthropic-ai/claude-agent-sdk@0.3.207(@anthropic-ai/sdk@0.111.0(zod@4.4.3))(@modelcontextprotocol/sdk@1.29.0(zod@4.4.3))(zod@4.4.3)':
dependencies:
'@anthropic-ai/sdk': 0.111.0(zod@4.4.3)
'@modelcontextprotocol/sdk': 1.29.0(zod@4.4.3)
zod: 4.4.3
optionalDependencies:
'@anthropic-ai/claude-agent-sdk-darwin-arm64': 0.3.207
'@anthropic-ai/claude-agent-sdk-darwin-x64': 0.3.207
'@anthropic-ai/claude-agent-sdk-linux-arm64': 0.3.207
'@anthropic-ai/claude-agent-sdk-linux-arm64-musl': 0.3.207
'@anthropic-ai/claude-agent-sdk-linux-x64': 0.3.207
'@anthropic-ai/claude-agent-sdk-linux-x64-musl': 0.3.207
'@anthropic-ai/claude-agent-sdk-win32-arm64': 0.3.207
'@anthropic-ai/claude-agent-sdk-win32-x64': 0.3.207
'@anthropic-ai/sdk@0.111.0(zod@4.4.3)':
dependencies: