From 808790bbc144002df4d671a252e01ac669c5a018 Mon Sep 17 00:00:00 2001 From: JJ Liebig Date: Wed, 14 Oct 2026 16:11:44 +0200 Subject: [PATCH] fix: decode windows agent hook stdin as utf-8 (#5270) Every bundled Windows PowerShell hook read its hook JSON with `[Console]::In.ReadToEnd()`, which decodes stdin using the process console input code page instead of UTF-8. On Korean Windows (CP949) that corrupts the payload, `ConvertFrom-Json` throws, and the hook silently exits without reporting the session. Read the raw stdin stream as UTF-8 (BOM-aware) in every PowerShell hook and bump the affected integration versions. refs #5253 (cherry picked from commit 00584a279427fb6c1af418c8cb4588376d7df089) --- .../antigravity_cli/herdr-agent-state.ps1 | 7 +- .../antigravity_cli/herdr-agent-state.sh | 2 +- .../assets/claude/herdr-agent-state.ps1 | 7 +- .../assets/claude/herdr-agent-state.sh | 2 +- .../assets/codex/herdr-agent-state.ps1 | 7 +- .../assets/codex/herdr-agent-state.sh | 2 +- .../assets/copilot/herdr-agent-state.ps1 | 7 +- .../assets/copilot/herdr-agent-state.sh | 2 +- .../assets/cursor/herdr-agent-state.ps1 | 7 +- .../assets/cursor/herdr-agent-state.sh | 2 +- .../assets/devin/herdr-agent-state.ps1 | 7 +- .../assets/devin/herdr-agent-state.sh | 2 +- .../assets/droid/herdr-agent-state.ps1 | 7 +- .../assets/droid/herdr-agent-state.sh | 2 +- .../assets/grok/herdr-agent-state.ps1 | 7 +- .../assets/grok/herdr-agent-state.sh | 2 +- .../assets/kimi/herdr-agent-state.ps1 | 7 +- .../assets/kimi/herdr-agent-state.sh | 2 +- .../assets/letta/herdr-agent-session.ps1 | 7 +- .../assets/letta/herdr-agent-session.sh | 2 +- .../assets/mastracode/herdr-agent-state.ps1 | 7 +- .../assets/mastracode/herdr-agent-state.sh | 2 +- .../assets/qodercli/herdr-agent-state.ps1 | 7 +- .../assets/qodercli/herdr-agent-state.sh | 2 +- .../assets/qwen/herdr-agent-session.ps1 | 7 +- .../assets/qwen/herdr-agent-session.sh | 2 +- src/integration/mod.rs | 26 +-- src/integration/tests.rs | 151 ++++++++++++++++-- 28 files changed, 230 insertions(+), 64 deletions(-) diff --git a/src/integration/assets/antigravity_cli/herdr-agent-state.ps1 b/src/integration/assets/antigravity_cli/herdr-agent-state.ps1 index e446a4312..f2caacf9b 100644 --- a/src/integration/assets/antigravity_cli/herdr-agent-state.ps1 +++ b/src/integration/assets/antigravity_cli/herdr-agent-state.ps1 @@ -2,7 +2,7 @@ # managed by herdr; reinstalling or updating the integration overwrites this file. # add custom hooks beside this file instead of editing it. # HERDR_INTEGRATION_ID=antigravity_cli -# HERDR_INTEGRATION_VERSION=3 +# HERDR_INTEGRATION_VERSION=4 # Session-only: this hook reports the Antigravity conversation so Herdr can # resume the pane. Lifecycle state comes from Herdr's screen detection. @@ -20,7 +20,10 @@ if ($Action -ne "session") { Exit-Hook } if ($env:HERDR_ENV -ne "1") { Exit-Hook } if ([string]::IsNullOrWhiteSpace($env:HERDR_PANE_ID)) { Exit-Hook } -$inputText = [Console]::In.ReadToEnd() +# Read hook input as raw bytes so UTF-8 payloads decode correctly even when the +# console input code page is not UTF-8 (for example CP949 on Korean Windows). +$stdinReader = New-Object System.IO.StreamReader([Console]::OpenStandardInput(), [System.Text.Encoding]::UTF8) +$inputText = $stdinReader.ReadToEnd() try { $payload = if ([string]::IsNullOrWhiteSpace($inputText)) { $null } else { $inputText | ConvertFrom-Json } } catch { diff --git a/src/integration/assets/antigravity_cli/herdr-agent-state.sh b/src/integration/assets/antigravity_cli/herdr-agent-state.sh index 2e55afad6..510157442 100644 --- a/src/integration/assets/antigravity_cli/herdr-agent-state.sh +++ b/src/integration/assets/antigravity_cli/herdr-agent-state.sh @@ -3,7 +3,7 @@ # managed by herdr; reinstalling or updating the integration overwrites this file. # add custom hooks beside this file instead of editing it. # HERDR_INTEGRATION_ID=antigravity_cli -# HERDR_INTEGRATION_VERSION=3 +# HERDR_INTEGRATION_VERSION=4 # Session-only: this hook reports the Antigravity conversation so Herdr can # resume the pane. Lifecycle state comes from Herdr's screen detection. diff --git a/src/integration/assets/claude/herdr-agent-state.ps1 b/src/integration/assets/claude/herdr-agent-state.ps1 index d62c5b9f9..b4bc6a319 100644 --- a/src/integration/assets/claude/herdr-agent-state.ps1 +++ b/src/integration/assets/claude/herdr-agent-state.ps1 @@ -2,7 +2,7 @@ # managed by herdr; reinstalling or updating the integration overwrites this file. # add custom hooks beside this file instead of editing it. # HERDR_INTEGRATION_ID=claude -# HERDR_INTEGRATION_VERSION=10 +# HERDR_INTEGRATION_VERSION=11 param([string]$Action = "") @@ -10,7 +10,10 @@ if ($Action -ne "session") { exit 0 } if ($env:HERDR_ENV -ne "1") { exit 0 } if ([string]::IsNullOrWhiteSpace($env:HERDR_PANE_ID)) { exit 0 } -$inputText = [Console]::In.ReadToEnd() +# Read hook input as raw bytes so UTF-8 payloads decode correctly even when the +# console input code page is not UTF-8 (for example CP949 on Korean Windows). +$stdinReader = New-Object System.IO.StreamReader([Console]::OpenStandardInput(), [System.Text.Encoding]::UTF8) +$inputText = $stdinReader.ReadToEnd() try { $payload = if ([string]::IsNullOrWhiteSpace($inputText)) { $null } else { $inputText | ConvertFrom-Json } } catch { diff --git a/src/integration/assets/claude/herdr-agent-state.sh b/src/integration/assets/claude/herdr-agent-state.sh index e0b88ce78..792bd54dd 100644 --- a/src/integration/assets/claude/herdr-agent-state.sh +++ b/src/integration/assets/claude/herdr-agent-state.sh @@ -3,7 +3,7 @@ # managed by herdr; reinstalling or updating the integration overwrites this file. # add custom hooks beside this file instead of editing it. # HERDR_INTEGRATION_ID=claude -# HERDR_INTEGRATION_VERSION=10 +# HERDR_INTEGRATION_VERSION=11 set -eu diff --git a/src/integration/assets/codex/herdr-agent-state.ps1 b/src/integration/assets/codex/herdr-agent-state.ps1 index 6d439e5c8..1ee0db046 100644 --- a/src/integration/assets/codex/herdr-agent-state.ps1 +++ b/src/integration/assets/codex/herdr-agent-state.ps1 @@ -2,7 +2,7 @@ # managed by herdr; reinstalling or updating the integration overwrites this file. # add custom hooks beside this file instead of editing it. # HERDR_INTEGRATION_ID=codex -# HERDR_INTEGRATION_VERSION=9 +# HERDR_INTEGRATION_VERSION=10 param([string]$Action = "") @@ -10,7 +10,10 @@ if ($Action -ne "session") { exit 0 } if ($env:HERDR_ENV -ne "1") { exit 0 } if ([string]::IsNullOrWhiteSpace($env:HERDR_PANE_ID)) { exit 0 } -$inputText = [Console]::In.ReadToEnd() +# Read hook input as raw bytes so UTF-8 payloads decode correctly even when the +# console input code page is not UTF-8 (for example CP949 on Korean Windows). +$stdinReader = New-Object System.IO.StreamReader([Console]::OpenStandardInput(), [System.Text.Encoding]::UTF8) +$inputText = $stdinReader.ReadToEnd() try { $payload = if ([string]::IsNullOrWhiteSpace($inputText)) { $null } else { $inputText | ConvertFrom-Json } } catch { diff --git a/src/integration/assets/codex/herdr-agent-state.sh b/src/integration/assets/codex/herdr-agent-state.sh index f8c5019e7..4e94757e9 100644 --- a/src/integration/assets/codex/herdr-agent-state.sh +++ b/src/integration/assets/codex/herdr-agent-state.sh @@ -3,7 +3,7 @@ # managed by herdr; reinstalling or updating the integration overwrites this file. # add custom hooks beside this file instead of editing it. # HERDR_INTEGRATION_ID=codex -# HERDR_INTEGRATION_VERSION=9 +# HERDR_INTEGRATION_VERSION=10 set -eu diff --git a/src/integration/assets/copilot/herdr-agent-state.ps1 b/src/integration/assets/copilot/herdr-agent-state.ps1 index f424f122c..be485778e 100644 --- a/src/integration/assets/copilot/herdr-agent-state.ps1 +++ b/src/integration/assets/copilot/herdr-agent-state.ps1 @@ -2,13 +2,16 @@ # managed by herdr; reinstalling or updating the integration overwrites this file. # add custom hooks beside this file instead of editing it. # HERDR_INTEGRATION_ID=copilot -# HERDR_INTEGRATION_VERSION=3 +# HERDR_INTEGRATION_VERSION=4 if ($env:HERDR_ENV -ne "1") { exit 0 } if ([string]::IsNullOrWhiteSpace($env:HERDR_PANE_ID)) { exit 0 } if ([string]::IsNullOrWhiteSpace($env:HERDR_SOCKET_PATH)) { exit 0 } -$inputText = [Console]::In.ReadToEnd() +# Read hook input as raw bytes so UTF-8 payloads decode correctly even when the +# console input code page is not UTF-8 (for example CP949 on Korean Windows). +$stdinReader = New-Object System.IO.StreamReader([Console]::OpenStandardInput(), [System.Text.Encoding]::UTF8) +$inputText = $stdinReader.ReadToEnd() try { $payload = if ([string]::IsNullOrWhiteSpace($inputText)) { @{} } else { $inputText | ConvertFrom-Json } } catch { diff --git a/src/integration/assets/copilot/herdr-agent-state.sh b/src/integration/assets/copilot/herdr-agent-state.sh index 82522ac1b..d58457b83 100644 --- a/src/integration/assets/copilot/herdr-agent-state.sh +++ b/src/integration/assets/copilot/herdr-agent-state.sh @@ -3,7 +3,7 @@ # managed by herdr; reinstalling or updating the integration overwrites this file. # add custom hooks beside this file instead of editing it. # HERDR_INTEGRATION_ID=copilot -# HERDR_INTEGRATION_VERSION=3 +# HERDR_INTEGRATION_VERSION=4 set -eu diff --git a/src/integration/assets/cursor/herdr-agent-state.ps1 b/src/integration/assets/cursor/herdr-agent-state.ps1 index ebfdb3938..ba0b0dc35 100644 --- a/src/integration/assets/cursor/herdr-agent-state.ps1 +++ b/src/integration/assets/cursor/herdr-agent-state.ps1 @@ -1,6 +1,6 @@ # managed by herdr; reinstalling the integration replaces this file. # HERDR_INTEGRATION_ID=cursor -# HERDR_INTEGRATION_VERSION=1 +# HERDR_INTEGRATION_VERSION=2 param([string]$Action = "") @@ -13,7 +13,10 @@ if ($Action -ne "session") { Exit-Hook } if ($env:HERDR_ENV -ne "1") { Exit-Hook } if ([string]::IsNullOrWhiteSpace($env:HERDR_PANE_ID)) { Exit-Hook } -$inputText = [Console]::In.ReadToEnd() +# Read hook input as raw bytes so UTF-8 payloads decode correctly even when the +# console input code page is not UTF-8 (for example CP949 on Korean Windows). +$stdinReader = New-Object System.IO.StreamReader([Console]::OpenStandardInput(), [System.Text.Encoding]::UTF8) +$inputText = $stdinReader.ReadToEnd() $jsonStart = $inputText.IndexOf("{") if ($jsonStart -gt 0) { $inputText = $inputText.Substring($jsonStart) diff --git a/src/integration/assets/cursor/herdr-agent-state.sh b/src/integration/assets/cursor/herdr-agent-state.sh index 20dcb9ba8..e80fba84d 100644 --- a/src/integration/assets/cursor/herdr-agent-state.sh +++ b/src/integration/assets/cursor/herdr-agent-state.sh @@ -1,7 +1,7 @@ #!/bin/sh # managed by herdr; reinstalling the integration replaces this file. # HERDR_INTEGRATION_ID=cursor -# HERDR_INTEGRATION_VERSION=1 +# HERDR_INTEGRATION_VERSION=2 [ "${1:-}" = "session" ] || exit 0 [ "${HERDR_ENV:-}" = "1" ] || exit 0 diff --git a/src/integration/assets/devin/herdr-agent-state.ps1 b/src/integration/assets/devin/herdr-agent-state.ps1 index 7d14ad757..31650d89a 100644 --- a/src/integration/assets/devin/herdr-agent-state.ps1 +++ b/src/integration/assets/devin/herdr-agent-state.ps1 @@ -2,7 +2,7 @@ # managed by herdr; reinstalling or updating the integration overwrites this file. # add custom hooks beside this file instead of editing it. # HERDR_INTEGRATION_ID=devin -# HERDR_INTEGRATION_VERSION=2 +# HERDR_INTEGRATION_VERSION=3 param([string]$Action = "") @@ -10,7 +10,10 @@ if ($Action -ne "session") { exit 0 } if ($env:HERDR_ENV -ne "1") { exit 0 } if ([string]::IsNullOrWhiteSpace($env:HERDR_PANE_ID)) { exit 0 } -$inputText = [Console]::In.ReadToEnd() +# Read hook input as raw bytes so UTF-8 payloads decode correctly even when the +# console input code page is not UTF-8 (for example CP949 on Korean Windows). +$stdinReader = New-Object System.IO.StreamReader([Console]::OpenStandardInput(), [System.Text.Encoding]::UTF8) +$inputText = $stdinReader.ReadToEnd() try { $payload = if ([string]::IsNullOrWhiteSpace($inputText)) { $null } else { $inputText | ConvertFrom-Json } } catch { diff --git a/src/integration/assets/devin/herdr-agent-state.sh b/src/integration/assets/devin/herdr-agent-state.sh index 86249b889..5b671ddd5 100644 --- a/src/integration/assets/devin/herdr-agent-state.sh +++ b/src/integration/assets/devin/herdr-agent-state.sh @@ -3,7 +3,7 @@ # managed by herdr; reinstalling or updating the integration overwrites this file. # add custom hooks beside this file instead of editing it. # HERDR_INTEGRATION_ID=devin -# HERDR_INTEGRATION_VERSION=2 +# HERDR_INTEGRATION_VERSION=3 set -eu diff --git a/src/integration/assets/droid/herdr-agent-state.ps1 b/src/integration/assets/droid/herdr-agent-state.ps1 index fa6a4e259..536507c65 100644 --- a/src/integration/assets/droid/herdr-agent-state.ps1 +++ b/src/integration/assets/droid/herdr-agent-state.ps1 @@ -2,7 +2,7 @@ # managed by herdr; reinstalling or updating the integration overwrites this file. # add custom hooks beside this file instead of editing it. # HERDR_INTEGRATION_ID=droid -# HERDR_INTEGRATION_VERSION=3 +# HERDR_INTEGRATION_VERSION=4 param([string]$Action = "") @@ -10,7 +10,10 @@ if ($Action -ne "session") { exit 0 } if ($env:HERDR_ENV -ne "1") { exit 0 } if ([string]::IsNullOrWhiteSpace($env:HERDR_PANE_ID)) { exit 0 } -$inputText = [Console]::In.ReadToEnd() +# Read hook input as raw bytes so UTF-8 payloads decode correctly even when the +# console input code page is not UTF-8 (for example CP949 on Korean Windows). +$stdinReader = New-Object System.IO.StreamReader([Console]::OpenStandardInput(), [System.Text.Encoding]::UTF8) +$inputText = $stdinReader.ReadToEnd() try { $payload = if ([string]::IsNullOrWhiteSpace($inputText)) { $null } else { $inputText | ConvertFrom-Json } } catch { diff --git a/src/integration/assets/droid/herdr-agent-state.sh b/src/integration/assets/droid/herdr-agent-state.sh index d777b2c11..ccb188492 100644 --- a/src/integration/assets/droid/herdr-agent-state.sh +++ b/src/integration/assets/droid/herdr-agent-state.sh @@ -3,7 +3,7 @@ # managed by herdr; reinstalling or updating the integration overwrites this file. # add custom hooks beside this file instead of editing it. # HERDR_INTEGRATION_ID=droid -# HERDR_INTEGRATION_VERSION=3 +# HERDR_INTEGRATION_VERSION=4 set -eu diff --git a/src/integration/assets/grok/herdr-agent-state.ps1 b/src/integration/assets/grok/herdr-agent-state.ps1 index 1b183a3b9..fd2c6749b 100644 --- a/src/integration/assets/grok/herdr-agent-state.ps1 +++ b/src/integration/assets/grok/herdr-agent-state.ps1 @@ -2,7 +2,7 @@ # managed by herdr; reinstalling or updating the integration overwrites this file. # add custom hooks beside this file instead of editing it. # HERDR_INTEGRATION_ID=grok -# HERDR_INTEGRATION_VERSION=2 +# HERDR_INTEGRATION_VERSION=3 param([string]$Action = "") @@ -10,7 +10,10 @@ if ($Action -ne "session") { exit 0 } if ($env:HERDR_ENV -ne "1") { exit 0 } if ([string]::IsNullOrWhiteSpace($env:HERDR_PANE_ID)) { exit 0 } -$inputText = [Console]::In.ReadToEnd() +# Read hook input as raw bytes so UTF-8 payloads decode correctly even when the +# console input code page is not UTF-8 (for example CP949 on Korean Windows). +$stdinReader = New-Object System.IO.StreamReader([Console]::OpenStandardInput(), [System.Text.Encoding]::UTF8) +$inputText = $stdinReader.ReadToEnd() try { $payload = if ([string]::IsNullOrWhiteSpace($inputText)) { $null } else { $inputText | ConvertFrom-Json } } catch { diff --git a/src/integration/assets/grok/herdr-agent-state.sh b/src/integration/assets/grok/herdr-agent-state.sh index 3eaa3431f..9b71eebf8 100644 --- a/src/integration/assets/grok/herdr-agent-state.sh +++ b/src/integration/assets/grok/herdr-agent-state.sh @@ -3,7 +3,7 @@ # managed by herdr; reinstalling or updating the integration overwrites this file. # add custom hooks beside this file instead of editing it. # HERDR_INTEGRATION_ID=grok -# HERDR_INTEGRATION_VERSION=2 +# HERDR_INTEGRATION_VERSION=3 set -eu diff --git a/src/integration/assets/kimi/herdr-agent-state.ps1 b/src/integration/assets/kimi/herdr-agent-state.ps1 index 3141326d1..ea390fa6e 100644 --- a/src/integration/assets/kimi/herdr-agent-state.ps1 +++ b/src/integration/assets/kimi/herdr-agent-state.ps1 @@ -2,7 +2,7 @@ # managed by herdr; reinstalling or updating the integration overwrites this file. # add custom hooks beside this file instead of editing it. # HERDR_INTEGRATION_ID=kimi -# HERDR_INTEGRATION_VERSION=7 +# HERDR_INTEGRATION_VERSION=8 param([string]$Action = "") @@ -10,7 +10,10 @@ if (@("session", "working", "blocked", "idle") -notcontains $Action) { exit 0 } if ($env:HERDR_ENV -ne "1") { exit 0 } if ([string]::IsNullOrWhiteSpace($env:HERDR_PANE_ID)) { exit 0 } -$inputText = [Console]::In.ReadToEnd() +# Read hook input as raw bytes so UTF-8 payloads decode correctly even when the +# console input code page is not UTF-8 (for example CP949 on Korean Windows). +$stdinReader = New-Object System.IO.StreamReader([Console]::OpenStandardInput(), [System.Text.Encoding]::UTF8) +$inputText = $stdinReader.ReadToEnd() try { $payload = if ([string]::IsNullOrWhiteSpace($inputText)) { $null } else { $inputText | ConvertFrom-Json } } catch { diff --git a/src/integration/assets/kimi/herdr-agent-state.sh b/src/integration/assets/kimi/herdr-agent-state.sh index c8ed8e239..e4f65e4c4 100644 --- a/src/integration/assets/kimi/herdr-agent-state.sh +++ b/src/integration/assets/kimi/herdr-agent-state.sh @@ -1,7 +1,7 @@ #!/bin/sh # managed by herdr; reinstalling the integration replaces this file. # HERDR_INTEGRATION_ID=kimi -# HERDR_INTEGRATION_VERSION=7 +# HERDR_INTEGRATION_VERSION=8 action="${1:-}" case "$action" in diff --git a/src/integration/assets/letta/herdr-agent-session.ps1 b/src/integration/assets/letta/herdr-agent-session.ps1 index 27f40e9bd..cbb93d027 100644 --- a/src/integration/assets/letta/herdr-agent-session.ps1 +++ b/src/integration/assets/letta/herdr-agent-session.ps1 @@ -1,6 +1,6 @@ # managed by herdr; reinstalling the integration replaces this file. # HERDR_INTEGRATION_ID=letta -# HERDR_INTEGRATION_VERSION=1 +# HERDR_INTEGRATION_VERSION=2 param([string]$Action = "") @@ -9,7 +9,10 @@ if ($env:HERDR_ENV -ne "1") { exit 0 } if ([string]::IsNullOrWhiteSpace($env:HERDR_PANE_ID)) { exit 0 } if ([string]::IsNullOrWhiteSpace($env:HERDR_SOCKET_PATH)) { exit 0 } -$inputText = [Console]::In.ReadToEnd() +# Read hook input as raw bytes so UTF-8 payloads decode correctly even when the +# console input code page is not UTF-8 (for example CP949 on Korean Windows). +$stdinReader = New-Object System.IO.StreamReader([Console]::OpenStandardInput(), [System.Text.Encoding]::UTF8) +$inputText = $stdinReader.ReadToEnd() try { $payload = if ([string]::IsNullOrWhiteSpace($inputText)) { $null } else { $inputText | ConvertFrom-Json } } catch { diff --git a/src/integration/assets/letta/herdr-agent-session.sh b/src/integration/assets/letta/herdr-agent-session.sh index d6e052d66..ee84742bb 100644 --- a/src/integration/assets/letta/herdr-agent-session.sh +++ b/src/integration/assets/letta/herdr-agent-session.sh @@ -1,7 +1,7 @@ #!/bin/sh # managed by herdr; reinstalling the integration replaces this file. # HERDR_INTEGRATION_ID=letta -# HERDR_INTEGRATION_VERSION=1 +# HERDR_INTEGRATION_VERSION=2 [ "${1:-}" = "session" ] || exit 0 [ "${HERDR_ENV:-}" = "1" ] || exit 0 diff --git a/src/integration/assets/mastracode/herdr-agent-state.ps1 b/src/integration/assets/mastracode/herdr-agent-state.ps1 index b0cca059e..6e47c8809 100644 --- a/src/integration/assets/mastracode/herdr-agent-state.ps1 +++ b/src/integration/assets/mastracode/herdr-agent-state.ps1 @@ -2,7 +2,7 @@ # managed by herdr; reinstalling or updating the integration overwrites this file. # add custom hooks beside this file instead of editing it. # HERDR_INTEGRATION_ID=mastracode -# HERDR_INTEGRATION_VERSION=2 +# HERDR_INTEGRATION_VERSION=3 param([string]$Action = "") @@ -10,7 +10,10 @@ if ($Action -notin @("session", "working", "idle", "blocked")) { exit 0 } if ($env:HERDR_ENV -ne "1") { exit 0 } if ([string]::IsNullOrWhiteSpace($env:HERDR_PANE_ID)) { exit 0 } -$inputText = [Console]::In.ReadToEnd() +# Read hook input as raw bytes so UTF-8 payloads decode correctly even when the +# console input code page is not UTF-8 (for example CP949 on Korean Windows). +$stdinReader = New-Object System.IO.StreamReader([Console]::OpenStandardInput(), [System.Text.Encoding]::UTF8) +$inputText = $stdinReader.ReadToEnd() try { $payload = if ([string]::IsNullOrWhiteSpace($inputText)) { $null } else { $inputText | ConvertFrom-Json } } catch { diff --git a/src/integration/assets/mastracode/herdr-agent-state.sh b/src/integration/assets/mastracode/herdr-agent-state.sh index 39a8461b1..338ce4ef0 100644 --- a/src/integration/assets/mastracode/herdr-agent-state.sh +++ b/src/integration/assets/mastracode/herdr-agent-state.sh @@ -3,7 +3,7 @@ # managed by herdr; reinstalling or updating the integration overwrites this file. # add custom hooks beside this file instead of editing it. # HERDR_INTEGRATION_ID=mastracode -# HERDR_INTEGRATION_VERSION=2 +# HERDR_INTEGRATION_VERSION=3 set -eu diff --git a/src/integration/assets/qodercli/herdr-agent-state.ps1 b/src/integration/assets/qodercli/herdr-agent-state.ps1 index 6d0e037b6..fde7534bb 100644 --- a/src/integration/assets/qodercli/herdr-agent-state.ps1 +++ b/src/integration/assets/qodercli/herdr-agent-state.ps1 @@ -2,7 +2,7 @@ # managed by herdr; reinstalling or updating the integration overwrites this file. # add custom hooks beside this file instead of editing it. # HERDR_INTEGRATION_ID=qodercli -# HERDR_INTEGRATION_VERSION=3 +# HERDR_INTEGRATION_VERSION=4 param([string]$Action = "") @@ -10,7 +10,10 @@ if ($Action -ne "session") { exit 0 } if ($env:HERDR_ENV -ne "1") { exit 0 } if ([string]::IsNullOrWhiteSpace($env:HERDR_PANE_ID)) { exit 0 } -$inputText = [Console]::In.ReadToEnd() +# Read hook input as raw bytes so UTF-8 payloads decode correctly even when the +# console input code page is not UTF-8 (for example CP949 on Korean Windows). +$stdinReader = New-Object System.IO.StreamReader([Console]::OpenStandardInput(), [System.Text.Encoding]::UTF8) +$inputText = $stdinReader.ReadToEnd() try { $payload = if ([string]::IsNullOrWhiteSpace($inputText)) { $null } else { $inputText | ConvertFrom-Json } } catch { diff --git a/src/integration/assets/qodercli/herdr-agent-state.sh b/src/integration/assets/qodercli/herdr-agent-state.sh index 8f03389a1..890a9751d 100644 --- a/src/integration/assets/qodercli/herdr-agent-state.sh +++ b/src/integration/assets/qodercli/herdr-agent-state.sh @@ -1,7 +1,7 @@ #!/bin/sh # managed by herdr; reinstalling the integration replaces this file. # HERDR_INTEGRATION_ID=qodercli -# HERDR_INTEGRATION_VERSION=3 +# HERDR_INTEGRATION_VERSION=4 [ "${1:-}" = "session" ] || exit 0 [ "${HERDR_ENV:-}" = "1" ] || exit 0 diff --git a/src/integration/assets/qwen/herdr-agent-session.ps1 b/src/integration/assets/qwen/herdr-agent-session.ps1 index 882caf861..4257568d6 100644 --- a/src/integration/assets/qwen/herdr-agent-session.ps1 +++ b/src/integration/assets/qwen/herdr-agent-session.ps1 @@ -2,7 +2,7 @@ # managed by herdr; reinstalling or updating the integration overwrites this file. # add custom hooks beside this file instead of editing it. # HERDR_INTEGRATION_ID=qwen -# HERDR_INTEGRATION_VERSION=1 +# HERDR_INTEGRATION_VERSION=2 param([string]$Action = "") @@ -11,7 +11,10 @@ if ($env:HERDR_ENV -ne "1") { exit 0 } if ([string]::IsNullOrWhiteSpace($env:HERDR_PANE_ID)) { exit 0 } if ([string]::IsNullOrWhiteSpace($env:HERDR_SOCKET_PATH)) { exit 0 } -$inputText = [Console]::In.ReadToEnd() +# Read hook input as raw bytes so UTF-8 payloads decode correctly even when the +# console input code page is not UTF-8 (for example CP949 on Korean Windows). +$stdinReader = New-Object System.IO.StreamReader([Console]::OpenStandardInput(), [System.Text.Encoding]::UTF8) +$inputText = $stdinReader.ReadToEnd() try { $payload = if ([string]::IsNullOrWhiteSpace($inputText)) { $null } else { $inputText | ConvertFrom-Json } } catch { diff --git a/src/integration/assets/qwen/herdr-agent-session.sh b/src/integration/assets/qwen/herdr-agent-session.sh index 226c2a97e..f2256ece7 100644 --- a/src/integration/assets/qwen/herdr-agent-session.sh +++ b/src/integration/assets/qwen/herdr-agent-session.sh @@ -1,7 +1,7 @@ #!/bin/sh # managed by herdr; reinstalling the integration replaces this file. # HERDR_INTEGRATION_ID=qwen -# HERDR_INTEGRATION_VERSION=1 +# HERDR_INTEGRATION_VERSION=2 [ "${1:-}" = "session" ] || exit 0 [ "${HERDR_ENV:-}" = "1" ] || exit 0 diff --git a/src/integration/mod.rs b/src/integration/mod.rs index 674f9ed2f..749c34ec8 100644 --- a/src/integration/mod.rs +++ b/src/integration/mod.rs @@ -49,7 +49,7 @@ const CLAUDE_HOOK_ASSET: &str = if cfg!(windows) { } else { include_str!("assets/claude/herdr-agent-state.sh") }; -const CLAUDE_INTEGRATION_VERSION: u32 = 10; +const CLAUDE_INTEGRATION_VERSION: u32 = 11; const CODEX_HOOK_INSTALL_NAME: &str = if cfg!(windows) { "herdr-agent-state.ps1" } else { @@ -60,7 +60,7 @@ const CODEX_HOOK_ASSET: &str = if cfg!(windows) { } else { include_str!("assets/codex/herdr-agent-state.sh") }; -const CODEX_INTEGRATION_VERSION: u32 = 9; +const CODEX_INTEGRATION_VERSION: u32 = 10; const KIMI_HOOK_INSTALL_NAME: &str = if cfg!(windows) { "herdr-agent-state.ps1" } else { @@ -71,7 +71,7 @@ const KIMI_HOOK_ASSET: &str = if cfg!(windows) { } else { include_str!("assets/kimi/herdr-agent-state.sh") }; -const KIMI_INTEGRATION_VERSION: u32 = 7; +const KIMI_INTEGRATION_VERSION: u32 = 8; const KIMI_CONFIG_BLOCK_BEGIN: &str = "# >>> herdr kimi integration"; const KIMI_CONFIG_BLOCK_END: &str = "# <<< herdr kimi integration"; const KIMI_MIN_VERSION: &str = "0.14.0"; @@ -113,7 +113,7 @@ const COPILOT_HOOK_ASSET: &str = if cfg!(windows) { } else { include_str!("assets/copilot/herdr-agent-state.sh") }; -const COPILOT_INTEGRATION_VERSION: u32 = 3; +const COPILOT_INTEGRATION_VERSION: u32 = 4; const COPILOT_HOOK_EVENTS: [&str; 1] = ["SessionStart"]; const COPILOT_REMOVED_LIFECYCLE_HOOK_EVENTS: [&str; 9] = [ "UserPromptSubmit", @@ -136,7 +136,7 @@ const DEVIN_HOOK_ASSET: &str = if cfg!(windows) { } else { include_str!("assets/devin/herdr-agent-state.sh") }; -const DEVIN_INTEGRATION_VERSION: u32 = 2; +const DEVIN_INTEGRATION_VERSION: u32 = 3; const DEVIN_HOOK_EVENTS: [(&str, &str); 6] = [ ("SessionStart", "session"), ("UserPromptSubmit", "session"), @@ -163,7 +163,7 @@ const DROID_HOOK_ASSET: &str = if cfg!(windows) { } else { include_str!("assets/droid/herdr-agent-state.sh") }; -const DROID_INTEGRATION_VERSION: u32 = 3; +const DROID_INTEGRATION_VERSION: u32 = 4; const DROID_HOOK_EVENTS: [(&str, &str); 1] = [("SessionStart", "session")]; const DROID_REMOVED_LIFECYCLE_HOOK_EVENTS: [(&str, &str); 9] = [ ("SessionStart", "idle"), @@ -204,7 +204,7 @@ const QODERCLI_HOOK_ASSET: &str = if cfg!(windows) { } else { include_str!("assets/qodercli/herdr-agent-state.sh") }; -const QODERCLI_INTEGRATION_VERSION: u32 = 3; +const QODERCLI_INTEGRATION_VERSION: u32 = 4; const QODERCLI_HOOK_EVENTS: [(&str, &str); 1] = [("SessionStart", "session")]; const QWEN_HOOK_INSTALL_NAME: &str = if cfg!(windows) { "herdr-agent-session.ps1" @@ -216,7 +216,7 @@ const QWEN_HOOK_ASSET: &str = if cfg!(windows) { } else { include_str!("assets/qwen/herdr-agent-session.sh") }; -const QWEN_INTEGRATION_VERSION: u32 = 1; +const QWEN_INTEGRATION_VERSION: u32 = 2; const QWEN_HOOK_EVENTS: [(&str, &str); 1] = [("SessionStart", "session")]; const LETTA_HOOK_INSTALL_NAME: &str = if cfg!(windows) { "herdr-agent-session.ps1" @@ -228,7 +228,7 @@ const LETTA_HOOK_ASSET: &str = if cfg!(windows) { } else { include_str!("assets/letta/herdr-agent-session.sh") }; -const LETTA_INTEGRATION_VERSION: u32 = 1; +const LETTA_INTEGRATION_VERSION: u32 = 2; const LETTA_HOOK_TIMEOUT_MS: u64 = 10_000; const QODERCLI_REMOVED_LIFECYCLE_HOOK_EVENTS: [(&str, &str); 12] = [ ("SessionStart", "idle"), @@ -254,7 +254,7 @@ const CURSOR_HOOK_ASSET: &str = if cfg!(windows) { } else { include_str!("assets/cursor/herdr-agent-state.sh") }; -const CURSOR_INTEGRATION_VERSION: u32 = 1; +const CURSOR_INTEGRATION_VERSION: u32 = 2; #[cfg(windows)] const ANTIGRAVITY_CLI_HOOK_INSTALL_NAME: &str = "herdr-agent-state.ps1"; #[cfg(not(windows))] @@ -265,7 +265,7 @@ const ANTIGRAVITY_CLI_HOOK_ASSET: &str = #[cfg(not(windows))] const ANTIGRAVITY_CLI_HOOK_ASSET: &str = include_str!("assets/antigravity_cli/herdr-agent-state.sh"); -const ANTIGRAVITY_CLI_INTEGRATION_VERSION: u32 = 3; +const ANTIGRAVITY_CLI_INTEGRATION_VERSION: u32 = 4; /// Antigravity CLI keys `hooks.json` by hook name, so every Herdr entry lives /// under one Herdr-owned block that install rewrites and uninstall removes. const ANTIGRAVITY_CLI_HOOK_BLOCK_NAME: &str = "herdr"; @@ -291,7 +291,7 @@ const MASTRACODE_HOOK_ASSET: &str = if cfg!(windows) { } else { include_str!("assets/mastracode/herdr-agent-state.sh") }; -const MASTRACODE_INTEGRATION_VERSION: u32 = 2; +const MASTRACODE_INTEGRATION_VERSION: u32 = 3; const MASTRACODE_HOOK_TIMEOUT_MS: u64 = 10_000; const MASTRACODE_REMOVED_HOOK_EVENTS: [(&str, &str); 2] = [("SessionStart", "idle"), ("SessionEnd", "release")]; @@ -319,7 +319,7 @@ const GROK_HOOK_ASSET: &str = if cfg!(windows) { } else { include_str!("assets/grok/herdr-agent-state.sh") }; -const GROK_INTEGRATION_VERSION: u32 = 2; +const GROK_INTEGRATION_VERSION: u32 = 3; pub(crate) const INSTALL_WARNING_PREFIX: &str = "warning:"; diff --git a/src/integration/tests.rs b/src/integration/tests.rs index f423074ed..147daff83 100644 --- a/src/integration/tests.rs +++ b/src/integration/tests.rs @@ -1132,7 +1132,7 @@ fn claude_v9_integration_status_is_outdated_until_reinstalled() { assert_eq!(claude.path, hook_path); assert_eq!(claude.installed_version, Some(9)); - assert_eq!(claude.expected_version, 10); + assert_eq!(claude.expected_version, 11); assert_eq!(claude.state, IntegrationStatusKind::Outdated); install_claude().unwrap(); @@ -1141,7 +1141,7 @@ fn claude_v9_integration_status_is_outdated_until_reinstalled() { hook_path, CLAUDE_INTEGRATION_VERSION, ); - assert_eq!(status.installed_version, Some(10)); + assert_eq!(status.installed_version, Some(11)); assert_eq!(status.state, IntegrationStatusKind::Current); std::env::remove_var("HOME"); @@ -1171,13 +1171,139 @@ fn claude_v2_integration_status_is_outdated() { assert_eq!(claude.path, hook_path); assert_eq!(claude.installed_version, Some(2)); - assert_eq!(claude.expected_version, 10); + assert_eq!(claude.expected_version, 11); assert_eq!(claude.state, IntegrationStatusKind::Outdated); std::env::remove_var("HOME"); let _ = fs::remove_dir_all(base); } +#[test] +fn windows_hooks_decode_stdin_as_utf8() { + // Windows PowerShell decodes `[Console]::In` with the console input code + // page, which corrupts UTF-8 hook payloads on non-UTF-8 locales such as + // Korean Windows (CP949) and makes the hook silently drop the session. + // Every bundled PowerShell hook must read the raw stdin stream as UTF-8. + // See #5253. + for (name, asset) in [ + ( + "antigravity_cli", + include_str!("assets/antigravity_cli/herdr-agent-state.ps1"), + ), + ( + "claude", + include_str!("assets/claude/herdr-agent-state.ps1"), + ), + ("codex", include_str!("assets/codex/herdr-agent-state.ps1")), + ( + "copilot", + include_str!("assets/copilot/herdr-agent-state.ps1"), + ), + ( + "cursor", + include_str!("assets/cursor/herdr-agent-state.ps1"), + ), + ("devin", include_str!("assets/devin/herdr-agent-state.ps1")), + ("droid", include_str!("assets/droid/herdr-agent-state.ps1")), + ("grok", include_str!("assets/grok/herdr-agent-state.ps1")), + ("kimi", include_str!("assets/kimi/herdr-agent-state.ps1")), + ( + "letta", + include_str!("assets/letta/herdr-agent-session.ps1"), + ), + ( + "mastracode", + include_str!("assets/mastracode/herdr-agent-state.ps1"), + ), + ( + "qodercli", + include_str!("assets/qodercli/herdr-agent-state.ps1"), + ), + ("qwen", include_str!("assets/qwen/herdr-agent-session.ps1")), + ] { + assert!( + !asset.contains("[Console]::In.ReadToEnd()"), + "{name} hook must not decode stdin with the console input code page" + ); + assert!( + asset.contains("[Console]::OpenStandardInput()") + && asset.contains("[System.Text.Encoding]::UTF8"), + "{name} hook must decode raw stdin as UTF-8" + ); + } +} + +#[cfg(windows)] +#[test] +fn claude_windows_hook_reports_utf8_session_path() { + use std::io::Write; + use std::process::{Command, Stdio}; + + let _lock = integration_env_lock(); + let base = unique_base(); + let home = base.join("home"); + fs::create_dir_all(home.join(".claude")).unwrap(); + let previous_home = std::env::var_os("HOME"); + std::env::set_var("HOME", &home); + let installed = install_claude().unwrap(); + + let capture = base.join("args.txt"); + let fake_herdr = base.join("herdr-capture.ps1"); + fs::write( + &fake_herdr, + "$capture = $env:HERDR_TEST_CAPTURE\n[System.IO.File]::WriteAllText($capture, ($args -join \"`n\"), [System.Text.UTF8Encoding]::new($false))\n", + ) + .unwrap(); + + // A Korean profile path: decoded with a non-UTF-8 console input code page + // (CP949 on Korean Windows) it garbles the JSON and the hook exits silently. + let transcript = "C:\\Users\\\u{d55c}\u{ae00}\\transcript.jsonl"; + let payload = format!( + r#"{{"hook_event_name":"SessionStart","session_id":"claude-session","transcript_path":{}}}"#, + serde_json::to_string(transcript).unwrap() + ); + + let mut child = Command::new("powershell") + .args(["-NoProfile", "-ExecutionPolicy", "Bypass", "-File"]) + .arg(&installed.hook_path) + .arg("session") + .env("HERDR_ENV", "1") + .env("HERDR_PANE_ID", "w1:p2") + .env("HERDR_BIN_PATH", &fake_herdr) + .env("HERDR_TEST_CAPTURE", &capture) + .env_remove("CURSOR_VERSION") + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .unwrap(); + child + .stdin + .take() + .unwrap() + .write_all(payload.as_bytes()) + .unwrap(); + let output = child.wait_with_output().unwrap(); + assert!( + output.status.success(), + "hook failed: stderr={}", + String::from_utf8_lossy(&output.stderr) + ); + + let args = fs::read_to_string(&capture).unwrap(); + assert!(args.contains("report-agent-session"), "captured: {args}"); + assert!(args.contains("--agent-session-id"), "captured: {args}"); + assert!(args.contains("claude-session"), "captured: {args}"); + assert!(args.contains(transcript), "captured: {args}"); + + if let Some(home) = previous_home { + std::env::set_var("HOME", home); + } else { + std::env::remove_var("HOME"); + } + let _ = fs::remove_dir_all(base); +} + #[test] fn uninstall_claude_removes_herdr_hooks_and_preserves_others() { let _lock = integration_env_lock(); @@ -1304,7 +1430,7 @@ fn codex_v2_integration_status_is_outdated() { assert_eq!(codex.path, hook_path); assert_eq!(codex.installed_version, Some(2)); - assert_eq!(codex.expected_version, 9); + assert_eq!(codex.expected_version, 10); assert_eq!(codex.state, IntegrationStatusKind::Outdated); std::env::remove_var("HOME"); @@ -3873,7 +3999,7 @@ fn install_qwen_writes_session_hook_and_preserves_settings() { assert!(settings.get("permissions").is_some()); let hook_asset = fs::read_to_string(&installed.hook_path).unwrap(); assert!(hook_asset.contains("HERDR_INTEGRATION_ID=qwen")); - assert!(hook_asset.contains("HERDR_INTEGRATION_VERSION=1")); + assert!(hook_asset.contains("HERDR_INTEGRATION_VERSION=2")); assert!(hook_asset.contains("herdr:qwen")); install_qwen().unwrap(); @@ -4251,7 +4377,7 @@ fn install_cursor_uses_cursor_config_dir_env() { } #[test] -fn cursor_v1_integration_status_is_current() { +fn cursor_v1_integration_status_is_outdated() { let _lock = integration_env_lock(); let base = unique_base(); let cursor_dir = base.join(".cursor"); @@ -4269,8 +4395,9 @@ fn cursor_v1_integration_status_is_current() { .iter() .find(|status| status.target == crate::api::schema::IntegrationTarget::Cursor) .expect("cursor integration status"); - assert_eq!(cursor.state, IntegrationStatusKind::Current); - assert_eq!(cursor.installed_version, Some(CURSOR_INTEGRATION_VERSION)); + assert_eq!(cursor.installed_version, Some(1)); + assert_eq!(cursor.expected_version, CURSOR_INTEGRATION_VERSION); + assert_eq!(cursor.state, IntegrationStatusKind::Outdated); clear_integration_path_env(); let _ = fs::remove_dir_all(base); @@ -4762,7 +4889,7 @@ fn install_antigravity_cli_writes_hook_and_updates_hooks_json() { } #[test] -fn antigravity_cli_v2_install_is_outdated_until_reinstalled() { +fn antigravity_cli_v3_install_is_outdated_until_reinstalled() { let _lock = integration_env_lock(); let base = unique_base(); let agy_dir = base.join(".gemini").join("config"); @@ -4771,7 +4898,7 @@ fn antigravity_cli_v2_install_is_outdated_until_reinstalled() { fs::write( hook_dir.join(ANTIGRAVITY_CLI_HOOK_INSTALL_NAME), ANTIGRAVITY_CLI_HOOK_ASSET - .replace("HERDR_INTEGRATION_VERSION=3", "HERDR_INTEGRATION_VERSION=2"), + .replace("HERDR_INTEGRATION_VERSION=4", "HERDR_INTEGRATION_VERSION=3"), ) .unwrap(); std::env::set_var(ANTIGRAVITY_CLI_CONFIG_DIR_ENV_VAR, &agy_dir); @@ -4784,8 +4911,8 @@ fn antigravity_cli_v2_install_is_outdated_until_reinstalled() { }; let outdated = status(); assert_eq!(outdated.state, IntegrationStatusKind::Outdated); - assert_eq!(outdated.installed_version, Some(2)); - assert_eq!(outdated.expected_version, 3); + assert_eq!(outdated.installed_version, Some(3)); + assert_eq!(outdated.expected_version, 4); install_antigravity_cli().unwrap(); assert_eq!(status().state, IntegrationStatusKind::Current);