Skip to content

fix(rpc): sign in a page reached through a port forward - #939

Merged
0xKT merged 7 commits into
mainfrom
fix/web_signin_forwarded_port
Oct 14, 2026
Merged

0xKT merged 7 commits into
mainfrom
fix/web_signin_forwarded_port

Conversation

@LivXue

@LivXue LivXue commented Oct 13, 2026 •

Copy link
Copy Markdown
Member

Summary

A page reached through a port forward could not sign in. The gateway
accepted a browser Origin only when it named the port the gateway
bound, and VS Code and ssh -L pick another local port whenever the
remote one is taken on the browser's machine, so every
POST /auth/exchange answered 403 bad origin and every /rpc
upgrade was refused with it. The sign-in page reported an expired link
over a refusal it cannot see, and re-running raven web minted one
more link into the same refusal. Measured on 2026-10-13: 13 refusals
in six minutes, each from a page at 127.0.0.1:18793 against a
gateway on 18792, all within a minute of their nonce being minted. The
same check had already forced the container's nginx to rewrite Origin
for every request it proxies.

_origin_ok now also accepts an Origin that asks the request's own
origin (Origin equals Host) on a loopback name. That is what a
browser sends through a forward, since a forwarder relays Host
unchanged. It still refuses a page another local server shows, because
cookies and SameSite ignore the port and that origin names a port this
page does not ask; and it still refuses DNS rebinding, where a name
resolving to 127.0.0.1 asks its own origin and the two agree. The
bound-port allowlist stays for the container's nginx, which forwards
Host without a port.

Three smaller changes ride with it:

  • The session cookie is named for the port the browser reached rather
    than the port bound, so two gateways that both bound 18792 no longer
    share one jar entry when one browser reaches them through different
    forwards. A Host without a port keeps the bound port, which is what
    the container's auto-login sets.
  • handle_auth_exchange answers with the reason it refused, and the
    sign-in page shows that text rather than calling every failure an
    expired link.
  • _open prints the sign-in link every time, and before handing it to
    a browser. webbrowser.open returns True whenever it ran something:
    VS Code's browser.sh exits 0 whatever happened, and a machine with
    no display hands the URL to a console browser that has no JavaScript
    and never redeems the nonce. The old fallback was unreachable, so the
    link was never printed at all.

The standalone serve test now uses a real aiohttp Application while replacing only AppRunner and TCPSite. It selects temporary UI locations for both an absent page and a built page, and checks that the response revalidation hook is registered only for the built page. This removes dependence on whether the checkout contains a UI build.

This PR also retains the reviewed Windows test guards and separate RAVEN_HOME directories for real-upgrade fixtures.

Type

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

Verification

Linux fixture follow-up (Python 3.12.13; the three-file suite and diff coverage were rechecked after rebasing onto the current main):

  • Before the fix, parameterizing TestTheSignInLink over an absent page and a built page produced 4 passed / 1 failed. Only the built-page case failed, with the same missing on_response_prepare as CI.
  • uv run --no-sync pytest -q -n 0 tests/test_cli_serve_commands.py::TestTheSignInLink: 5 passed after the fix.
  • uv run --no-sync pytest -q tests/test_rpc_transport.py tests/test_cli_serve_commands.py tests/test_docker_runtime.py --cov=raven.rpc.transports.ws --cov=raven.cli.serve_commands --cov-report=json:<coverage-json>: 234 passed. <coverage-json> denotes the local coverage output file.
  • uv run --no-sync python scripts/coverage_gate.py --coverage-json <coverage-json> diff --base-ref origin/main: 100.00% (32/32 executable changed lines), passing the 90% threshold. This measures diff coverage, not the full-suite ratchet.
  • A temporary test plugin removed the response hook without changing the checkout. The built-page case failed its hook assertion and the absent-page control passed (1 failed / 1 passed).
  • uv run --no-sync ruff check tests/test_cli_serve_commands.py raven/cli/serve_commands.py, uv run --no-sync ruff format --check tests/test_cli_serve_commands.py raven/cli/serve_commands.py, and uv run --no-sync ty check raven/cli/serve_commands.py tests/test_cli_serve_commands.py: passed.
  • UV_NO_SYNC=1 make lint-python lint-imports lint-deps lint-types: passed after rebase (2268 Python files formatted; 10 import contracts kept; no dependency issues; type checks passed). Both commit-message validators and the repository source-language and large-file gates also passed.
  • The earlier description incorrectly called this an unrelated CI failure. It was introduced by this PR's standalone-serve test, and the CI build made that incomplete fixture fail. The fixture follow-up was validated on Linux; the native Windows and full-suite measurements below are earlier verification. CI must validate the pushed revision.

Native Windows follow-up (build 10.0.26300):

  • Parallel real-upgrade cases now get separate RAVEN_HOME directories, isolating both upgrade markers and downloaded wheels. A scratch barrier forced both helpers to finish writing the same partial-wheel name before rename: the original fixture produced 1 failed / 1 passed with Distribution not found in the shared cache; the identical probe with isolated homes produced 2 passed on Python 3.13.12. The ordinary run had passed before the forced overlap, so this result identifies a race rather than a consistently failing upgrade.
  • Captured upgrade diagnostics tolerate localized Windows error bytes and include stderr on a missing success message. Version and companion-package assertions remain strict.
  • Three reported unit-test constraints were reproduced. Symlink setup now skips only Windows privilege error 1314; other errors propagate and confinement still runs when a link can be created. Owner-only mode bits and two SIGTERM-handler tests are explicitly POSIX-only. A native subprocess probe confirmed that Windows Popen.terminate does not execute the Python SIGTERM handler.
  • PYTHONUTF8=1; uv run --frozen --no-sync pytest tests/test_rpc_transport.py tests/test_cli_serve_commands.py tests/test_docker_runtime.py::test_container_bootstraps_the_browser_after_the_engine_is_ready -n 0 -x --tb=short: 195 passed, 30 skipped on Python 3.13.12. Twenty-six skips were already POSIX-specific; four now identify the reported mode/signal/symlink preconditions. The skipped checks are not Windows equivalents.
  • PYTHONUTF8=1, UV_PYTHON=3.12, and an isolated caller RAVEN_HOME; uv run --no-project --python 3.12 --with 'pytest>=8,<10' --with pytest-asyncio --with pytest-xdist pytest --noconftest tests/integration/test_cli_upgrade_real_uv.py -q --tb=short -n 4: 3 passed, covering two real uv upgrade variants and the workflow contract.
  • Whole-PR pre-commit, full Python-target Ruff check/format, all ten import contracts, changed-production type checks, commitlint and repository commit/asset/source-language gates passed.

