Skip to content

fix(agent): read the interactive PATH on Windows instead of a frozen login shell - #880

Merged
0xKT merged 5 commits into
mainfrom
fix/windows_login_path_probe
Oct 9, 2026
Merged

0xKT merged 5 commits into
mainfrom
fix/windows_login_path_probe

Conversation

@LivXue

@LivXue LivXue commented Oct 8, 2026 •

Copy link
Copy Markdown
Member

Summary

On Windows the sub-agents page and the launches read the environment the gateway started with, so an agent installed after it started stayed "not on the login shell PATH" until a restart. This reads the PATH a freshly opened terminal would get from the registry, takes it again on Check again, Connect and Test, and starts a bare program from it.

  • login_shell_env and refresh_login_shell_env both go through _capture_now: the registry on Windows, the login shell elsewhere. A refresh on Windows used to look for a login shell, find none, and keep the first capture.
  • _capture_windows returns raven's own environment with only PATH rebuilt: the machine and user stores' Path, %VAR% references expanded, followed by the live PATH. The stores' other values are not copied over raven's: they are what Windows builds an environment from (ComSpec and TEMP unexpanded, the machine's USERNAME=SYSTEM).
  • The capture keeps the upper-case names os.environ has on Windows, which is the spelling probe._login_path and the other readers ask for, so an agent already on the gateway's PATH stays found beside the ones the stores add.
  • A stored reference expands with raven's own values first (they are this logon's, and the child carries them), then user over machine for a variable an installer added after raven started.
  • resolve_program: CreateProcess looks a bare name up on the gateway's own PATH and never on the env block it is handed, so on Windows the acp launch, the cli launch and the Kimi Code ask look it up on the child's PATH instead, by CreateProcess's own rule that a name with no extension means .exe. Only an .exe or .com is put in: CreateProcess never turns a bare name into a .cmd or .bat, and doing it here would put a cli prompt through cmd.exe's parser.
  • On Windows no shell is driven, including the Git for Windows bash that SHELL can name there: its MSYS environment carries a :-joined POSIX PATH and no SystemRoot.

Left out on purpose: splitting the command line. The probe keeps splitting a command the way both launchers do; a Windows rule for the probe alone (posix=False) keeps the quotes shlex.quote and a Program Files path carry and disagrees with the launch. #864 routes the probe and the launchers through one parser (raven/utils/commands.py), so the backslash mangling of a {PYTHON} command is closed there. Once both land, #864's launch_argv (a PATHEXT lookup on the gateway's PATH) and resolve_program here want to become one lookup on the child's PATH; whichever lands second folds them.

Type

  • Fix
  • Feature
  • Docs
  • CI / tooling
  • Refactor
  • Other

