Skip to content
Closed
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
40 changes: 40 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
171 changes: 170 additions & 1 deletion apps/server/src/provider/Drivers/ClaudeExecutable.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string>;
readonly shimContents?: Readonly<Record<string, string>>;
}) {
const existing = new Set(input.existingFiles ?? []);
const shimContents = input.shimContents ?? {};
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)),
Effect.provideService(ClaudeExecutableShimReader, (filePath) => shimContents[filePath]),
);
}

Expand Down Expand Up @@ -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/<pkg>@<version>/node_modules/<pkg>/...), 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);
}),
);
});
});
81 changes: 81 additions & 0 deletions apps/server/src/provider/Drivers/ClaudeExecutable.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,13 +19,33 @@ const WINDOWS_SHIM_EXTENSIONS: ReadonlySet<string> = 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 {
Expand All @@ -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<ExecutableFileCheck>(
"server/provider/Drivers/ClaudeExecutableFileCheck",
Expand All @@ -43,6 +71,45 @@ export const ClaudeExecutableFileCheck = Context.Reference<ExecutableFileCheck>(
},
);

/** Injectable shim-script reader so tests can run against fake shim contents. */
export const ClaudeExecutableShimReader = Context.Reference<ShimScriptReader>(
"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/<pkg>/...` layout, but pnpm's global shims point into a
* version-pinned pnpm store path instead
* (`global/5/.pnpm/<pkg>@<version>/node_modules/<pkg>/...`), 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<string> {
const pattern = extension === ".ps1" ? PS1_BASEDIR_REFERENCE_PATTERN : CMD_DP0_REFERENCE_PATTERN;
const targets: Array<string> = [];
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`.
Expand All @@ -67,13 +134,27 @@ 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)) {
return resolved;
}

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)) {
Expand Down
Loading
Loading