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
45 changes: 45 additions & 0 deletions packages/core/src/clients/claude.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,7 @@
import { describe, test, expect, mock, beforeEach, afterEach, spyOn } from 'bun:test';
import { mkdirSync, writeFileSync, readFileSync, rmSync } from 'fs';
import { tmpdir } from 'os';
import { join } from 'path';
import { createMockLogger } from '../test/mocks/logger';

const mockLogger = createMockLogger();
Expand Down Expand Up @@ -1106,3 +1109,45 @@ describe('ClaudeClient', () => {
});
});
});

describe('resolveWindowsBunfsCliPath', () => {
const { resolveWindowsBunfsCliPath } = claudeModule;

test('returns non-Windows paths unchanged', () => {
const realPath = '/tmp/claude-agent-sdk-abc123/cli.js';
expect(resolveWindowsBunfsCliPath(realPath)).toBe(realPath);
});

test('returns $bunfs paths unchanged (already extracted by SDK)', () => {
const bunfsPath = '/$bunfs/root/cli.js';
expect(resolveWindowsBunfsCliPath(bunfsPath)).toBe(bunfsPath);
});

test('extracts Windows Bun virtual FS paths to a real temp file', () => {
const testContent = '// fake cli.js content for test';
const fakeBunDir = join(tmpdir(), 'archon-test-bunfs');
mkdirSync(fakeBunDir, { recursive: true });
const fakeBunPath = join(fakeBunDir, '~BUN-test-cli.js');
writeFileSync(fakeBunPath, testContent);

try {
const result = resolveWindowsBunfsCliPath(fakeBunPath);
expect(result).not.toBe(fakeBunPath);
expect(result).toContain('cli.js');
expect(readFileSync(result, 'utf-8')).toBe(testContent);
} finally {
rmSync(fakeBunDir, { recursive: true, force: true });
}
});

test('falls back to original path if extraction fails', () => {
// Path contains ~BUN but doesn't exist on disk
const windowsBunfsPath = 'B:/~BUN/root/nonexistent-cli.js';
mockLogger.warn.mockClear();
const result = resolveWindowsBunfsCliPath(windowsBunfsPath);
expect(result).toBe(windowsBunfsPath);
// Verify the structured logger warn fires so operators can diagnose failures
expect(mockLogger.warn).toHaveBeenCalledTimes(1);
expect(mockLogger.warn.mock.calls[0][1]).toBe('claude.windows_bunfs_extraction_failed');
});
});
52 changes: 51 additions & 1 deletion packages/core/src/clients/claude.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,10 @@
* - CLAUDE_USE_GLOBAL_AUTH=false: Use explicit tokens from env vars
* - Not set: Auto-detect - use tokens if present in env, otherwise global auth
*/
import { readFileSync, writeFileSync, mkdirSync, chmodSync, renameSync } from 'fs';
import { join } from 'path';
import { tmpdir } from 'os';
import { createHash } from 'crypto';
import {
query,
type Options,
Expand All @@ -27,6 +31,10 @@ import {
// the build host's absolute node_modules path, producing a "Module not found
// /Users/runner/..." error on any machine other than the CI runner.
// Safe in dev too: resolves to the real on-disk cli.js.
//
// NOTE: The SDK's extractFromBunfs only handles $bunfs (Linux/Mac) paths.
// On Windows, Bun uses B:/~BUN/root/ for its virtual FS — see resolveWindowsBunfsCliPath
// below for the Windows workaround.
import cliPath from '@anthropic-ai/claude-agent-sdk/embed';
import {
type AssistantRequestOptions,
Expand All @@ -40,13 +48,55 @@ import { scanPathForSensitiveKeys, EnvLeakError } from '../utils/env-leak-scanne
import * as codebaseDb from '../db/codebases';
import { loadConfig } from '../config/config-loader';

/**
* On Windows, Bun's virtual filesystem uses `B:/~BUN/root/` instead of `$bunfs`.
* The SDK's `extractFromBunfs` only checks for `$bunfs`, so Windows virtual FS
* paths are returned unchanged — child processes cannot access the parent's virtual
* FS, causing "Module not found B:/~BUN/root/..." errors.
*
* This function detects Windows Bun FS paths and extracts them to a real temp file
* using the same logic as `extractFromBunfs`, so `pathToClaudeCodeExecutable` always
* points to a real file accessible to child processes.
*/
export function resolveWindowsBunfsCliPath(embeddedPath: string): string {
// Windows Bun virtual FS paths contain '~BUN' (e.g. B:/~BUN/root/cli-xxx.js).
// Non-Windows paths are expected to have been resolved already by extractFromBunfs
// (real temp file or real node_modules path) and won't contain this marker.
if (!embeddedPath.includes('~BUN')) {
return embeddedPath;
}
try {
const content = readFileSync(embeddedPath);
const hash = createHash('sha256').update(content).digest('hex').slice(0, 16);
const tmpDir = join(tmpdir(), `claude-agent-sdk-${hash}`);
const tmpPath = join(tmpDir, 'cli.js');
mkdirSync(tmpDir, { recursive: true });
// Atomic write: write to temp file then rename to avoid truncation races
const tmpFile = join(tmpDir, `cli.js.tmp.${process.pid}`);
writeFileSync(tmpFile, content);
chmodSync(tmpFile, 0o755);
renameSync(tmpFile, tmpPath);
return tmpPath;
} catch (err) {
// Log warning but fall back to original path — will fail but that was the
// pre-fix behavior. Fail-open here since we can't throw at module init time.
getLog().warn({ err, embeddedPath }, 'claude.windows_bunfs_extraction_failed');
return embeddedPath;
}
}

/** Lazy-initialized logger (deferred so test mocks can intercept createLogger) */
let cachedLog: ReturnType<typeof createLogger> | undefined;
function getLog(): ReturnType<typeof createLogger> {
if (!cachedLog) cachedLog = createLogger('client.claude');
return cachedLog;
}

// Module-level cached result: resolved once at import time so every call to
// pathToClaudeCodeExecutable returns the same real path without re-extracting.
// Must be declared after getLog() to avoid TDZ if extraction fails and logs a warning.
const resolvedCliPath = resolveWindowsBunfsCliPath(cliPath);

/**
* Content block type for assistant messages
* Represents text or tool_use blocks from Claude API responses
Expand Down Expand Up @@ -324,7 +374,7 @@ export class ClaudeClient implements IAssistantClient {

const options: Options = {
cwd,
pathToClaudeCodeExecutable: cliPath,
pathToClaudeCodeExecutable: resolvedCliPath,
env: requestOptions?.env
? { ...buildSubprocessEnv(), ...requestOptions.env }
: buildSubprocessEnv(),
Expand Down
Loading