diff --git a/README.md b/README.md index fac9441a5..453261a29 100644 --- a/README.md +++ b/README.md @@ -420,3 +420,7 @@ Every change to the app that lands on `main` is published as a GitHub release by ## License [MIT](LICENSE) + +### Codex hooks on Windows + +Windows hook commands use an encoded PowerShell launch. After an update that changes hooks, restart or resume existing Codex workers and review the updated commands in /hooks. See [agent configuration](docs/agents.md). diff --git a/docs/agents.md b/docs/agents.md index 02016b48b..82f0f48b7 100644 --- a/docs/agents.md +++ b/docs/agents.md @@ -75,3 +75,5 @@ DeepSeek Harness metrics come from ACP `usage_update`: tokens in context and the ## The office's prompts Everything the office tells a worker by itself can be rewritten in ⚙️ Settings → **🤖 Workers** → **📝 Edit the prompts…**: what **🤖 Hand to a worker**, **🔍 Review**, **Fix up & merge**, **Fix conflicts & merge** and **✍️ Ask a worker** send from the boards, what a meeting about an issue or a review panel starts with, the note the queue adds to a worktree task, the three board agents' briefs, every part the meeting room hands out, and the instructions for the model that writes the signs over workers' heads. Each one lists its `{{placeholders}}` (the issue number, the PR's branch, the file a meeting waits for…), which the office fills in when it sends it, and warns when one the office counts on is missing. **↺ Default** puts the office's own wording back. Admins edit them; they're the same on every floor and kept in `.agent-office/prompts.json` with the Default worker. + +On Windows, Office lifecycle hooks launch through an encoded PowerShell command so executable and project paths containing spaces or shell characters stay literal. After updating the office, restart or resume existing Codex workers so they receive the corrected hook commands; review changed commands in /hooks when Codex asks. diff --git a/src/server/codex.ts b/src/server/codex.ts index 788f4474b..89106150a 100644 --- a/src/server/codex.ts +++ b/src/server/codex.ts @@ -90,10 +90,18 @@ function shellQuote(value: string): string { * Build the CLI config overrides for all lifecycle hooks. The command is encoded as a TOML basic * string so paths containing spaces remain valid; the command itself is shell-quoted. */ -export function codexHookArgs(hookPath: string): string[] { +export function codexHookArgs(hookPath: string, platform: NodeJS.Platform = process.platform): string[] { const args: string[] = []; for (const event of CODEX_HOOK_EVENTS) { - const command = [process.execPath, hookPath, event].map(shellQuote).join(' '); + const argv = [process.execPath, hookPath, event]; + // A quoted executable is not invoked by PowerShell without &, and cmd.exe does not + // recognize POSIX single quotes. An encoded PowerShell command works from either + // Windows shell and keeps paths (including apostrophes and metacharacters) literal. + const script = "$ProgressPreference = 'SilentlyContinue'; $OutputEncoding = [Console]::InputEncoding = [System.Text.UTF8Encoding]::new($false); " + + "[Console]::In.ReadToEnd() | & " + argv.map((arg) => `'${arg.replaceAll("'", "''")}'`).join(' '); + const command = platform === 'win32' + ? `powershell.exe -NoProfile -NonInteractive -EncodedCommand ${Buffer.from(script, 'utf16le').toString('base64')}` + : argv.map(shellQuote).join(' '); const config = `hooks.${event}=[{hooks=[{type="command",command=${JSON.stringify(command)},timeout=3}]}]`; args.push('-c', config); } diff --git a/tests/codex.test.ts b/tests/codex.test.ts index 71d1863f0..a945ed807 100644 --- a/tests/codex.test.ts +++ b/tests/codex.test.ts @@ -38,7 +38,7 @@ test('rejects unknown, malformed, empty, oversized, and child-scoped events', () }); test('generates one stable CLI hook override per supported event', () => { - const args = codexHookArgs('/tmp/office data/agent-office-codex-hook.cjs'); + const args = codexHookArgs('/tmp/office data/agent-office-codex-hook.cjs', 'linux'); assert.equal(args.length, CODEX_HOOK_EVENTS.length * 2); for (let i = 0; i < CODEX_HOOK_EVENTS.length; i++) { assert.equal(args[i * 2], '-c'); @@ -140,3 +140,46 @@ test('helper forwards only the bounded root event fields to the authenticated br rmSync(dir, { recursive: true, force: true }); } }); + +// Run the generated command, rather than spawning the helper directly: the shell +// launch is what failed on Windows even though the direct helper tests passed. +test('Windows hook command forwards UTF-8 stdin through the shell with literal paths', { skip: process.platform !== 'win32' }, async () => { + const dir = mkdtempSync(path.join(tmpdir(), "office ' & $ hooks-")); + const server = createServer((req, res) => { + const chunks: Buffer[] = []; + req.on('data', (chunk: Buffer) => chunks.push(chunk)); + req.on('end', () => { + received.push(JSON.parse(Buffer.concat(chunks).toString('utf8'))); + res.writeHead(200).end(); + }); + }); + const received: unknown[] = []; + try { + const file = writeCodexHook(dir); + const config = codexHookArgs(file)[3]; // UserPromptSubmit + const match = config.match(/command=("(?:[^"\\]|\\.)*"),timeout/); + assert.ok(match); + const command = JSON.parse(match[1]) as string; + await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)); + const address = server.address(); + assert.ok(address && typeof address === 'object'); + for (const shell of ['cmd.exe', 'powershell.exe']) { + const args = shell === 'cmd.exe' ? ['/d', '/s', '/c', command] : ['-NoProfile', '-NonInteractive', '-Command', command]; + const child = spawn(shell, args, { + env: { ...process.env, AGENT_OFFICE_HOOK_URL: `http://127.0.0.1:${address.port}`, AGENT_OFFICE_HOOK_TOKEN: 'test', AGENT_OFFICE_WORKER_ID: 'worker-1' }, + stdio: ['pipe', 'pipe', 'pipe'], + }); + let stdout = '', stderr = ''; + child.stdout.on('data', (chunk) => { stdout += chunk; }); + child.stderr.on('data', (chunk) => { stderr += chunk; }); + const exited = new Promise((resolve, reject) => { child.on('error', reject); child.on('close', resolve); }); + child.stdin.end(JSON.stringify({ session_id: 'thread-1', prompt: 'Fix caf\u00e9 \u4fee\u590d \u2728' })); + assert.equal(await exited, 0, stderr); + assert.equal(stdout, '{}', stderr); + } + assert.deepEqual(received, Array(2).fill({ session_id: 'thread-1', hook_event_name: 'UserPromptSubmit', prompt: 'Fix caf\u00e9 \u4fee\u590d \u2728' })); + } finally { + await new Promise((resolve) => server.close(() => resolve())); + rmSync(dir, { recursive: true, force: true }); + } +});