Skip to content
Open
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
56 changes: 53 additions & 3 deletions packages/cli/src/local-supervisor.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
})
})

Expand All @@ -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')
Expand All @@ -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()
})
})
81 changes: 68 additions & 13 deletions packages/cli/src/local-supervisor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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))
Expand All @@ -188,18 +230,31 @@ export function buildLocalSupervisorEnv(
agentHome: string,
binDir: string,
port: number,
platform: NodeJS.Platform = process.platform,
): NodeJS.ProcessEnv {
const env = { ...baseEnv }

// Do not leak outer Codex session controls into the nested local supervisor.
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,
Expand Down