| 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,6 +70,7 @@ import * as Stream from "effect/Stream"; |
| 70 | import { resolveAttachmentPath } from "../../attachmentStore.ts"; | 70 | import { resolveAttachmentPath } from "../../attachmentStore.ts"; |
| 71 | import { ServerConfig } from "../../config.ts"; | 71 | import { ServerConfig } from "../../config.ts"; |
| 72 | import * as McpProviderSession from "../../mcp/McpProviderSession.ts"; | 72 | import * as McpProviderSession from "../../mcp/McpProviderSession.ts"; |
| 73 | import { resolveClaudeSdkExecutablePath } from "../Drivers/ClaudeExecutable.ts"; | ||
| 73 | import { makeClaudeEnvironment } from "../Drivers/ClaudeHome.ts"; | 74 | import { makeClaudeEnvironment } from "../Drivers/ClaudeHome.ts"; |
| 74 | import { | 75 | import { |
| 75 | getClaudeModelCapabilities, | 76 | getClaudeModelCapabilities, |
| ... | @@ -1347,6 +1348,10 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( | ... | @@ -1347,6 +1348,10 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( |
| 1347 | const claudeEnvironment = yield* makeClaudeEnvironment(claudeSettings, options?.environment).pipe( | 1348 | const claudeEnvironment = yield* makeClaudeEnvironment(claudeSettings, options?.environment).pipe( |
| 1348 | Effect.provideService(Path.Path, path), | 1349 | Effect.provideService(Path.Path, path), |
| 1349 | ); | 1350 | ); |
| 1351 | const claudeSdkExecutablePath = yield* resolveClaudeSdkExecutablePath( | ||
| 1352 | claudeSettings.binaryPath, | ||
| 1353 | claudeEnvironment, | ||
| 1354 | ); | ||
| 1350 | const nativeEventLogger = | 1355 | const nativeEventLogger = |
| 1351 | options?.nativeEventLogger ?? | 1356 | options?.nativeEventLogger ?? |
| 1352 | (options?.nativeEventLogPath !== undefined | 1357 | (options?.nativeEventLogPath !== undefined |
| ... | @@ -3405,7 +3410,7 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( | ... | @@ -3405,7 +3410,7 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( |
| 3405 | const canUseTool: CanUseTool = (toolName, toolInput, callbackOptions) => | 3410 | const canUseTool: CanUseTool = (toolName, toolInput, callbackOptions) => |
| 3406 | runPromise(canUseToolEffect(toolName, toolInput, callbackOptions)); | 3411 | runPromise(canUseToolEffect(toolName, toolInput, callbackOptions)); |
| 3407 | 3412 | ||
| 3408 | const claudeBinaryPath = claudeSettings.binaryPath; | 3413 | const claudeBinaryPath = claudeSdkExecutablePath; |
| 3409 | const extraArgs = parseCliArgs(claudeSettings.launchArgs).flags; | 3414 | const extraArgs = parseCliArgs(claudeSettings.launchArgs).flags; |
| 3410 | const modelSelection = | 3415 | const modelSelection = |
| 3411 | input.modelSelection?.instanceId === boundInstanceId ? input.modelSelection : undefined; | 3416 | input.modelSelection?.instanceId === boundInstanceId ? input.modelSelection : undefined; |
apps/server/src/provider/Layers/ClaudeProvider.ts+6-1| ... | @@ -37,6 +37,7 @@ import { | ... | @@ -37,6 +37,7 @@ import { |
| 37 | spawnAndCollect, | 37 | spawnAndCollect, |
| 38 | type ServerProviderDraft, | 38 | type ServerProviderDraft, |
| 39 | } from "../providerSnapshot.ts"; | 39 | } from "../providerSnapshot.ts"; |
| 40 | import { resolveClaudeSdkExecutablePath } from "../Drivers/ClaudeExecutable.ts"; | ||
| 40 | import { makeClaudeEnvironment } from "../Drivers/ClaudeHome.ts"; | 41 | import { makeClaudeEnvironment } from "../Drivers/ClaudeHome.ts"; |
| 41 | 42 | ||
| 42 | const DEFAULT_CLAUDE_MODEL_CAPABILITIES: ModelCapabilities = createModelCapabilities({ | 43 | const DEFAULT_CLAUDE_MODEL_CAPABILITIES: ModelCapabilities = createModelCapabilities({ |
| ... | @@ -588,6 +589,10 @@ const probeClaudeCapabilities = ( | ... | @@ -588,6 +589,10 @@ const probeClaudeCapabilities = ( |
| 588 | const abort = new AbortController(); | 589 | const abort = new AbortController(); |
| 589 | return Effect.gen(function* () { | 590 | return Effect.gen(function* () { |
| 590 | const claudeEnvironment = yield* makeClaudeEnvironment(claudeSettings, environment); | 591 | const claudeEnvironment = yield* makeClaudeEnvironment(claudeSettings, environment); |
| 592 | const executablePath = yield* resolveClaudeSdkExecutablePath( | ||
| 593 | claudeSettings.binaryPath, | ||
| 594 | claudeEnvironment, | ||
| 595 | ); | ||
| 591 | return yield* Effect.tryPromise(async () => { | 596 | return yield* Effect.tryPromise(async () => { |
| 592 | const q = claudeQuery({ | 597 | const q = claudeQuery({ |
| 593 | // Never yield — we only need initialization data, not a conversation. | 598 | // Never yield — we only need initialization data, not a conversation. |
| ... | @@ -598,7 +603,7 @@ const probeClaudeCapabilities = ( | ... | @@ -598,7 +603,7 @@ const probeClaudeCapabilities = ( |
| 598 | })(), | 603 | })(), |
| 599 | options: { | 604 | options: { |
| 600 | persistSession: false, | 605 | persistSession: false, |
| 601 | pathToClaudeCodeExecutable: claudeSettings.binaryPath, | 606 | pathToClaudeCodeExecutable: executablePath, |
| 602 | abortController: abort, | 607 | abortController: abort, |
| 603 | settingSources: ["user", "project", "local"], | 608 | settingSources: ["user", "project", "local"], |
| 604 | allowedTools: [], | 609 | allowedTools: [], |