Skip to content

fix(*): stop offering a checkout the page upgrade, name each refusal's recovery - #1003

Merged
LivXue merged 12 commits into
EverMind-AI:mainfrom
yudongyouqing:fix/upgrade_refusal_recovery
Oct 23, 2026
Merged

LivXue merged 12 commits into
EverMind-AI:mainfrom
yudongyouqing:fix/upgrade_refusal_recovery

Conversation

@yudongyouqing

@yudongyouqing yudongyouqing commented Oct 18, 2026 •

Copy link
Copy Markdown
Contributor

Summary

  • The A remainder of feat: make the one-click update in Settings > About work under raven web #716 after feat(*): upgrade from the page with progress, open it for bare raven #806 landed most of A and B: a source checkout is no longer offered a page upgrade it can never take, and every refusal that has a recovery of its own names it instead of the card's fixed raven upgrade.
  • update_notice._upgrade_command_works() now excludes editable installs, as its docstring always claimed: an editable uv-tool install (a checkout under ./install.sh) passes the receipt check alone, and both raven upgrade and the page's fallback refuse it.
  • system.upgrade refusals carry an optional data.command the card copies: unsupervised names raven upgrade then raven web, and a new editable refusal fires before the release lookup and names git pull plus the platform's install script (.\install.ps1 on Windows, ./install.sh elsewhere).
  • The refusals classify before they manage: not_serving, then editable, then the gateway's unsupervised and busy answers. An editable checkout under a hand-started gateway therefore gets the checkout's own recovery, not the unsupervised sequence whose first command (raven upgrade) always refuses a checkout.
  • The failure card renders and copies the refusal's own command, line per line, keeping the default when none was given; the editable frame is translated (gui.upg.why.editable, en and zh, TUI catalogue regenerated).
  • Tests that simulate a release install now pin _is_editable_install to False: a dev venv (and CI) install raven editable from the tree, so without the pin the new pre-check would fire everywhere.

Type

  • Fix

Verification

  • Relevant tests pass locally: uv run pytest tests/test_rpc_system.py tests/test_updates_update_notice.py -> 84 passed (the overlap test arms the hosted-unsupervised state with the editable probe true and asserts the checkout answer wins); npx vitest run src/state/upgradeShade.test.ts src/app/updates.test.ts -> 25 passed (both file orders); npm run type-check and eslint on the four touched web files clean
  • The new coverage fails on the base: stashing only the source changes -> the editable-refusal test, the card-command test and the overlap test fail; restoring -> pass
  • Relevant lint / type checks pass locally: ruff check + ruff format --check on the touched Python files; ty check identical to a stashed baseline (zero new)
  • User-facing docs or screenshots are updated when needed: the refusal copy and its zh translation are the user-facing surface and are in the catalogue

Risk

  • Security impact considered: the command strings are constants chosen by reason, never assembled from user input
  • Backward compatibility considered: refusals without a command keep today's card verbatim; the button's absence on checkouts is the docstring's promised behaviour
  • Rollback path is clear: plain revert

Related Issues

Part of #716 (the remainder scoped in its thread; #806 already landed most of A and B)

@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: the editable/unsupervised overlap still offers a command that cannot work.

I reviewed the full github/main...HEAD diff, the upgrade and installer callers, the originating issue and relevant history, backward compatibility, test changes, and the repository's AGENTS/CLAUDE/context constraints. The reason-specific command plumbing and editable notice suppression otherwise look coherent, and I did not find weakened tests.

Verification: uv run pytest tests/test_rpc_system.py tests/test_updates_update_notice.py (83 passed); npx vitest run src/state/upgradeShade.test.ts src/app/updates.test.ts (25 passed); TypeScript type-check and ESLint on the four touched web files passed; git diff --check github/main...HEAD passed. I also reproduced the overlap directly: editable + gateway-hosted + no supervisor returns reason=unsupervised with raven upgrade as the first recovery command.

Comment thread raven/rpc/methods/system.py Resolved
@yudongyouqing
yudongyouqing force-pushed the fix/upgrade_refusal_recovery branch from 1ce1419 to 6f15a78 Compare October 22, 2026 03:37

@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.

The new revision fixes the editable/unsupervised overlap and adds the missing regression coverage. I re-read the full github/main...HEAD diff and the new delta, relevant upgrade and installer callers and history, backward compatibility, test strength, architecture boundaries, and AGENTS/CLAUDE/context constraints. I found no new plain error or weakened test.

Verification: uv run pytest tests/test_rpc_system.py tests/test_updates_update_notice.py (84 passed); npx vitest run src/state/upgradeShade.test.ts src/app/updates.test.ts (25 passed); TypeScript type-check passed; git diff --check github/main...HEAD passed. My prior thread is replied to and resolved.

Comment thread raven/rpc/methods/system.py Resolved
@0xKT

0xKT commented Oct 22, 2026

Copy link
Copy Markdown
Member

Not a blocker (review of 6f15a788, main 43709e20); the blocking item is in the thread on raven/rpc/methods/system.py. Re-checked against the code at the head:

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.

  1. The unsupervised card's copied commands leave the old gateway running. The card now copies raven upgrade then raven web (raven/rpc/methods/system.py:298). For a gateway started by hand, raven upgrade does not stop or relaunch it: without a relaunch the helper ends with "Restart any other running Raven process to use the new version." (raven/updates/upgrade.py:800-801), and raven web then attaches to the gateway that is still running ("attaching to the gateway already running", raven/cli/serve_commands.py:1503-1506). So a reader who runs exactly what the card copies is back on the page served by the old process. The detail sentence says "then start Raven again", which needs the old one stopped first; the command could include that step, or say it.
  2. The checkout's update command now has two spellings. raven upgrade on a checkout prints git pull && ./install.sh on every platform (raven/cli/upgrade_commands.py:54, pre-existing), while the page sends git pull then ./install.sh, or .\install.ps1 on Windows (system.py:286). On Windows the page's is the right one (install.sh:66 stops with "on Windows use install.ps1"). One definition in raven/updates/ that both read would keep them from drifting.
  3. Smaller: plan_upgrade's own editable refusal (raven/updates/upgrade.py:1352-1353) can no longer be reached from the page, since system.py refuses first with a different sentence; and the zh string of gui.upg.why.editable uses an ASCII comma (U+002C) where its neighbours use the full-width U+FF0C.

@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: the newly reported in-flight metadata failure must be resolved.

New evidence changes my standing clean verdict. I reproduced that when _is_editable_install() raises PackageNotFoundError in the install-replacement window, system_upgrade() now lets it escape before the existing in_progress refusal handler; through the dispatcher this becomes internal_error and restores the unsafe default raven upgrade fallback. The open review thread from @0xKT carries the full finding, so I am not duplicating or re-grading it.

Verification: uv run pytest tests/test_rpc_system.py tests/test_updates_update_notice.py still passes (84 passed), confirming the current test setup does not catch this path. A direct invocation with the editable probe raising PackageNotFoundError reproduced the uncaught exception.

@yudongyouqing
yudongyouqing force-pushed the fix/upgrade_refusal_recovery branch from 6f15a78 to 2bc1fb9 Compare October 22, 2026 07:15
@yudongyouqing

Copy link
Copy Markdown
Contributor Author

@gloryfromca resolved in this push: the editable probe's PackageNotFoundError is answered as in_progress, through the same sentence the release lookup uses (one constant, both readers), so the card never falls back to the withheld default. The regression test drives the raising probe and asserts the refusal; it errors uncaught on the previous head, as in your direct invocation.

@gloryfromca

Copy link
Copy Markdown
Member

Blocking: malformed install metadata still escapes the refusal handlers.

The PackageNotFoundError path from my reproduction is fixed, but the open blocker also covers _is_editable_install() raising UpgradeError for malformed direct_url.json. The new guard catches only PackageNotFoundError; I reproduced UpgradeError: Malformed Raven installation metadata escaping system_upgrade() before the existing not_upgradable handler. So my blocker is not yet cleared on this head.

@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: the existing malformed-metadata path still needs to be brought under a refusal handler.

