11import * as fs from 'node:fs'
22import * as path from 'node:path'
33import { tmpdir } from 'node:os'
4- import { afterEach , describe , expect , it } from 'vitest'
4+ import { afterEach , describe , expect , it , vi } from 'vitest'
55import {
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 ( / ^ h t t p : \/ \/ 1 2 7 \. 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 ( / c o d e 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