Earlier author verification before this Windows follow-up:

  • uv run --frozen --python 3.12 --all-extras pytest -q tests/test_rpc_transport.py tests/test_cli_serve_commands.py tests/test_docker_runtime.py -> 233 passed.
  • The same three files plus every tests/test_rpc_*.py and tests/test_cli_smoke.py -> 3121 passed on the first revision, before the fixture follow-up.
  • The earlier full suite, under --cov=raven --cov-branch -> 8 failed, 29791 passed, 158 skipped, then scripts/coverage_gate.py diff --base-ref origin/main --threshold 90 -> 100.00% (32/32 executable changed lines), passed. All eight failures are local baselines: five proxy tests (tests/test_config_update_providers.py) that fail whenever the shell carries this box's proxy; test_install_script.py::test_resolve_node_dir_answers_each_case_it_exists_for (node/npm resolution on this box); test_subagent_node_runtime.py::test_what_cannot_be_read_names_nothing (fails as root); and test_knowledge_parser_excel.py::test_a_legacy_workbook_is_converted_and_read. The last three were replayed in an unmodified baseline worktree and fail there too (3 failed).
  • The tests were written before the code and watched fail on it, each for its own reason: the forwarded origin as 403: bad origin, the port-forward cookie case as two sign-ins wrote one jar entry: ['raven_session_18792'], the launcher cases on empty output. Against main's two source files the three test files now fail 18 tests.
  • Eleven mutants, one construct each, all killed, with both files restored and compared byte for byte: the forwarded rule off, Origin equal to Host dropped, the loopback test dropped, the localhost branch removed, either parse left unguarded, the cookie keyed to the bound port, the two refusals given one text, the link not printed, the link printed after the browser, and raven serve --open bypassing _open.
  • ruff check and ruff format --check clean; lint-imports reports 10 kept, 0 broken.
  • scripts/check_source_language.py origin/main...HEAD and scripts/check_large_files.py origin/main...HEAD both exit 0.
  • Real browser, end to end, against an isolated raven serve on its own RAVEN_HOME reached through a TCP forwarder: the tab signed in and landed on /, the cookie it kept was raven_session_<forwarded port>, and an RPC call over a socket opened from that page was answered (server log: serve: ws client connected, system.hello). A direct tab on the bound port signed in too. With fetch stubbed, the page rendered each of the four answers it can receive (refused address, used or expired link, other status, no answer) with the matching text.

