Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
124 changes: 124 additions & 0 deletions apps/server/src/provider/Drivers/ClaudeExecutable.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
import { describe, expect, it } from "@effect/vitest";
import { HostProcessPlatform } from "@t3tools/shared/hostProcess";
import { SpawnExecutableResolution } from "@t3tools/shared/shell";
import * as Effect from "effect/Effect";

import { ClaudeExecutableFileCheck, resolveClaudeSdkExecutablePath } from "./ClaudeExecutable.ts";

const NPM_DIR = "C:\\Users\\dev\\AppData\\Roaming\\npm";
const NPM_SHIM = `${NPM_DIR}\\claude.cmd`;
const NPM_PACKAGE_EXE = `${NPM_DIR}\\node_modules\\@anthropic-ai\\claude-code\\bin\\claude.exe`;
const NPM_PACKAGE_CLI = `${NPM_DIR}\\node_modules\\@anthropic-ai\\claude-code\\cli.js`;

function withWindowsResolution(input: {
readonly resolvedCommand: string | undefined;
readonly existingFiles?: ReadonlyArray<string>;
}) {
const existing = new Set(input.existingFiles ?? []);
return <A, E, R>(effect: Effect.Effect<A, E, R>) =>
effect.pipe(
Effect.provideService(HostProcessPlatform, "win32"),
Effect.provideService(SpawnExecutableResolution, () => input.resolvedCommand),
Effect.provideService(ClaudeExecutableFileCheck, (filePath) => existing.has(filePath)),
);
}

describe("resolveClaudeSdkExecutablePath", () => {
it.effect("returns the configured path unchanged on non-Windows platforms", () =>
Effect.gen(function* () {
expect(
yield* resolveClaudeSdkExecutablePath("claude", {}).pipe(
Effect.provideService(HostProcessPlatform, "darwin"),
Effect.provideService(SpawnExecutableResolution, () => {
throw new Error("must not resolve on non-Windows platforms");
}),
),
).toBe("claude");
}),
);

it.effect("returns the resolved absolute path for native Windows executables", () =>
Effect.gen(function* () {
const nativeBinary = "C:\\Users\\dev\\.local\\bin\\claude.exe";
expect(
yield* resolveClaudeSdkExecutablePath("claude", {}).pipe(
withWindowsResolution({ resolvedCommand: nativeBinary }),
),
).toBe(nativeBinary);
}),
);

it.effect("follows an npm launcher shim to the packaged native binary", () =>
Effect.gen(function* () {
expect(
yield* resolveClaudeSdkExecutablePath("claude", {}).pipe(
withWindowsResolution({
resolvedCommand: NPM_SHIM,
existingFiles: [NPM_PACKAGE_EXE, NPM_PACKAGE_CLI],
}),
),
).toBe(NPM_PACKAGE_EXE);
}),
);

it.effect("follows .bat and .ps1 launcher shims the same way", () =>
Effect.gen(function* () {
for (const shim of [`${NPM_DIR}\\claude.bat`, `${NPM_DIR}\\claude.ps1`]) {
expect(
yield* resolveClaudeSdkExecutablePath("claude", {}).pipe(
withWindowsResolution({
resolvedCommand: shim,
existingFiles: [NPM_PACKAGE_EXE],
}),
),
).toBe(NPM_PACKAGE_EXE);
}
}),
);

it.effect("normalizes mixed-case shim extensions before matching", () =>
Effect.gen(function* () {
expect(
yield* resolveClaudeSdkExecutablePath("claude", {}).pipe(
withWindowsResolution({
resolvedCommand: `${NPM_DIR}\\claude.CMD`,
existingFiles: [NPM_PACKAGE_EXE],
}),
),
).toBe(NPM_PACKAGE_EXE);
}),
);

it.effect("falls back to cli.js when the package ships no native binary", () =>
Effect.gen(function* () {
expect(
yield* resolveClaudeSdkExecutablePath("claude", {}).pipe(
withWindowsResolution({
resolvedCommand: NPM_SHIM,
existingFiles: [NPM_PACKAGE_CLI],
}),
),
).toBe(NPM_PACKAGE_CLI);
}),
);

it.effect("returns the configured path when a shim has no known package entry", () =>
Effect.gen(function* () {
expect(
yield* resolveClaudeSdkExecutablePath("claude", {}).pipe(
withWindowsResolution({ resolvedCommand: NPM_SHIM }),
),
).toBe("claude");
}),
);

it.effect("returns the configured path when command resolution finds nothing", () =>
Effect.gen(function* () {
expect(
yield* resolveClaudeSdkExecutablePath("claude", {}).pipe(
withWindowsResolution({ resolvedCommand: undefined }),
),
).toBe("claude");
}),
);
});
90 changes: 90 additions & 0 deletions apps/server/src/provider/Drivers/ClaudeExecutable.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
// @effect-diagnostics nodeBuiltinImport:off
import * as NodeFS from "node:fs";
import * as NodePath from "node:path";

