diff --git a/src/server/__tests__/agent-hooks.test.ts b/src/server/__tests__/agent-hooks.test.ts index a5cedacf..009c58df 100644 --- a/src/server/__tests__/agent-hooks.test.ts +++ b/src/server/__tests__/agent-hooks.test.ts @@ -1,9 +1,54 @@ import { describe, expect, it } from "vitest"; +import { spawn, spawnSync } from "node:child_process"; import * as fs from "node:fs"; +import * as http from "node:http"; +import type { AddressInfo } from "node:net"; import * as os from "node:os"; import * as path from "node:path"; import { installAgentHooks } from "../../../electron/agent-hooks"; +// Every PowerShell on PATH: pwsh (preinstalled on the CI runners) and, on +// Windows, Windows PowerShell 5.1 — the host issue #130 was reported against. +const POWERSHELLS = (process.platform === "win32" ? ["pwsh", "powershell"] : ["pwsh"]).filter( + (bin) => + spawnSync(bin, ["-NoProfile", "-NonInteractive", "-Command", "exit 0"], { stdio: "ignore" }) + .status === 0, +); + +/** Install the Windows Claude hooks into a temp project and look commands up by event. */ +function windowsClaudeHookCommands(): (event: string, matcher?: string) => string { + const cwd = fs.mkdtempSync(path.join(os.tmpdir(), "mc-hooks-")); + installAgentHooks("claude-code", cwd, "win32"); + const settings = JSON.parse( + fs.readFileSync(path.join(cwd, ".claude", "settings.local.json"), "utf8"), + ) as { + hooks: Record }>>; + }; + return (event, matcher) => + settings.hooks[event]?.find((g) => matcher === undefined || g.matcher === matcher) + ?.hooks?.[0]?.command ?? ""; +} + +function runHook( + bin: string, + command: string, + stdin: Buffer, + env: NodeJS.ProcessEnv, +): Promise<{ code: number | null; stdout: Buffer; stderr: string }> { + return new Promise((resolve, reject) => { + const child = spawn(bin, ["-NoProfile", "-NonInteractive", "-Command", command], { env }); + const out: Buffer[] = []; + const err: Buffer[] = []; + child.stdout.on("data", (chunk: Buffer) => out.push(chunk)); + child.stderr.on("data", (chunk: Buffer) => err.push(chunk)); + child.on("error", reject); + child.on("close", (code) => + resolve({ code, stdout: Buffer.concat(out), stderr: Buffer.concat(err).toString("utf8") }), + ); + child.stdin.end(stdin); + }); +} + describe("agent hook installation", () => { it("does not register Claude interrupt hooks", () => { const cwd = fs.mkdtempSync(path.join(os.tmpdir(), "mc-hooks-")); @@ -223,11 +268,159 @@ describe("agent hook installation", () => { type: "command", shell: "powershell", }); - expect(hook?.command).toContain("Invoke-RestMethod"); + expect(hook?.command).toContain("Invoke-WebRequest"); expect(hook?.command).toContain("$env:MC_API_URL"); expect(hook?.command).not.toContain("if ["); }); + it("keeps non-Latin-1 payloads intact through the Windows PowerShell hooks", () => { + const commandFor = windowsClaudeHookCommands(); + const askQuestion = commandFor("PreToolUse", "AskUserQuestion"); + const userPrompt = commandFor("UserPromptSubmit"); + expect(askQuestion).not.toBe(""); + expect(userPrompt).not.toBe(""); + + // Windows PowerShell 5.1 decodes stdin with the console code page and sends + // a string -Body as ISO-8859-1, so a Cyrillic AskUserQuestion reached the + // overlay as "?????" (issue #130). Stdin must be read as UTF-8 bytes and the + // body posted as bytes, which both 5.1 and pwsh send verbatim. + for (const command of [askQuestion, userPrompt]) { + expect(command).toContain( + "[System.IO.StreamReader]::new([Console]::OpenStandardInput(), [System.Text.UTF8Encoding]::new($false))", + ); + expect(command).toContain("[System.Text.Encoding]::UTF8.GetBytes($payload)"); + expect(command).toContain("-Body $body"); + expect(command).toContain('-ContentType "application/json; charset=utf-8"'); + expect(command).not.toContain("[Console]::In.ReadToEnd()"); + expect(command).not.toContain("-Body $payload"); + } + + // injectContext events hand Claude the server's response bytes untouched + // (no ISO-8859-1 decode + ConvertTo-Json round trip); status-only events + // discard the response. + expect(userPrompt).toContain("$r.RawContentStream.ToArray()"); + expect(userPrompt).toContain("[Console]::OpenStandardOutput()"); + expect(userPrompt).not.toContain("ConvertTo-Json"); + expect(userPrompt).not.toContain("Out-Null"); + expect(askQuestion).toContain("| Out-Null"); + expect(askQuestion).not.toContain("OpenStandardOutput"); + }); + + // The substring assertions above can't tell a script that parses from one + // that doesn't, so run the generated commands for real against a local server + // and compare what crosses the wire in both directions. + it.skipIf(POWERSHELLS.length === 0)( + "round-trips UTF-8 through the generated PowerShell hooks when executed", + async () => { + const commandFor = windowsClaudeHookCommands(); + const payload = Buffer.from( + JSON.stringify({ + hook_event_name: "PreToolUse", + tool_input: { questions: [{ question: "Какой вариант выбрать? — 日本語 🚀" }] }, + }), + "utf8", + ); + const injected = Buffer.from( + JSON.stringify({ + hookSpecificOutput: { + hookEventName: "UserPromptSubmit", + additionalContext: "Память проекта — 記憶 🧠", + }, + }), + "utf8", + ); + + const received: Array<{ url: URL; headers: http.IncomingHttpHeaders; body: string }> = []; + const server = http.createServer((req, res) => { + const chunks: Buffer[] = []; + req.on("data", (chunk: Buffer) => chunks.push(chunk)); + req.on("end", () => { + received.push({ + url: new URL(req.url ?? "", "http://127.0.0.1"), + headers: req.headers, + body: Buffer.concat(chunks).toString("utf8"), + }); + res.writeHead(200, { "content-type": "application/json" }); + res.end(injected); + }); + }); + await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); + const env = { + ...process.env, + MC_TASK_ID: "task 1/a", + MC_API_URL: `http://127.0.0.1:${(server.address() as AddressInfo).port}`, + MC_API_TOKEN: "test-token", + }; + + try { + for (const bin of POWERSHELLS) { + received.length = 0; + + // Status-only event: the payload arrives intact, stdout stays empty. + const ask = await runHook(bin, commandFor("PreToolUse", "AskUserQuestion"), payload, env); + expect(ask.stderr, bin).toBe(""); + expect(ask.code, bin).toBe(0); + expect(ask.stdout.toString("utf8"), bin).toBe(""); + + // injectContext event: the response reaches stdout byte for byte. + const prompt = await runHook(bin, commandFor("UserPromptSubmit"), payload, env); + expect(prompt.stderr, bin).toBe(""); + expect(prompt.code, bin).toBe(0); + expect(prompt.stdout.toString("utf8"), bin).toBe(injected.toString("utf8")); + + expect(received.map((r) => r.url.searchParams.get("hookEvent")), bin).toEqual([ + "PreToolUse", + "UserPromptSubmit", + ]); + for (const request of received) { + expect(request.url.pathname, bin).toBe("/api/hooks/claude"); + expect(request.url.searchParams.get("taskId"), bin).toBe("task 1/a"); + expect(request.headers.authorization, bin).toBe("Bearer test-token"); + expect(request.headers["content-type"], bin).toBe("application/json; charset=utf-8"); + expect(request.body, bin).toBe(payload.toString("utf8")); + } + } + } finally { + server.closeAllConnections(); + await new Promise((resolve) => server.close(() => resolve())); + } + }, + 60_000, + ); + + // `catch {}` swallows the error but leaves `$?` false, which `-Command` turns + // into exit code 1 — and Claude reports any non-zero hook exit as a hook + // error. The POSIX hook ends in `|| true`; this one must exit 0 as well. + it.skipIf(POWERSHELLS.length === 0)( + "exits 0 and stays silent when Mission Control is unreachable", + async () => { + const commandFor = windowsClaudeHookCommands(); + const server = http.createServer(); + await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); + const { port } = server.address() as AddressInfo; + await new Promise((resolve) => server.close(() => resolve())); + const env = { + ...process.env, + MC_TASK_ID: "task-1", + MC_API_URL: `http://127.0.0.1:${port}`, + MC_API_TOKEN: "test-token", + }; + + for (const bin of POWERSHELLS) { + for (const command of [ + commandFor("PreToolUse", "AskUserQuestion"), + commandFor("UserPromptSubmit"), + ]) { + const down = await runHook(bin, command, Buffer.from("{}", "utf8"), env); + expect(down.stderr, bin).toBe(""); + expect(down.stdout.toString("utf8"), bin).toBe(""); + expect(down.code, bin).toBe(0); + } + } + }, + 60_000, + ); + it("registers Codex lifecycle hooks in Codex's matcher-group format", () => { const cwd = fs.mkdtempSync(path.join(os.tmpdir(), "mc-hooks-")); diff --git a/src/server/__tests__/ask-user-question-api.test.ts b/src/server/__tests__/ask-user-question-api.test.ts index 29718375..5cb9a0ef 100644 --- a/src/server/__tests__/ask-user-question-api.test.ts +++ b/src/server/__tests__/ask-user-question-api.test.ts @@ -122,6 +122,47 @@ describe("AskUserQuestion hook API", () => { expect(stored?.questions[0]?.options).toHaveLength(3); }); + it("keeps Cyrillic question text intact from the raw UTF-8 body to the read endpoint", async () => { + // Regression for issue #130: the overlay showed every Cyrillic character as + // "?". The POSIX hook POSTs the payload verbatim (curl --data-binary), so + // the server must decode the raw bytes as UTF-8 end to end. + const question = "Какой вариант выбрать?"; + const header = "Следующий шаг"; + const label = "Завершить текущую задачу"; + const description = "Доделать изменённый файл — u1d1–5"; + const payload = { + hook_event_name: "PreToolUse", + session_id: SESSION_ID, + tool_name: "AskUserQuestion", + tool_use_id: TOOL_USE_ID, + tool_input: { + questions: [ + { question, header, multiSelect: false, options: [{ label, description }] }, + ], + }, + }; + + const res = await handleApiRequest( + authed(`/api/hooks/claude?taskId=${encodeURIComponent(taskId)}`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: new TextEncoder().encode(JSON.stringify(payload)), + }), + ); + expect(res?.status).toBe(200); + + expect(getPendingQuestion(taskId)?.questions).toEqual([ + { question, header, multiSelect: false, options: [{ label, description }] }, + ]); + + const read = await getQuestion(taskId); + const body = (await read?.json()) as { + question: { questions: Array<{ question: string; header?: string; options: Array<{ label: string; description?: string }> }> } | null; + }; + expect(body.question?.questions[0]).toMatchObject({ question, header }); + expect(body.question?.questions[0]?.options[0]).toEqual({ label, description }); + }); + it("serves the pending question over the read endpoint", async () => { await postAskUserQuestion(taskId); diff --git a/src/shared/agent-hooks.ts b/src/shared/agent-hooks.ts index 38241869..2894f509 100644 --- a/src/shared/agent-hooks.ts +++ b/src/shared/agent-hooks.ts @@ -142,6 +142,20 @@ function buildPosixHookCommand( ); } +// Windows PowerShell 5.1 (what Claude Code's `shell: "powershell"` may run +// when pwsh isn't installed) is not UTF-8 by default at either end of this +// script: +// - `[Console]::In` decodes stdin with the console code page (OEM/ANSI), so +// the UTF-8 hook payload Claude writes is mis-decoded before we even POST; +// - a STRING `-Body` is encoded as ISO-8859-1, which replaces every code +// point outside Latin-1 (Cyrillic, CJK, emoji, "–") with `?`. +// Together they turned a Cyrillic AskUserQuestion into a wall of `?` in the +// native overlay (issue #130). So: read stdin as raw UTF-8 bytes, POST the +// body as bytes (sent verbatim on both 5.1 and 7), and for injectContext +// events pass the server's response bytes straight to stdout instead of +// decoding them (also ISO-8859-1 on 5.1) and re-serializing via +// ConvertTo-Json — the same verbatim passthrough `curl --data-binary @-` +// gives the POSIX hook. function buildPowerShellHookCommand( endpointSlug: string, event: string, @@ -156,19 +170,29 @@ function buildPowerShellHookCommand( const continueOutput = style === "cursor" ? '; Write-Output \'{"continue":true}\'' : ""; - // injectContext: emit the JSON response to stdout (re-serialized) so Claude can - // inject it; otherwise pipe to Out-Null. Both swallow errors (fail-soft). + const request = + "Invoke-WebRequest -UseBasicParsing -Method Post -Uri $url -Headers $headers " + + '-Body $body -ContentType "application/json; charset=utf-8" -TimeoutSec 3 -ErrorAction Stop'; + // injectContext: stream the response bytes to stdout untouched so Claude can + // inject them; otherwise discard the response. Both swallow errors (fail-soft) + // — but `catch {}` leaves `$?` false, which `-Command` reports as exit code 1, + // so the script ends in an explicit `exit 0` (the POSIX hook's `|| true`). const invoke = injectContext - ? 'try { $resp = Invoke-RestMethod -Method Post -Uri $url -Headers $headers -Body $payload -ContentType "application/json" -TimeoutSec 3 -ErrorAction Stop; if ($resp) { $resp | ConvertTo-Json -Depth 10 -Compress } } catch {}' - : 'try { Invoke-RestMethod -Method Post -Uri $url -Headers $headers -Body $payload -ContentType "application/json" -TimeoutSec 3 -ErrorAction Stop | Out-Null } catch {}'; + ? `try { $r = ${request}; $bytes = $r.RawContentStream.ToArray(); ` + + "if ($bytes.Length -gt 0) { $stdout = [Console]::OpenStandardOutput(); " + + "$stdout.Write($bytes, 0, $bytes.Length); $stdout.Flush() } } catch {}" + : `try { ${request} | Out-Null } catch {}`; return [ missingEnv, - "$payload = [Console]::In.ReadToEnd()", + "$ProgressPreference = 'SilentlyContinue'", + "$payload = [System.IO.StreamReader]::new([Console]::OpenStandardInput(), [System.Text.UTF8Encoding]::new($false)).ReadToEnd()", + "$body = [System.Text.Encoding]::UTF8.GetBytes($payload)", "$taskId = [System.Uri]::EscapeDataString($env:MC_TASK_ID)", `$url = "$($env:MC_API_URL)/api/hooks/${endpointSlug}?taskId=$taskId&hookEvent=${eventParam}"`, '$headers = @{ Authorization = "Bearer $($env:MC_API_TOKEN)"; "X-Mission-Control-Runtime" = "electron-local" }', invoke + continueOutput, + "exit 0", ].join("; "); }