I reviewed the new delta and the resulting github/main...HEAD diff, including the upgrade metadata callers and refusal ordering, the bundled recovery-copy changes, backward compatibility, test changes, repository rules/context, and the unrelated formatter-only files required by the whole-tree hook. The PackageNotFoundError replacement-window regression is fixed and covered, and I found no separate new finding. The existing open blocker remains partially unresolved because _is_editable_install() can also raise UpgradeError for malformed direct_url.json, which still escapes before the later not_upgradable handler.

Verification: uv run pytest tests/test_rpc_system.py tests/test_updates_update_notice.py (85 passed); npx vitest run src/state/upgradeShade.test.ts src/app/updates.test.ts (25 passed); git diff --check github/main...HEAD passed. A direct probe raising UpgradeError("Malformed Raven installation metadata") reproduced the uncaught exception.

@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.

The new guard now covers both metadata failures introduced by the early editable probe: PackageNotFoundError becomes in_progress, and malformed-metadata UpgradeError becomes not_upgradable with its detail preserved. I reviewed the delta and resulting github/main...HEAD diff, relevant callers and history, refusal ordering and backward compatibility, test strength, repository rules/context, and the formatter/generated-file cleanup. I found no new plain error or weakened test. The remaining open thread belongs to another reviewer, so I left it untouched.

Verification: uv run pytest tests/test_rpc_system.py tests/test_updates_update_notice.py (86 passed); npx vitest run src/state/upgradeShade.test.ts src/app/updates.test.ts (25 passed); TUI lint:rpc, lint:i18n, and type-check passed; git diff --check github/main...HEAD passed. A direct malformed-metadata probe returned reason=not_upgradable with the expected detail.

@yudongyouqing

Copy link
Copy Markdown
Contributor Author

@gloryfromca one more push after the approval, from a self-review pass -- four gaps in what the card actually shows, all inside the branch's own theme:

  • The stop-first advice for a hand-started gateway lived only in the server detail; the card prefers the catalogue key, whose text still said to just start Raven again. The catalogue carries the advice now (en and zh).
  • A not_upgradable refusal offered no command, and the card falls back to the default raven upgrade when none is given -- the command this refusal proved fails. The card learns an explicit no-command (the row is dropped) and not_upgradable maps to it, so both the probe path and the release-lookup path stop offering it.
  • The editable probe now runs off the event loop, as the release lookup below it always has.
  • The checkout refusal's catalogue mapping and the no-command pass-through are pinned by ui-web tests.

The in_progress adopt-the-run reload onto a still-running CLI-upgraded server (pre-existing) and the three hand-mirrored copies of the install classification stay declared as follow-ups. rpc suites 86, ui-web 27, both generators in sync.

@gloryfromca

Copy link
Copy Markdown
Member

Blocking: the new no-command UI path must pass the required TypeScript check.

The four runtime gaps are addressed coherently, but npm run type-check now exits 2 with three errors introduced by this delta: the optional refusal reason passed to Set.has, nullable command passed to copyCommand, and possibly absent captured call indexed in the new test. The Python and Vitest suites pass.

@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: the new no-command refusal path must type-check before this can merge.

I reviewed the new delta and the resulting github/main...HEAD diff, the backend refusal and web-card callers, translation flow, backward compatibility, test strength, repository rules/context, and prior review history. The four self-review fixes are directionally sound, and the runtime suites pass, but the required TypeScript check fails in three places introduced by this push. This satisfies the late-round blocker bar: the change introduces it, every type-check/build gate reaches it, and the branch cannot pass that gate without a code change.

Named nonblocking follow-up: when command is null, UpgradeShade.open().fail() still appends gui.upg.manual to failure.sub, so the card says "Run this in a terminal instead:" while intentionally rendering no command row.

Verification: uv run pytest tests/test_rpc_system.py tests/test_updates_update_notice.py (86 passed); npx vitest run src/state/upgradeShade.test.ts src/app/updates.test.ts (27 passed); git diff --check github/main...HEAD passed; ./node_modules/.bin/tsc --noEmit -p tsconfig.json failed with three errors.

Comment thread ui-web/src/app/updates.ts 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; suggestions only, and they are marked inline.

Reviewed the current delta and the full main-to-head diff under AGENTS.md, including the RPC, update-notice, TUI, and web callers; relevant history and compatibility; and whether the tests were weakened. The three strict-TypeScript failures are fixed without changing the intended no-command runtime branch. Verification: 86 targeted Python tests passed, 27 targeted Vitest tests passed, tsc --noEmit passed, and git diff --check passed.

The previously named nonblocking follow-up remains: when a refusal intentionally has no command, the card hides the command row but still appends the manual-command prompt. That copy should eventually be conditional too, but the card remains dismissible and this does not hold this revision. No new plain errors found.

@yudongyouqing

Copy link
Copy Markdown
Contributor Author

A note on the red unit shard, so the X does not read as a test failure: shard 3/4 of the latest run reports 9925 passed, 112 skipped, 1 xfailed and no failure, and the job's nonzero exit comes after the summary, from the shard harness rather than any test -- siblings 1/4, 2/4 and 4/4 are green, and this head's delta is three ui-web TypeScript files. The same runner day has already produced an unrelated Windows temp-file PermissionError and a cancelled audit step; flagging for a rerun or a merge-time re-check.

@LivXue

LivXue commented Oct 22, 2026

Copy link
Copy Markdown
Member

Windows compatibility review of head 194a3eae38b8517077fb7f35dd3aa22c7c135249 against base dcc019a1b68ff51135b8c9fada2a584a82ebb6b9.

No new actionable Windows compatibility issue found in the inspected scope: the complete 13-file diff, upgrade/install classification and callers, RPC refusal ordering, web rendering/copying, both catalogues, TUI formatting changes, tests, and all existing discussion pages/threads. Code inspection confirms the editable recovery selects git pull followed by .\install.ps1 on Windows, and the renderer preserves the separate lines. The CLI's older POSIX recovery wording is inherited and already discussed here; this PR does not change that CLI path.

Native Windows verification: 86 targeted Python tests and 27 targeted web tests passed; npm.cmd run type-check and git diff --check exited 0. Web commands, from ui-web: npm.cmd ci --ignore-scripts --cache C:/Users/livxu/AppData/Local/Temp/raven-windows-review/3c7e882fc3954e1cb719654d256eb750/web-cache, then npm.cmd test -- src/state/upgradeShade.test.ts src/app/updates.test.ts --no-file-parallelism, then npm.cmd run type-check (all exit 0).

Limitations: upgrade/spawn calls are mocked in these tests. No installer, live upgrade/restart, real browser clipboard, PowerShell execution-policy scenario, full repository suite or TUI runtime test was exercised. This is compatibility coverage of the inspected changes, not a general merge verdict.

Exact verification commands and results

Commands ran in an isolated detached checkout, with temporary profile, Raven/OAuth home and test directories. Imports were checked against that checkout. UTF-8 mode was enabled.

$run = 'C:\Users\livxu\AppData\Local\Temp\raven-windows-review\3c7e882fc3954e1cb719654d256eb750'
$env:UV_PROJECT_ENVIRONMENT = Join-Path $run 'envs/review'
$env:UV_PYTHON = '3.13'
$env:PYTHONUTF8 = '1'
# 1003-upgrade: 86 passed, 0 failed, 0 skipped; exit 0
Set-Location -LiteralPath 'C:\Users\livxu\AppData\Local\Temp\raven-windows-review\3c7e882fc3954e1cb719654d256eb750\pr1003\head'
$env:PYTHONPATH = 'C:\Users\livxu\AppData\Local\Temp\raven-windows-review\3c7e882fc3954e1cb719654d256eb750\pr1003\head'
uv 'run' '--frozen' '--no-sync' 'pytest' 'tests/test_rpc_system.py' 'tests/test_updates_update_notice.py' '-n' '0' '-x' '--tb=short' '--basetemp' 'C:\Users\livxu\AppData\Local\Temp\raven-windows-review\3c7e882fc3954e1cb719654d256eb750\checks\1003-upgrade\pytest' '--junitxml' 'C:\Users\livxu\AppData\Local\Temp\raven-windows-review\3c7e882fc3954e1cb719654d256eb750\checks\1003-upgrade\junit.xml'

git diff --check dcc019a1b68ff51135b8c9fada2a584a82ebb6b9 194a3eae38b8517077fb7f35dd3aa22c7c135249 # exit 0