import { HostProcessPlatform } from "@t3tools/shared/hostProcess";
import { SpawnExecutableResolution } from "@t3tools/shared/shell";
import * as Context from "effect/Context";
import * as Effect from "effect/Effect";

/**
* Windows launcher-script extensions that Node cannot spawn without a shell
* (`spawn EINVAL` since Node 20.12) and that the Claude Agent SDK therefore
* cannot use as `pathToClaudeCodeExecutable`.
*/
const WINDOWS_SHIM_EXTENSIONS: ReadonlySet<string> = new Set([".cmd", ".bat", ".ps1"]);

/**
* Entry points of the npm `@anthropic-ai/claude-code` package relative to the
* global `node_modules` directory that sits next to the npm launcher shim.
* Newer package versions ship a native `bin/claude.exe`; older versions only
* ship `cli.js`, which the SDK runs with a JavaScript runtime.
*/
const NPM_PACKAGE_ENTRY_CANDIDATES = [
["node_modules", "@anthropic-ai", "claude-code", "bin", "claude.exe"],
["node_modules", "@anthropic-ai", "claude-code", "cli.js"],
] as const;

export type ExecutableFileCheck = (filePath: string) => boolean;

function isExistingFile(filePath: string): boolean {
try {
return NodeFS.statSync(filePath).isFile();
} catch {
return false;
}
}

/** Injectable file-existence check so tests can run against a fake filesystem. */
export const ClaudeExecutableFileCheck = Context.Reference<ExecutableFileCheck>(
"server/provider/Drivers/ClaudeExecutableFileCheck",
{
defaultValue: () => isExistingFile,
},
);

