diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 052a8c20cf78..60299b782aea 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -110,6 +110,46 @@ jobs: - name: Test resource monitor run: cargo test --locked --manifest-path native/resource-monitor/Cargo.toml + windows_claude_provider_tests: + name: Windows Claude/WSL Provider Tests + runs-on: windows-latest + timeout-minutes: 15 + steps: + - name: Checkout + uses: actions/checkout@v6 + with: + sparse-checkout: | + /* + !/.repos/ + sparse-checkout-cone-mode: false + + - name: Setup Vite+ + uses: voidzero-dev/setup-vp@v1 + with: + node-version-file: package.json + cache: true + run-install: | + args: + - --filter=t3... + - --filter=@t3tools/desktop... + + # Windows-only path resolution (npm/pnpm launcher shims, PATH/PATHEXT + # search) and the desktop WSL backend can't be exercised by the + # Ubuntu/macOS `test` job even though they're unit-tested with a mocked + # platform service everywhere. Running the real, Windows-specific + # subset here catches drift a mock can't. Scoped to Claude/WSL-owning + # files, not the full suite: e.g. Codex's shadow-home tests hit an + # unrelated Windows symlink-privilege wall on hosted runners. + - name: Test Claude and WSL provider code on Windows + run: > + vp test run + apps/server/src/provider/Drivers/ClaudeExecutable.test.ts + apps/server/src/provider/Layers/ClaudeAdapter.test.ts + apps/server/src/provider/Layers/ProviderAdapterRegistry.test.ts + apps/desktop/src/wsl/DesktopWslBackend.test.ts + apps/desktop/src/wsl/DesktopWslEnvironment.test.ts + apps/desktop/src/wsl/wslPathParsing.test.ts + mobile_native_static_analysis: name: Mobile Native Static Analysis runs-on: blacksmith-6vcpu-macos-26 diff --git a/apps/server/src/provider/Drivers/ClaudeExecutable.test.ts b/apps/server/src/provider/Drivers/ClaudeExecutable.test.ts index 020fc48a4656..e8ed0f079a19 100644 --- a/apps/server/src/provider/Drivers/ClaudeExecutable.test.ts +++ b/apps/server/src/provider/Drivers/ClaudeExecutable.test.ts @@ -3,23 +3,68 @@ import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; import { SpawnExecutableResolution } from "@t3tools/shared/shell"; import * as Effect from "effect/Effect"; -import { ClaudeExecutableFileCheck, resolveClaudeSdkExecutablePath } from "./ClaudeExecutable.ts"; +import { + ClaudeExecutableFileCheck, + ClaudeExecutableShimReader, + 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`; +// Real content captured from an npm-generated `claude.cmd` shim (Node's +// documented cmd-shim convention: resolve own directory via %~dp0/%dp0%, +// invoke the real entry relative to it). +const npmCmdShimContent = (relativeTarget: string) => `@ECHO off +GOTO start +:find_dp0 +SET dp0=%~dp0 +EXIT /b +:start +SETLOCAL +CALL :find_dp0 +"%dp0%\\${relativeTarget}" %* +`; + +// Real content captured from a pnpm global `.cmd` shim: the real entry lives +// in a version-pinned pnpm store path, not under a fixed node_modules layout. +const pnpmCmdShimContent = (relativeTarget: string) => `@SETLOCAL +@IF EXIST "%~dp0\\node.exe" ( + "%~dp0\\node.exe" "%~dp0\\${relativeTarget}" %* +) ELSE ( + node "%~dp0\\${relativeTarget}" %* +) +`; + +// Real content captured from a pnpm global `.ps1` shim. +const pnpmPs1ShimContent = (relativeTarget: string) => `#!/usr/bin/env pwsh +$basedir=Split-Path $MyInvocation.MyCommand.Definition -Parent +$exe="" +if ($PSVersionTable.PSVersion -lt "6.0" -or $IsWindows) { + $exe=".exe" +} +if (Test-Path "$basedir/node$exe") { + & "$basedir/node$exe" "$basedir/${relativeTarget.replace(/\\/g, "/")}" $args +} else { + & "node$exe" "$basedir/${relativeTarget.replace(/\\/g, "/")}" $args +} +`; + function withWindowsResolution(input: { readonly resolvedCommand: string | undefined; readonly existingFiles?: ReadonlyArray; + readonly shimContents?: Readonly>; }) { const existing = new Set(input.existingFiles ?? []); + const shimContents = input.shimContents ?? {}; return (effect: Effect.Effect) => effect.pipe( Effect.provideService(HostProcessPlatform, "win32"), Effect.provideService(SpawnExecutableResolution, () => input.resolvedCommand), Effect.provideService(ClaudeExecutableFileCheck, (filePath) => existing.has(filePath)), + Effect.provideService(ClaudeExecutableShimReader, (filePath) => shimContents[filePath]), ); } @@ -121,4 +166,128 @@ describe("resolveClaudeSdkExecutablePath", () => { ).toBe("claude"); }), ); + + it.effect("follows an npm .cmd shim via shim-content parsing, not just the fixed fallback", () => + Effect.gen(function* () { + const relativeTarget = "node_modules\\@anthropic-ai\\claude-code\\bin\\claude.exe"; + expect( + yield* resolveClaudeSdkExecutablePath("claude", {}).pipe( + withWindowsResolution({ + resolvedCommand: NPM_SHIM, + existingFiles: [NPM_PACKAGE_EXE], + shimContents: { [NPM_SHIM]: npmCmdShimContent(relativeTarget) }, + }), + ), + ).toBe(NPM_PACKAGE_EXE); + }), + ); + + describe("pnpm and other cmd-shim-convention global installs", () => { + // pnpm keeps the real entry in a version-pinned store path + // (global/5/.pnpm/@/node_modules//...), which the + // fixed npm-layout candidate list cannot anticipate. The shim itself + // still resolves its own directory and references the real entry + // relative to it, so parsing the shim's source finds it. + const PNPM_DIR = "C:\\Users\\dev\\AppData\\Local\\pnpm"; + const PNPM_SHIM_CMD = `${PNPM_DIR}\\claude.cmd`; + const PNPM_SHIM_PS1 = `${PNPM_DIR}\\claude.ps1`; + const PNPM_STORE_RELATIVE = + "global\\5\\.pnpm\\@anthropic-ai+claude-code@2.1.224\\node_modules\\@anthropic-ai\\claude-code\\bin\\claude.exe"; + const PNPM_STORE_ABSOLUTE = `${PNPM_DIR}\\${PNPM_STORE_RELATIVE}`; + + it.effect("follows a pnpm .cmd global shim to its version-pinned store entry", () => + Effect.gen(function* () { + expect( + yield* resolveClaudeSdkExecutablePath("claude", {}).pipe( + withWindowsResolution({ + resolvedCommand: PNPM_SHIM_CMD, + existingFiles: [PNPM_STORE_ABSOLUTE], + shimContents: { [PNPM_SHIM_CMD]: pnpmCmdShimContent(PNPM_STORE_RELATIVE) }, + }), + ), + ).toBe(PNPM_STORE_ABSOLUTE); + }), + ); + + it.effect("skips the shim's own node.exe reference and finds the real target", () => + Effect.gen(function* () { + // pnpmCmdShimContent references "%~dp0\node.exe" before the real + // target; a naive first-match parse would return the Node runtime + // itself instead of the package entry. + expect( + yield* resolveClaudeSdkExecutablePath("claude", {}).pipe( + withWindowsResolution({ + resolvedCommand: PNPM_SHIM_CMD, + existingFiles: [`${PNPM_DIR}\\node.exe`, PNPM_STORE_ABSOLUTE], + shimContents: { [PNPM_SHIM_CMD]: pnpmCmdShimContent(PNPM_STORE_RELATIVE) }, + }), + ), + ).toBe(PNPM_STORE_ABSOLUTE); + }), + ); + + it.effect("follows a pnpm .ps1 global shim via its $basedir reference", () => + Effect.gen(function* () { + expect( + yield* resolveClaudeSdkExecutablePath("claude", {}).pipe( + withWindowsResolution({ + resolvedCommand: PNPM_SHIM_PS1, + existingFiles: [PNPM_STORE_ABSOLUTE], + shimContents: { [PNPM_SHIM_PS1]: pnpmPs1ShimContent(PNPM_STORE_RELATIVE) }, + }), + ), + ).toBe(PNPM_STORE_ABSOLUTE); + }), + ); + + it.effect("falls back to the fixed npm layout when shim parsing finds nothing usable", () => + Effect.gen(function* () { + expect( + yield* resolveClaudeSdkExecutablePath("claude", {}).pipe( + withWindowsResolution({ + resolvedCommand: NPM_SHIM, + existingFiles: [NPM_PACKAGE_EXE], + shimContents: { [NPM_SHIM]: "not a recognizable shim format" }, + }), + ), + ).toBe(NPM_PACKAGE_EXE); + }), + ); + }); + + describe("install directories containing spaces", () => { + const SPACED_DIR = "C:\\Users\\Jane Doe\\AppData\\Roaming\\npm"; + const SPACED_SHIM = `${SPACED_DIR}\\claude.cmd`; + const SPACED_PACKAGE_EXE = `${SPACED_DIR}\\node_modules\\@anthropic-ai\\claude-code\\bin\\claude.exe`; + + it.effect("resolves an npm shim under a directory with spaces", () => + Effect.gen(function* () { + expect( + yield* resolveClaudeSdkExecutablePath("claude", {}).pipe( + withWindowsResolution({ + resolvedCommand: SPACED_SHIM, + existingFiles: [SPACED_PACKAGE_EXE], + }), + ), + ).toBe(SPACED_PACKAGE_EXE); + }), + ); + + it.effect("resolves a pnpm-style shim referencing a store path with spaces", () => + Effect.gen(function* () { + const relativeTarget = + "global\\5\\.pnpm\\@anthropic-ai+claude-code@2.1.224\\node_modules\\@anthropic-ai\\claude-code\\bin\\claude.exe"; + const absoluteTarget = `${SPACED_DIR}\\${relativeTarget}`; + expect( + yield* resolveClaudeSdkExecutablePath("claude", {}).pipe( + withWindowsResolution({ + resolvedCommand: SPACED_SHIM, + existingFiles: [absoluteTarget], + shimContents: { [SPACED_SHIM]: pnpmCmdShimContent(relativeTarget) }, + }), + ), + ).toBe(absoluteTarget); + }), + ); + }); }); diff --git a/apps/server/src/provider/Drivers/ClaudeExecutable.ts b/apps/server/src/provider/Drivers/ClaudeExecutable.ts index febfdb26f9e3..12856ce11b6e 100644 --- a/apps/server/src/provider/Drivers/ClaudeExecutable.ts +++ b/apps/server/src/provider/Drivers/ClaudeExecutable.ts @@ -19,13 +19,33 @@ const WINDOWS_SHIM_EXTENSIONS: ReadonlySet = new Set([".cmd", ".bat", ". * 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. + * + * Kept as a fallback after {@link extractShimRelativeTargets} — that parser + * covers this layout too, but this list is cheap, exactly matches npm's + * documented layout, and protects against a shim format the parser doesn't + * anticipate. */ const NPM_PACKAGE_ENTRY_CANDIDATES = [ ["node_modules", "@anthropic-ai", "claude-code", "bin", "claude.exe"], ["node_modules", "@anthropic-ai", "claude-code", "cli.js"], ] as const; +/** + * Matches the launcher's own directory reference in `.cmd`/`.bat` shims + * (`%~dp0\...` or `%dp0%\...`), capturing everything up to the closing quote + * or line end. + */ +const CMD_DP0_REFERENCE_PATTERN = /%~?dp0%?[\\/]([^"'\r\n]+)/gi; + +/** + * Matches the launcher's own directory reference in `.ps1` shims + * (`$basedir/...`), capturing everything up to the closing quote, a `$` + * (variable interpolation, e.g. `$basedir/node$exe`), or line end. + */ +const PS1_BASEDIR_REFERENCE_PATTERN = /\$basedir[\\/]([^"'\r\n$]+)/gi; + export type ExecutableFileCheck = (filePath: string) => boolean; +export type ShimScriptReader = (filePath: string) => string | undefined; function isExistingFile(filePath: string): boolean { try { @@ -35,6 +55,14 @@ function isExistingFile(filePath: string): boolean { } } +function readShimScript(filePath: string): string | undefined { + try { + return NodeFS.readFileSync(filePath, "utf8"); + } catch { + return undefined; + } +} + /** Injectable file-existence check so tests can run against a fake filesystem. */ export const ClaudeExecutableFileCheck = Context.Reference( "server/provider/Drivers/ClaudeExecutableFileCheck", @@ -43,6 +71,45 @@ export const ClaudeExecutableFileCheck = Context.Reference( }, ); +/** Injectable shim-script reader so tests can run against fake shim contents. */ +export const ClaudeExecutableShimReader = Context.Reference( + "server/provider/Drivers/ClaudeExecutableShimReader", + { + defaultValue: () => readShimScript, + }, +); + +function isNodeExecutableTarget(relativeTarget: string): boolean { + const base = NodePath.win32.basename(relativeTarget).toLowerCase(); + return base === "node.exe" || base === "node"; +} + +/** + * Extracts candidate real-target paths (relative to the shim's own + * directory) from a launcher shim's source text. + * + * Both npm and pnpm generate `.cmd`/`.bat`/`.ps1` launcher shims from the + * same underlying convention (`cmd-shim`): the script resolves its own + * directory (`%~dp0` / `$basedir`) and invokes the real entry point via a + * path relative to it. npm keeps that target at a fixed + * `node_modules//...` layout, but pnpm's global shims point into a + * version-pinned pnpm store path instead + * (`global/5/.pnpm/@/node_modules//...`), which no fixed + * candidate list can anticipate. Parsing the shim's own reference works for + * both, and for any other tool built on the same convention (e.g. corepack). + */ +function extractShimRelativeTargets(shimContent: string, extension: string): ReadonlyArray { + const pattern = extension === ".ps1" ? PS1_BASEDIR_REFERENCE_PATTERN : CMD_DP0_REFERENCE_PATTERN; + const targets: Array = []; + for (const match of shimContent.matchAll(pattern)) { + const relative = match[1]?.trim(); + if (relative && relative.length > 0) { + targets.push(relative.replace(/\//g, "\\")); + } + } + return targets; +} + /** * Resolves the configured Claude binary path into a value the Claude Agent * SDK can spawn directly via `pathToClaudeCodeExecutable`. @@ -67,6 +134,7 @@ export const resolveClaudeSdkExecutablePath = Effect.fn("resolveClaudeSdkExecuta const resolveExecutable = yield* SpawnExecutableResolution; const isFile = yield* ClaudeExecutableFileCheck; + const readShim = yield* ClaudeExecutableShimReader; const resolved = resolveExecutable(binaryPath, platform, environment) ?? binaryPath; const extension = NodePath.win32.extname(resolved).toLowerCase(); if (!WINDOWS_SHIM_EXTENSIONS.has(extension)) { @@ -74,6 +142,19 @@ export const resolveClaudeSdkExecutablePath = Effect.fn("resolveClaudeSdkExecuta } const shimDirectory = NodePath.win32.dirname(resolved); + const shimContent = readShim(resolved); + if (shimContent) { + for (const relativeTarget of extractShimRelativeTargets(shimContent, extension)) { + if (isNodeExecutableTarget(relativeTarget)) { + continue; + } + const candidate = NodePath.win32.join(shimDirectory, relativeTarget); + if (isFile(candidate)) { + return candidate; + } + } + } + for (const entrySegments of NPM_PACKAGE_ENTRY_CANDIDATES) { const candidate = NodePath.win32.join(shimDirectory, ...entrySegments); if (isFile(candidate)) { diff --git a/docs/user/providers-claude.md b/docs/user/providers-claude.md index 79f1211cf40d..07d8c45489a8 100644 --- a/docs/user/providers-claude.md +++ b/docs/user/providers-claude.md @@ -10,6 +10,36 @@ Common reasons: - run Claude through a router such as Claude Code Router - use external providers exposed through a Claude-compatible workflow +## Windows: Native Vs WSL + +Claude Code runs two ways on a Windows T3 Code server: natively, or inside WSL. + +### Native Windows + +T3 Code finds `claude` on Windows the same way it finds any provider binary: it searches `PATH` +using `PATHEXT`, or uses the explicit **Binary path** you set in Settings. Node cannot spawn a +`.cmd`/`.bat`/`.ps1` launcher script directly, so when that search lands on one of those (the +normal result for a global `npm install -g @anthropic-ai/claude-code`, or an equivalent global +install with pnpm, Yarn, or corepack), T3 Code reads the shim and follows it to the real +`claude.exe` or `cli.js` next to it before handing the path to the Claude Agent SDK. This covers +the common global-install layouts without needing WSL. + +If T3 Code can't work out the real target from an unusual install layout, it falls back to the +shim path itself, which will fail to start a session. Set an explicit **Binary path** pointing at +the real executable (for example `...\node_modules\@anthropic-ai\claude-code\bin\claude.exe`) to +work around it, and consider filing an issue with your install layout. + +### WSL + +T3 Code Desktop can also run the whole server inside a WSL distro instead of natively on Windows +(Settings → Connections → WSL backend, or WSL-only mode). When it does, Claude Code runs exactly +like it does on Linux — no shim-following, no Windows-specific resolution — because the server +process itself is a WSL process. Use this if you'd rather manage Claude Code (and other providers) +inside your existing WSL setup, or if native resolution isn't working for your install. + +Either mode uses the same Claude provider configuration (config directory, environment variables, +etc.) described below; only where the CLI itself runs changes. + ## I Only Use One Claude Account Use the default provider.