Comment thread raven/rpc/methods/system.py Resolved
@LivXue
LivXue requested review from 0xKT and gloryfromca October 23, 2026 07:54

@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; suggestions only, and they are marked inline.

Reviewed the three-commit delta and the full main-to-head change under AGENTS.md, CLAUDE.md, and the runtime/web context rules; I also traced the metadata probe through plan_upgrade, checked its callers and history, compatibility, and whether tests were weakened. The replacement-window and malformed-metadata cases now exercise the real _direct_url_data/_is_editable_install path through the production dispatcher. Unexpected probe failures no longer escape as -32603, and plan_upgrade rechecks install eligibility before any helper can spawn, so I found no concrete unsafe-upgrade path from the broad fallback.

Verification: the first test attempt had 89 setup errors because uv recreated the environment without the dev workspace plugin; after the repository-prescribed uv sync --frozen --extra dev --dev, the unchanged command passed all 89 affected Python tests. The source-language gate and git diff --check also passed. The Python-only delta does not disturb the previously verified web type-check and tests.

The previously named nonblocking follow-up remains: a refusal with no command hides the command row while retaining the manual-command prompt. The card is dismissible, so this still does not hold the revision. I found no new plain error.

@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; suggestions only, and they are marked inline.

Reviewed the test-only delta and rechecked it against the full main-to-head behavior, project rules and context, the production metadata-probe caller chain, history, compatibility, and test strength. The new plan_upgrade tripwires make the real-probe regressions fail if the early guard is removed; they prevent the later release lookup from masking that defect. No runtime behavior changed and no test was weakened.

Verification: 89 affected Python tests passed; the source-language gate and git diff --check passed.

The previously named nonblocking follow-up remains unchanged: a no-command refusal hides the command row but retains the manual-command prompt. I found no new plain error.

An editable install under a hand-started gateway hit the unsupervised
refusal first, whose recovery opens with raven upgrade -- a command that
always refuses a checkout, so the overlap was offered a step that cannot
work. The refusal order is now availability, then the nature of the
install, then the gateway's process answers: not_serving, editable,
unsupervised, busy. The overlap test arms the hosted-unsupervised state
with the editable probe true and asserts the checkout answer wins.
The pre-commit prettier pass checks the whole tree, so a commit that
touches nothing of theirs still fails on their drift; formatting only,
no behavior.
The editable probe reads install metadata outside the refusal handlers,
so during an upgrade already in flight -- uv mid-replacement of the
environment -- its PackageNotFoundError escaped as internal_error, whose
card falls back to the default command this branch exists to withhold.
The probe now answers in_progress through the same sentence the release
lookup does, hoisted to one constant both read.

The unsupervised recovery says the quiet part now: the upgrade does not
stop a hand-started gateway, and a new raven web would only attach to
the old one still running, so the reader is told to stop this Raven
first. The zh editable note joins its neighbours on a full-width comma.
The TUI catalogue is regenerated.
yudongyouqing and others added 7 commits October 23, 2026 08:41
_is_editable_install raises UpgradeError for a direct_url.json it cannot
read, and the new guard caught only PackageNotFoundError, so malformed
metadata still escaped as internal_error -- whose card falls back to the
default command this branch exists to withhold. The guard now answers
not_upgradable with the exception's own message, as the release lookup's
handler does. rpc/generated.ts is restored to the generator's output:
the previous commit's tree-wide prettier pass had reformatted it, which
the gen-rpc check compares by hash.
Self-review after the approval, four gaps in what the card actually
shows. The stop-first advice for a hand-started gateway lived only in
the server detail: the card prefers the catalogue key, whose text still
said to just start Raven again -- the catalogue now carries the advice,
en and zh. A not_upgradable refusal offered no command, and the card
falls back to the default raven upgrade when none is given -- the very
command this refusal proved fails, so the card learns an explicit no
command (the row is dropped) and not_upgradable maps to it. The editable
probe reads the filesystem on the serving loop; it runs off the loop now,
as the release lookup below it always has. The checkout refusal's
catalogue mapping and the no-command pass-through are pinned by tests.
NO_COMMAND.has received reason as string | undefined, the test indexed
an array element strict mode cannot prove exists, and the copy button
still offered failure.command to a copyCommand that had not learned
null. npm run type-check passes on all three now.
The editable-install probe reads install metadata, and the guard around
it caught only PackageNotFoundError and UpgradeError. Anything else the
read can hit -- an undecodable direct_url.json, an OSError from a
filesystem racing the in-flight install -- still escaped as
internal_error, whose card falls back to the default `raven upgrade`
this branch exists to withhold. The probe only classifies a checkout,
so an unreadable probe is not a checkout: it now falls back to
editable_install = False and the release lookup below answers as it
does for an ordinary install. The clause sits after the two narrow
ones on purpose, so PackageNotFoundError still answers in_progress and
UpgradeError still answers not_upgradable.
The probe cases already in the file replace _is_editable_install itself,
so they only show the handler names the right reason for an exception it
is handed. These three replace the importlib.metadata distribution lookup
underneath instead, so the genuine _direct_url_data and
_is_editable_install run end to end through the dispatcher.

PackageNotFoundError still answers in_progress, and a direct_url.json
that does not parse still answers not_upgradable. An OSError from the
read, which neither narrow clause names, now answers as an ordinary
install and the upgrade is handed off; before the catch-all it escaped
as internal_error (-32603). The stub answers for raven only and forwards
every other name, because importlib.metadata.version calls distribution
internally.
The replacement-window and malformed-metadata tests left plan_upgrade
real. It reads the same metadata as the probe and refuses with the same
reason, so both tests passed with the probe's guard deleted (verified by
mutation). Make the release lookup unreachable so the refusal has to come
from the probe.
@LivXue
LivXue force-pushed the fix/upgrade_refusal_recovery branch from 9c5e21c to aa7740c Compare October 23, 2026 08:44
@yudongyouqing
yudongyouqing force-pushed the fix/upgrade_refusal_recovery branch from aa7740c to e37f9b0 Compare October 23, 2026 08:47

@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.

The wake-up named aa7740c, but that revision had already been force-pushed away; GitHub reports e37f9b0 as the current PR head, so this review is attached to the revision that can merge.

Reviewed the current delta and full main-to-head diff under the project rules and runtime/web context, including refusal producers and consumers, history, compatibility, and test strength. The server now sends the no-command verdict per refusal, preserving the default command for plan failures where it still works; the shade removes both the row and the manual-command label for explicit null. This resolves my previously named follow-up. No tests were weakened and I found no new plain error.

Verification: 88 affected Python tests passed, 29 targeted web tests passed, strict TypeScript passed, and the source-language and git diff checks passed.

A card whose refusal proved no command works dropped the command row
but kept the 'run this in a terminal instead' label, pointing at a
command it did not show; and which reasons meant that lived in a
client-side set, so the plan path's not_upgradable -- the raven
executable is missing, where the CLI upgrade genuinely works -- lost a
working suggestion, and the two check_failed origins could not be told
apart. The refusal now carries the verdict itself: _refuse takes a
sentinel that sends command as an explicit null, the probe's
not_upgradable and check_failed use it, and the card hides the row and
the label on null while an absent command still means the default.
Pinned on both sides: the probe's refusals carry an explicit null, the
plan path's keeps the default, and the shade drops row and label
together.
@yudongyouqing
yudongyouqing force-pushed the fix/upgrade_refusal_recovery branch from e37f9b0 to ede6a2a Compare October 23, 2026 08:56

@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.

Reviewed the rebuilt head against current main, including the repository rules and domain context, the full diff and latest delta, upgrade callers/history, backward compatibility, and whether the consolidated tests weakened coverage. The explicit-null versus omitted-command contract is now consistent end to end: refusal-specific null suppresses both the command row and manual-run label, while an absent command retains the usable terminal fallback. The probe tests exercise the real metadata boundary with release planning tripwired, and unexpected probe failures still reach the release lookup's own classified response.

Verification: uv run pytest tests/test_rpc_system.py tests/test_updates_update_notice.py (88 passed); targeted Vitest (29 passed); strict TypeScript, source-language gate, and git diff --check all passed. No new plain error found.

@LivXue
LivXue merged commit 2cbacba into EverMind-AI:main Oct 23, 2026
26 checks passed
0xKT pushed a commit that referenced this pull request Oct 24, 2026
…#1086)