Risk

  • Security impact considered. The change widens one check: an Origin that equals the request's Host on a loopback name. The cross-origin local-page and DNS-rebinding cases remain refused, and both are covered by tests. What it accepts that the old rule did not is a page whose origin is exactly the address it asked for -- which only something serving that address can show, and a file the agent produced arrives with the CSP sandbox directive and so has an opaque origin.
  • Backward compatibility: a tab signed in directly on the bound port keeps its cookie name and stays signed in. A tab reaching the gateway through a forward had no working session before, so there is nothing to migrate. The container's auto-login cookie is unchanged, and a test derives the entrypoint's name from cookie_name so the two cannot drift.
  • Rollback: revert the commit. Nothing is written outside the process except the cookie already in the browser, and no config or format changed.
  • A Host that does not parse (127.0.0.1:99999, 127.0.0.1:abc, [::1) is answered 401 on /rpc, as on main, and an Origin equal to such a Host is answered 403. Without the guard both would be a 500, since the cookie's name is read before any credential is checked.
  • Not fixed here: the page's own "this tab's sign-in expired" notice (main page, i18n gui.auth.stale) still names the remedy correctly but not the cause, so a tab that reaches the gateway by a non-loopback address still reports a sign-in failure it cannot act on. Also unmeasured: a real VS Code forward and a real container (both request shapes were reproduced instead).

Related Issues

N/A

@LivXue
LivXue requested review from 0xKT and gloryfromca October 13, 2026 07:40

@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 diff and surrounding RPC/CLI/Docker callers, checked the relevant history, and traced the browser security boundary: forwarded requests require Origin to equal Host on a literal loopback name, while the existing bound-port exception preserves the nginx path. Cookie naming remains backward-compatible for direct and nginx access and separates forwarded ports as intended.

I also checked AGENTS.md/CLAUDE.md and the runtime context map, backward compatibility, architecture boundaries, and whether the tests were weakened; the change adds coverage without relaxing existing assertions. The duplicated helper definition and damaged module-docstring sentence are cleanup nits with no runtime effect, so I am not holding the change for them.

Verification: uv run pytest tests/test_rpc_transport.py tests/test_cli_serve_commands.py tests/test_docker_runtime.py -x passed (221 tests). The first attempt did not reach tests because the fresh environment lacked the declared dev extra; after uv sync --extra dev, the same command passed. uv run ruff check raven/rpc/transports/ws.py raven/cli/serve_commands.py tests/test_rpc_transport.py tests/test_cli_serve_commands.py tests/test_docker_runtime.py also passed.

@LivXue

LivXue commented Oct 13, 2026

Copy link
Copy Markdown
Member Author

Windows compatibility review: no newly introduced Windows issue found in the inspected scope. Broader validation encountered inherited platform constraints.

Reviewed all seven changed files at e4b4a68b52d8ffba1b78e28f5be9999521e94dc9, against base/merge base ee969dbc41a4b00978b4430aae9590a5ebfba010: Origin/Host admission, forwarded-port cookie naming and lookup, exchange error text, both browser-opening callers, Docker/nginx contract and tests. All 4,458 snapshot blobs matched the head tree. Docker changes are comments; its changed cookie-contract assertion was checked without starting a container.

Native Windows / Python 3.13.12, separate run-owned uv environment and isolated user/Raven/temp directories; PYTHONUTF8=1, UV_NO_SYNC=1, imports verified against this head. Exact commands from the snapshot root:

$R = 'C:\Users\livxu\AppData\Local\Temp\raven-windows-review\40676d5dc0d6427594f56d74f56a4232'
uv run pytest tests/test_rpc_transport.py tests/test_cli_serve_commands.py tests/test_docker_runtime.py::test_container_bootstraps_the_browser_after_the_engine_is_ready -n 0 -x --tb=short --basetemp "$R\checks\939-auth\pytest" --junitxml "$R\checks\939-auth\junit.xml"
# 27 passed, 1 failed: symlink creation requires a Windows privilege this host lacks (WinError 1314).
uv run pytest tests/test_rpc_transport.py tests/test_cli_serve_commands.py tests/test_docker_runtime.py::test_container_bootstraps_the_browser_after_the_engine_is_ready -k 'not symlink' -n 0 -x --tb=short --basetemp "$R\checks\939-auth-portable\pytest" --junitxml "$R\checks\939-auth-portable\junit.xml"
# 116 passed, 10 skipped, 1 deselected, 1 failed: the existing POSIX 0600 assertion sees 0666 mode bits on Windows.
uv run pytest tests/test_rpc_transport.py tests/test_cli_serve_commands.py tests/test_docker_runtime.py::test_container_bootstraps_the_browser_after_the_engine_is_ready -k 'not symlink and not test_the_state_file_stays_owner_only' -n 0 -x --tb=short --basetemp "$R\checks\939-auth-filtered\pytest" --junitxml "$R\checks\939-auth-filtered\junit.xml"
# 129 passed, 10 skipped, 2 deselected, 1 failed: the supervisor fixture cannot launch its `sleep 600` command (WinError 2).
uv run pytest tests/test_rpc_transport.py tests/test_cli_serve_commands.py::TestTheSignInLink tests/test_docker_runtime.py::test_container_bootstraps_the_browser_after_the_engine_is_ready -k 'not symlink' -n 0 -x --tb=short --basetemp "$R\checks\939-auth-focused\pytest" --junitxml "$R\checks\939-auth-focused\junit.xml"
# 50 passed, 1 deselected.

The three failing test paths and their relevant setup/assertions also exist in the recorded base; source comparison attributes none to this PR. They limit the broad suite here. The final selection covers the changed authentication behavior on real local aiohttp servers, including forwarded sign-in and WebSocket upgrades, cross-port/rebinding refusals, cookie separation, proxy compatibility, refusal messages and printing the link before browser launch. Browser opening itself is mocked.

No live browser/VS Code forward, Docker container, Windows ACL audit or full repository suite was exercised. The skips require POSIX process groups. This is a scoped Windows result, not full compatibility or an ACL-security conclusion.

@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 new commit and the resulting full change. The delta removes the duplicate helper, restores the module documentation, and strengthens coverage without changing the forwarded-port admission or cookie behavior previously reviewed. In particular, the standalone serve --open path is now executed rather than only inspected, and malformed Host plus localhost/IPv6 origin cases are covered.

I rechecked the project rules/context, affected callers, compatibility and security boundary, architecture placement, and that no assertions were weakened. No new issue changes the prior clean assessment.

Verification: uv run pytest tests/test_rpc_transport.py tests/test_cli_serve_commands.py tests/test_docker_runtime.py -x passed (233 tests); the focused Ruff check passed. There are no review threads from me to resolve.

@LivXue

LivXue commented Oct 13, 2026

Copy link
Copy Markdown
Member Author

Thanks, both nits were real and are fixed in 5dbb767.

  • The duplicated _loopback_origin came from a move done as two edits: the second, the deletion, failed, and I read the failure as the old copy already being gone. ruff's F811 does not report a redefined underscore name (checked with --select F811: g is reported, _f is not), so lint passed it. The first copy was shadowed at import and could not run, and those ten lines are also most of what failed the coverage gates check (60.47%, 26/43).
  • The module docstring sentence is restored to the text on main.

Closing the coverage gap turned up a vacuous test. The localhost case sent the bound port, which the old allowlist already accepted, so the new rule's localhost branch never ran. It now goes through a forward and fails on main. A tidy-up I tried on the way also showed the two except ValueError arms are reachable. Over raw HTTP a Host of 127.0.0.1:99999, 127.0.0.1:abc or [::1 makes the parse raise, and without the arms /rpc answered 500 where main answers 401. Both arms stay, and each now has a test.

At this head: diff coverage 100% (26/26) locally; 11 single-construct mutants each killed, and a base control fails all 18 new tests.

@LivXue

LivXue commented Oct 13, 2026

Copy link
Copy Markdown
Member Author

Addressed the native Windows test constraints and a shared-cache race that can produce the Windows self-upgrade failure. The existing branch now points to 230a498dd90e97fc31a978b738c8b16990db9524; your concurrent origin-helper and coverage changes were preserved.

  • e9cb24ef: each real upgrade fixture gets its own RAVEN_HOME, so its marker and cached wheels cannot collide with the other worker. Localized failure diagnostics are retained. The ordinary native run initially passed; a scratch barrier forcing both helpers to write their partial wheel before rename reproduced 1 failed / 1 passed with Distribution not found in the shared cache. The identical overlap probe after isolation passed both cases.
  • 538ab20d: skip symlink setup only for Windows privilege error 1314. Other filesystem errors propagate, and the confinement assertion still executes when creation succeeds.
  • ea211292 and 230a498d: scope POSIX mode-bit and SIGTERM-handler assertions to POSIX. A native probe confirmed that Windows Popen.terminate() terminates the process without invoking its Python SIGTERM handler; replacing the missing sleep command alone would not make those assertions valid.

Native Windows build 10.0.26300, PYTHONUTF8=1:

uv run --frozen --no-sync pytest tests/test_rpc_transport.py tests/test_cli_serve_commands.py tests/test_docker_runtime.py::test_container_bootstraps_the_browser_after_the_engine_is_ready -n 0 -x --tb=short

Python 3.13.12: 195 passed, 30 skipped. Four skips identify the newly handled privilege/mode/signal preconditions; 26 existing POSIX skips remain. No ACL or symlink-confinement success is claimed for the skipped cases.

With UV_PYTHON=3.12 and an isolated caller Raven home:

uv run --no-project --python 3.12 --with 'pytest>=8,<10' --with pytest-asyncio --with pytest-xdist pytest --noconftest tests/integration/test_cli_upgrade_real_uv.py -q --tb=short -n 4

3 passed: both real uv upgrade variants and the workflow contract. Whole-PR pre-commit, Ruff, import contracts, changed-production type checks, commitlint and repository gates passed.

The independent Linux CI failure in the new fake Application (on_response_prepare missing when a built page is present) is outside this Windows follow-up and remains disclosed in the description. New CI is pending. No full repository suite, live browser/container or Windows ACL audit was run, and no review thread was resolved.

@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 four-commit delta and the resulting full change. The Windows guards are limited to unavailable POSIX mode, SIGTERM-delivery, and symlink-privilege behavior; they retain each underlying assertion on platforms where its setup is meaningful. The upgrade fixture isolation and replacement decoding are test-only and do not alter product behavior.

I rechecked the repository rules/context, affected test callers and history, compatibility, architecture boundaries, and assertion strength. No tests were weakened to manufacture a pass, and this delta does not change the previously reviewed forwarded-port implementation.

Verification: uv run pytest tests/test_rpc_transport.py tests/test_cli_serve_commands.py tests/test_docker_runtime.py -x passed (233 tests). The modified real-uv upgrade case passed both parameters (2 tests); pytest emitted its existing warning that these unmarked cases exceed the advisory idle ceiling. Focused Ruff checks passed. There are no review threads from me to resolve.

@LivXue

LivXue commented Oct 13, 2026

Copy link
Copy Markdown
Member Author

Windows compatibility re-review: native auth/serve checks pass, but the already-disclosed built-page test fixture failure also reproduces on Windows.

Scope: all eight changed files at 230a498dd90e97fc31a978b738c8b16990db9524, against base/merge base ee969dbc41a4b00978b4430aae9590a5ebfba010: forwarded Origin/Host admission, cookie naming, browser-opening callers, Docker/nginx contract, Windows test guards and upgrade-fixture isolation. All 4,458 source blobs in the isolated snapshot matched the head tree; the full diff passed git diff --check.

P2 - Complete the new Application test double for a built page (reproduced). test_cli_serve_commands.py:473-478 supplies on_shutdown but no on_response_prepare. When the workspace contains a built page, the real build_app registers its response hook and the new standalone-serve test raises AttributeError before checking the sign-in link. The baseline has no such test double. A native Windows scratch probe invoked that exact test with resolve_ui_dist selecting a temporary index.html; it failed at ws.py:833. Use a complete application fixture or provide the response-prepare signal and explicitly cover both UI-presence branches. This confirms the Windows impact of the author's existing disclosure; it is a test-fixture defect, not evidence that production authentication fails.

Native Windows / Python 3.13.12; reused uv environment with identical pyproject.toml and uv.lock, UV_NO_SYNC=1, PYTHONUTF8=1, isolated user/Raven/temp directories, and imports verified against this head. Exact commands from the snapshot root:

$R = 'C:/Users/livxu/AppData/Local/Temp/raven-windows-review/0d8ab4cfce454f15b5d09028fe345dbb'
uv run pytest tests/test_rpc_transport.py tests/test_cli_serve_commands.py tests/test_docker_runtime.py::test_container_bootstraps_the_browser_after_the_engine_is_ready -n 0 -x --tb=short --basetemp "$R/checks/939-auth-updated/pytest" --junitxml "$R/checks/939-auth-updated/junit.xml"
# 195 passed, 30 skipped; exit 0 (snapshot without built UI assets).
uv run pytest "$R/test_windows_built_page_fixture.py" -n 0 -x --tb=short --basetemp "$R/checks/939-built-page/pytest" --junitxml "$R/checks/939-built-page/junit.xml"
# 1 failed: AttributeError, _Application has no on_response_prepare.

This broader selection now completes on Windows without filtering tests by name. It covers forwarded authentication/WebSocket requests, malformed Host rejection, localhost/IPv6 admission, cookie separation, the standalone serve/open path and printing the sign-in link before browser launch. Browser launching is mocked. The symlink setup skips specifically for Windows privilege error 1314; other filesystem failures still propagate. The mode-bit and SIGTERM checks now declare their POSIX requirement.

Code-reading only: the real uv-upgrade fixture now supplies its own spaced RAVEN_HOME, so its marker/wheel cache is per case, and preserves failure diagnostics with replacement decoding. That real installation/upgrade test was not executed. Skips also cover existing POSIX process-group tests.

Limits: no live browser/VS Code forward, Docker container, real uv self-upgrade, Windows ACL audit or full repository suite. Skipping POSIX permissions does not establish equivalent Windows ACL enforcement.

The gateway accepted a browser Origin only when it named the port the
gateway bound, so any port forward that picked another local port was
refused: every /auth/exchange answered 403 and every /rpc upgrade with
it. The page reported an expired sign-in link over a refusal it cannot
see, and re-running `raven web` minted one more link into the same
refusal, 13 times on 2026-10-13. The same check already forced the
container's nginx to rewrite Origin for every request it proxies.

Origin now also passes when it asks the request's own origin on a
loopback name. That is what a browser sends through a forward, since
the forwarder relays Host unchanged, and it still refuses a page another
local server shows (cookies and SameSite ignore the port) and DNS
rebinding (the name is not loopback). The bound-port allowlist stays
for nginx, which does not forward a port in Host.

The session cookie is now named for the port the browser reached, so
two gateways that both bound 18792 do not share one jar entry when one
browser reaches them through different forwards.

The exchange answers with the reason it refused, and the sign-in page
shows that text instead of calling every failure an expired link.

`_open` prints the sign-in link every time, before handing it to a
browser: webbrowser.open returns True whenever it ran something, so the
fallback was unreachable, and a console browser holds the terminal.
The previous commit defined _loopback_origin twice and replaced one
sentence of the module docstring with an unrelated one. The first copy
was shadowed at import, so behaviour was unchanged, but its ten lines
could never run and took the diff-coverage gate to 60% (26/43). ruff's
F811 does not report a redefined underscore name, so lint stayed clean.

Both are restored, and tests now reach each branch the gate listed:

- the localhost spelling through a forward. The old test used the bound
  port, which the previous allowlist already accepted, so the new branch
  never ran;
- a Host whose port does not parse (out of range, not a number, an open
  IPv6 bracket), answered 401 rather than a 500;
- an Origin equal to its Host that does not parse, answered 403;
- the loopback rule asked directly, over the spellings a request-level
  test would each need a forward for;
- `raven serve --open` run end to end, rather than pinned by source.

Diff coverage at this head is 100% locally (26/26).
Give each real uv upgrade case its own marker and wheel cache so parallel Windows workers cannot replace or delete cached wheels from a sibling. Preserve localized failure diagnostics when capturing subprocess output.
Skip only the unsupported symlink setup when Windows reports privilege error 1314. Keep the path-confinement assertion active wherever symlink creation succeeds and propagate other filesystem errors.
Windows reports writable DOS mode bits rather than POSIX owner-only permissions. Keep the existing mode assertion on POSIX and identify Windows permission coverage as unsupported by this test.
Popen.terminate uses TerminateProcess on Windows and cannot exercise Python SIGTERM cleanup handlers. Keep these signal-delivery regressions on POSIX, alongside the existing process-group tests.
Use the real aiohttp Application while replacing the runner and TCP site,
so build_app can register its response hook without a partial fake.

Resolve the UI from temporary candidates, cover both page states, and
assert that revalidation is registered only when a built page is served.
Correct the startup comment now that the coroutine is exercised in tests.
@LivXue
LivXue force-pushed the fix/web_signin_forwarded_port branch from 230a498 to 4aed82d Compare October 13, 2026 17:09

@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 rebased revision with git range-diff so upstream main changes were not attributed to this PR. The six previously reviewed commits are patch-equivalent; the only new patch replaces the incomplete aiohttp application fake with a real web.Application, exercises both built-page states, and corrects the now-stale startup comment. It changes no authentication behavior.

I rechecked the security boundary, repository rules/context, affected caller, history, backward compatibility, architecture placement, and assertion strength. The real application makes the test cover the response hook that failed under the partial fake, without weakening the sign-in assertions.

Verification: uv run pytest tests/test_rpc_transport.py tests/test_cli_serve_commands.py tests/test_docker_runtime.py -x passed (234 tests), and the focused Ruff check passed. There are no review threads from me to resolve.

@LivXue

LivXue commented Oct 13, 2026

Copy link
Copy Markdown
Member Author

Windows compatibility re-review: no newly introduced Windows issue found in the inspected scope; the previously reported built-page test fixture issue is fixed.

Reviewed all eight changed files at 4aed82d0cc9067e044a7a894936458672cdd3b13, against base/merge base efe0f46aed75f0f25666f8eba1f1f1b1ed231011: Origin/Host admission, forwarded-port cookies, authentication handlers and callers, browser-link output, Docker/nginx contract, Windows test guards and upgrade-fixture isolation. git range-diff confirms the six previously reviewed patches are equivalent after rebase; the added patch completes the standalone-serve fixture and updates its startup comment. git diff --check passed.

Native Windows / Python 3.13.12: 196 passed, 30 skipped. Both without-page and with-page standalone-serve cases passed. They now use a real aiohttp Application, select temporary UI locations, and check response-hook registration only for the built page. This resolves the previous P2 finding.

Exact commands from the isolated detached head checkout; separate uv environment and home/temp/token directories, PYTHONUTF8=1, UV_NO_SYNC=1 for tests, and imports verified against this head:

uv sync --frozen --python 3.13 --all-packages --extra dev
$R = 'C:/Users/livxu/AppData/Local/Temp/raven-windows-review/7d6eb1a69a25421f93a87e4aa3303329'
uv run pytest tests/test_rpc_transport.py tests/test_cli_serve_commands.py tests/test_docker_runtime.py::test_container_bootstraps_the_browser_after_the_engine_is_ready -n 0 -x --tb=short --basetemp "$R/checks/939-auth/pytest" --junitxml "$R/checks/939-auth/junit.xml"
# 196 passed, 30 skipped; exit 0

The selection exercises local HTTP/WebSocket authentication, forwarded cookies, malformed hosts, cross-port/rebinding refusals and link output. Browser launching and the standalone test's socket runner are mocked. Skips cover unavailable Windows symlink privilege and POSIX mode/signal/process-group requirements; they do not establish equivalent Windows ACL enforcement.

Code-reading only: per-case RAVEN_HOME and replacement decoding remain in the real-upgrade fixture. No real uv self-upgrade, live browser/VS Code forward, Docker container, ACL audit or full repository suite was run.

@0xKT

0xKT commented Oct 13, 2026

Copy link
Copy Markdown
Member

Not a blocker. The port-forward sign-in fences the origin correctly -- I confirmed _loopback_origin (raven/rpc/transports/ws.py:96) accepts only canonical loopback (localhost, 127.0.0.1/8, ::1) and refuses every adversarial spelling I tried (DNS-rebind evil.localhost and 127.0.0.1.evil.com, a public IP, ::ffff-mapped, decimal 2130706433, hex 0x7f000001, an open IPv6 bracket), and _origin_ok (ws.py:252) additionally requires Origin == http://Host for the forwarded case, so there is no cross-origin or DNS-rebind sign-in. One nit:

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

(nit, one-definition) _loopback_origin reimplements the loopback-host predicate the security vocabulary already owns. The same decision -- "is this origin a canonical loopback host, refusing legacy spellings" -- lives in raven/security/hosts.py (is_local_name / as_ip / is_legacy_ip_form) composed as raven/security/urls.py:_host_is_local. rpc is a surface and may import the security leaf, so there is no seat barrier to reusing it. Current behavior matches _host_is_local on every adversarial spelling (the only divergence is that _loopback_origin is stricter -- it refuses localhost.localdomain, harmless), so this is not a defect today; the risk is future drift -- a later fix to the security address vocabulary (a new IP spelling) would not reach this origin check. Suggest calling _host_is_local (or the hosts.py predicates) instead of the local urlsplit+ipaddress reimplementation.

@0xKT
0xKT merged commit 24433cb into main Oct 14, 2026
23 checks passed
@0xKT
0xKT deleted the fix/web_signin_forwarded_port branch October 14, 2026 03:05
0xKT pushed a commit that referenced this pull request Oct 14, 2026
… cache (#942)

## Summary

A long-running Raven host held on to three kinds of memory it no longer
used. Observed on one dev box on 2026-10-12:

- Two ACP agents, started on 2026-09-20 by a host that exited without
closing them, were still running 22 days later. They had been reparented
to init and held 523 MiB PSS between them.
- A gateway's glibc thread arenas had grown from 85 to 214 MiB over 18
days of light use.
- `SessionManager` kept every session it had ever loaded.

This PR cleans up all three automatically.

**1. ACP agents that outlive their host (Linux).** An ACP agent starts
in a session of its own, so it gets none of the signals sent to its
host's process group or terminal, and while the host lives only
`AcpClient.close` ends it. A host that exited without closing its
agents, or was killed, crashed or OOM-killed before its teardown, left
them running.

- The agent now starts through a one-line exec wrapper
(`raven/acp_client/leftovers.py`). It runs the host's own interpreter
with `-I -S`, arms `PR_SET_PDEATHSIG(SIGKILL)`, checks that its parent
is still the host, restores SIGPIPE and SIGXFSZ to their defaults and
execs the agent in place, with the same pid and group and the agent's
own argv. The agent's process then ends with the host.
- The signal reaches only the direct child, and an `npx` launcher's real
server is a grandchild. So every host also records the process groups it
launched, one file per host under `acp-launches/` in the config file's
directory. The first ACP launch in any process ends the groups recorded
by hosts that have since died. That sweep runs once per process on a
daemon thread. Every launch waits for it for at most 5 s, and once one
launch has waited that long the rest stop waiting for it.
- A group is ended only when all of the following hold:
- the record is of this version and comes from this boot and PID
namespace;
  - its host is gone, judged by pid and start time;
  - the group is not group 0, 1 or the sweeper's own;
  - its leader, if still running, has the start time on record;
- a current member still carries the dead host's mark,
`RAVEN_ACP_HOST=<host pid>-<host start ticks>`, which every launched
agent gets last in its environment.

A read that fails, of a record or of `/proc`, keeps the record and ends
nothing; a record that does not parse is deleted.

**2. Native heap trimming (Linux with glibc).** glibc keeps the memory
native code frees, such as PyMuPDF rasters and tokenizer tables, in its
arenas. `raven/utils/heap.py` checks every ten minutes and, once
anonymous memory stands 32 MiB above its lowest reading since the last
trim, runs `malloc_trim(0)` in a worker thread. The five long-running
hosts start it: `raven gateway`, `raven serve`, the TUI's RPC host,
`raven acp` and `raven a2a serve`.

**3. A bounded session cache.** `SessionManager._cache` becomes an LRU
with a soft limit of 64 sessions.

- A session leaves the cache only when nothing about it is unsaved. Its
message count must match what is on disk, and a fingerprint of its
metadata, consolidation mark and pending clarification must match the
one taken when it was last read or written. A metadata patch counts its
own keys as saved even when the caller set them on its copy first.
- A session that a caller still holds comes back as the same object.
- A reloaded session keeps the `updated_at` it was saved with.

`CONTEXT.md` gains two terms, **Leftover agent** and **Launch mark**.

Measured on this box on 2026-10-13:

- **PyMuPDF.** 112 pages rasterised three times in a 3-thread pool. In
two runs, anonymous memory was 279.8 and 315.3 MiB after
`store_shrink(100)`, and 93.5 and 84.6 MiB after `heap.trim()`.
- **Gateway.** An isolated gateway with a key-less config, run for 100
s. With the interval shortened to 45 s, one trim fired at t+47.7 s and
took anonymous memory from 293 to 273 MiB; with the default interval,
none fired. Final PSS was 321.6 against 328.6 MiB. Its boot check
launched 16 ACP agents through the wrapper: the five discovered agents
under `agents/`, which that config cannot start, and eleven presets,
eight of which completed the handshake (Claude Code, Codex, OpenCode,
Hermes Agent, OpenClaw, CodeBuddy, Qoder and Pi). Afterwards no marked
process and no launch record remained.
- **Killed host.** A stand-in host was SIGKILLed after launching through
`AcpClient`. On main, a direct agent, an `npx`-shaped launcher and its
grandchild all survive with ppid 1. On this branch, the direct agent and
the launcher end with the host, and the next host's first launch ends
the grandchild.
- **Discovered agents, with and without the wrapper.** Each of the five
under `agents/` was verified twice in a home holding only dummy keys (a
handshake calls no model): Raven-Design completed the handshake both
ways, and the other four stopped on the same missing key both ways.

The Windows regression fixtures use explicit executable filenames and
native project-path strings. The unreadable-transcript symlink fixture
skips only when Windows reports missing symlink privilege (WinError
1314); other creation errors still fail. These test corrections do not
extend the Linux-only cleanup mechanisms.

## Type

- [x] Fix
- [ ] Feature
- [ ] Docs
- [ ] CI / tooling
- [ ] Refactor
- [ ] Other

## Verification

The original author's Linux measurements below precede the Windows
test-fixture follow-up. The native Windows results that follow were run
against the final follow-up tree.

- **Author's Linux full suite before the Windows test-fixture
follow-up:** `uv run --frozen --python 3.12 --all-extras pytest -q -p
no:cacheprovider` (repo addopts: `-n 4`, integration and e2e
deselected). Result: 3 failed, 30261 passed, 158 skipped, 2 xfailed. The
failures are the ones this box gives on main too:
`test_install_script.py::test_resolve_node_dir_answers_each_case_it_exists_for`,
`test_knowledge_parser_excel.py::test_a_legacy_workbook_is_converted_and_read`
(no LibreOffice) and
`test_subagent_node_runtime.py::test_what_cannot_be_read_names_nothing`
(run as root).
- **Directly affected files:** `tests/test_acp_leftovers.py`,
`tests/test_utils_heap.py`, `tests/test_session_manager.py`,
`tests/test_cli_{a2a,acp,gateway,serve,tui}_commands.py` and
`tests/test_subagent_host_env.py`, under `--idle-ceiling-strict` as CI
runs them. Result: 620 passed.
- **Neighbours:** `tests/test_rpc_session.py`,
`tests/test_session_{export,title,project_grouping}.py`,
`tests/test_defer_manager.py`, `tests/test_subagent_acp.py` and
`tests/test_acp_{ask_user,elicitation,remote}.py`. Result: 683 passed.
- **Lint and gates:** `make lint-python lint-imports lint-deps
lint-types check-large-files check-source-language check-commits`.
Result: all seven exit 0; import contracts 10 kept, 0 broken.
- **Mutation:** guards were deleted or inverted one at a time, out of
tree, and each such mutant failed the test written for it. They covered
the sweep's wait, bound, daemon flag and start failure; the version,
boot-time, PID namespace, liveness, leader and mark checks; the
`/proc/<pid>/stat` parse; the trimmer's reader and threshold; and the
cache's eviction order, fingerprint, `updated_at` restore and patch
rule.
- **Diff coverage:** 96.27% (387 of 402 executable changed lines) from
the directly affected and neighbouring files alone, against CI's 90%
gate. What stays uncovered is the arms for a `/proc` read or a `killpg`
that fails, and the import inside `raven a2a serve`, which only a
running server reaches.

**Native Windows test-fixture follow-up (2026-10-14):** Windows
10.0.26300, Python 3.13.12, `PYTHONUTF8=1`, a dedicated worktree and `uv
sync --frozen --python 3.13 --all-packages --extra dev`. The imported
Raven package was verified to come from that worktree. Pytest's isolated
homes and a separate caller `RAVEN_HOME` were used. JUnit output paths
are omitted from the command below.

```powershell
uv run --frozen --no-sync pytest tests/test_acp_leftovers.py tests/test_utils_heap.py tests/test_session_manager.py tests/test_session_project_grouping.py tests/test_subagent_host_env.py tests/test_cli_a2a_commands.py::test_serve_starts_the_heap_trimmer tests/test_cli_acp_commands.py::TestServe::test_the_heap_trimmer_starts_before_the_channel_is_served tests/test_cli_gateway_commands.py::test_run_starts_the_heap_trimmer_at_boot tests/test_cli_serve_commands.py::test_serve_starts_the_heap_trimmer_at_boot tests/test_cli_tui_commands.py::test_the_terminal_host_starts_the_heap_trimmer -k 'not test_acp_child_inherits_custom_raven_home and not test_acp_child_is_told_it_serves_as_a_subagent' -n 0 --idle-ceiling-strict --tb=short
```

Result: **229 passed, 73 skipped, 2 deselected**, exit 0 with the idle
ceiling enforced. Of the skips, 72 are existing Linux/glibc cases and
one is the measured lack of Windows symlink privilege. The three
corrected discovery/path cases failed before the corrections and pass in
this selection. Before the symlink guard, the same broader selection had
229 passes, 72 skips, two deselections and one WinError 1314 failure.

The two deselected real ACP child tests still depend on the native close
fix in #908. The broader CLI permission/signal limitations reported in
the Windows review are covered separately by #939. This follow-up claims
no full Windows suite pass, real ACP lifecycle validation, privileged
symlink run, Windows ACL audit, or new Linux run.

- `uv run --frozen --no-sync ruff check raven evolver agents
plugins-dist tests scripts docs-site` and the matching `ruff format
--check`: passed (2,272 files formatted); the final symlink edit also
passed both file-scoped checks.
- `uv run --frozen --no-sync lint-imports`: 10 contracts kept. `uv run
--frozen --no-sync ty check raven/acp_client/client.py
raven/acp_client/leftovers.py raven/cli/a2a_commands.py
raven/cli/acp_commands.py raven/cli/gateway_commands.py
raven/cli/serve_commands.py raven/cli/tui_commands.py
raven/session/manager.py raven/utils/heap.py`: passed.
- `uv run --frozen --no-sync pre-commit run --from-ref origin/main
--to-ref HEAD`: all applicable hooks passed.
- `node node_modules/@commitlint/cli/cli.js --from origin/main --to HEAD
--config commitlint.config.cjs`, `uv run --frozen --no-sync python
scripts/check_commit_messages.py origin/main..HEAD`, `uv run --frozen
--no-sync python scripts/check_large_files.py origin/main...HEAD`, `uv
run --frozen --no-sync python scripts/check_source_language.py
origin/main...HEAD`, and `git diff --check origin/main...HEAD`: passed.

The relevant-tests checkbox remains unchecked because the broader
Windows files still contain the disclosed limitations above.

- [ ] Relevant tests pass locally
- [x] Relevant lint / type checks pass locally
- [ ] User-facing docs or screenshots are updated when needed

No user-facing docs change, because no command, flag or setting does.
The two new terms are glossary entries in `CONTEXT.md`.

## Risk

User-visible behaviour changes:

- **An ACP agent that exists but cannot be executed now fails after the
spawn.** Examples are a bad interpreter line or a binary for the wrong
architecture. The wrapper writes `raven: cannot start <path>: <error>`
to the agent's stderr and exits 127, so the failure surfaces as a
connection that ends with that line in its stderr, where
`AcpClient.launch` used to raise `cannot start` at once. A missing
program still fails at launch, as before.
- **Agent environment.** Every ACP agent's environment carries
`RAVEN_ACP_HOST`. It is an identifier, not a secret; its parts are
world-readable in `/proc/<pid>/stat`.
- **Locale.** An agent started under a C or POSIX locale gets
`LC_CTYPE=C.UTF-8`, because the wrapper interpreter's PEP 538 coercion
carries through the exec.
- **Launch records.** Every ACP launch and close writes a small JSON
file under `acp-launches/` beside the config file, synchronously and
with an fsync.
- **First launches.** While dead hosts' records are swept, a process's
ACP launches wait for the sweep, 5 s at most in all: once one launch has
waited that long, the rest start without it. Normally the sweep takes
milliseconds.
- **Session timestamps.** A reloaded session's `updated_at` is the one
it was saved with, where it used to be the time of the load, so a save
that adds no message no longer moves it. Sentinel's defer timing and the
export header read it in memory, and the session list orders by the
saved value.

Left as today:

- Anything but Linux. #928 tracks macOS and Windows.
- A group whose surviving processes carry no readable mark: one started
with a fresh environment, another user's, or an undumpable one.
- An agent binary that is set-id or carries file capabilities: its exec
clears the parent-death signal, so only the sweep can end it, and only
where its environment is readable.
- Spawns other than ACP agents: the CLI sub-agent backend, background
exec and the sandbox executor.
- Leftovers created during one long-lived host's life. They wait for
another process's first ACP launch.
- A host started with another `--config` sweeps that directory's records
only.

Edge cases of the records directory:

- **Another PID namespace.** Its records are kept until a reboot, about
300 bytes per container start.
- **A directory shared between machines.** Another machine's record
older than this machine's boot is removed by this machine's sweep. That
host's agents would then not be swept if it died before its next launch
or close rewrote the file. Nothing is ever signalled across machines.

Edge cases of the session cache:

- A change made through a reference to a session that had already left
the cache, and then released unsaved, is lost, as it would be in a fresh
process.
- An in-memory undo whose save failed, followed by new messages back to
the same count, reads as clean. `save()` already mishandles that state,
so the residue predates this PR.

A group is signalled only when it passes all five checks above. A
crafted record cannot point a sweep at a live host's agents, because the
mark names the identity the liveness check reads.

- [x] Security impact considered
- [x] Backward compatibility considered
- [x] Rollback path is clear for risky changes

Rollback is a revert. Nothing persisted changes shape: session files are
written exactly as before, and `acp-launches/` only holds what the next
sweep reads, so it can be deleted at any time.

## Related Issues

#928 (macOS and Windows; stays open)
LivXue added a commit that referenced this pull request Oct 14, 2026
## Summary

RPC browser-origin checks and URL plaintext checks each implemented the
same loopback IP classification. Both now use
`security.hosts.is_loopback_ip`, which reuses the existing `as_ip`
parser.

The callers retain their name policies: RPC accepts `localhost`
alongside loopback IPs; URL plaintext checks also allow
`localhost.localdomain` and trailing-dot aliases. Origin/Host matching
and malformed-origin refusals remain in the RPC layer. Regression cases
exercise both accepted and refused hosts through the real sign-in
handler.

## Type

- [ ] Fix
- [ ] Feature
- [ ] Docs
- [ ] CI / tooling
- [x] Refactor
- [ ] Other

## Verification

Linux, Python 3.12.13, with existing dependencies matching the lockfile
for the affected modules and check tools:

```bash
uv run --no-sync pytest tests/test_security_hosts.py tests/test_security_urls.py tests/test_rpc_transport.py tests/test_security_network.py tests/test_security_web_ssrf.py tests/test_browser_policy.py tests/test_market.py tests/test_rpc_skillhub.py tests/test_skill_hub_policy.py tests/test_skill_hub_client.py tests/test_cli_serve_commands.py -q
```

726 passed; one supervisor test timed out waiting for its child to write
the state file. Its isolated rerun passed:

```bash
uv run --no-sync pytest tests/test_cli_serve_commands.py::TestTheSupervisorCleansUpOnTheSignalThatStopsIt::test_sigterm_removes_the_state_file -q -n 0
```

The expanded URL/RPC boundary cases passed before the production
refactor (60 passed). A separate comparison of the original and shared
predicates found no differences across 6,535 host strings and 39,214
origins, including legacy addresses, aliases, mapped IPv6 and malformed
brackets.

These checks passed:

```bash
uv run --no-sync ruff check raven/security/hosts.py raven/security/urls.py raven/rpc/transports/ws.py tests/test_security_hosts.py tests/test_security_urls.py tests/test_rpc_transport.py
uv run --no-sync ruff format --check raven/security/hosts.py raven/security/urls.py raven/rpc/transports/ws.py tests/test_security_hosts.py tests/test_security_urls.py tests/test_rpc_transport.py
uv run --no-sync ty check raven/security raven/rpc/transports
uv run --no-sync lint-imports
uv run --no-sync pre-commit run --files raven/security/hosts.py raven/security/urls.py raven/rpc/transports/ws.py tests/test_security_hosts.py tests/test_security_urls.py tests/test_rpc_transport.py
```

- [x] Relevant tests pass locally
- [x] Relevant lint / type checks pass locally
- [ ] User-facing docs or screenshots are updated when needed

No user-facing documentation change is needed for this refactor. The
full repository suite and native Windows/macOS runs were not performed.
The supervisor timeout was not compared against an unmodified main
checkout.

## Risk

- [x] Security impact considered
- [x] Backward compatibility considered
- [x] Rollback path is clear for risky changes

The change touches authentication and URL trust predicates. It retains
each caller's local-name rules and the standard library's IP
classification, including acceptance of IPv4-mapped loopback on Python
3.12.13. No DNS resolution, listeners, credentials or configuration are
added. Reverting this refactor restores the previous separate checks
without a data migration.

## Related Issues

#939
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