From 0885a979f0c015bf691b1e525c3ae2002f377e46 Mon Sep 17 00:00:00 2001 From: webdevcody Date: Wed, 16 Sep 2026 22:09:41 -0400 Subject: [PATCH 1/2] fix(hooks): keep non-Latin-1 hook payloads intact on Windows (#130) Windows PowerShell 5.1 decodes stdin with the console code page and encodes a string -Body as ISO-8859-1, so every Cyrillic character in an AskUserQuestion payload reached the native overlay as "?". The POSIX hook (curl --data-binary) was never affected. Read stdin as raw UTF-8 bytes, POST the body as bytes (sent verbatim on both 5.1 and pwsh), and for injectContext events pass the server's response bytes straight to stdout instead of decoding them and re-serializing via ConvertTo-Json. Adds a regression test on the Windows hook installer and a Cyrillic end-to-end test on the hook API. Co-Authored-By: Claude Fable 5.1 --- src/server/__tests__/agent-hooks.test.ts | 46 ++++++++++++++++++- .../__tests__/ask-user-question-api.test.ts | 41 +++++++++++++++++ src/shared/agent-hooks.ts | 31 +++++++++++-- 3 files changed, 112 insertions(+), 6 deletions(-) diff --git a/src/server/__tests__/agent-hooks.test.ts b/src/server/__tests__/agent-hooks.test.ts index a5cedacf..97a713ca 100644 --- a/src/server/__tests__/agent-hooks.test.ts +++ b/src/server/__tests__/agent-hooks.test.ts @@ -223,11 +223,55 @@ 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 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 }>>; + }; + const commandFor = (event: string, matcher?: string) => + settings.hooks[event]?.find((g) => matcher === undefined || g.matcher === matcher) + ?.hooks?.[0]?.command ?? ""; + 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"); + }); + 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..e2704e3e 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,15 +170,22 @@ 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). 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" }', From 5f2f8f17a1d2db22cbc8b1b895dc02e84609050a Mon Sep 17 00:00:00 2001 From: webdevcody Date: Thu, 17 Sep 2026 00:56:34 -0400 Subject: [PATCH 2/2] fix(hooks): exit 0 from the Windows hook and execute it under real PowerShell The PowerShell hook was only ever asserted on by substring, so nothing proved the generated script parses or runs. Add tests that execute the generated commands under every PowerShell on PATH (pwsh on the CI runners, plus Windows PowerShell 5.1 on Windows) against a local server and compare the bytes that cross the wire in both directions. They skip when no PowerShell is installed. Running it surfaced a fail-soft gap: `catch {}` swallows the error but leaves `$?` false, which `-Command` turns into exit code 1, so Claude reported a hook error on every event whenever Mission Control was unreachable. End the script with an explicit `exit 0`, matching the POSIX hook's `|| true`. Co-Authored-By: Claude Fable 5.1 --- src/server/__tests__/agent-hooks.test.ts | 173 +++++++++++++++++++++-- src/shared/agent-hooks.ts | 5 +- 2 files changed, 165 insertions(+), 13 deletions(-) diff --git a/src/server/__tests__/agent-hooks.test.ts b/src/server/__tests__/agent-hooks.test.ts index 97a713ca..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-")); @@ -229,18 +274,7 @@ describe("agent hook installation", () => { }); it("keeps non-Latin-1 payloads intact through the Windows PowerShell hooks", () => { - 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 }>>; - }; - const commandFor = (event: string, matcher?: string) => - settings.hooks[event]?.find((g) => matcher === undefined || g.matcher === matcher) - ?.hooks?.[0]?.command ?? ""; + const commandFor = windowsClaudeHookCommands(); const askQuestion = commandFor("PreToolUse", "AskUserQuestion"); const userPrompt = commandFor("UserPromptSubmit"); expect(askQuestion).not.toBe(""); @@ -272,6 +306,121 @@ describe("agent hook installation", () => { 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/shared/agent-hooks.ts b/src/shared/agent-hooks.ts index e2704e3e..2894f509 100644 --- a/src/shared/agent-hooks.ts +++ b/src/shared/agent-hooks.ts @@ -174,7 +174,9 @@ function buildPowerShellHookCommand( "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). + // 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 { $r = ${request}; $bytes = $r.RawContentStream.ToArray(); ` + "if ($bytes.Length -gt 0) { $stdout = [Console]::OpenStandardOutput(); " + @@ -190,6 +192,7 @@ function buildPowerShellHookCommand( `$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("; "); }