From 3177eaafd152d91c06b994ce499830508d1eb7b4 Mon Sep 17 00:00:00 2001 From: Guo Yu Date: Thu, 25 Jun 2026 05:41:16 +0800 Subject: [PATCH] fix: repair Windows agent CLI wrappers --- packages/cli/src/local-supervisor.test.ts | 288 +++++++++++++++++++++- packages/cli/src/local-supervisor.ts | 83 ++++++- 2 files changed, 354 insertions(+), 17 deletions(-) diff --git a/packages/cli/src/local-supervisor.test.ts b/packages/cli/src/local-supervisor.test.ts index 5f85ca6..2032260 100644 --- a/packages/cli/src/local-supervisor.test.ts +++ b/packages/cli/src/local-supervisor.test.ts @@ -1,7 +1,7 @@ import * as fs from 'node:fs' import * as path from 'node:path' import { tmpdir } from 'node:os' -import { afterEach, describe, expect, it } from 'vitest' +import { afterEach, describe, expect, it, vi } from 'vitest' import { buildLocalSupervisorEnv, createHomeLayout, @@ -9,6 +9,7 @@ import { installSharedSkills, resolveCliEntrypoint, resolveRuntimeEntrypoint, + startLocalSupervisor, syncHomeEntry, } from './local-supervisor.js' @@ -172,13 +173,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('#!/usr/bin/env bash') expect(fs.readFileSync(wanmanWrapper, 'utf-8')).toContain(JSON.stringify(cliEntrypoint)) - 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(layout.binDir) + expect(fs.readFileSync(path.join(layout.agentHome, '.bash_profile'), 'utf-8')).toContain(JSON.stringify(layout.binDir)) + }) + + it('creates Windows command shims and a PowerShell profile', () => { + 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', + }) + + expect(fs.existsSync(path.join(layout.binDir, 'wanman'))).toBe(false) + expect(fs.readFileSync(path.join(layout.binDir, 'wanman.cmd'), 'utf-8')).toContain(cliEntrypoint) + expect(fs.readFileSync(path.join(layout.binDir, 'wanman.ps1'), 'utf-8')).toContain(cliEntrypoint) + expect(fs.readFileSync(path.join(layout.binDir, 'pnpm.cmd'), 'utf-8')).toContain('corepack pnpm') + expect(fs.readFileSync(path.join(layout.binDir, 'pnpm.ps1'), 'utf-8')).toContain('corepack pnpm') + expect(fs.readFileSync(path.join(layout.agentHome, 'Documents', 'PowerShell', 'Microsoft.PowerShell_profile.ps1'), 'utf-8')).toContain(layout.binDir) + expect(fs.lstatSync(path.join(layout.agentHome, '.config')).isSymbolicLink()).toBe(true) }) }) @@ -200,7 +224,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 +240,260 @@ describe('buildLocalSupervisorEnv', () => { expect(env['CODEX_SANDBOX_NETWORK_DISABLED']).toBeUndefined() expect(env['CODEX_THREAD_ID']).toBeUndefined() }) + + it('uses Windows PATH semantics and USERPROFILE for agent shells', () => { + const env = buildLocalSupervisorEnv({ + PATH: 'C:\\Windows\\System32', + }, { + configPath: 'C:\\tmp\\agents.json', + workspaceRoot: 'C:\\tmp\\agents', + gitRoot: 'C:\\tmp\\repo', + sharedSkillsDir: 'C:\\tmp\\skills', + homeRoot: 'C:\\tmp\\home-root', + }, 'C:\\tmp\\home', 'C:\\tmp\\bin', 3120, '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') + }) +}) + +describe('startLocalSupervisor', () => { + const tmpDirs: string[] = [] + + afterEach(() => { + for (const dir of tmpDirs.splice(0)) { + fs.rmSync(dir, { recursive: true, force: true }) + } + }) + + function makeTmpDir(prefix: string): string { + const dir = fs.mkdtempSync(path.join(tmpdir(), prefix)) + tmpDirs.push(dir) + return dir + } + + function writeJson(filePath: string, value: unknown): void { + fs.writeFileSync(filePath, JSON.stringify(value, null, 2)) + } + + it('starts a local supervisor process, captures logs, and exits cleanly', async () => { + const root = makeTmpDir('wanman-start-supervisor-') + const sharedSkillsDir = path.join(root, 'skills') + const configPath = path.join(root, 'agents.json') + const workspaceRoot = path.join(root, 'workspace') + const gitRoot = path.join(root, 'repo') + const homeRoot = path.join(root, 'home-root') + const hostHome = path.join(root, 'host-home') + const cliEntrypoint = path.join(root, 'host-cli.js') + const runtimeEntrypoint = path.join(root, 'runtime-entrypoint.mjs') + + fs.mkdirSync(path.join(sharedSkillsDir, 'takeover-context'), { recursive: true }) + fs.writeFileSync(path.join(sharedSkillsDir, 'takeover-context', 'SKILL.md'), '# Takeover\n') + fs.mkdirSync(path.join(hostHome, '.config'), { recursive: true }) + fs.mkdirSync(workspaceRoot, { recursive: true }) + fs.mkdirSync(gitRoot, { recursive: true }) + fs.writeFileSync(cliEntrypoint, '') + writeJson(configPath, { agents: [] }) + fs.writeFileSync(runtimeEntrypoint, [ + 'console.log("runtime boot")', + 'console.error("runtime err")', + 'setTimeout(() => process.exit(0), 200)', + '', + ].join('\n')) + + const originalHome = process.env['HOME'] + process.env['HOME'] = hostHome + + try { + const handle = await startLocalSupervisor({ + configPath, + workspaceRoot, + gitRoot, + sharedSkillsDir, + homeRoot, + runtimeEntrypoint, + cliHostEntrypoint: cliEntrypoint, + goal: 'ship', + runtime: 'codex', + codexModel: 'gpt-test', + codexReasoningEffort: 'high', + }) + + try { + await new Promise(resolve => setTimeout(resolve, 150)) + + const logs = await handle.readLogs(0) + expect(logs.lines).toContain('runtime boot') + expect(logs.lines).toContain('runtime err') + expect(handle.endpoint).toMatch(/^http:\/\/127\.0\.0\.1:\d+$/) + expect(handle.port).toBeGreaterThan(0) + expect(handle.runtime).toBeDefined() + + const config = JSON.parse(fs.readFileSync(configPath, 'utf-8')) as { port?: number } + expect(config.port).toBe(handle.port) + + const beforeSigInt = process.listenerCount('SIGINT') + const beforeSigTerm = process.listenerCount('SIGTERM') + const beforeSigTermListeners = process.listeners('SIGTERM') + const detach = handle.attachSignalForwarding() + expect(process.listenerCount('SIGINT')).toBe(beforeSigInt + 1) + expect(process.listenerCount('SIGTERM')).toBe(beforeSigTerm + 1) + const forwardSigTerm = process.listeners('SIGTERM').find(listener => !beforeSigTermListeners.includes(listener)) + expect(forwardSigTerm).toBeTypeOf('function') + const killSpy = vi.spyOn(handle.child, 'kill').mockReturnValue(true) + try { + forwardSigTerm?.('SIGTERM') + expect(killSpy).toHaveBeenCalledWith('SIGTERM') + } finally { + killSpy.mockRestore() + } + detach() + expect(process.listenerCount('SIGINT')).toBe(beforeSigInt) + expect(process.listenerCount('SIGTERM')).toBe(beforeSigTerm) + + await handle.waitForExit() + } finally { + if (handle.child.exitCode === null) { + handle.child.kill('SIGKILL') + await handle.waitForExit() + } + } + } finally { + if (originalHome === undefined) delete process.env['HOME'] + else process.env['HOME'] = originalHome + } + }, 10_000) + + it('force-stops a running supervisor process', async () => { + const root = makeTmpDir('wanman-start-supervisor-stop-') + const sharedSkillsDir = path.join(root, 'skills') + const configPath = path.join(root, 'agents.json') + const workspaceRoot = path.join(root, 'workspace') + const gitRoot = path.join(root, 'repo') + const homeRoot = path.join(root, 'home-root') + const hostHome = path.join(root, 'host-home') + const cliEntrypoint = path.join(root, 'host-cli.js') + const runtimeEntrypoint = path.join(root, 'runtime-entrypoint.mjs') + + fs.mkdirSync(path.join(sharedSkillsDir, 'takeover-context'), { recursive: true }) + fs.writeFileSync(path.join(sharedSkillsDir, 'takeover-context', 'SKILL.md'), '# Takeover\n') + fs.mkdirSync(path.join(hostHome, '.config'), { recursive: true }) + fs.mkdirSync(workspaceRoot, { recursive: true }) + fs.mkdirSync(gitRoot, { recursive: true }) + fs.writeFileSync(cliEntrypoint, '') + writeJson(configPath, { agents: [] }) + fs.writeFileSync(runtimeEntrypoint, 'setInterval(() => {}, 1000)\n') + + const originalHome = process.env['HOME'] + process.env['HOME'] = hostHome + + try { + const handle = await startLocalSupervisor({ + configPath, + workspaceRoot, + gitRoot, + sharedSkillsDir, + homeRoot, + runtimeEntrypoint, + cliHostEntrypoint: cliEntrypoint, + }) + + try { + await handle.stop(true) + await new Promise(resolve => setTimeout(resolve, 200)) + expect(handle.child.killed).toBe(true) + } finally { + if (handle.child.exitCode === null) { + handle.child.kill('SIGKILL') + } + } + } finally { + if (originalHome === undefined) delete process.env['HOME'] + else process.env['HOME'] = originalHome + } + }, 10_000) + + it('rejects waitForExit when the supervisor exits with a non-zero code', async () => { + const root = makeTmpDir('wanman-start-supervisor-fail-') + const sharedSkillsDir = path.join(root, 'skills') + const configPath = path.join(root, 'agents.json') + const workspaceRoot = path.join(root, 'workspace') + const gitRoot = path.join(root, 'repo') + const homeRoot = path.join(root, 'home-root') + const hostHome = path.join(root, 'host-home') + const cliEntrypoint = path.join(root, 'host-cli.js') + const runtimeEntrypoint = path.join(root, 'runtime-entrypoint.mjs') + + fs.mkdirSync(path.join(sharedSkillsDir, 'takeover-context'), { recursive: true }) + fs.writeFileSync(path.join(sharedSkillsDir, 'takeover-context', 'SKILL.md'), '# Takeover\n') + fs.mkdirSync(path.join(hostHome, '.config'), { recursive: true }) + fs.mkdirSync(workspaceRoot, { recursive: true }) + fs.mkdirSync(gitRoot, { recursive: true }) + fs.writeFileSync(cliEntrypoint, '') + writeJson(configPath, { agents: [] }) + fs.writeFileSync(runtimeEntrypoint, 'process.exit(3)\n') + + const originalHome = process.env['HOME'] + process.env['HOME'] = hostHome + + try { + const handle = await startLocalSupervisor({ + configPath, + workspaceRoot, + gitRoot, + sharedSkillsDir, + homeRoot, + runtimeEntrypoint, + cliHostEntrypoint: cliEntrypoint, + }) + + await expect(handle.waitForExit()).rejects.toThrow(/code 3/) + } finally { + if (originalHome === undefined) delete process.env['HOME'] + else process.env['HOME'] = originalHome + } + }) + + it('resolves waitForExit when the supervisor already exited cleanly', async () => { + const root = makeTmpDir('wanman-start-supervisor-exit-') + const sharedSkillsDir = path.join(root, 'skills') + const configPath = path.join(root, 'agents.json') + const workspaceRoot = path.join(root, 'workspace') + const gitRoot = path.join(root, 'repo') + const homeRoot = path.join(root, 'home-root') + const hostHome = path.join(root, 'host-home') + const cliEntrypoint = path.join(root, 'host-cli.js') + const runtimeEntrypoint = path.join(root, 'runtime-entrypoint.mjs') + + fs.mkdirSync(path.join(sharedSkillsDir, 'takeover-context'), { recursive: true }) + fs.writeFileSync(path.join(sharedSkillsDir, 'takeover-context', 'SKILL.md'), '# Takeover\n') + fs.mkdirSync(path.join(hostHome, '.config'), { recursive: true }) + fs.mkdirSync(workspaceRoot, { recursive: true }) + fs.mkdirSync(gitRoot, { recursive: true }) + fs.writeFileSync(cliEntrypoint, '') + writeJson(configPath, { agents: [] }) + fs.writeFileSync(runtimeEntrypoint, 'process.exit(0)\n') + + const originalHome = process.env['HOME'] + process.env['HOME'] = hostHome + + try { + const handle = await startLocalSupervisor({ + configPath, + workspaceRoot, + gitRoot, + sharedSkillsDir, + homeRoot, + runtimeEntrypoint, + cliHostEntrypoint: cliEntrypoint, + }) + + await new Promise(resolve => setTimeout(resolve, 100)) + await expect(handle.waitForExit()).resolves.toBeUndefined() + } finally { + if (originalHome === undefined) delete process.env['HOME'] + else process.env['HOME'] = originalHome + } + }) }) diff --git a/packages/cli/src/local-supervisor.ts b/packages/cli/src/local-supervisor.ts index 201cf05..f0ee3b5 100644 --- a/packages/cli/src/local-supervisor.ts +++ b/packages/cli/src/local-supervisor.ts @@ -150,33 +150,87 @@ export function installSharedSkills(sharedSkillsDir: string, agentHome: string): } } +function isWindowsPlatform(platform: NodeJS.Platform): boolean { + return platform === 'win32' +} + +function writeUnixWrapper(filePath: string, command: string): void { + fs.writeFileSync(filePath, `#!/usr/bin/env bash\nexec ${command} "$@"\n`) + fs.chmodSync(filePath, 0o755) +} + +function quoteBatchPath(value: string): string { + return `"${value.replace(/"/g, '""')}"` +} + +function quotePowerShellPath(value: string): string { + return `'${value.replace(/'/g, "''")}'` +} + +function writeWindowsCmdWrapper(filePath: string, command: string): void { + fs.writeFileSync(filePath, `@echo off\r\n${command}\r\n`) +} + +function writeWindowsPowerShellWrapper(filePath: string, command: string): void { + fs.writeFileSync(filePath, `${command}\r\nexit $LASTEXITCODE\r\n`) +} + +function writeWindowsPowerShellProfile(agentHome: string, binDir: string): void { + const profileDir = path.join(agentHome, 'Documents', 'PowerShell') + fs.mkdirSync(profileDir, { recursive: true }) + const profilePath = path.join(profileDir, 'Microsoft.PowerShell_profile.ps1') + const quotedBinDir = quotePowerShellPath(binDir) + fs.writeFileSync( + profilePath, + [ + `$wanmanBin = ${quotedBinDir}`, + 'if (-not $env:PATH) {', + ' $env:PATH = $wanmanBin', + `} elseif (($env:PATH -split [System.IO.Path]::PathSeparator) -notcontains $wanmanBin) {`, + ' $env:PATH = $wanmanBin + [System.IO.Path]::PathSeparator + $env:PATH', + '}', + '', + ].join('\r\n'), + ) +} + /** @internal exported for testing */ export function createHomeLayout( homeRoot: string, - options: { home?: string; cliHostEntrypoint?: string } = {}, + options: { home?: string; cliHostEntrypoint?: string; platform?: NodeJS.Platform } = {}, ): { agentHome: string; binDir: string } { const home = options.home ?? process.env['HOME'] ?? '/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 (isWindowsPlatform(platform)) { + writeWindowsCmdWrapper( + path.join(binDir, 'wanman.cmd'), + `${quoteBatchPath(process.execPath)} ${quoteBatchPath(cliHostEntrypoint)} %*`, + ) + writeWindowsPowerShellWrapper( + path.join(binDir, 'wanman.ps1'), + `& ${quotePowerShellPath(process.execPath)} ${quotePowerShellPath(cliHostEntrypoint)} @args`, + ) + writeWindowsCmdWrapper(path.join(binDir, 'pnpm.cmd'), 'call corepack pnpm %*') + writeWindowsPowerShellWrapper(path.join(binDir, 'pnpm.ps1'), 'corepack pnpm @args') + writeWindowsPowerShellProfile(agentHome, binDir) + } else { + writeUnixWrapper( + path.join(binDir, 'wanman'), + `${JSON.stringify(process.execPath)} ${JSON.stringify(cliHostEntrypoint)}`, + ) + writeUnixWrapper(path.join(binDir, 'pnpm'), 'corepack pnpm') + fs.writeFileSync(path.join(agentHome, '.bash_profile'), `export PATH=${JSON.stringify(binDir)}:$PATH\n`) + } for (const entry of ['.codex', '.claude', '.config', '.gitconfig', '.git-credentials', '.ssh']) { syncHomeEntry(path.join(home, entry), path.join(agentHome, entry)) } - fs.writeFileSync(path.join(agentHome, '.bash_profile'), `export PATH=${JSON.stringify(binDir)}:$PATH\n`) return { agentHome, binDir } } @@ -188,8 +242,10 @@ export function buildLocalSupervisorEnv( agentHome: string, binDir: string, port: number, + platform: NodeJS.Platform = process.platform, ): NodeJS.ProcessEnv { const env = { ...baseEnv } + const pathValue = [binDir, baseEnv['PATH'] || ''].filter(Boolean).join(isWindowsPlatform(platform) ? ';' : ':') // Do not leak outer Codex session controls into the nested local supervisor. delete env['CODEX_CI'] @@ -199,7 +255,8 @@ export function buildLocalSupervisorEnv( return { ...env, HOME: agentHome, - PATH: `${binDir}:${baseEnv['PATH'] || ''}`, + ...(isWindowsPlatform(platform) ? { USERPROFILE: agentHome } : {}), + PATH: pathValue, WANMAN_URL: `http://127.0.0.1:${port}`, WANMAN_CONFIG: opts.configPath, WANMAN_WORKSPACE: opts.workspaceRoot,