fix(tailnet): decode the tailscale CLI as utf-8, not the host code page - #9351
fix(tailnet): decode the tailscale CLI as utf-8, not the host code page#9351leonlaiyc wants to merge 1 commit into
Conversation
Design Review (Fable 5, fork) — ✅ PASSDesign-level review of All verified against the base tree: Design-Verdict: PASS Root-cause fix using the repo's own established primitive, scoped to exactly the two sites whose child encoding is knowable, with a real-boundary test. [DESIGN-REVIEWED] d6b1989 |
Opus 4.8 Review (fork) — ✅ no blocking findingsReviewed |
GPT 5.6 Review (fork) — ✅ no blocking findingsReviewed Review detailsNo findings. |
First Principles Review (Fable 5, fork) — ✅ PASSPremise-level review of All claims verified against the base: First-Principles-Verdict: PASS Verify the new What this change shipsIntent: make the tailnet dashboard survive non-ASCII tailscale device names on non-UTF-8 Windows hosts — a FIX. Inventory (4 items)
Item 3 is the same defect (same child binary, same missing [FIRST-PRINCIPLES-REVIEWED] d6b1989 |
Both tailscale spawns run in text mode with no `encoding=`, so the child's output is decoded with `locale.getpreferredencoding()` -- UTF-8 on POSIX, but the legacy ANSI code page on Windows. Tailnet device names are operator-chosen free text, and `tailscale ... --json` is JSON, which is defined as UTF-8 (RFC 8259 s8.1), so the correct decode is known rather than guessed. Both sites now splat `UTF8_TEXT`, the mapping this repository already uses for children whose encoding is knowable. Two distinct failures, measured rather than assumed: * Bytes that are also legal in the host code page decode to *different characters*, silently. A device named `測試-desk` comes back as other CJK characters on a cp950 host, and `_valid_magicdns_name` then rejects a name that was valid -- the tailnet origin is simply never derived. * Bytes that decode under neither take a platform-specific path, and neither one is caught. On POSIX `subprocess.run` raises `UnicodeDecodeError`, which is a `ValueError` and so passes straight through `except (OSError, subprocess.SubprocessError)`. On Windows `capture_output` reads on a helper thread, so the same error kills that thread, prints a traceback to the gateway's stderr, and leaves `proc.stdout` as `None`. Both break the contract `_run_json_detail` documents in its own docstring -- "``None`` on ANY failure ... every failure mode here means the same thing" -- by either answering wrongly or escaping. `errors="replace"` (carried by `UTF8_TEXT`) restores it: a malformed byte costs one character, not the payload, matching the policy kirodotdev#3669 established. `tailnet_serve.py` carries the identical spawn and is fixed in the same commit, because its own docstring says a second, subtly different copy of this spawn is how one comes to be hardened and its sibling not. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
67f31c5 to
d6b1989
Compare
|
CI triage on Branch-caused — fixed. A docstring I added cited a PR number, which that gate forbids in comments and docstrings ( Not branch-caused — The decisive evidence is that the same I have therefore changed no product code for those, and pushed once. |
Problem / Motivation
Both spawns of the
tailscaleCLI run in text mode with noencoding=:text=Truewithout an encoding decodes the child withlocale.getpreferredencoding(): UTF-8 on POSIX, but the legacy ANSI codepage on Windows. What the child actually writes is not in doubt here —
tailscaleis a Go binary, andtailscale ... --jsonemits JSON, which isdefined as UTF-8 (RFC 8259 §8.1). So this is a known encoding being decoded
with the wrong one, not a guess about an unknowable child.
The payload is operator-chosen free text: a tailnet device name. Non-ASCII
there is ordinary, not exotic.
Two distinct failures, both measured on a
cp950host, not inferred.1 — bytes that are also legal in the host code page decode to different
characters, silently.
測試-deskencodes toe6 b8 ac e8 a9 a6, and each ofthose is a legal Big5 lead or trail byte, so nothing raises — the name simply
comes back as other characters.
_valid_magicdns_namethen rejects a name thatwas valid, and the tailnet origin is never derived. The red-before below shows
this as a plain inequality of two rendered names.
2 — bytes that decode under neither take a platform-specific path, and
neither is caught. Both sites guard with
except (OSError, subprocess.SubprocessError):POSIX —
subprocess.runraisesUnicodeDecodeError. That is aValueError, so it is caught by neither arm and propagates out of thefunction.
Windows —
capture_outputreads on a helper thread, so the same errorkills that thread, prints an unhandled-thread traceback to the gateway's
stderr, and leaves
proc.stdoutasNone. Measured:Why it matters
_run_json_detailstates its contract in its own docstring, and both failuresbreak it:
Failure 1 returns a wrong name rather than "a name or nothing". Failure 2
either escapes the function entirely (POSIX) or drops the whole payload and
leaves a stray traceback in the gateway's log (Windows). The listed failure
modes were all meant to converge on
None; a decode failure is the one thatdoes not.
It is invisible where most development happens: on POSIX
locale.getpreferredencoding()already returns UTF-8, so every one of thesecalls is correct there. Only a non-UTF-8 Windows host sees it, and only for an
operator who named a device in their own script.
What changed (motivation → approach → change)
Root cause: a child whose output encoding is knowable was decoded with the
host's locale instead.
Both spawns now splat
UTF8_TEXT—src/kiro_crew/subprocess_utf8.py,the repository's existing mapping for exactly this, whose own docstring names
gh("a Go binary; always writes UTF-8") as the same case. Three lines ofproduction change plus two imports.
errors="replace"comes with it, and it is what restores the documentedcontract on the undecodable path: a malformed byte costs one character inside
the string, the surrounding JSON still parses, and the caller gets its answer.
That is the policy fix(dashboard): decode file diffs as utf-8 #3669 established rather than a new one.
tailnet_serve.pyis fixed in the same commit, not left as a follow-up.Its own docstring gives the reason:
Its
_runhas the identical spawn and the identicalexcept (OSError, subprocess.SubprocessError)guard, and its stdout/stderr are shown to theoperator verbatim.
.github/subprocess-encoding-baseline.txtloses both entries. The repo'sratchet requires a file whose count shrinks to be pruned, so this is the
gate's own bookkeeping, not an unrelated edit.
Deliberately NOT widened. The other 112 baselined files stay as they are.
Several of them genuinely should keep locale decoding —
subprocess_utf8'sdocstring names
systeminfoand user shells, where pinning UTF-8 trades onemojibake for another — so a sweep would need a per-child judgement each time and
would not be this defect.
Tests
Added
TestTheCliIsDecodedAsUtf8NotTheHostCodePagetotest/test_tailnet_e2e.py— the suite that already spawns a real fake
tailscalethrough theproduction
_cli_pathand the productionsubprocess.run, so the decode undertest is the real process boundary rather than a mock.
The existing fake could not exercise this at all: it answers via
print(json.dumps(...)), which is ASCII by construction (ensure_asciidefaultson) and re-encodes through the child's stdout encoding, so it can never place a
non-ASCII byte on the pipe. The new helper writes to
sys.stdout.buffer, whichputs exactly the intended bytes there — what a Go binary does.
Red-before (product code reverted to
origin/main, tests kept):The second failure is the mojibake itself, printed side by side.
The two halves are deliberately different in strength, and the difference is
stated rather than papered over:
test_undecodable_bytes_degrade_to_a_name_rather_than_escapingisdeterministic on every host, CI included. It does not depend on what the
host code page is, only on the decode being strict, so it goes red on a UTF-8
Linux runner (where it raises) and on Windows (where the reader thread dies)
alike.
test_a_non_ascii_device_name_round_tripsis host-conditioned — a UTF-8runner decodes it correctly either way — so it is paired with the above rather
than relied on alone. It is kept because it is the thing that actually breaks
for the user.
test_the_fixture_really_defeats_a_legacy_code_pagepasses on both builds bydesign: it asserts
測試-deskis a name cp950 reads differently, so theround-trip test cannot pass vacuously on a payload that was ASCII all along.
Green-after: 450 passed / 3 skipped across
test_tailnet_e2e.py,test_tailnet_serve.py,test_tailnet_cli.py,test_tailnet_origin.py,test_tailnet_mobile.py,test_tailnet_peer.py,test_tailnet_governance.pyand
test_spawn_audit.py— the last of which auditsdashboard/tailnet.py::_run_json_detailby name as a spawn primitive.Gates:
flake8,isort --check-only,mypy --platform linuxandscripts/check_subprocess_encoding.pyall pass;scripts/check_black_formatting.pypasses (the two pre-existing black findingsin
test_tailnet_e2e.pyare baseline entries in untouched regions, so the filewas not reformatted).
Manual verification
N/A — unit coverage sufficient: the defect is a decode at a real process
boundary, and these tests exercise that exact boundary with a real child process
emitting real bytes, which is precisely what a manual run against a daemon would
do. The suite's own
TestAgainstARealDaemonstill covers a genuinetailscalewhere one is installed.
Related Issues
None. Found by auditing
.github/subprocess-encoding-baseline.txt— the repo'sown record of pre-existing sites in the failure class
#3219/#3669/#5249established — for entries whose child has a knowable encoding.
Contention checked immediately before opening: across all 357 open PRs, the only
other PR touching these files is #9333, a comments-only "strip history narration"
sweep with no overlap with these lines.
Pattern harvest
Rule candidate:
review-promptPattern:
UnicodeDecodeErroris aValueError, soexcept (OSError, subprocess.SubprocessError)does not catch it — the guardaround almost every spawn in this repository looks total and is not. Any function
that documents "None on ANY failure" while decoding a child in text mode is
making a promise its own except clause cannot keep. When reviewing a spawn, check
the exception type lattice against the decode, not just against the spawn.
Second lesson, and the one that cost the most to find here: the same decode
failure has two different shapes by platform. POSIX
communicate()decodes onthe calling thread and raises into the caller; Windows
capture_outputdecodeson a reader thread, so the exception is swallowed by the threading machinery and
the caller silently receives
stdout is None. A test asserting "it raises" wouldhave passed on Linux and proved nothing on Windows. Assert the post-condition
the caller depends on (a parsed payload came back) rather than the mechanism, and
one test covers both.
Third: an
ensure_asciifake cannot test a decode. The existing e2e fakelooked like a real process boundary and was structurally incapable of putting a
non-ASCII byte on the pipe. When the property under test is an encoding, the
fixture has to write bytes, not call
print.Checklist
feat|fix|docs|refactor|perf|test|chore|ci|build|revert: ...)Contribution License Agreement
🤖 Generated with Claude Code