| author | |
| committer | |
| log | 3795dbb01723d7474db8ddf805b1f64d5b56919c |
| tree | 8c0bd686e37d6a513630fd45c0dcfef6adba45ad |
| parent | 2640e6dcfcb6a5ba2f01f20295ce8bacd2fdb05e |
| signature | Signed by PGP key B5690EEEBB952194 |
4 files changed, 226 insertions(+), 2 deletions(-)
apps/server/src/provider/Drivers/ClaudeExecutable.test.ts created+124| ... | ... | @@ -0,0 +1,124 @@ |
| 1 | import { describe, expect, it } from "@effect/vitest"; | |
| 2 | import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; | |
| 3 | import { SpawnExecutableResolution } from "@t3tools/shared/shell"; | |
| 4 | import * as Effect from "effect/Effect"; | |
| 5 | ||
| 6 | import { ClaudeExecutableFileCheck, resolveClaudeSdkExecutablePath } from "./ClaudeExecutable.ts"; | |
| 7 | ||
| 8 | const NPM_DIR = "C:\\Users\\dev\\AppData\\Roaming\\npm"; | |
| 9 | const NPM_SHIM = `${NPM_DIR}\\claude.cmd`; | |
| 10 | const NPM_PACKAGE_EXE = `${NPM_DIR}\\node_modules\\@anthropic-ai\\claude-code\\bin\\claude.exe`; | |
| 11 | const NPM_PACKAGE_CLI = `${NPM_DIR}\\node_modules\\@anthropic-ai\\claude-code\\cli.js`; | |
| 12 | ||
| 13 | function withWindowsResolution(input: { | |
| 14 | readonly resolvedCommand: string | undefined; | |
| 15 | readonly existingFiles?: ReadonlyArray<string>; | |
| 16 | }) { | |
| 17 | const existing = new Set(input.existingFiles ?? []); | |
| 18 | return <A, E, R>(effect: Effect.Effect<A, E, R>) => | |
| 19 | effect.pipe( | |
| 20 | Effect.provideService(HostProcessPlatform, "win32"), | |
| 21 | Effect.provideService(SpawnExecutableResolution, () => input.resolvedCommand), | |
| 22 | Effect.provideService(ClaudeExecutableFileCheck, (filePath) => existing.has(filePath)), | |
| 23 | ); | |
| 24 | } | |
| 25 | ||
| 26 | describe("resolveClaudeSdkExecutablePath", () => { | |
| 27 | it.effect("returns the configured path unchanged on non-Windows platforms", () => | |
| 28 | Effect.gen(function* () { | |
| 29 | expect( | |
| 30 | yield* resolveClaudeSdkExecutablePath("claude", {}).pipe( | |
| 31 | Effect.provideService(HostProcessPlatform, "darwin"), | |
| 32 | Effect.provideService(SpawnExecutableResolution, () => { | |
| 33 | throw new Error("must not resolve on non-Windows platforms"); | |
| 34 | }), | |
| 35 | ), | |
| 36 | ).toBe("claude"); | |
| 37 | }), | |
| 38 | ); | |
| 39 | ||
| 40 | it.effect("returns the resolved absolute path for native Windows executables", () => | |
| 41 | Effect.gen(function* () { | |
| 42 | const nativeBinary = "C:\\Users\\dev\\.local\\bin\\claude.exe"; | |
| 43 | expect( | |
| 44 | yield* resolveClaudeSdkExecutablePath("claude", {}).pipe( | |
| 45 | withWindowsResolution({ resolvedCommand: nativeBinary }), | |
| 46 | ), | |
| 47 | ).toBe(nativeBinary); | |
| 48 | }), | |
| 49 | ); | |
| 50 | ||
| 51 | it.effect("follows an npm launcher shim to the packaged native binary", () => | |
| 52 | Effect.gen(function* () { | |
| 53 | expect( | |
| 54 | yield* resolveClaudeSdkExecutablePath("claude", {}).pipe( | |
| 55 | withWindowsResolution({ | |
| 56 | resolvedCommand: NPM_SHIM, | |
| 57 | existingFiles: [NPM_PACKAGE_EXE, NPM_PACKAGE_CLI], | |
| 58 | }), | |
| 59 | ), | |
| 60 | ).toBe(NPM_PACKAGE_EXE); | |
| 61 | }), | |
| 62 | ); | |
| 63 | ||
| 64 | it.effect("follows .bat and .ps1 launcher shims the same way", () => | |
| 65 | Effect.gen(function* () { | |
| 66 | for (const shim of [`${NPM_DIR}\\claude.bat`, `${NPM_DIR}\\claude.ps1`]) { | |
| 67 | expect( | |
| 68 | yield* resolveClaudeSdkExecutablePath("claude", {}).pipe( | |
| 69 | withWindowsResolution({ | |
| 70 | resolvedCommand: shim, | |
| 71 | existingFiles: [NPM_PACKAGE_EXE], | |
| 72 | }), | |
| 73 | ), | |
| 74 | ).toBe(NPM_PACKAGE_EXE); | |
| 75 | } | |
| 76 | }), | |
| 77 | ); | |
| 78 | ||
| 79 | it.effect("normalizes mixed-case shim extensions before matching", () => | |
| 80 | Effect.gen(function* () { | |
| 81 | expect( | |
| 82 | yield* resolveClaudeSdkExecutablePath("claude", {}).pipe( | |
| 83 | withWindowsResolution({ | |
| 84 | resolvedCommand: `${NPM_DIR}\\claude.CMD`, | |
| 85 | existingFiles: [NPM_PACKAGE_EXE], | |
| 86 | }), | |
| 87 | ), | |
| 88 | ).toBe(NPM_PACKAGE_EXE); | |
| 89 | }), | |
| 90 | ); | |
| 91 | ||
| 92 | it.effect("falls back to cli.js when the package ships no native binary", () => | |
| 93 | Effect.gen(function* () { | |
| 94 | expect( | |
| 95 | yield* resolveClaudeSdkExecutablePath("claude", {}).pipe( | |
| 96 | withWindowsResolution({ | |
| 97 | resolvedCommand: NPM_SHIM, | |
| 98 | existingFiles: [NPM_PACKAGE_CLI], | |
| 99 | }), | |
| 100 | ), | |
| 101 | ).toBe(NPM_PACKAGE_CLI); | |
| 102 | }), | |
| 103 | ); | |
| 104 | ||
| 105 | it.effect("returns the configured path when a shim has no known package entry", () => | |
| 106 | Effect.gen(function* () { | |
| 107 | expect( | |
| 108 | yield* resolveClaudeSdkExecutablePath("claude", {}).pipe( | |
| 109 | withWindowsResolution({ resolvedCommand: NPM_SHIM }), | |
| 110 | ), | |
| 111 | ).toBe("claude"); | |
| 112 | }), | |
| 113 | ); | |
| 114 | ||
| 115 | it.effect("returns the configured path when command resolution finds nothing", () => | |
| 116 | Effect.gen(function* () { | |
| 117 | expect( | |
| 118 | yield* resolveClaudeSdkExecutablePath("claude", {}).pipe( | |
| 119 | withWindowsResolution({ resolvedCommand: undefined }), | |
| 120 | ), | |
| 121 | ).toBe("claude"); | |
| 122 | }), | |
| 123 | ); | |
| 124 | }); |
apps/server/src/provider/Drivers/ClaudeExecutable.ts created+90| ... | ... | @@ -0,0 +1,90 @@ |
| 1 | // @effect-diagnostics nodeBuiltinImport:off | |
| 2 | import * as NodeFS from "node:fs"; | |
| 3 | import * as NodePath from "node:path"; | |
| 4 | ||
| 5 | import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; | |
| 6 | import { SpawnExecutableResolution } from "@t3tools/shared/shell"; | |
| 7 | import * as Context from "effect/Context"; | |
| 8 | import * as Effect from "effect/Effect"; | |
| 9 | ||
| 10 | /** | |
| 11 | * Windows launcher-script extensions that Node cannot spawn without a shell | |
| 12 | * (`spawn EINVAL` since Node 20.12) and that the Claude Agent SDK therefore | |
| 13 | * cannot use as `pathToClaudeCodeExecutable`. | |
| 14 | */ | |
| 15 | const WINDOWS_SHIM_EXTENSIONS: ReadonlySet<string> = new Set([".cmd", ".bat", ".ps1"]); | |
| 16 | ||
| 17 | /** | |
| 18 | * Entry points of the npm `@anthropic-ai/claude-code` package relative to the | |
| 19 | * global `node_modules` directory that sits next to the npm launcher shim. | |
| 20 | * Newer package versions ship a native `bin/claude.exe`; older versions only | |
| 21 | * ship `cli.js`, which the SDK runs with a JavaScript runtime. | |
| 22 | */ | |
| 23 | const NPM_PACKAGE_ENTRY_CANDIDATES = [ | |
| 24 | ["node_modules", "@anthropic-ai", "claude-code", "bin", "claude.exe"], | |
| 25 | ["node_modules", "@anthropic-ai", "claude-code", "cli.js"], | |
| 26 | ] as const; | |
| 27 | ||
| 28 | export type ExecutableFileCheck = (filePath: string) => boolean; | |
| 29 | ||
| 30 | function isExistingFile(filePath: string): boolean { | |
| 31 | try { | |
| 32 | return NodeFS.statSync(filePath).isFile(); | |
| 33 | } catch { | |
| 34 | return false; | |
| 35 | } | |
| 36 | } | |
| 37 | ||
| 38 | /** Injectable file-existence check so tests can run against a fake filesystem. */ | |
| 39 | export const ClaudeExecutableFileCheck = Context.Reference<ExecutableFileCheck>( | |
| 40 | "server/provider/Drivers/ClaudeExecutableFileCheck", | |
| 41 | { | |
| 42 | defaultValue: () => isExistingFile, | |
| 43 | }, | |
| 44 | ); | |
| 45 | ||
| 46 | /** | |
| 47 | * Resolves the configured Claude binary path into a value the Claude Agent | |
| 48 | * SDK can spawn directly via `pathToClaudeCodeExecutable`. | |
| 49 | * | |
| 50 | * The SDK spawns the given path without a shell and without Windows PATH / | |
| 51 | * PATHEXT resolution, so a bare command name like `claude` fails with | |
| 52 | * "native binary not found" and an npm `claude.cmd` shim fails with | |
| 53 | * `spawn EINVAL`. CLI probes avoid this via `resolveSpawnCommand`, which can | |
| 54 | * fall back to `shell: true`; the SDK offers no such escape hatch. | |
| 55 | * | |
| 56 | * On Windows this resolves the command against PATH/PATHEXT and, when the | |
| 57 | * result is an npm launcher shim, follows it to the real package entry | |
| 58 | * (`bin/claude.exe`, or `cli.js` for older package versions). On other | |
| 59 | * platforms the configured value is returned unchanged. | |
| 60 | */ | |
| 61 | export const resolveClaudeSdkExecutablePath = Effect.fn("resolveClaudeSdkExecutablePath")( | |
| 62 | function* (binaryPath: string, environment: NodeJS.ProcessEnv): Effect.fn.Return<string> { | |
| 63 | const platform = yield* HostProcessPlatform; | |
| 64 | if (platform !== "win32") { | |
| 65 | return binaryPath; | |
| 66 | } | |
| 67 | ||
| 68 | const resolveExecutable = yield* SpawnExecutableResolution; | |
| 69 | const isFile = yield* ClaudeExecutableFileCheck; | |
| 70 | const resolved = resolveExecutable(binaryPath, platform, environment) ?? binaryPath; | |
| 71 | const extension = NodePath.win32.extname(resolved).toLowerCase(); | |
| 72 | if (!WINDOWS_SHIM_EXTENSIONS.has(extension)) { | |
| 73 | return resolved; | |
| 74 | } | |
| 75 | ||
| 76 | const shimDirectory = NodePath.win32.dirname(resolved); | |
| 77 | for (const entrySegments of NPM_PACKAGE_ENTRY_CANDIDATES) { | |
| 78 | const candidate = NodePath.win32.join(shimDirectory, ...entrySegments); | |
| 79 | if (isFile(candidate)) { | |
| 80 | return candidate; | |
| 81 | } | |
| 82 | } | |
| 83 | ||
| 84 | yield* Effect.logWarning( | |
| 85 | "Claude launcher shim resolved but no known package entry was found next to it; the Claude Agent SDK cannot spawn launcher scripts directly.", | |
| 86 | { binaryPath, resolvedShimPath: resolved }, | |
| 87 | ); | |
| 88 | return binaryPath; | |
| 89 | }, | |
| 90 | ); |
apps/server/src/provider/Layers/ClaudeAdapter.ts+6-1| ... | ... | @@ -70,6 +70,7 @@ import * as Stream from "effect/Stream"; |
| 70 | 70 | import { resolveAttachmentPath } from "../../attachmentStore.ts"; |
| 71 | 71 | import { ServerConfig } from "../../config.ts"; |
| 72 | 72 | import * as McpProviderSession from "../../mcp/McpProviderSession.ts"; |
| 73 | import { resolveClaudeSdkExecutablePath } from "../Drivers/ClaudeExecutable.ts"; | |
| 73 | 74 | import { makeClaudeEnvironment } from "../Drivers/ClaudeHome.ts"; |
| 74 | 75 | import { |
| 75 | 76 | getClaudeModelCapabilities, |
| ... | ... | @@ -1347,6 +1348,10 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( |
| 1347 | 1348 | const claudeEnvironment = yield* makeClaudeEnvironment(claudeSettings, options?.environment).pipe( |
| 1348 | 1349 | Effect.provideService(Path.Path, path), |
| 1349 | 1350 | ); |
| 1351 | const claudeSdkExecutablePath = yield* resolveClaudeSdkExecutablePath( | |
| 1352 | claudeSettings.binaryPath, | |
| 1353 | claudeEnvironment, | |
| 1354 | ); | |
| 1350 | 1355 | const nativeEventLogger = |
| 1351 | 1356 | options?.nativeEventLogger ?? |
| 1352 | 1357 | (options?.nativeEventLogPath !== undefined |
| ... | ... | @@ -3405,7 +3410,7 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( |
| 3405 | 3410 | const canUseTool: CanUseTool = (toolName, toolInput, callbackOptions) => |
| 3406 | 3411 | runPromise(canUseToolEffect(toolName, toolInput, callbackOptions)); |
| 3407 | 3412 | |
| 3408 | const claudeBinaryPath = claudeSettings.binaryPath; | |
| 3413 | const claudeBinaryPath = claudeSdkExecutablePath; | |
| 3409 | 3414 | const extraArgs = parseCliArgs(claudeSettings.launchArgs).flags; |
| 3410 | 3415 | const modelSelection = |
| 3411 | 3416 | input.modelSelection?.instanceId === boundInstanceId ? input.modelSelection : undefined; |
apps/server/src/provider/Layers/ClaudeProvider.ts+6-1| ... | ... | @@ -37,6 +37,7 @@ import { |
| 37 | 37 | spawnAndCollect, |
| 38 | 38 | type ServerProviderDraft, |
| 39 | 39 | } from "../providerSnapshot.ts"; |
| 40 | import { resolveClaudeSdkExecutablePath } from "../Drivers/ClaudeExecutable.ts"; | |
| 40 | 41 | import { makeClaudeEnvironment } from "../Drivers/ClaudeHome.ts"; |
| 41 | 42 | |
| 42 | 43 | const DEFAULT_CLAUDE_MODEL_CAPABILITIES: ModelCapabilities = createModelCapabilities({ |
| ... | ... | @@ -588,6 +589,10 @@ const probeClaudeCapabilities = ( |
| 588 | 589 | const abort = new AbortController(); |
| 589 | 590 | return Effect.gen(function* () { |
| 590 | 591 | const claudeEnvironment = yield* makeClaudeEnvironment(claudeSettings, environment); |
| 592 | const executablePath = yield* resolveClaudeSdkExecutablePath( | |
| 593 | claudeSettings.binaryPath, | |
| 594 | claudeEnvironment, | |
| 595 | ); | |
| 591 | 596 | return yield* Effect.tryPromise(async () => { |
| 592 | 597 | const q = claudeQuery({ |
| 593 | 598 | // Never yield — we only need initialization data, not a conversation. |
| ... | ... | @@ -598,7 +603,7 @@ const probeClaudeCapabilities = ( |
| 598 | 603 | })(), |
| 599 | 604 | options: { |
| 600 | 605 | persistSession: false, |
| 601 | pathToClaudeCodeExecutable: claudeSettings.binaryPath, | |
| 606 | pathToClaudeCodeExecutable: executablePath, | |
| 602 | 607 | abortController: abort, |
| 603 | 608 | settingSources: ["user", "project", "local"], |
| 604 | 609 | allowedTools: [], |