Skip to content

Commit 3177eaa

Browse files
committed
fix: repair Windows agent CLI wrappers
1 parent d3a8138 commit 3177eaa

2 files changed

Lines changed: 354 additions & 17 deletions

File tree

packages/cli/src/local-supervisor.test.ts

Lines changed: 284 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,15 @@
11
import * as fs from 'node:fs'
22
import * as path from 'node:path'
33
import { tmpdir } from 'node:os'
4-
import { afterEach, describe, expect, it } from 'vitest'
4+
import { afterEach, describe, expect, it, vi } from 'vitest'
55
import {
66
buildLocalSupervisorEnv,
77
createHomeLayout,
88
createLocalLogBuffer,
99
installSharedSkills,
1010
resolveCliEntrypoint,
1111
resolveRuntimeEntrypoint,
12+
startLocalSupervisor,
1213
syncHomeEntry,
1314
} from './local-supervisor.js'
1415

@@ -172,13 +173,36 @@ describe('local supervisor home layout helpers', () => {
172173
const layout = createHomeLayout(homeRoot, {
173174
home: hostHome,
174175
cliHostEntrypoint: cliEntrypoint,
176+
platform: 'linux',
175177
})
176178

177179
const wanmanWrapper = path.join(layout.binDir, 'wanman')
180+
expect(fs.readFileSync(wanmanWrapper, 'utf-8')).toContain('#!/usr/bin/env bash')
178181
expect(fs.readFileSync(wanmanWrapper, 'utf-8')).toContain(JSON.stringify(cliEntrypoint))
179-
expect(fs.statSync(wanmanWrapper).mode & 0o111).toBeGreaterThan(0)
180182
expect(fs.lstatSync(path.join(layout.agentHome, '.config')).isSymbolicLink()).toBe(true)
181-
expect(fs.readFileSync(path.join(layout.agentHome, '.bash_profile'), 'utf-8')).toContain(layout.binDir)
183+
expect(fs.readFileSync(path.join(layout.agentHome, '.bash_profile'), 'utf-8')).toContain(JSON.stringify(layout.binDir))
184+
})
185+
186+
it('creates Windows command shims and a PowerShell profile', () => {
187+
const hostHome = makeTmpDir('wanman-host-home-win-')
188+
const homeRoot = makeTmpDir('wanman-home-layout-win-')
189+
const cliEntrypoint = path.join(homeRoot, 'host-cli.js')
190+
fs.writeFileSync(cliEntrypoint, '')
191+
fs.mkdirSync(path.join(hostHome, '.config'))
192+
193+
const layout = createHomeLayout(homeRoot, {
194+
home: hostHome,
195+
cliHostEntrypoint: cliEntrypoint,
196+
platform: 'win32',
197+
})
198+
199+
expect(fs.existsSync(path.join(layout.binDir, 'wanman'))).toBe(false)
200+
expect(fs.readFileSync(path.join(layout.binDir, 'wanman.cmd'), 'utf-8')).toContain(cliEntrypoint)
201+
expect(fs.readFileSync(path.join(layout.binDir, 'wanman.ps1'), 'utf-8')).toContain(cliEntrypoint)
202+
expect(fs.readFileSync(path.join(layout.binDir, 'pnpm.cmd'), 'utf-8')).toContain('corepack pnpm')
203+
expect(fs.readFileSync(path.join(layout.binDir, 'pnpm.ps1'), 'utf-8')).toContain('corepack pnpm')
204+
expect(fs.readFileSync(path.join(layout.agentHome, 'Documents', 'PowerShell', 'Microsoft.PowerShell_profile.ps1'), 'utf-8')).toContain(layout.binDir)
205+
expect(fs.lstatSync(path.join(layout.agentHome, '.config')).isSymbolicLink()).toBe(true)
182206
})
183207
})
184208

@@ -200,7 +224,7 @@ describe('buildLocalSupervisorEnv', () => {
200224
runtime: 'codex',
201225
codexModel: 'gpt-test',
202226
codexReasoningEffort: 'high',
203-
}, '/tmp/home', '/tmp/bin', 3333)
227+
}, '/tmp/home', '/tmp/bin', 3333, 'linux')
204228