Verification

  • python -m pytest tests/test_subagent_host_env.py tests/test_subagent_probe.py tests/test_subagent_third_party.py tests/test_subagent_kimi_code.py tests/test_rpc_subagents.py tests/test_cli_agents_commands.py -q (project venv, all extras, this tree on PYTHONPATH): 620 passed, 1 skipped.

  • Full suite, python -m pytest -q the same way: 27683 passed, 119 skipped, 8 failed. Seven fail on this host at main too: five test_config_update_providers.py proxy cases that read the host's proxy variables, test_subagent_node_runtime.py::test_what_cannot_be_read_names_nothing under root, and test_install_script.py::test_resolve_node_dir_answers_each_case_it_exists_for with an npm on PATH.

  • The eighth, test_subagent_kimi_code.py::test_a_kimi_that_does_not_know_acp_is_told_to_upgrade, is a load race at main as well: with the stand-in forced to exit before the host's first _send, main and this branch both answer "stdin is closed" with no remedy.

  • Mutation check: 13 single-construct mutants (refresh routing, the PATH spelling, the expansion order, the overlay, the union with the live PATH, folding store names, matching references without case, each of the three launch call sites, the .exe rule, the .cmd refusal, the first capture's platform switch), each caught by the test written for it.

  • Windows is simulated on Linux: a fake winreg holding the stock store values (REG_EXPAND_SZ unexpanded), os.environ with upper-case names, and ; as the path separator for the probe test. Not run on a Windows host.

  • ruff check and ruff format --check over CI's targets, lint-imports (10 kept, 0 broken), ty check on the touched modules, and scripts/check_large_files.py and scripts/check_source_language.py over the change: all clean.

  • Relevant tests pass locally

  • Relevant lint / type checks pass locally

  • User-facing docs or screenshots are updated when needed

Risk

Windows only. On POSIX _capture_now is _capture and resolve_program returns the argv it was given, so behavior matches main. On Windows a child gets raven's environment with PATH rebuilt registry-first, and a bare program found as an .exe or .com on that PATH starts from there; anything else is left to CreateProcess's own search, as before. Not changed here: a bare name that exists only as a .cmd shim (npx and other npm installs) still does not start on Windows, and a %PATH% reference inside a stored Path stays unexpanded. Rollback: revert the squash commit.

  • Security impact considered
  • Backward compatibility considered
  • Rollback path is clear for risky changes

Related Issues

#864

@LivXue
LivXue requested review from 0xKT and gloryfromca October 8, 2026 17:41

@gloryfromca gloryfromca left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Blocking: make refresh and probe command/path handling consume the Windows environment correctly.

I reviewed the full github/main...HEAD diff, the probe and spawn callers, the refresh RPC paths, relevant history, backward compatibility, test changes, AGENTS.md, CONTEXT-MAP.md, and the Runtime architecture terms. The new tests add coverage rather than weakening existing assertions, but they miss the three end-to-end seams marked inline.

Verification: uv sync --extra dev; uv run pytest tests/test_subagent_host_env.py tests/test_subagent_probe.py tests/test_subagent_third_party.py -x (355 passed); git diff --check github/main...HEAD (passed).

Comment thread raven/agent/subagent/backends/env.py Outdated
Comment thread raven/agent/subagent/backends/env.py Outdated
Comment thread raven/agent/subagent/probe.py Outdated
LivXue and others added 5 commits October 9, 2026 03:45
…login shell

The WebUI reported an installed subagent "not on the login shell PATH"
while its interpreter sat on disk. Two Windows-only defects stacked:

shlex.split defaults to POSIX, so the agent's launch command
"C:\...\python.exe -m raven acp" had every backslash read as an escape
and reached shutil.which as the bare relative name "C:Users...python.exe",
which can only miss a PATH lookup. On win32 the command is tokenized
posix=False, the way CommandLineToArgvW parses a real spawn, so the
absolute interpreter is handed to which whole. The git-bash .exe and
backslash spellings of SHELL are also recognized as drivable.

Windows has no login shell to run "bash -lic env -0" against, so
login_shell_env fell back to the gateway's frozen os.environ and an
agent installed after boot was invisible forever. _capture_windows now
reads the HKLM and HKCU Environment stores a fresh terminal is assembled
from, recursively expands %VAR% references, and unions the store PATH
with the live process PATH (a service's own additions like MinGit's
usr/bin survive). Everything else is kept from os.environ so a child
loses no proxy, temp or conda variables.
The probe alone split a command with posix=False on Windows, while
AcpClient.launch and the cli backend kept POSIX shlex.split. The two
then disagreed in both directions: the probe found the interpreter of
an unquoted {PYTHON} command that the launch still mangled into
C:Users...python.exe, and it kept the quotes shlex.quote puts round
host_raven_acp_command's path, so it reported missing a program the
launch starts.

One parser for the probe and both launchers is what #864 adds, so the
probe goes back to the split the launchers use rather than gaining a
second Windows rule here.

Co-authored-by: Claude (claude-opus-5-5[1m]) <noreply@anthropic.com>
The Windows capture stored its PATH under the registry's spelling,
Path, while every reader asks for PATH: CPython keeps os.environ names
upper-case on Windows, which is what the fallback this replaced had
handed them. The probe read an empty PATH, shutil.which(exe, path="")
answers None, and every bare-name agent read as missing, including the
ones found on the gateway's PATH before the capture existed.

It also copied every other stored value over raven's own. Those are
what Windows builds an environment from, not what it hands a process:
ComSpec and TEMP arrive unexpanded (%SystemRoot%\system32\cmd.exe,
%USERPROFILE%\...), and the machine store's USERNAME is SYSTEM until a
logon replaces it. A child given that ComSpec cannot run
Popen(shell=True), which uses it only when it is an absolute path.

The capture is now dict(os.environ) with PATH rebuilt from the stored
machine and user Path followed by the live PATH. A stored reference
expands with raven's own values first, since they are this logon's and
the child carries them, then user over machine for a variable an
installer added after raven started.

The tests take Windows' shape: the stock store values unexpanded, an
upper-case os.environ, and the ambient environment cleared, so a host
that holds HTTP_PROXY beside http_proxy no longer fails them. The
capture is checked through probe_all, its reader.

Co-authored-by: Claude (claude-opus-5-5[1m]) <noreply@anthropic.com>
Check again, and every Connect or Test, refresh the capture through
refresh_login_shell_env, which called the login-shell capture on every
host. On Windows it found no shell, kept the first capture, and an
agent installed after that stayed missing until a restart.

With Git for Windows' SHELL it was worse: that bash is exported as
C:\Program Files\Git\usr\bin\bash.exe, which _login_shell had been
taught to drive, so a refresh ran bash -lic "env -0" from the POSIX
bootstrap base and swapped an MSYS environment in, a ':'-joined PATH of
/c/... entries with no SystemRoot.

The first capture and every refresh now go through _capture_now, which
reads the registry on Windows and the login shell elsewhere. The
git-bash spelling is no longer recognized: _capture is not reachable on
Windows any more, and a POSIX host never sees that spelling. The test
fixture that pins the POSIX capture pins the same switch.

Co-authored-by: Claude (claude-opus-5-5[1m]) <noreply@anthropic.com>
CreateProcess looks a bare program name up on the PATH of the process
that calls it and never on the env block it is handed; POSIX exec
reads the PATH of that env. So on Windows an agent installed after the
gateway started, which the refreshed capture and the probe now find,
still would not start: the launch searched the gateway's frozen PATH.

resolve_program looks the name up on the child's PATH instead, by
CreateProcess's own rule that a name with no extension means .exe, and
puts in only what CreateProcess runs natively. A .cmd or .bat shim is
left alone: CreateProcess never turns a bare name into one, and doing
it here would put the shim's arguments, a cli agent's prompt among
them, through cmd.exe's parser. The acp launch, the cli launch and the
Kimi Code ask all start their program this way; POSIX is unchanged.

Co-authored-by: Claude (claude-opus-5-5[1m]) <noreply@anthropic.com>
@LivXue
LivXue force-pushed the fix/windows_login_path_probe branch from 8388258 to e79c65f Compare October 9, 2026 03:49
@LivXue

LivXue commented Oct 9, 2026

Copy link
Copy Markdown
Member Author

Round two is pushed as e79c65f. Two things the threads do not carry:

  • The branch was force-pushed to correct the first commit's author. Its noreply address resolved to an unrelated GitHub account, so it is now the author's own address, with the tree unchanged (8388258 became 3d0e7e3, same tree). Its Co-authored-by: Claude (kimi-k3) trailer is dropped as well, since that session was not a Claude model. The four commits after it are the fixes.
  • One fix answers no thread: e79c65f starts a bare program from the child's own PATH on Windows (resolve_program). With the capture fixed, the probe finds an agent installed after the gateway started, but CreateProcess searches the gateway's PATH, so the Connect that follows could not start it. Only an .exe or .com is resolved, never a .cmd shim, which would route a cli agent's prompt through cmd.exe.

Command-line splitting is left to #864; the description says where the two meet.

@gloryfromca gloryfromca left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No blockers; this can merge as far as I am concerned.

I reviewed the full github/main...HEAD diff and the delta from my prior round, including the capture/refresh and probe/spawn callers, the new ACP/CLI/Kimi launch lookup, relevant history, backward compatibility, test changes, AGENTS.md, CLAUDE.md, CONTEXT-MAP.md, and the Runtime architecture terms. The three prior findings are fixed or correctly withdrawn, their tests cover the previously missing seams, and I found no newly introduced plain error. The explicitly documented Windows command-tokenization and .cmd work remains with #864 and is not a blocker for this scoped improvement.

Verification: uv sync --extra dev; uv run pytest tests/test_subagent_host_env.py tests/test_subagent_probe.py tests/test_subagent_third_party.py tests/test_subagent_kimi_code.py tests/test_rpc_subagents.py tests/test_cli_agents_commands.py -q (621 passed); git diff --check github/main...HEAD (passed); commit-message non-ASCII scan (passed). All three threads I opened in the prior round have been replied to and resolved.

@0xKT

0xKT commented Oct 9, 2026

Copy link
Copy Markdown
Member

Not a blocker -- notes from the acceptance pass on head e79c65f (merged onto tip 6d509a8). Nothing here holds the merge; each is small and can land as a follow-up.

Severity of this one finding, not a verdict on the pull request. The blocking findings from this pass are review threads on the changed files; GitHub renders those collapsed, as a file name with no text.

  1. _expand_windows re-expands without a size bound (raven/agent/subagent/backends/env.py, the _depth > 16 cap). The cap bounds the number of passes, not the output: a variable that references itself twice doubles every pass, three times triples it. Measured here: {"A": "%A%;%A%"} already gives 524,287 characters, and with three references the same 16 passes grow it 3^16-fold, into hundreds of megabytes, inside login_shell_env, which every subagent launch awaits. Windows itself expands a stored value once. A single pass, or a cap on the result length, would remove the cliff.

  2. A literal % swallows the next reference: _expand_windows("C:\\50%;%USERPROFILE%\\bin", {"USERPROFILE": "C:\\U"}) returns the input unchanged, because %([^%]+)% reads %;% as an undefined name and resumes after it.

  3. The capture's headline overstates what it returns. _capture_windows says it hands back "the PATH a freshly opened Windows terminal would start with", but it returns stored;live (env.py:240), and on an interactive logon the live PATH already contains the stored entries, so each appears twice (measured through login_shell_env with a fake registry and a 41-entry live PATH: 869 -> 1654 characters, 78 entries, 37 of them duplicates; raven's MinGit entries land after every stored one). The union is deliberate per the comment at :235-238; only the first line needs adjusting. At that ratio a live PATH above roughly 4.3k characters doubles past the 8191-character limit cmd.exe applies to a variable it expands; what a .cmd-launched child then does was not run here.

  4. Two docs still describe the old capture on Windows: raven/config/env_file.py:3-6 and raven/cli/onboard_web.py:19-23 say every cli/acp sub-agent's environment "is a capture of $SHELL -lic". On Windows it is now raven's own environment plus the registry PATH.

  5. Three behaviours the suite does not pin. Each change below leaves tests/test_subagent_host_env.py, tests/test_subagent_third_party.py and tests/test_subagent_kimi_code.py green (345 passed), while a control mutant in the same code -- dropping the case-insensitive lookup in _expand_windows -- is caught: accepting non-string registry values (isinstance(value, str) in _read_store), removing the _depth > 16 cap, and dropping the ntpath.dirname check so a path-bearing argv[0] is looked up again. One test each would hold them.

Full suite on the merged tree: 27698 passed, 115 skipped, 7 failed -- the same 7 that fail on base in this environment with no PR code present.

@0xKT
0xKT merged commit fc9bc98 into main Oct 9, 2026
34 checks passed
@0xKT
0xKT deleted the fix/windows_login_path_probe branch October 9, 2026 06:30
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants