From 7cc3d1416b807b94ea97a83578d0382fff9e928f Mon Sep 17 00:00:00 2001 From: nike-beep Date: Thu, 25 Jun 2026 05:45:21 +0800 Subject: [PATCH] Fix Windows local supervisor CLI wrappers --- packages/cli/src/local-supervisor.test.ts | 56 +++++++++++++++- packages/cli/src/local-supervisor.ts | 81 +++++++++++++++++++---- 2 files changed, 121 insertions(+), 16 deletions(-) diff --git a/packages/cli/src/local-supervisor.test.ts b/packages/cli/src/local-supervisor.test.ts index 5f85ca6..8ed36d3 100644 --- a/packages/cli/src/local-supervisor.test.ts +++ b/packages/cli/src/local-supervisor.test.ts @@ -172,13 +172,36 @@ describe('local supervisor home layout helpers', () => { const layout = createHomeLayout(homeRoot, { home: hostHome, cliHostEntrypoint: cliEntrypoint, + platform: 'linux', }) const wanmanWrapper = path.join(layout.binDir, 'wanman') expect(fs.readFileSync(wanmanWrapper, 'utf-8')).toContain(JSON.stringify(cliEntrypoint)) - expect(fs.statSync(wanmanWrapper).mode & 0o111).toBeGreaterThan(0) + if (process.platform !== 'win32') { + expect(fs.statSync(wanmanWrapper).mode & 0o111).toBeGreaterThan(0) + } + expect(fs.lstatSync(path.join(layout.agentHome, '.config')).isSymbolicLink()).toBe(true) + expect(fs.readFileSync(path.join(layout.agentHome, '.bash_profile'), 'utf-8')).toContain(JSON.stringify(layout.binDir)) + }) + + it('creates Windows command wrappers for PowerShell and cmd', () => { + const hostHome = makeTmpDir('wanman-host-home-win-') + const homeRoot = makeTmpDir('wanman-home-layout-win-') + const cliEntrypoint = path.join(homeRoot, 'host-cli.js') + fs.writeFileSync(cliEntrypoint, '') + fs.mkdirSync(path.join(hostHome, '.config')) + + const layout = createHomeLayout(homeRoot, { + home: hostHome, + cliHostEntrypoint: cliEntrypoint, + platform: 'win32', + }) + + const wanmanWrapper = path.join(layout.binDir, 'wanman.cmd') + const pnpmWrapper = path.join(layout.binDir, 'pnpm.cmd') + expect(fs.readFileSync(wanmanWrapper, 'utf-8')).toContain(cliEntrypoint) + expect(fs.readFileSync(pnpmWrapper, 'utf-8')).toContain('corepack pnpm %*') expect(fs.lstatSync(path.join(layout.agentHome, '.config')).isSymbolicLink()).toBe(true) - expect(fs.readFileSync(path.join(layout.agentHome, '.bash_profile'), 'utf-8')).toContain(layout.binDir) }) }) @@ -200,7 +223,7 @@ describe('buildLocalSupervisorEnv', () => { runtime: 'codex', codexModel: 'gpt-test', codexReasoningEffort: 'high', - }, '/tmp/home', '/tmp/bin', 3333) + }, '/tmp/home', '/tmp/bin', 3333, 'linux') expect(env['HOME']).toBe('/tmp/home') expect(env['PATH']).toBe('/tmp/bin:/usr/bin') @@ -216,4 +239,31 @@ describe('buildLocalSupervisorEnv', () => { expect(env['CODEX_SANDBOX_NETWORK_DISABLED']).toBeUndefined() expect(env['CODEX_THREAD_ID']).toBeUndefined() }) + + it('uses Windows PATH semantics and USERPROFILE for nested supervisors', () => { + const env = buildLocalSupervisorEnv({ + Path: 'C:\\Windows\\System32', + CODEX_CI: '1', + CODEX_THREAD_ID: 'thread', + KEEP: 'yes', + }, { + configPath: 'C:\\tmp\\agents.json', + workspaceRoot: 'C:\\tmp\\agents', + gitRoot: 'C:\\tmp\\repo', + sharedSkillsDir: 'C:\\tmp\\skills', + homeRoot: 'C:\\tmp\\home-root', + goal: 'ship', + runtime: 'codex', + codexModel: 'gpt-test', + codexReasoningEffort: 'high', + }, 'C:\\tmp\\home', 'C:\\tmp\\bin', 3333, 'win32') + + expect(env['HOME']).toBe('C:\\tmp\\home') + expect(env['USERPROFILE']).toBe('C:\\tmp\\home') + expect(env['Path']).toBe('C:\\tmp\\bin;C:\\Windows\\System32') + expect(env['PATH']).toBeUndefined() + expect(env['KEEP']).toBe('yes') + expect(env['CODEX_CI']).toBeUndefined() + expect(env['CODEX_THREAD_ID']).toBeUndefined() + }) }) diff --git a/packages/cli/src/local-supervisor.ts b/packages/cli/src/local-supervisor.ts index 201cf05..33d73cc 100644 --- a/packages/cli/src/local-supervisor.ts +++ b/packages/cli/src/local-supervisor.ts @@ -150,28 +150,70 @@ export function installSharedSkills(sharedSkillsDir: string, agentHome: string): } } +function writePosixWrapper(target: string, body: string): void { + fs.writeFileSync(target, body) + fs.chmodSync(target, 0o755) +} + +function quoteForCmd(value: string): string { + return `"${value.replace(/"/g, '""')}"` +} + +function writeWindowsCmdWrapper(target: string, commandLine: string): void { + fs.writeFileSync(target, `@echo off\r\n${commandLine}\r\n`) +} + +function resolvePathEnvKey( + baseEnv: NodeJS.ProcessEnv, + platform: NodeJS.Platform, +): string { + if (platform !== 'win32') { + return 'PATH' + } + return Object.keys(baseEnv).find(key => key === 'Path') + ?? Object.keys(baseEnv).find(key => key.toLowerCase() === 'path') + ?? 'Path' +} + +function pathDelimiterFor(platform: NodeJS.Platform): string { + return platform === 'win32' ? ';' : ':' +} + +interface HomeLayoutOptions { + home?: string + cliHostEntrypoint?: string + platform?: NodeJS.Platform +} + /** @internal exported for testing */ export function createHomeLayout( homeRoot: string, - options: { home?: string; cliHostEntrypoint?: string } = {}, + options: HomeLayoutOptions = {}, ): { agentHome: string; binDir: string } { - const home = options.home ?? process.env['HOME'] ?? '/root' + const home = options.home ?? process.env['HOME'] ?? process.env['USERPROFILE'] ?? '/root' + const platform = options.platform ?? process.platform const binDir = path.join(homeRoot, 'bin') const agentHome = path.join(homeRoot, 'home') fs.mkdirSync(binDir, { recursive: true }) fs.mkdirSync(agentHome, { recursive: true }) const cliHostEntrypoint = options.cliHostEntrypoint ?? resolveCliEntrypoint() - const wanmanWrapper = path.join(binDir, 'wanman') - fs.writeFileSync( - wanmanWrapper, - `#!/usr/bin/env bash\nexec ${JSON.stringify(process.execPath)} ${JSON.stringify(cliHostEntrypoint)} "$@"\n`, - ) - fs.chmodSync(wanmanWrapper, 0o755) - - const pnpmWrapper = path.join(binDir, 'pnpm') - fs.writeFileSync(pnpmWrapper, '#!/usr/bin/env bash\nexec corepack pnpm "$@"\n') - fs.chmodSync(pnpmWrapper, 0o755) + if (platform === 'win32') { + writeWindowsCmdWrapper( + path.join(binDir, 'wanman.cmd'), + `${quoteForCmd(process.execPath)} ${quoteForCmd(cliHostEntrypoint)} %*`, + ) + writeWindowsCmdWrapper(path.join(binDir, 'pnpm.cmd'), 'corepack pnpm %*') + } else { + writePosixWrapper( + path.join(binDir, 'wanman'), + `#!/usr/bin/env bash\nexec ${JSON.stringify(process.execPath)} ${JSON.stringify(cliHostEntrypoint)} "$@"\n`, + ) + writePosixWrapper( + path.join(binDir, 'pnpm'), + '#!/usr/bin/env bash\nexec corepack pnpm "$@"\n', + ) + } for (const entry of ['.codex', '.claude', '.config', '.gitconfig', '.git-credentials', '.ssh']) { syncHomeEntry(path.join(home, entry), path.join(agentHome, entry)) @@ -188,6 +230,7 @@ export function buildLocalSupervisorEnv( agentHome: string, binDir: string, port: number, + platform: NodeJS.Platform = process.platform, ): NodeJS.ProcessEnv { const env = { ...baseEnv } @@ -195,11 +238,23 @@ export function buildLocalSupervisorEnv( delete env['CODEX_CI'] delete env['CODEX_SANDBOX_NETWORK_DISABLED'] delete env['CODEX_THREAD_ID'] + for (const key of Object.keys(env)) { + if (key.toLowerCase() === 'path') { + delete env[key] + } + } + + const pathKey = resolvePathEnvKey(baseEnv, platform) + const currentPath = baseEnv[pathKey] ?? baseEnv['PATH'] ?? baseEnv['Path'] ?? '' + const updatedPath = currentPath + ? `${binDir}${pathDelimiterFor(platform)}${currentPath}` + : binDir return { ...env, HOME: agentHome, - PATH: `${binDir}:${baseEnv['PATH'] || ''}`, + ...(platform === 'win32' ? { USERPROFILE: agentHome } : {}), + [pathKey]: updatedPath, WANMAN_URL: `http://127.0.0.1:${port}`, WANMAN_CONFIG: opts.configPath, WANMAN_WORKSPACE: opts.workspaceRoot,