205229
expect(env['HOME']).toBe('/tmp/home')
206230
expect(env['PATH']).toBe('/tmp/bin:/usr/bin')
@@ -216,4 +240,260 @@ describe('buildLocalSupervisorEnv', () => {
216240
expect(env['CODEX_SANDBOX_NETWORK_DISABLED']).toBeUndefined()
217241
expect(env['CODEX_THREAD_ID']).toBeUndefined()
218242
})
243+
244+
it('uses Windows PATH semantics and USERPROFILE for agent shells', () => {
245+
const env = buildLocalSupervisorEnv({
246+
PATH: 'C:\\Windows\\System32',
247+
}, {
248+
configPath: 'C:\\tmp\\agents.json',
249+
workspaceRoot: 'C:\\tmp\\agents',
250+
gitRoot: 'C:\\tmp\\repo',
251+
sharedSkillsDir: 'C:\\tmp\\skills',
252+
homeRoot: 'C:\\tmp\\home-root',
253+
}, 'C:\\tmp\\home', 'C:\\tmp\\bin', 3120, 'win32')
254+
255+
expect(env['HOME']).toBe('C:\\tmp\\home')
256+
expect(env['USERPROFILE']).toBe('C:\\tmp\\home')
257+
expect(env['PATH']).toBe('C:\\tmp\\bin;C:\\Windows\\System32')
258+
})
259+
})
260+
261+
describe('startLocalSupervisor', () => {
262+
const tmpDirs: string[] = []
263+
264+
afterEach(() => {
265+
for (const dir of tmpDirs.splice(0)) {
266+
fs.rmSync(dir, { recursive: true, force: true })
267+
}
268+
})
269+
270+
function makeTmpDir(prefix: string): string {
271+
const dir = fs.mkdtempSync(path.join(tmpdir(), prefix))
272+
tmpDirs.push(dir)
273+
return dir
274+
}
275+
276+
function writeJson(filePath: string, value: unknown): void {
277+
fs.writeFileSync(filePath, JSON.stringify(value, null, 2))
278+
}
279+
280+
it('starts a local supervisor process, captures logs, and exits cleanly', async () => {
281+
const root = makeTmpDir('wanman-start-supervisor-')
282+
const sharedSkillsDir = path.join(root, 'skills')
283+
const configPath = path.join(root, 'agents.json')
284+
const workspaceRoot = path.join(root, 'workspace')
285+
const gitRoot = path.join(root, 'repo')
286+
const homeRoot = path.join(root, 'home-root')
287+
const hostHome = path.join(root, 'host-home')
288+
const cliEntrypoint = path.join(root, 'host-cli.js')
289+
const runtimeEntrypoint = path.join(root, 'runtime-entrypoint.mjs')
290+
291+
fs.mkdirSync(path.join(sharedSkillsDir, 'takeover-context'), { recursive: true })
292+
fs.writeFileSync(path.join(sharedSkillsDir, 'takeover-context', 'SKILL.md'), '# Takeover\n')
293+
fs.mkdirSync(path.join(hostHome, '.config'), { recursive: true })
294+
fs.mkdirSync(workspaceRoot, { recursive: true })
295+
fs.mkdirSync(gitRoot, { recursive: true })
296+
fs.writeFileSync(cliEntrypoint, '')
297+
writeJson(configPath, { agents: [] })
298+
fs.writeFileSync(runtimeEntrypoint, [
299+
'console.log("runtime boot")',
300+
'console.error("runtime err")',
301+
'setTimeout(() => process.exit(0), 200)',
302+
'',
303+
].join('\n'))
304+
305+
const originalHome = process.env['HOME']
306+
process.env['HOME'] = hostHome
307+
308+
try {
309+
const handle = await startLocalSupervisor({
310+
configPath,
311+
workspaceRoot,
312+
gitRoot,
313+
sharedSkillsDir,
314+
homeRoot,
315+
runtimeEntrypoint,
316+
cliHostEntrypoint: cliEntrypoint,
317+
goal: 'ship',
318+
runtime: 'codex',
319+
codexModel: 'gpt-test',
320+
codexReasoningEffort: 'high',
321+
})
322+
323+
try {
324+
await new Promise(resolve => setTimeout(resolve, 150))
325+
326+
const logs = await handle.readLogs(0)
327+
expect(logs.lines).toContain('runtime boot')
328+
expect(logs.lines).toContain('runtime err')
329+
expect(handle.endpoint).toMatch(/^http:\/\/127\.0\.0\.1:\d+$/)
330+
expect(handle.port).toBeGreaterThan(0)
331+
expect(handle.runtime).toBeDefined()
332+
333+
const config = JSON.parse(fs.readFileSync(configPath, 'utf-8')) as { port?: number }
334+
expect(config.port).toBe(handle.port)
335+
336+
const beforeSigInt = process.listenerCount('SIGINT')
337+
const beforeSigTerm = process.listenerCount('SIGTERM')
338+
const beforeSigTermListeners = process.listeners('SIGTERM')
339+
const detach = handle.attachSignalForwarding()
340+
expect(process.listenerCount('SIGINT')).toBe(beforeSigInt + 1)
341+
expect(process.listenerCount('SIGTERM')).toBe(beforeSigTerm + 1)
342+
const forwardSigTerm = process.listeners('SIGTERM').find(listener => !beforeSigTermListeners.includes(listener))
343+
expect(forwardSigTerm).toBeTypeOf('function')
344+
const killSpy = vi.spyOn(handle.child, 'kill').mockReturnValue(true)
345+
try {
346+
forwardSigTerm?.('SIGTERM')
347+
expect(killSpy).toHaveBeenCalledWith('SIGTERM')
348+
} finally {
349+
killSpy.mockRestore()
350+
}
351+
detach()
352+
expect(process.listenerCount('SIGINT')).toBe(beforeSigInt)
353+
expect(process.listenerCount('SIGTERM')).toBe(beforeSigTerm)
354+
355+
await handle.waitForExit()
356+
} finally {
357+
if (handle.child.exitCode === null) {
358+
handle.child.kill('SIGKILL')
359+
await handle.waitForExit()
360+
}
361+
}
362+
} finally {
363+
if (originalHome === undefined) delete process.env['HOME']
364+
else process.env['HOME'] = originalHome
365+
}
366+
}, 10_000)
367+
368+
it('force-stops a running supervisor process', async () => {
369+
const root = makeTmpDir('wanman-start-supervisor-stop-')
370+
const sharedSkillsDir = path.join(root, 'skills')
371+
const configPath = path.join(root, 'agents.json')
372+
const workspaceRoot = path.join(root, 'workspace')
373+
const gitRoot = path.join(root, 'repo')
374+
const homeRoot = path.join(root, 'home-root')
375+
const hostHome = path.join(root, 'host-home')
376+
const cliEntrypoint = path.join(root, 'host-cli.js')
377+
const runtimeEntrypoint = path.join(root, 'runtime-entrypoint.mjs')
378+
379+
fs.mkdirSync(path.join(sharedSkillsDir, 'takeover-context'), { recursive: true })
380+
fs.writeFileSync(path.join(sharedSkillsDir, 'takeover-context', 'SKILL.md'), '# Takeover\n')
381+
fs.mkdirSync(path.join(hostHome, '.config'), { recursive: true })
382+
fs.mkdirSync(workspaceRoot, { recursive: true })
383+
fs.mkdirSync(gitRoot, { recursive: true })
384+
fs.writeFileSync(cliEntrypoint, '')
385+
writeJson(configPath, { agents: [] })
386+
fs.writeFileSync(runtimeEntrypoint, 'setInterval(() => {}, 1000)\n')
387+
388+
const originalHome = process.env['HOME']
389+
process.env['HOME'] = hostHome
390+
391+
try {
392+
const handle = await startLocalSupervisor({
393+
configPath,
394+
workspaceRoot,
395+
gitRoot,
396+
sharedSkillsDir,
397+
homeRoot,
398+
runtimeEntrypoint,
399+
cliHostEntrypoint: cliEntrypoint,
400+
})
401+
402+
try {
403+
await handle.stop(true)
404+
await new Promise(resolve => setTimeout(resolve, 200))
405+
expect(handle.child.killed).toBe(true)
406+
} finally {
407+
if (handle.child.exitCode === null) {
408+
handle.child.kill('SIGKILL')
409+
}
410+
}
411+
} finally {
412+
if (originalHome === undefined) delete process.env['HOME']
413+
else process.env['HOME'] = originalHome
414+
}
415+
}, 10_000)
416+
417+
it('rejects waitForExit when the supervisor exits with a non-zero code', async () => {
418+
const root = makeTmpDir('wanman-start-supervisor-fail-')
419+
const sharedSkillsDir = path.join(root, 'skills')
420+
const configPath = path.join(root, 'agents.json')
421+
const workspaceRoot = path.join(root, 'workspace')
422+
const gitRoot = path.join(root, 'repo')
423+
const homeRoot = path.join(root, 'home-root')
424+
const hostHome = path.join(root, 'host-home')
425+
const cliEntrypoint = path.join(root, 'host-cli.js')
426+
const runtimeEntrypoint = path.join(root, 'runtime-entrypoint.mjs')
427+
428+
fs.mkdirSync(path.join(sharedSkillsDir, 'takeover-context'), { recursive: true })
429+
fs.writeFileSync(path.join(sharedSkillsDir, 'takeover-context', 'SKILL.md'), '# Takeover\n')
430+
fs.mkdirSync(path.join(hostHome, '.config'), { recursive: true })
431+
fs.mkdirSync(workspaceRoot, { recursive: true })
432+
fs.mkdirSync(gitRoot, { recursive: true })
433+
fs.writeFileSync(cliEntrypoint, '')
434+
writeJson(configPath, { agents: [] })
435+
fs.writeFileSync(runtimeEntrypoint, 'process.exit(3)\n')
436+
437+
const originalHome = process.env['HOME']
438+
process.env['HOME'] = hostHome
439+
440+
try {
441+
const handle = await startLocalSupervisor({
442+
configPath,
443+
workspaceRoot,
444+
gitRoot,
445+
sharedSkillsDir,
446+
homeRoot,
447+
runtimeEntrypoint,
448+
cliHostEntrypoint: cliEntrypoint,
449+
})
450+
451+
await expect(handle.waitForExit()).rejects.toThrow(/code 3/)
452+
} finally {
453+
if (originalHome === undefined) delete process.env['HOME']
454+
else process.env['HOME'] = originalHome
455+
}
456+
})
457+
458+
it('resolves waitForExit when the supervisor already exited cleanly', async () => {
459+
const root = makeTmpDir('wanman-start-supervisor-exit-')
460+
const sharedSkillsDir = path.join(root, 'skills')
461+
const configPath = path.join(root, 'agents.json')
462+
const workspaceRoot = path.join(root, 'workspace')
463+
const gitRoot = path.join(root, 'repo')
464+
const homeRoot = path.join(root, 'home-root')
465+
const hostHome = path.join(root, 'host-home')
466+
const cliEntrypoint = path.join(root, 'host-cli.js')
467+
const runtimeEntrypoint = path.join(root, 'runtime-entrypoint.mjs')
468+
469+
fs.mkdirSync(path.join(sharedSkillsDir, 'takeover-context'), { recursive: true })
470+
fs.writeFileSync(path.join(sharedSkillsDir, 'takeover-context', 'SKILL.md'), '# Takeover\n')
471+
fs.mkdirSync(path.join(hostHome, '.config'), { recursive: true })
472+
fs.mkdirSync(workspaceRoot, { recursive: true })
473+
fs.mkdirSync(gitRoot, { recursive: true })
474+
fs.writeFileSync(cliEntrypoint, '')
475+
writeJson(configPath, { agents: [] })
476+
fs.writeFileSync(runtimeEntrypoint, 'process.exit(0)\n')
477+
478+
const originalHome = process.env['HOME']
479+
process.env['HOME'] = hostHome
480+
481+
try {
482+
const handle = await startLocalSupervisor({
483+
configPath,
484+
workspaceRoot,
485+
gitRoot,
486+
sharedSkillsDir,
487+
homeRoot,
488+
runtimeEntrypoint,
489+
cliHostEntrypoint: cliEntrypoint,
490+
})
491+
492+
await new Promise(resolve => setTimeout(resolve, 100))
493+
await expect(handle.waitForExit()).resolves.toBeUndefined()
494+
} finally {
495+
if (originalHome === undefined) delete process.env['HOME']
496+
else process.env['HOME'] = originalHome
497+
}
498+
})
219499
})

0 commit comments

Comments
 (0)