## Summary

Follow-ups to #1003 on the commands the upgrade card offers when
`system.upgrade` refuses. Six gaps were left open there; each is fixed
here and pinned by tests that hold on any runner.

**Unsupervised gateway, Windows ordering.** The card offered `raven
upgrade` then `raven web` on every platform. On native Windows `raven
upgrade` hands the install to a helper and returns at once ("Wait for
the completion message before running Raven again"); the helper waits
for its parent process to exit, stops whatever still runs from the old
environment, and then runs `uv tool install`. A `raven web` on the next
line therefore starts inside that install. The Windows command now ends
after `raven upgrade`, and the sentence tells the reader to start Raven
with `raven web` once the upgrade reports it has finished. On Linux and
macOS `raven upgrade` execs the helper and installs in the foreground,
so `raven web` still follows it on the next line. This is the same
sequence the quick-start page already documents for a command-line
update.

**Unsupervised gateway, the missing stop.** The sentence told the reader
to stop this Raven first, but the command had no stop step. The command
now starts with `raven web --stop`, which stops the gateway recorded in
`serve.json` even when no supervisor is running, the hand-started case
this refusal is for. The server's English sentence names each command
the card copies, and the page's translated sentence (en and zh) is
reworded to describe the steps on both platforms.

**Source checkout, `git pull` in the wrong repository.** The command was
`git pull` then the installer, with no directory change, so it pulled
whatever repository the terminal was in. It now moves into the checkout
the install was made from, read from the install's PEP 610 metadata
(`direct_url.json`) through a new
`raven.updates.upgrade.editable_checkout()`, which
`editable_checkout_status()` now reuses. POSIX gets `cd <shlex-quoted
path>`; PowerShell gets `Set-Location -LiteralPath '<path>'`, with every
single quote PowerShell recognises doubled, the four curly ones
included, so a profile folder spelled with one cannot end the literal
early. When the path cannot be read the refusal sends an explicit null
command and the card drops the row, since a bare `git pull` is the wrong
suggestion. The `_NO_COMMAND` comment and the page's matching comment
now say "none it can stand behind" rather than "proved none can work".

**Source checkout, reinstalling under a running Raven (Windows).** The
Windows checkout command reached `.\install.ps1` with the checkout's
Raven running. `uv tool install --force` replaces the environment that
Raven runs from, and Windows cannot delete an executable still in use
(the review captured `WinError 5`); `install.ps1` stops a resident
gateway only in `Start-Web`, after that install. The Windows command now
starts with `raven web --stop`, so the serving Raven is gone before the
installer begins. POSIX replaces a running executable in place and
`install.sh` stops and restarts the gateway itself after the install, so
the POSIX command is unchanged. The exact-command test asserts each
platform's form, and a separate test pins the stop ahead of the
installer.

**The upgrade helper's leftover sweep (pre-existing, Windows).**
`stop_leftovers_of` embeds the uv tool directory in a single-quoted
PowerShell string and doubled only the ASCII apostrophe. PowerShell also
reads the four curly single quotes as quote characters, so a path
carrying U+2019 (as in a profile folder spelled O\u2019Neil) produced
`TerminatorExpectedAtEndOfString` / `UnexpectedToken` and the sweep
could not run. All five are doubled now. `ask()` also ignored the
query's exit code, so a query that failed left an empty stdout that read
as "no survivors" before `uv tool install --force` deleted the
environment something might still run from; a non-zero exit is now
"could not ask", and the helper refuses the install instead of guessing.
The queries also run with `$ErrorActionPreference = 'Stop'`, because a
non-terminating `Get-CimInstance` failure otherwise still exits 0,
measured in both installed PowerShell versions.

**Tests that only checked the runner's platform.** The checkout test
asserted `.\install.ps1 if os.name == "nt" else ./install.sh`, the
production condition copied, so an inverted branch could not fail it.
Both commands are now built by functions that take the platform as an
argument (`_checkout_recovery`, `_unsupervised_recovery`), switched by
`_on_windows()`. The tests parametrize the platform and assert the exact
POSIX and Windows forms through the real handler, and a separate test
pins `_on_windows()` to `os.name`.

Two existing assertions encoded the old behaviour and were changed: the
unsupervised command (`raven upgrade\nraven web`) and the checkout
command's `startswith("git pull")`. The Windows quoting test now locates
the `Set-Location` line rather than assuming it comes first.

Not changed here: the CLI's own message for a source checkout (`raven
upgrade` on an editable install) still prints `git pull && ./install.sh`
on every platform. It already prints the checkout path above that line,
so only its Windows spelling is off, and it belongs to a CLI change
rather than this card.

**Coordination with #1087.** Both branches rewrite the source-checkout
refusal in `raven/rpc/methods/system.py` and conflict textually there.
#1087 adds a second route to the same refusal: `plan_upgrade` raises
`EditableInstallError` when the probe cannot classify the install, and
`system.upgrade` maps it through `_refuse_checkout()`. After both land,
that second route must build its command with
`_checkout_recovery(editable_checkout(), ...)`, falling back to the
explicit null, so both refusals name the same recovery. This branch does
not add that route.

## Type

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

## Verification

Earlier round, Linux, base `2cbacbaa3`:

- `uv run --frozen --all-extras pytest -n 0 tests/test_rpc_system.py
tests/test_updates_upgrade.py -k "<the 11 new or changed test names>"`:
21 passed. Before the fix the same selection failed on the missing
helpers and on the old command forms; the curly-quote cases failed on
the ASCII-only escape and passed once it covered all four.
- Adjacent suites, `uv run --frozen --all-extras pytest -n 4` over
`test_rpc_system`, `test_updates_upgrade`, `test_cli_upgrade_commands`,
`test_updates_install_guard`, `test_updates_beta_channel`,
`test_updates_update_notice`, `test_rpc_serve_control`,
`test_cli_serve_commands`, `test_cli_gateway_commands`,
`test_cli_gateway_page`, `test_cli_gateway_health`,
`test_i18n_boundary`, `test_i18n_prompts`, `test_i18n_sensitive_notes`,
`test_channels_display_names`, `test_stint_record`: 673 passed, 2
skipped (truststore on this interpreter), exit 0.
- Mutation pass over `raven/rpc/methods/system.py`, one change at a time
against `tests/test_rpc_system.py`: 12 of 12 killed (inverted
`_on_windows`, swapped checkout branches, dropped `cd`, unquoted POSIX
path, no PowerShell escape, ASCII-only PowerShell escape, `raven web`
added back to the Windows command, the stop dropped on either platform,
either handler ignoring the platform, a bare `git pull` when the path is
unreadable).
- `ruff check` and `ruff format --check` on the four Python files, `ty
check` on the two source files, `lint-imports` (10 kept, 0 broken): all
pass.
- `node ui-tui/scripts/gen-i18n.mjs --check`: up to date after
regenerating the TUI catalogue.
- `scripts/check_commit_messages.py`, `commitlint`,
`scripts/check_large_files.py`, `scripts/check_source_language.py` over
the branch: all exit 0.

The Linux runs above are the earlier round's; this head was not re-run
on Linux. The commits since then touch the same two source files and
their two test files only, and the platform-independent tests and checks
below ran natively here.

At this head, native Windows (Windows 11, CPython 3.12.13,
`PYTHONUTF8=1`, a scratch `USERPROFILE`):

- `pytest -n 0 tests/test_rpc_system.py tests/test_updates_upgrade.py`:
266 passed, 1 skipped (the POSIX permission-bit case), exit 0.
- Native PowerShell probes of the generated sweep script: for a uv tool
directory containing U+2019, both Windows PowerShell 5.1.26100.9711 and
PowerShell 7.6.7 parsed the script and read `$root` back with code point
U+2019 intact. The same script with an unknown CIM class exited 1 with
empty stdout in both, which the exit-code check turns into "could not
ask".
- Doubling each of the five quote characters round-tripped to the
original character in both shells; the un-doubled form produced a parse
error in both.
- Five new mutants, one at a time, each killed by the tests added here:
dropping the exit-code check (two tests red), dropping the `survivors is
None` guard (one red), ASCII-only escaping (four curly-quote cases red,
the ASCII control green), dropping the Windows stop line (the
exact-command Windows case and the ordering test red), dropping
`$ErrorActionPreference = 'Stop'` (the loud-failure test red).
- `ruff check`, `ruff format --check`, `git diff --check`: clean. `ty
check` on the two source files exits 0 with its two pre-existing
`url2pathname` deprecation warnings. `scripts/check_commit_messages.py
origin/main..HEAD`: exit 0.

An actual `install.ps1` reinstallation with a running Raven was not
executed, and neither was an actual upgrade helper run. That Windows
refuses to replace a running executable is the review's native evidence;
the stop ordering is asserted as a string, and the helper's quoting and
exit code are executed against both native parsers above.

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

The quick-start page already describes the corrected order, so no docs
change was needed.

## Risk

User-visible: the upgrade card's copyable commands for the unsupervised
and source-checkout refusals change, and so does the unsupervised
sentence in both languages. On Windows the unsupervised command no
longer starts Raven; the reader does so once the upgrade reports it has
finished. On Windows the checkout command now stops the serving Raven
before `git pull`, so a pull the reader abandons leaves Raven stopped
until they start it again, and a source checkout whose install metadata
does not name a local directory now shows no command row instead of a
`git pull` that may target the wrong repository.

The checkout path from the install metadata is put into a shell command
the reader copies, so it is quoted for each shell rather than spliced
in.

The upgrade helper now refuses the install when its process query exits
non-zero. That turns a Windows upgrade which used to continue through an
unanswerable query into a refusal that says nothing was changed; the
reader can retry once PowerShell can answer.

Rollback: revert this branch.

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

## Related Issues

Follows up #1003. Coordinates with #1087 on the second refusal route
described above.
0xKT pushed a commit that referenced this pull request Oct 24, 2026
…1023)

## Summary

A machine registered by password under an ssh config alias, under a
`HostKeyAlias`, or under a non-canonical address could never log in, and
its registration blamed the password. Reported by 0xKT in the post-merge
review of #1005 (point 1); point 2 is not in this PR.

Cause: the askpass helper answers only `<user>@<host>'s password:` and
`(<user>@<host>) Password:`, with the host as the registry row spells
it. ssh names the host it resolved instead: the `HostName` the owner's
ssh config gives an alias, the canonical form of a numeric address
(`::1` for `0:0:0:0:0:0:0:1`), or the `HostKeyAlias` in place of either.
Measured with OpenSSH 8.9p1 against a loopback test sshd: `alice@devbox`
with `HostName 127.0.0.1` asks `alice@127.0.0.1's password: `,
`alice@aliasbox` with `HostKeyAlias labbox` asks `alice@labbox's
password: `, and `alice@0:0:0:0:0:0:0:1` asks `alice@::1's password: `.
The helper refused each, ssh sent an empty password, and the probe
appended "The password was refused, or this machine does not accept
password logins".

What changes:

- `transport.ssh_env` now names, as the prompt the helper waits for, the
host `ssh -G` resolves for the row: the `hostkeyalias` it prints, else
its `hostname`. `ssh -G` is asked with the password connection's own
options, before ssh starts and never from the helper (a helper that ran
`ssh -G` itself once recursed into thousands of processes against a test
ssh that called askpass), with askpass switched off for anything it
starts, a 10 s timeout and tolerant decoding. When ssh cannot say (no
ssh, a failure, a hang, no `hostname` line) the row's spelling stands,
which is the behaviour before this change.
- A password row whose host is the id of a `Host` alias Raven itself
keeps in `~/.ssh/config` for a key machine is refused by name when that
alias sends ssh to another address, or when its block is not the one
Raven writes (a mark gone, a keyword Raven does not write such as
`ProxyCommand` or `Include`, a missing or second `HostName`, a second
name on the `Host` line), before ssh starts, and its password is not
sent (review round, blocking thread). One whose `HostName` is its own id
is followed. Without this, a key machine registered under the id
`labbox`, which writes `Host labbox` / `HostName <its address>`, made
the helper of a password machine registered at `labbox` wait for, and
answer, the key machine's prompt; the model can register such a machine
through `ops_connection_add`. The managed marks are read from the config
ssh reads (the account's passwd home, which `$HOME` does not move) and
from the one Raven writes (`~`); names compare ignoring case, the
cautious direction, though OpenSSH is not case-blind here (measured
2026-10-20: `Host labbox` took `labbox` and not `LabBox`), and a block
with one mark left still counts. `ALIAS_MARK` moves to `transport` so
both modules share it. `machines.add` reports this refusal as `invalid`.
The owner's own aliases resolve as before.
- A declined prompt is no longer reported as a refused password
anywhere. `connection_add.probe` says ssh asked something other than the
account's password and that the password was not sent; `remote.explain`
(node install, the tunnel) says the same instead of "check the password
kept for it"; `machines.add` has a new reason, `prompt`, ahead of
`login`, carried through `openrpc.json`, `models.py`, both generated
clients, the register sheet and the en/zh catalog. A wrong password
still reads as before.
- When `ssh -G` fails, times out, cannot start or names no host, the
fallback is no longer silent: `ssh_env` hands the reason to the helper
(`RAVEN_MACHINE_PROMPT_UNRESOLVED`, empty when resolved), whose refusal
then names it and the prompt it waited for, and the probe and
`remote.explain` name the failed resolution instead of sending the owner
to a key.
- The resolution runs a subprocess (about 4 ms here), so every async
caller that built a password runner on the event loop now builds it in a
thread: exec's machine channel (`run_on_machine`), the openai sub-agent
tunnel (`remote.tunnel`), and on-call's status, round-zero setup and
backend in `ops_submit`, a declared action's runner, the campaigns
listing, `ops_kill`, `ops_outputs`, the two case-dict tools, a reading's
runner and the event watcher's backend.
- `docs-site/docs/machines.md` (both languages), `CONTEXT.md` and the
unreleased CHANGELOG entry say the prompt is the one for the address as
ssh resolves it, so an alias from the owner's ssh config works, and that
an alias Raven wrote for another machine is refused.

Review round (gloryfromca, 0xKT):

- Blocking thread (a managed alias redirects another machine's
password): fixed as above, at the one place the helper's environment is
built, so it also covers machines registered before the alias was
written. Registration now refuses the pair up front too (second round,
below).
- 1 (on-call builds the runner on the loop): fixed at the ten on-call
call sites above. Each has a test except the edit tool of the case-dict
pair, which shares its helper with the case-changes tool's.
- 2 (two of three classifiers still blame the password): fixed. On "the
zh reader gets only the headline": the sheet shows ssh's English detail
under the headline in both languages; what was wrong is that the zh (and
en) headline told the reader to check the password.
- Nit 1 (two ACP tests run the real `ssh -G`): fixed; they resolve
through a stand-in, and the module now fails any test that runs the real
`ssh -G` without `-F`.
- Nit 2 (`ssh_defaults` is a second `ssh -G` reader that still decodes
strictly): fixed in the second round, below.
- Nit 3 (silent fallback): fixed as above.

Second round (closing what the first left open):

- One reader of `ssh -G`. `transport.resolve_with_ssh` is now the only
one: the password machine's prompt and a key machine's registration
defaults (`connection_add.ssh_defaults`: port, user, identity files in
ssh's order) both resolve through it, each with its own command line.
The key reader used to raise on a config byte that is not UTF-8 and on
an ssh it could not start, and handed its `ssh -G` the caller's terminal
and askpass; now it decodes tolerantly, reads such failures as "ssh
cannot say" (empty defaults, as a missing ssh already was), and starts
nothing that finds an askpass or a terminal. Where ssh answered before,
a key machine's defaults are unchanged; the existing defaults tests pass
as they were.
- Registration refuses the colliding pair in either order, with what to
do: a key machine whose id is a registered password machine's address
(compared ignoring case, the cautious direction; OpenSSH itself took
`Host labbox` for `labbox` only), and a password machine at a registered
key machine's id (its alias may not be written yet) or at an alias Raven
keeps with no machine behind it, a lone mark included
(`connection_add.alias_collision`). `machines.add` (as `invalid`), the
CLI and `ops_connection_add` ask before anything is kept, resolved or
reached; `connection_add.write` asks again under the registry lock,
which is what stops `raven ops connection add --skip-probe` (it wrote
such a pair, since nothing connected). The refusal on connecting stays,
for pairs registered before this check or written by hand.
- Host rewriting. `ssh -G` makes none of the DNS lookups
`CanonicalizeHostname` makes, while a connection that canonicalizes
re-reads the config under the canonical name, where a `Host <id>` block
Raven wrote can apply (OpenSSH 8.9 reports applying it under `-v`); with
a `HostKeyAlias` the prompt would still match the helper. The password
connection options now carry `CanonicalizeHostname=no`, which outranks
the owner's config, so the connection reaches the host `ssh -G` named.
The one later pass left is `Match final`: measured, it applies a Raven
block re-read under the owner's `HostName`, but ssh keeps the first
`HostName` it read, the owner's, and a test pins that. A machine that
relied on canonicalization already failed at the prompt (the helper
waited for the uncanonicalized name); it is registered at its full name.
- Test isolation. Every password connection now asks `ssh -G` first, and
seven tests that stubbed the connection still reached the real one
through the resolution (two node-link tests, four registration cases,
one aborted CLI add). The ACP module's guard is now a `no_real_ssh_g`
fixture applied to the nine modules whose code reaches the reader, and
those tests answer `-G` with a stand-in. A lock test pins that
`transport.ssh_env` is the one place that builds the environment handing
ssh a kept password, which every path (registration probes, CLI,
`machines.add`, exec's machine channel, the ACP tunnel and node link,
on-call runners and rsync) goes through.

Windows test correction: the structural askpass-owner test now renders
its discovered repository-relative paths with POSIX separators. Its
scan, matching predicate, expected owner and assertion are unchanged, so
native Windows no longer fails solely because pathlib displays
backslashes.

Third review round (0xKT; each point answered on the PR):

- A key machine at its own name (`gpu1` at `gpu1`) beside a password
machine at `gpu1` was refused at registration and on every connection,
though ssh still logs in at `gpu1`: fixed.
`transport.managed_alias_hostnames` reads where each managed block sends
ssh, and `ssh_env` and `alias_collision` refuse only when a block in
either home sends the name elsewhere or is not the one Raven writes;
registration also asks the registry row the next add rewrites the block
from, in both orders. The refusals that remain point at the IP address
or full domain name.
- The on-call event watcher built a campaign's backend on the loop: it
now builds it with `asyncio.to_thread`, like the tools, and a new test
counts the loop's heartbeats while a slow factory builds it.
- The two resolution-failure tests capped every `ssh -G` stand-in at 0.3
s, which macOS's first exec of a freshly written script (320-530 ms)
exceeded: only the stand-in that sleeps keeps that cap now, and a new
case that starts 0.45 s late must still be named by its exit status.

After main merged #1018 (0xKT, blocking; confirmed by gloryfromca):

- `no_real_ssh_g` sat on `subprocess.run` and knew a stand-in only by
the ssh first on `PATH`, so on the merged tree it stopped the two
`ssh_defaults` tests #1018 rewrote before their Python stand-in, a
swapped `subprocess.Popen`, took over. The guard is now a
`subprocess.Popen` subclass that judges the argv it is finally handed: a
wrapper's rewritten command line passes, and an `ssh -G` that names the
computer's own ssh fails, named bare or by path, even with a stand-in
first on `PATH`. A swapped `Popen` alone is not taken as a stand-in. Two
more tests pin what the guard judges: an ssh started without -G runs,
and the PATH it searches is the one handed to Popen.

Found while re-checking every registration path against the narrower
alias check:

- `ops_connection_add` asked `alias_collision` before the row carried
its address, so the model's path still refused key machine `gpu1` at
`gpu1` beside a password machine at `gpu1`, naming "this machine's
address ('')", while the CLI, `machines.add` and the registry write
accepted the pair. The address now goes on an ssh row before the check;
an ssh row with no address is still refused before anything is reached.

Left as it was, and why:

- The resolution reuses the connection's options (`-p`, the
password-only options). No test notices if it read others, since none of
them changes `HostName` or `HostKeyAlias`; the two mutants that swap
them survive, and this is why.
- An owner's own `Host` block with the same name as a managed id, placed
earlier in the file, is refused too when Raven's block for that name
sends ssh elsewhere: ssh would follow the owner's block, but a name
Raven also routes to another address is ambiguous, and refusing sends no
password anywhere. A key and a password registration of one physical
machine under a shared name register as a pair when the key machine's
alias names its own id as the address (key machine `gpu1` at `gpu1`,
password machine `gpu1-shared` at `gpu1`); a pair whose alias would send
ssh elsewhere is still refused, and the refusal says to register the
password machine at its IP address or full domain name.
- `test_the_table_agrees_with_the_ssh_on_this_computer` in
`test_shell_machine_channel.py` (from #552, not touched here) asks the
computer's own `ssh -G` how ssh parses its arguments, so it reads the
runner's ssh config; it is not under the new guard, and the runs below
deselect it.

## Type

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

## Verification

Current head, rebased onto main with #1003: `git range-diff` maps the 18
earlier commits as `=`, in order, and two commits sit on top: the
guard's two tests and the registration fix above.

- The registration fix: its tool test failed at the commit before it,
refused with "this machine's address ('')", and passes after.
Single-construct mutants: 5 of 5 killed (the old check order, the
address set after the check, the address left off the row, the alias
check removed, the empty-address refusal removed). `pytest -n 2 -p
no:randomly` over `test_ops_connection_add.py`,
`test_cli_ops_connection_commands.py`, `test_shell_machine_channel.py`,
`test_agents_code_launcher.py` and `test_agents_oncall_launcher.py`: 359
passed, 1 skipped; with the tool file as it was before the fix, 358
passed, 1 skipped, and only the new test failed.
- The guard: both `ssh_defaults` tests and the three guard tests (named
bare, by path, a `Popen` wrapper that leaves ssh alone): 5 passed.
Mutants of the two conditions the guard's two new tests pin (the `-G`
check dropped, this process's `PATH` read instead of the one handed to
`Popen`) each fail one of them. gloryfromca, on this base before the two
commits on top: every module under the guard, 492 passed with 4 existing
tcsh/csh skips.
- Full suite, `pytest -q -n 4 -p no:randomly` on py3.12 with child
processes importing the tree under test: 13 failed, 34431 passed, 182
skipped, 2 xfailed. Those 13 tests, rerun alone on main with #1003 and
on this tree with the tool file as it was before the registration fix,
fail the same way; none is in a file this PR changes.
- Native Windows (Python 3.13.12) on the head before this rebase, whose
18 commits are patch-identical: 554 tests passed and 59 POSIX-only cases
skipped, including both `ssh_defaults` stand-in cases, the guard tests,
the registration collisions, the on-call thread boundaries and the RPC
schema check; 5 probes with the real Windows OpenSSH against a scratch
`-F` config only; 165 machine-page tests, the web and TUI type checks,
both RPC generator checks and the TUI i18n check.
- `ruff check` and `ruff format --check` over the changed files, the
commit message lint, `scripts/check_source_language.py
origin/main...HEAD`, `scripts/check_large_files.py origin/main...HEAD`
and `git diff --check`: pass.

Earlier rebase onto main with #992 (the branch had ten commits then):

- `git range-diff` maps all ten commits as `=`, and the branch's own
diff is identical to the one before the rebase apart from hunk offsets.
The rebase had no conflict and changed no file outside this PR. The
second-round, first-round and native Windows results further down were
measured before this rebase and keep their original scope.
- The `unit 1/4` failure on the previous head
(`test_a_cancel_while_this_computer_goes_on_ends_the_command_and_keeps_no_record`,
`assert remote_runs.pending() == [] and not remote_runs.was_left("t1")`)
was a mark one test left for the next, not this PR's code. Both cases of
`test_node_stderr_replay.py::test_a_left_cli_run_s_reason_on_stderr_reaches_its_pick_up`,
which run in the same shard, end with dispatch `t1` marked as left and
never read. A later test on the same worker that cancels `t1` while this
computer goes on then reads that stale mark. Reproduced by running the
two in that order on one worker: before the rebase the cancel test fails
with the CI assertion verbatim. On plain main and on the rebased branch
the same three tests pass, because #992's `_this_process_goes_on`
fixture now clears the marks after every test. The cancel test passes on
its own on all three trees. No test or product code was changed for it.
- The strict idle-ceiling failure in `unit 4/4` on the head before last
(setup idle of 3.45 s and 3.04 s in two unmarked tests whose own work
takes about 0.4 s and 0.01 s) is read as a runner stall. No `slow` or
`production_timing` marker was added, since it would record a reason
that is not true. It did not recur in the 4/4 shard of the previous
head.
- On the rebased tree, serially (`-n 0`), with py3.12 and child
processes that import the tree under test: `pytest -n 0
--idle-ceiling-strict` over the 17 test modules this PR touches plus
`test_rpc_bootstrap.py`, `test_subagent_acp_remote.py`,
`test_subagent_cli_remote.py`, `test_subagent_manager.py`,
`test_resume_remote.py`, `test_remote_resume.py`,
`test_node_stderr_replay.py`, `test_acp_client_resume.py` and
`test_remote_runs.py`: 1113 passed, 7 skipped, nothing deselected, and
no test started with a left mark from an earlier one.
`tests/test_subagent_cli_remote.py` alone: 60 passed.
`test_rpc_schema_match.py`, `test_rpc_registration.py`,
`test_rpc_instances_declare_their_errors.py` and
`test_channels_display_names.py`: 525 passed.
- Gates on the rebased branch: `ruff check` and `ruff format --check`
over the repository's lint targets, `ty check` over the source targets,
`lint-imports` (10 kept, 0 broken), `scripts/check_commit_messages.py
origin/main..HEAD`, `scripts/check_large_files.py origin/main..HEAD`,
`scripts/check_source_language.py origin/main...HEAD`, `git diff
--check`, and the generator checks of ui-web (`gen:check`) and ui-tui
(`lint:rpc`, `lint:i18n`), since main and this branch both edit
`openrpc.json` and the generated clients: pass. The ui-web vitest suite
was not re-run locally; CI runs it on the new head.

Second round:

- New tests, each watched failing at the commit before it and passing
after: 4 for the one reader (an undecodable byte raised
`UnicodeDecodeError`, an ssh that cannot start raised `PermissionError`,
the key machine's `ssh -G` read the terminal's input and inherited
askpass, and the two machines used two readers), 2 for canonicalization
(real OpenSSH reported `canonicalizehostname true` / `always` for the
password options before), 8 for registration (the writer in both orders,
a lone mark with no machine, the model's tool, `machines.add` and the
CLI with `--skip-probe` in both orders; the CLI wrote the pair before).
Lock tests that pass on both sides: owners' rows that do not collide
still register; `Match final` keeps the owner's `HostName`; one function
builds the askpass environment (a stray module naming its variable fails
it).
- Single-construct mutants for this round: 14 of 14 killed (the reader's
tolerant decoding, askpass, stdin and start-failure handling, a separate
defaults reader, canonicalization back on, the collision check removed
from the writer, `machines.add`, the CLI and the tool in turn, either
direction, the case fold, the registry lookup and the managed-alias
lookup).
- `pytest -n 0 tests/test_ops_transport.py
tests/test_ops_connection_add.py tests/test_rpc_machines.py
tests/test_cli_ops_connection_commands.py tests/test_acp_remote.py
tests/test_node_client.py tests/test_shell_machine_channel.py
tests/test_agents_oncall_flow_*.py tests/test_rpc_schema_match.py -k
"not test_the_table_agrees_with_the_ssh_on_this_computer"`: 1846 passed,
9 skipped, 14 deselected. On the merged tree: 1823 passed, 9 skipped, 23
failed; the 23 are 22 `test_node_client` IDs plus
`test_install_node_closes_the_live_node_it_asked`, and the same IDs fail
on a plain `origin/main` tree run the same way (their node is started
with `python -I` and imports another tree than the one under test), so
none is introduced.
- `ruff check` and `ruff format --check` over the changed Python files,
`ty check` over the changed source files, `lint-imports` (10 kept, 0
broken), `scripts/check_source_language.py origin/main...HEAD`,
`scripts/check_large_files.py origin/main...HEAD`,
`scripts/check_commit_messages.py origin/main..HEAD`, `commitlint`, `git
diff --check`: pass. Nothing in the web client, TUI or RPC contract
changed this round.

First round, still standing for the code it covered:

- 16 tests for the managed alias and the declined-prompt wording, 1 for
the register sheet, 8 for on-call, the two ACP tests under the guard; 18
of 19 mutants killed (the survivor was the ACP guard itself, with both
tests on stand-ins).
- `vitest run src/features/extAgents/ExtAgentsPage.test.tsx`: 160 passed
(165 on the merged tree); the ui-web contract gates (24 passed), `tsc
--noEmit`, `eslint` and the three generator checks: clean.
- End-to-end runs against an asyncssh test sshd on loopback with the
real OpenSSH client (alias, `HostKeyAlias`, `0:0:0:0:0:0:0:1` and
keyboard-interactive alias rows register and log in; an sshd that asks
only "Verification code:" gets an empty answer). They were not re-run
for the second round, and the full suite was not run for either round.
Those historical runs did not use native Windows; the native follow-up
below is separate.

Native Windows follow-up (Windows 11 build 26300, Python 3.13.12, UTF-8
mode):

- Reproduced the reported structural test failure before editing: the
discovered path was `raven\ops\transport.py`, while the expected
repository spelling used `/`. The one-line `.as_posix()` correction
passed the focused test with the strict idle ceiling enabled.
- After rebasing and a frozen workspace/dev sync, ran:

```powershell
uv run --frozen --no-sync pytest tests/test_ops_transport.py -n 0 -q --tb=short --idle-ceiling-strict --basetemp "$evidenceDir/rebased" --junitxml "$evidenceDir/rebased.xml"
```

Result: 10 passed, 45 skipped, exit 0. The skips are 43 POSIX
askpass/helper cases and two POSIX file-mode cases. `$evidenceDir` is an
external report directory. Imports were verified to resolve to the
dedicated worktree; profile and OAuth paths were isolated.

- An earlier native full-module run passed every assertion but failed
the strict idle gate in `test_the_runner_sends_what_it_always_sent`
(3.43 s idle). The final run above retained the strict gate and passed;
no timing marker, ceiling or test assertion was weakened.
- Full-PR `uv run --frozen --no-sync pre-commit run --from-ref
origin/main --to-ref HEAD` passed using Git Bash. Its frontend hooks
return without running their tools when the corresponding `node_modules`
is absent; frontend tests/generators were not rerun locally for this
test-only correction.
- `uv run --frozen --no-sync ty check` over all changed production
Python files passed. `uv run --frozen --no-sync lint-imports` kept all
10 contracts. The complete-range source-language, large-file and
commit-message gates, lockfile-installed commitlint, and `git diff
--check origin/main...HEAD` passed. On Windows the commit-message
wrapper could not spawn `npx`, so its two checks were run directly: the
installed `commitlint.cmd` and
`scripts.commit_lint.check_commit_message`.
- The previous remote head's Linux unit shard had 8666 passed, 23
skipped and 1 xfailed, but failed the strict idle ceiling for
`test_the_window_starts_when_the_run_is_released` and
`test_an_install_the_login_path_misses_is_not_reported_as_absent` (3.45
s and 3.04 s). Those tests and the ceiling were not changed by this
correction. New-head CI is assessed separately.
- No live SSH server, Linux suite, browser run, or full-repository
native suite was run for this correction. Earlier broad results are
retained above with their original scope.

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

## Risk

- A machine registered under an alias, a `HostKeyAlias` or a
non-canonical address now logs in; nothing else changes which prompt the
helper answers. The password still goes only to the host ssh connects to
for the row, and never through a `Host` block Raven wrote that sends ssh
to another address or is not the block Raven writes.
- A password machine registered at a name that is also a key machine's
id is now refused by name, with the password unsent, when that machine's
alias sends ssh to another address; before this PR its login went to the
key machine's address and failed there. Registering such a pair is
refused in either order on every registration path, and a pair already
registered is refused on connecting. A key machine whose alias names its
own address (`gpu1` at `gpu1`) leaves a password machine at `gpu1` free
to register and log in.
- A key machine's registration defaults come from the same reader:
unchanged where ssh answered, empty instead of an exception where it
could not (an undecodable config byte, an ssh that cannot start).
- Password connections no longer canonicalize the host; a password
machine that relied on `CanonicalDomains` to complete a short name is
registered at its full name.
- Every password connection starts one `ssh -G` first (about 4 ms here;
10 s at most, after which the row's spelling stands), off the event loop
everywhere. It reads the owner's ssh config, so a `Match exec` there
runs once more.
- `machines.add` can answer the new reason `prompt`; the web client and
the TUI types are regenerated with it. The askpass helper's bytes
change, and it is rewritten on the next password connection, as before.
- Rollback: revert the commits; nothing is stored or migrated.

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

## Related Issues

Follows the post-merge review of #1005.
0xKT pushed a commit that referenced this pull request Oct 26, 2026
… refusal (#1087)

## Summary

Two follow-ups to #1003, both from its review.

- A checkout that `plan_upgrade` finds now gets the same `editable`
refusal as one the pre-check finds. `system.upgrade` classifies a
checkout first, but its probe takes an unreadable metadata read as "not
a checkout" and lets the upgrade go on. `plan_upgrade` then reads the
metadata again and refused the checkout with a plain `UpgradeError`,
which reached the page as `not_upgradable` with no command, so the card
fell back to `raven upgrade`, which a checkout refuses. In review this
read as "plan_upgrade's own editable refusal can no longer be reached
from the page"; it is unreachable only while the probe can read the
metadata.
- `plan_upgrade` now raises `EditableInstallError`, an `UpgradeError`
subclass, and checks for a checkout before the release lookup: "already
up to date" against a release says nothing true about a checkout, and
the old order let that refusal answer first. `system.upgrade` maps
`EditableInstallError` to the editable refusal through one helper shared
with the pre-check, so both name `git pull` and the platform's install
script.
- The guard in `plan_upgrade` stays on purpose. An `./install.sh`
checkout is an editable uv tool install with a receipt of its own, so
`_uv_tool_target()` resolves for it; without the guard, the path above
would plan a release wheel over the checkout.
- The web client's cast of the refusal payload now types `data.command`
as `string | null`. The server sends an explicit null when a refusal
proved no command can work, and the card hides its command row on
exactly that null; the old `string` type did not describe the wire. Its
only reader passes it to `shade.fail(..., command?: string | null)`,
which already took null, so nothing narrowed on the old type.

Not changed here:
- On a hand-started gateway the hosted checks run before `plan_upgrade`,
so the probe-failure path there still answers `unsupervised` (its copied
commands start with `raven upgrade`). It needs the probe read to fail,
and the pre-check already answers a readable checkout first.
- The CLI still prints `git pull && ./install.sh` on every platform
while the page picks `.\install.ps1` on Windows; #1085 tracks one shared
definition.

## Type

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

## Verification

- [x] Relevant tests pass locally: `uv run --frozen --python 3.12
--all-extras --all-packages pytest -q -p no:cacheprovider -n 2
tests/test_rpc_system.py tests/test_updates_update_notice.py` -> 89
passed. The new test drives the real `plan_upgrade`: the first metadata
read raises, the second finds a checkout, the release lookup and the
handoff are tripwired, and it asserts two reads, reason `editable` and
the `git pull` plus install-script command.
- [x] After the rebase onto `e8332e5d`, on native Windows with CPython
3.12.13: `pytest tests/test_rpc_system.py
tests/test_updates_update_notice.py tests/test_updates_upgrade.py` ->
305 passed, 1 skipped (a POSIX permission-bit case). Main's #1086 landed
the shared checkout recovery after this branch was cut, so the refusal
now names the checkout's own path
(`raven/rpc/methods/system.py::_checkout_recovery`) instead of a
hardcoded `git pull` line; the branch keeps the PR's
`EditableInstallError` mapping and the one shared helper, and the new
test patches `editable_checkout` and asserts the command carries `git
pull` and the install script rather than starting with it. Web: `npx
vitest run src/app/updates.test.ts src/state/upgradeShade.test.ts` -> 29
passed; `npm run type-check` exit 0, with `tsc --listFiles` confirming
it read this tree's `src/app/updates.ts`.
- [x] The new test fails without the fix: with only the source changes
reverted it fails (`assert 1 == 2`, the release lookup ran before the
guard). Four single-construct mutants each fail it too: dropping the
`EditableInstallError` mapping (`not_upgradable`), raising a plain
`UpgradeError` from the guard (`not_upgradable`), deleting the guard (`1
== 2`), and moving the guard back after the release lookup (`1 == 2`).
- [x] Web: `npx vitest run src/state/upgradeShade.test.ts
src/app/updates.test.ts` -> 29 passed; `npm run type-check` exit 0, with
`tsc --listFiles` confirming it read this tree's `src/app/updates.ts`;
`npx eslint src/app/updates.ts` clean.
- [x] Relevant lint / type checks pass locally: `ruff check` and `ruff
format --check` on the three touched Python files; `ty check` on the two
touched modules; `lint-imports` 10 kept, 0 broken; `git diff --check`;
`make check-commits check-large-files check-source-language` exit 0.
- [ ] User-facing docs or screenshots are updated when needed: no copy
changed; the probe-failure path now shows the existing editable refusal
text.

## Risk

- [x] Security impact considered: the command strings are the existing
constants chosen by reason, never built from input.
- [x] Backward compatibility considered: `EditableInstallError`
subclasses `UpgradeError`, so every existing `except UpgradeError` still
catches it; `plan_upgrade` has one caller, `system.upgrade`. For an
install that is not a checkout the order change only moves one local
metadata read ahead of the release lookup; a malformed `direct_url.json`
is refused by the pre-check before `plan_upgrade` runs.
- [x] Rollback path is clear: plain revert.

## Related Issues

Follow-up to #1003. #1085 tracks the CLI and page spelling of the
checkout update command (not changed here).
gloryfromca pushed a commit that referenced this pull request Oct 28, 2026
…#1132)

## Summary

Bring the release notes template's Install and Upgrade sections up to
what 0.2.6
ships, so the next CI draft does not need these three edits by hand:

- Install: when a download host cannot be reached, the installer falls
back to
a mirror and says so, except for Raven's own release files, which come
from
GitHub Releases and have no mirror; `RAVEN_NO_MIRROR=1` keeps every
download
  on its default host (#1053).
- Upgrade: a page served by a source checkout refuses the sidebar
upgrade too,
  and the note says so next to the checkout's own remedy, `git pull` and
`./install.sh` in the checkout, since `raven upgrade` refuses an
editable
  install (#1003, #1087).
- Upgrade: neither `raven upgrade` nor the update row downloads the
knowledge
reader's word dictionary and PDF models, so an install that never
fetched
them runs `raven resources` once. The 0.2.5 and 0.2.6 notes carried this
  paragraph by hand.

Only `.github/release-notes-template.md` changes. The template is read
by
`release.yml` when a tag is pushed, so this affects the next release's
draft and
not the v0.2.6 draft already written.

## Type

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

## Verification

- `git grep -n RAVEN_NO_MIRROR origin/main -- install.sh install.ps1
raven/utils/mirrors.py`:
the opt-out is read in both installers and in
`raven/utils/mirrors.py:46`
- `git grep -n editable origin/main -- raven/updates/update_notice.py`:
the
  update row is withheld for editable installs (line 213)
- `git grep -n resources origin/main -- raven/updates/upgrade.py
raven/rpc/methods/system.py`:
  no match, so neither upgrade path fetches the resources
- `git grep -n "Could not resolve" origin/main -- install.sh
install.ps1`: the
Raven wheel is resolved from GitHub Releases with no mirror in either
installer
- `raven/cli/upgrade_commands.py:41-56` on `origin/main`: `raven
upgrade` exits
  1 for an editable install and names `git pull && ./install.sh`
- `npx commitlint --from origin/main --to HEAD --config
commitlint.config.cjs`: exit 0
- `PYTHONPATH=. python3 scripts/check_commit_messages.py
origin/main..HEAD`: exit 0
- No test run: the change is a Markdown template only.

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

## Risk

No code change. The next release draft carries the new paragraphs;
rollback is
reverting this commit.

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

## Related Issues

N/A

---------

Co-authored-by: Claude (claude-fable-5-1) <noreply@anthropic.com>
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.

4 participants