/**
* Resolves the configured Claude binary path into a value the Claude Agent
* SDK can spawn directly via `pathToClaudeCodeExecutable`.
*
* The SDK spawns the given path without a shell and without Windows PATH /
* PATHEXT resolution, so a bare command name like `claude` fails with
* "native binary not found" and an npm `claude.cmd` shim fails with
* `spawn EINVAL`. CLI probes avoid this via `resolveSpawnCommand`, which can
* fall back to `shell: true`; the SDK offers no such escape hatch.
*
* On Windows this resolves the command against PATH/PATHEXT and, when the
* result is an npm launcher shim, follows it to the real package entry
* (`bin/claude.exe`, or `cli.js` for older package versions). On other
* platforms the configured value is returned unchanged.
*/
export const resolveClaudeSdkExecutablePath = Effect.fn("resolveClaudeSdkExecutablePath")(
function* (binaryPath: string, environment: NodeJS.ProcessEnv): Effect.fn.Return<string> {
const platform = yield* HostProcessPlatform;
if (platform !== "win32") {
return binaryPath;
}

const resolveExecutable = yield* SpawnExecutableResolution;
const isFile = yield* ClaudeExecutableFileCheck;
const resolved = resolveExecutable(binaryPath, platform, environment) ?? binaryPath;
const extension = NodePath.win32.extname(resolved).toLowerCase();
if (!WINDOWS_SHIM_EXTENSIONS.has(extension)) {
return resolved;
}

const shimDirectory = NodePath.win32.dirname(resolved);
for (const entrySegments of NPM_PACKAGE_ENTRY_CANDIDATES) {
const candidate = NodePath.win32.join(shimDirectory, ...entrySegments);
if (isFile(candidate)) {
return candidate;
}
}

yield* Effect.logWarning(
"Claude launcher shim resolved but no known package entry was found next to it; the Claude Agent SDK cannot spawn launcher scripts directly.",
{ binaryPath, resolvedShimPath: resolved },
);
return binaryPath;
},
);
7 changes: 6 additions & 1 deletion apps/server/src/provider/Layers/ClaudeAdapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,7 @@ import * as Stream from "effect/Stream";
import { resolveAttachmentPath } from "../../attachmentStore.ts";
import { ServerConfig } from "../../config.ts";
import * as McpProviderSession from "../../mcp/McpProviderSession.ts";
import { resolveClaudeSdkExecutablePath } from "../Drivers/ClaudeExecutable.ts";
import { makeClaudeEnvironment } from "../Drivers/ClaudeHome.ts";
import {
getClaudeModelCapabilities,
Expand Down Expand Up @@ -1347,6 +1348,10 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* (
const claudeEnvironment = yield* makeClaudeEnvironment(claudeSettings, options?.environment).pipe(
Effect.provideService(Path.Path, path),
);
const claudeSdkExecutablePath = yield* resolveClaudeSdkExecutablePath(
claudeSettings.binaryPath,
claudeEnvironment,
);
const nativeEventLogger =
options?.nativeEventLogger ??
(options?.nativeEventLogPath !== undefined
Expand Down Expand Up @@ -3405,7 +3410,7 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* (
const canUseTool: CanUseTool = (toolName, toolInput, callbackOptions) =>
runPromise(canUseToolEffect(toolName, toolInput, callbackOptions));

const claudeBinaryPath = claudeSettings.binaryPath;
const claudeBinaryPath = claudeSdkExecutablePath;
const extraArgs = parseCliArgs(claudeSettings.launchArgs).flags;
const modelSelection =
input.modelSelection?.instanceId === boundInstanceId ? input.modelSelection : undefined;
Expand Down
7 changes: 6 additions & 1 deletion apps/server/src/provider/Layers/ClaudeProvider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ import {
spawnAndCollect,
type ServerProviderDraft,
} from "../providerSnapshot.ts";
import { resolveClaudeSdkExecutablePath } from "../Drivers/ClaudeExecutable.ts";
import { makeClaudeEnvironment } from "../Drivers/ClaudeHome.ts";

const DEFAULT_CLAUDE_MODEL_CAPABILITIES: ModelCapabilities = createModelCapabilities({
Expand Down Expand Up @@ -587,6 +588,10 @@ const probeClaudeCapabilities = (
const abort = new AbortController();
return Effect.gen(function* () {
const claudeEnvironment = yield* makeClaudeEnvironment(claudeSettings, environment);
const executablePath = yield* resolveClaudeSdkExecutablePath(
claudeSettings.binaryPath,
claudeEnvironment,
);
return yield* Effect.tryPromise(async () => {
const q = claudeQuery({
// Never yield — we only need initialization data, not a conversation.
Expand All @@ -597,7 +602,7 @@ const probeClaudeCapabilities = (
})(),
options: {
persistSession: false,
pathToClaudeCodeExecutable: claudeSettings.binaryPath,
pathToClaudeCodeExecutable: executablePath,
abortController: abort,
settingSources: ["user", "project", "local"],
allowedTools: [],
Expand Down
Loading