Skip to content

feat(*): a Raven daemon on a registered machine, reached over one watched ssh link - #920

Merged
0xKT merged 13 commits into
EverMind-AI:mainfrom
Handsome-wzw:feat/subagent_server_node
Oct 14, 2026
Merged

0xKT merged 13 commits into
EverMind-AI:mainfrom
Handsome-wzw:feat/subagent_server_node

Conversation

@Handsome-wzw

@Handsome-wzw Handsome-wzw commented Oct 12, 2026 •

Copy link
Copy Markdown
Contributor

Summary

First of three changes that let a Raven dispatch work to the machines in
its machine registry on a design where the machine owns the work: a Raven
daemon runs there, the ssh is only the way to it, and an ssh that goes
mid-task does not end the task. This one puts the daemon on a machine and
keeps the link to it; nothing dispatches to a machine yet. The next change
runs acp sub-agents through it, the one after reads and writes files there
by address.

It follows #893 (the ssh line and the remote working directory) and
replaces #897 and #901, closed when the design changed: their code is in
here, reworked to the daemon.

After the review's second round, four more things hold: a page read
(read_file, here and on a machine) reads the file a chunk at a time
and decodes only the page's own lines, so no one step holds the
interpreter for the whole file and the file is never whole in memory;
the whole-file calls on a machine go through one gate (readers
together, a writer alone, a waiting writer first), so a read never
answers with the old page and the new digest; the install reads the
index pip is configured with on the machine the way pip does, names it
in the plan and hands it to uv pip install; and a machine without uv
gets one pinned version as an unmanaged install, which writes no
receipt under the home directory and changes no PATH, while a node
directory that was there keeps its mode.

What a daemon is (raven/node/):

  • This Raven's own code, running as a daemon on a registered machine.
    The host's ssh runs a relay there (python -I -m raven.node --attach --dir <node_dir>, raven/node/relay.py): it bridges the ssh session
    to the daemon's unix socket under the node directory, starts the daemon
    when none answers (--serve, detached, its words in log/serve.log),
    and leaves with the ssh. The daemon leaves on its own after ten minutes
    with no connection and no running agent; the next relay starts it again,
    so nothing is registered as a service and a reboot costs one connect.
    It opens no network port; the socket is the registered user's own
    (mode 600) in run/ (0700), or, when that path is too long for a
    socket, in a directory of the user's own under the temp directory
    (0700, checked before use: a directory of the user's, never a link,
    never another user's, narrowed when wider). The relay forwards only to
    a socket of the user's and starts no daemon beside anything else; a
    daemon whose socket's place holds a thing it cannot remove says so in
    one line and leaves rather than listen beside it. ssh login is the
    authentication.
  • It speaks newline-framed JSON-RPC 2.0, the framing ACP uses, so the
    host drives it with AcpClient (over an Endpoint seam this change
    adds: a process, or a channel of a link). Protocol 3.
  • File calls (fs/read, list, find, grep, stat, write, edit,
    pull) run Raven's own file tools there, fenced to the roots each
    call names, so a remote read comes back exactly as a local one would.
    A write may carry expect, the digest the file had when it was read,
    and is refused with CHANGED when it no longer matches. A page read
    with its digest, a stat with one, a chunk and a checked write run in a
    thread of the daemon's, writers one at a time under a lock, so the
    loop that answers the heartbeat and forwards every agent's frames is
    not held for the length of a large file.
  • Agents (agent/launch, frame, stderr, exit, cancel, close):
    the command named is started in the machine user's login environment
    (captured once by the daemon itself, under bash or zsh -lic),
    in its own process group, under a task id of the host's. Its ACP
    frames travel on a numbered channel of the connection that holds it,
    and are kept in the task's record on disk (<node_dir>/tasks/<task>/:
    task.json, frames.jsonl, stderr.log; the directories 0700 and the
    files 0600, as the host's own journal is) as they arrive. A connection
    that goes leaves the agent running; agent/attach on a later
    connection replays the record from the frame the host last read and
    takes the task over, and a finished task is replayed from its record
    alone, even by a daemon that never ran it. agent/cancel ends the
    whole process tree: the tree is read off ps before the first signal,
    so a command the agent put in a session of its own (Claude Code's shell
    tool does) goes too. task/list names the tasks running or remembered;
    records are kept seven days. node/shutdown ends agents only under
    --stdio, the one mode that ties them to the connection (tests, and a
    fallback).
  • Probes (probe/machine, probe/agent) and session/dir, which makes
    a session directory and says where it is as the machine resolves it.
  • Small on purpose: it imports the file tools and nothing that talks to a
    model, reads a config or opens a port; beside Raven's code the machine
    needs uv (or curl or wget, to fetch one with), a Python 3.12 if it has
    one (else uv brings one), loguru and pillow (about 55 MB, more with a
    fetched uv or Python), ripgrep-bin when its index has it. A test runs
    the node with nothing importable beyond the standard library, its own
    code and those two packages, and asks every file call, so the next
    import added under raven/agent/tools is caught here rather than on
    every installed node.

The same code at both ends (raven/node/bundle.py): the code is named by
a digest of the package's files, and node/hello compares it, so a
daemon of other code is refused with the command that updates it. An
install lives in <node_dir>/<version>-<digest>.

The host side (raven/node/client.py):

  • One link per machine, shared by every caller in the process; a link
    idle for ten minutes with no agent on it and no call on its way to it
    is closed the next time any link is asked for. A heartbeat every 5 s;
    one unanswered for 15 s means the link is lost.
  • A lost link no longer fails the agents on it. Each endpoint waits,
    holding what the host writes meanwhile (up to 1000 frames); the link is
    tried again on a backoff (2, 4, 8, 16 s) for as long as any agent
    waits; a link that comes back attaches every task again from the frame
    the host last read, sends what waited and any cancel asked in between.
    Only a task the machine no longer knows ends an endpoint, in one
    sentence. A link closed on purpose just closes: the daemon keeps its
    agents, and tasks() on a later link lists them. The listing says
    "link lost: ...; N tasks continue on the machine; reconnecting".
  • Every failure is one sentence that names the machine and what to do,
    never its address: not installed (with the install command),
    unreachable, login refused, other code, a refused handshake, a call
    that ran out of time, or the daemon's own last word.

The registry's package (raven/ops/remote_path.py) tells a remote path
from a local one (a UNC or extended Windows path, and on a Windows host a
drive-relative one, are this computer's), resolves it against the
registry, and lends the tools the pool through one seam (use_nodes,
nodes) -- the tools must not import the node package, which imports
them. raven/acp_client/remote.py keeps the ssh command, the machine
lookup, the posix wrapping of a remote command and the one-sentence
reading of an ssh failure from #893, and loses the launch command, the
env preamble and the session-directory ssh that #897 would have used:
the daemon does those. The ssh command is handed to the connection as an
argv, and AcpClient.launch takes one as it is, so no host's own grammar
reads it again (a Windows host's parser split the line into twelve
words). What reaches a model is a path on the machine written the way
scp writes it, address included, because that is how a file there is
named to the tools; the way in -- the key path, ssh's options, ssh's own
messages -- reaches no config file, log line or model, and an error names
a machine by id and name only. The lane stays
debug-only (RAVEN_DEBUG_MACHINES=1, ruled on #897): with it off no
daemon is started and no install is planned, each saying why.

Installing (raven ops connection install-node <id>,
raven/node/install.py): one look at the machine (the install, its uv or
a curl or wget to fetch one with, a Python 3.12 if it has one, the free
space), then a question, then everything through uv, as the repository's
dependency rule asks: a uv fetched under the node directory from its
installer when the machine has none, a virtualenv uv makes of the
machine's Python 3.12 or of one it brings, the pinned packages with
uv pip, the code as a tarball on ssh's stdin, each step one ssh call
and a failed step removing the half-made install; the node directory is
made 0700. A machine with neither uv nor a way to fetch one is refused
with the installer's address. --dir keeps installs elsewhere and
records it on the row; --prune removes other versions. Nothing else on
the machine changes. raven ops connection doctor reports the registry
as before.

Also: CONTEXT.md defines the Raven node and seats raven.node inner in
the import contract (pyproject.toml, lint-imports); the repo layout
table gets its row; the gateway, the TUI and the RPC entrance close the
node pool with the ACP pool on shutdown.

Type

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

Verification

  • uv run pytest tests/test_node_bundle.py tests/test_node_server.py tests/test_node_client.py tests/test_node_relay.py tests/test_node_install.py tests/test_acp_client_teardown.py tests/test_acp_remote.py tests/test_ops_remote_path.py tests/test_cli_ops_connection_commands.py tests/test_ops_connection_add.py tests/test_ops_transport.py tests/test_subagent_acp.py tests/test_acp_client*.py tests/test_cli_gateway*.py
    -- 859 passed on this tree; diff coverage of this change's lines from
    these groups alone passes the 90% gate. Covered, beside what feat(*): run a Raven node on a registered machine to answer file calls there #901's
    tests covered (parity of every file call with the tool's own answer,
    the fence, each malformed field, the handshake, the stdio loop, the
    install's scripts and one real install on this computer, through uv):

    • The socket's directory is the user's own or not used (a file, a
      link and another user's directory refused; wider bits narrowed); a
      daemon refused its socket's place says so and leaves; the relay
      forwards to nothing that is not the user's and starts no daemon
      beside it; a task's records are 0700 and 0600; a ping is answered
      while a file's digest is taken; a call on its way keeps its link;
      the ssh argv reaches the connection as it is and AcpClient.launch
      takes an argv; the installer fetches uv when the machine has none
      (curl or wget) and refuses a machine with neither; a drive-relative
      path is this computer's on a Windows host and scp's elsewhere, an
      extended or UNC path this computer's anywhere; the node answers
      every file call with only its requirements importable.
    • A task is recorded as it runs and goes on when its connection ends;
      a second connection attaches from the frame the host had not read and
      hears the exit; a finished task is replayed from its record by a
      daemon that never ran it; a record left "running" by a daemon that
      died reads as lost; an attach for a task nobody knows is refused by
      code; the task list and the sweep of old records; shutdown from a host
      leaves a daemon's tasks running.
    • The daemon on its socket: two connections in turn, the agent surviving
      the first, the daemon leaving when idle and leaving at once when
      another serves the same directory; --serve and --attach need
      --dir.
    • The link, over a stand-in ssh that hands the line to a real login
      shell and starts the real relay and daemon: a request made while the
      link is down is answered after the link is back, on the same daemon;
      a daemon told to stop takes its agents and the turn says so; a link
      closed on purpose leaves the agent for the next link to list, attach
      and cancel; a heartbeat lost parks the endpoint and the reconnect
      attaches it again; a task the machine no longer knows ends its
      endpoint in one sentence; an attach the link cannot carry parks every
      waiting endpoint again; held frames are capped; a file fetched whole
      and checked, and refused outside the roots.
    • The relay in this process: it starts a daemon when none answers,
      bridges bytes both ways, reaches the same daemon again without
      starting another, and gives up in one sentence when none comes up.
    • Cancel ends a child the agent put in a session of its own; the tree
      is read off ps; the daemon's own group is never signalled.
  • uv run pytest --collect-only -- no import error; lint-imports --
    10 kept, 0 broken. The daemon's two test modules skip on win32 before
    importing it (fcntl, unix sockets), and the two POSIX teardown tests
    lend a SIGKILL where the platform has none.

  • uv run pytest on the final tree of the three changes, which includes
    this one -- 29217 passed, 121 skipped, 3 failed:
    test_rpc_files.py::test_a_host_without_libreoffice_says_so,
    test_simulation_scenario.py::test_a_fresh_trial_refreshes_the_subagent_homes_before_it_runs
    and test_simulation_suite.py::test_a_stopped_run_is_marked_in_its_record_and_finished_with_its_evidence,
    which fail the same way on main on this machine. The groups above
    also pass under --idle-ceiling-strict, CI's idle ceiling.

  • Real machines (the final tree of the three changes, two registered
    Linux machines, a scratch registry and RAVEN_HOME):

    • Install through uv on both: on the machine without uv, uv fetched
      with the machine's curl under the node directory, then a virtualenv
      of the machine's own Python 3.12, 16 s in all; on the machine whose
      home disk was full, under /tmp, with a Python uv brought, 11 s.
      Both nodes answered the handshake with this code's digest.
    • Connect and handshake 0.7 to 0.9 s; session/dir 11 to 18 ms;
      agent/launch 13 ms; a request's round trip 12 ms.
    • With a real Claude Code running a 25 s shell loop: the host's ssh
      killed mid-turn, the link read as lost in 5 s, the turn finished 27 s
      after the kill with the agent's reply, 25 of 25 lines written, the
      same daemon on both sides of the gap; a cancel left 0 processes 3 s
      later; three sessions at once answered in 8 s; with every link closed
      the daemon stayed with its records and no agent process remained.
  • The second round's four notes (tests/test_filesystem_tools.py tests/test_node_install.py tests/test_node_server.py tests/test_node_client.py tests/test_read_file_image.py -- 337
    passed, 100% diff coverage of that commit's lines): a page is the
    lines splitlines gives whatever the chunk edge, over every break it
    knows; a 120 MiB file pages with every ping answered within 0.25 s; a
    read with a slowed digest beside a write of the same file answers with
    the old page and the old digest and the write lands after; a waiting
    writer goes before new readers; the probe reads pip.conf and
    PIP_INDEX_URL as pip does under a real shell, the plan names the
    index and the install hands it to uv; the uv step fetches one pinned
    version unmanaged; a node directory that was there keeps its mode and
    one the install makes is owner-only. On a registered Linux machine
    with the node reinstalled: a page read of a 1 GiB file there beside
    pings every 50 ms kept the longest wait of a ping at 0.34 s; the reads
    sent during a write all answered with a page and a digest of one
    version; the probe found the mirror that machine's pip is configured
    with; an install into a fresh node directory, forced down the fetched
    path, put uv 0.13.0 under it unmanaged in 24 s, with the receipt under
    the home directory untouched and the directory at 700.

  • The full review's five findings (tests/test_node_client.py tests/test_node_server.py tests/test_node_relay.py -- 223 passed, the
    seven new tests failing on the code before the fix, diff coverage of
    that commit's lines past the gate): a link the heartbeat has not yet
    seen die, replaced by an ordinary call, brings its parked agents along
    (agent/attach on the new link, nothing left parked); a replay the new
    link cannot carry keeps every frame it did not take, parks the endpoint
    being replayed and every one not yet reached, and the link is tried
    again; a cancel asked on a dead link, or failing on the new one, stays
    wanted until a link delivers it, and a refusal clears it; two installs
    on one long node directory get sockets apart; a launch still capturing
    the login environment is counted, so a second one at the limit is
    refused before the first spawns. On a registered Linux machine, one
    script run before and after the fix (the local relay killed with the
    heartbeat kept out of the way, then a plain call; a cancel asked in
    that gap; eleven launches at once against the limit of eight): before,
    the agent stayed parked and never answered, the cancelled task ran on
    with its record saying running, nine launches started and ten tasks ran
    there; after, the call took 0.3 s and the agent answered through the
    new link, the task ended within the reattach with its record saying
    so, eight started and three were refused with eight running, and
    nothing was left running on the machine. The other half of the first
    finding, a relaunch that fails under the call with agents parked: on the
    machine, the relay killed and the relaunch made to fail once, before the
    agent was still parked 30 s after the machine was reachable again and no
    reconnect was under way; after, the reconnect the heartbeat loss
    schedules runs here too, the agent was attached again on the first retry
    after the machine answered and answered through the restored link.

  • scripts/check_source_language.py, scripts/check_commit_messages.py
    and scripts/check_large_files.py on the branch range, commitlint on
    the commit and the prospective squash header, ruff -- all clean.

  • Relevant tests pass locally

  • Relevant lint / type checks pass locally

  • User-facing docs or screenshots are updated when needed

The command describes itself in --help; user docs follow with the
change that runs sub-agents on a machine.

Risk

Opt-in and inert: nothing starts a daemon unless the debug lane is on and
a caller asks for a machine, and an install runs only when the owner asks,
after saying what it will do. On a machine it adds one directory under
~/.raven-node (or the row's node_dir): the install, a run/ with the
socket, lock and pid of this install, a tasks/ with the records, a
log/; removing the directory uninstalls it. The daemon runs as the
registered user, listens on a socket in a directory only that user can
open (and the relay forwards to a socket of that user's only), reads and
writes only inside the roots each call names, and leaves when idle. A
task's record holds the agent's ACP frames, which may quote what the
agent read: it is the user's alone (0700, 0600) and is removed after
seven days. The way in stays in the ssh argv, in memory; no error names
the address, and a model sees it only as part of a path on the machine,
written the way scp writes it. Rollback is a plain revert; an installed
daemon is then an inert directory that leaves once idle.

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

Related Issues

Follows #893; replaces #897 and #901.

…ched ssh link

A registered machine gets this Raven's own code as a daemon (raven/node):
started by the relay the host's ssh runs there (`python -I -m raven.node
--attach --dir <node_dir>`) when none answers on its socket under the
node directory, and left on its own once idle. It answers file calls with
Raven's own tools fenced to the directories each call names (fs/read,
list, find, grep, stat, write, edit, pull), starts acp agents under task
ids of the host's in their own process groups in the machine user's
login environment, keeps each agent's frames in the task's record on
disk so a connection that goes leaves the agent running and the next one
takes it over from the frame the host last read (agent/attach), lists
the tasks it runs or remembers, and probes the machine and an agent's
command. It opens no network port; only --stdio ties the agents to the
connection. `raven ops connection install-node <id>` puts it on a
machine after one look and one question, and `--prune` removes older
installs.

The host keeps one link per machine (raven/node/client.py): the ssh line
runs the relay, a heartbeat watches the link, a lost link parks the
agents on it and is tried again on a backoff for as long as any waits,
and a link that comes back attaches every task again by its id, sending
what the host wrote meanwhile and any cancel asked in between. The
registry's package tells a remote path from a local one and lends the
tools the pool through one seam (raven/ops/remote_path.py); the machine
lane stays debug-only (RAVEN_DEBUG_MACHINES=1).

First of three changes on this design; the next runs acp sub-agents on
a machine through this daemon, the one after reads and writes files
there by address. Nothing dispatches to a machine yet.

Co-authored-by: Claude (claude-fable-5-1) <noreply@anthropic.com>
@Handsome-wzw
Handsome-wzw requested a review from LivXue October 12, 2026 11:06

@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 node installer must use uv instead of pip.

I reviewed the full github/main...HEAD diff and covered the repository rules (AGENTS.md, CLAUDE.md, CONTEXT-MAP.md, and the Raven node additions to CONTEXT.md), affected ACP/node/file-path callers, the single-commit history, backward compatibility of the generalized ACP endpoint, test changes, and the documented layer-seat constraints. I found no weakened tests.

Verification after uv sync --all-extras --all-groups:

  • uv run pytest tests/test_acp_client_teardown.py tests/test_acp_remote.py tests/test_cli_ops_connection_commands.py tests/test_node_bundle.py tests/test_node_client.py tests/test_node_install.py tests/test_node_relay.py tests/test_node_server.py tests/test_ops_connection_add.py tests/test_ops_remote_path.py tests/test_ops_transport.py -q - 443 passed, 4 skipped because /bin/tcsh and /bin/csh are unavailable.
  • uv run pytest tests/test_subagent_acp.py tests/test_acp_protocol.py tests/test_acp_journal.py -q - 365 passed.
  • Focused Ruff, source-language, large-file, commit, and diff-whitespace checks passed (the make executable is unavailable, so the two repository check scripts were invoked directly).

The initial focused pytest attempt exercised no tests because the fresh environment lacked the local raven_everos development package; synchronizing the declared extras/groups resolved that setup issue.

One blocker is marked inline.

Comment thread raven/node/install.py Outdated
@LivXue

LivXue commented Oct 12, 2026

Copy link
Copy Markdown
Member

Windows compatibility review: two findings.

Scope: complete 34-file PR diff at 71f47d1354976ef26da43ed1dddfd53becc35e23, API base 82c76a82a9a7d312ef6cd75e560404bccd81d292, merge base 829eb28e1db877dffdd3df4f835d707e1f049d3a, including source, callers, tests and surrounding platform guards. Inspected isolated exact-SHA source archives; all changed-file blob hashes and all patch addition/deletion counts matched GitHub.

  1. P2 - Serialize the host SSH argv with the host's quoting rules. Reproduced. remote.py:188 uses shlex.join, but NodePool._launch sends that string to AcpClient.launch and its Windows command_argv parser. With the machine lane enabled, the actual node launch's one remote-command argument becomes 12 arguments; the reconstructed remote shell command starts with the program name exec /bin/sh -c echo;, rather than executing exec. Node handshakes, including the final install-node check, therefore cannot start correctly from Windows. Pass argv directly or serialize it with the same platform grammar as the consumer, keeping POSIX quoting inside the remote-command value. The base's older launch_command already had this quoting pattern, but has no production callers in the base tree; the new node path makes it reachable.

  2. P2 - Gate POSIX-only tests before importing their implementation. Reproduced. test_node_server.py:29 and test_node_relay.py:17 import the POSIX daemon unconditionally, stopping Windows collection at import fcntl. The new teardown test also forces _POSIX=True without providing SIGKILL, and fails on Windows. Skip the daemon modules at module level before those imports, and skip or fully emulate the POSIX teardown branch. This finding concerns host test collection; the daemon itself is intentionally POSIX. The real Windows teardown branch passed its two selected tests.

Native Windows / Python 3.13.12, PYTHONUTF8=1, UV_NO_SYNC=1, isolated user/Raven/temp directories. A pre-synced uv environment supplied the unchanged locked dependencies; imports were asserted to come from each inspected snapshot. Commands ran from the named snapshot root.

uv run python 'C:\Users\livxu\AppData\Local\Temp\raven-windows-review\5f9cc24f22014021985a6c637edc57e3\probe_windows.py' node-ssh
# 12 remote-command arguments instead of 1; native producer/parser mismatch
uv run pytest 'tests/test_node_bundle.py' 'tests/test_ops_remote_path.py' -n 0 -x --tb=short --basetemp 'C:\Users\livxu\AppData\Local\Temp\raven-windows-review\5f9cc24f22014021985a6c637edc57e3\pr920\node-tests-pytest'
# 93 passed, 1 skipped (symlink privilege)
uv run pytest 'tests/test_node_client.py' -n 0 -x --tb=short --basetemp 'C:\Users\livxu\AppData\Local\Temp\raven-windows-review\5f9cc24f22014021985a6c637edc57e3\pr920\client-tests-pytest'
# 24 passed, 29 skipped (POSIX-shell fixtures)
uv run pytest 'tests/test_node_server.py' 'tests/test_node_relay.py' -n 0 -x --collect-only --tb=short --basetemp 'C:\Users\livxu\AppData\Local\Temp\raven-windows-review\5f9cc24f22014021985a6c637edc57e3\pr920\collect-pytest'
# collection failed: ModuleNotFoundError: fcntl; exit 1
uv run pytest 'tests/test_acp_client_teardown.py' -n 0 -x --tb=short --basetemp 'C:\Users\livxu\AppData\Local\Temp\raven-windows-review\5f9cc24f22014021985a6c637edc57e3\pr920\teardown-pytest'
# 1 failed: signal.SIGKILL absent; exit 1
uv run pytest 'tests/test_acp_client_teardown.py' -k 'without_process_groups or platform_is_read' -n 0 -x --tb=short --basetemp 'C:\Users\livxu\AppData\Local\Temp\raven-windows-review\5f9cc24f22014021985a6c637edc57e3\pr920\windows-teardown-pytest'
# 2 passed, 2 deselected

The argument probe uses the real NodePool._prepare and command_argv, with only the registry lookup substituted. No live SSH, remote install, daemon execution, live agent credentials or full suite was exercised. The existing uv-only installer finding remains a separate discussion.

@LivXue

LivXue commented Oct 12, 2026

Copy link
Copy Markdown
Member

Blocking: 1 finding needs correction before this revision can merge; it is the first of the notes below.

Scope: none of this PR's 34 files is under raven/agent/**, the part of the tree I review, so nothing is filed inline. That package is still reached: the node runs its file tools on the registered machine (eight raven.agent modules, raven/agent/tools/filesystem.py and file_search.py among them), the AcpClient endpoint seam sits under the ACP backends in raven/agent/subagent, and make_ssh_runner serves exec(machine=...). I read every non-test file of the diff, and the tests where a note turns on them; the notes below are about the files that carry the package there, stated here rather than inline. The Blocking note at raven/node/install.py:187 and the Windows compatibility review above stand on their own and are not repeated here.

Notes:

  1. Blocker - keep the daemon's socket in the owner-only run/ directory, or check the listener before forwarding to it. socket_path (raven/node/server.py:545) puts the socket under <node_dir>/run/, which the daemon makes 0700, only while that path fits in 100 bytes. Past that -- a node directory longer than 72 bytes with this head's install name -- it uses raven-node-<digest of the node directory>.sock in the temp directory (/tmp unless TMPDIR says otherwise), outside any directory the machine user owns. The relay (raven/node/relay.py:63) then forwards the host's lines to whatever is listening at that path, without checking that the listener is the machine user's own daemon, so nothing guarantees that the link's traffic, including the env each agent/launch carries, reaches that daemon. A file at that path that the user cannot remove also keeps the daemon from starting: sock.unlink() (server.py:1214) suppresses only FileNotFoundError. Run as one account: with a 78-byte node directory the socket path moved to /tmp/raven-node-<16 hex>.sock; a listener already bound there received the line the relay forwarded, and the relay started no daemon. The cross-account case is code-reading. The default ~/.raven-node stays under the limit for an ordinary home; a longer --dir is what reaches the fallback. The Risk section's "a socket only that user can open" holds for the socket the daemon binds; the relay does not check that the socket it reaches is that one.

  2. Nonblocking - create the task records owner-only, as the host's own frame journal is. The daemon makes tasks/ and each tasks/<task>/ with the default mode and opens frames.jsonl and stderr.log the same way (raven/node/server.py:798, :834, :617; task.json through _write_json, :760), and the installer's mkdir -p "$dir" makes the node directory that way too (raven/node/install.py:178, :184); only run/ (0700) and the socket (0600) are narrowed. Under umask 022 a real daemon (--serve) left the node directory, tasks/ and the task's directory at 0755 and all three record files at 0644. frames.jsonl holds the agent's output frames verbatim -- its replies and tool results, which can quote any file it read -- so any account that can traverse the user's home can read them. The host writes the same kind of record at 0600 (raven/acp_client/journal.py:161), and the Risk section describes the record as living "in the user's own directory".

  3. Nonblocking - keep whole-file work off the daemon's event loop. One fs/read runs ReadFileTool.execute, which reads and decodes the whole file (raven/agent/tools/filesystem.py:264), and then hashes the whole file (raven/node/server.py:253); both run on the daemon's only event loop, which also answers the heartbeat and forwards every agent's frames. fs/stat with hash, the first fs/pull chunk and an fs/write with expect hash the whole file the same way. Measured with python -m raven.node --stdio at this head, pinging every 0.2 s, on a 1 GiB file read from the page cache: asking for its first 10 lines took 3.50 s, with no ping answered for 3.46 s of it (baseline 0.2 ms); fs/stat with hash took 0.88 s, with a 0.78 s gap. Scaled linearly, a file of about 4.3 GiB holds the loop past the 15 s heartbeat limit, after which the host treats the link as lost and parks every agent on the machine; that last step is extrapolated, not run, and a cold or network disk reaches it sooner. Separately, NodePool.call treats any call that outlasts its timeout as a lost link (raven/node/client.py:347), even while the heartbeat is being answered.

  4. Nonblocking - pin the node's import budget with a test. A node is installed with loguru and pillow only (protocol.REQUIREMENTS), yet it imports eight raven.agent modules, and nothing on the host fails if one of them gains another third-party import: the one real install (tests/test_node_install.py:569) stands in for pip and then runs only --version, which needs the standard library alone, and the stand-in ssh tests run the node with the test's own interpreter (exec {sys.executable}). The budget holds today -- with every other third-party import blocked, all eight file calls and node/hello ran at this head (31 raven modules loaded, 8 under raven.agent) -- so this is about the next change to raven/agent/tools, which would break every installed node while the suite stays green. A test that runs the file calls with only the declared requirements importable would catch it.

  5. Nonblocking - count a call in flight as use before closing a link as idle. _close_idle (raven/node/client.py:767) closes another machine's link when it has no agent channel and its used is older than IDLE_CLOSE_S, and used moves only when a call returns (:358). A call that starts on a link near that limit is closed under itself as soon as a call for any other machine acquires a link, and it fails with "the Raven node on machine ... stopped", which blames the node for the host's own close. Reproduced with two stand-in links and idle_close_s=5: link A last used 4.5 s earlier, a 2 s call on A, a call on B one second in; A's link was closed and its call failed with that sentence.

  6. Nonblocking - make the absent directory in the login-shell test one the test controls. test_a_command_reads_the_same_under_every_login_shell (tests/test_acp_remote.py:385) checks [ ! -d /nonexistent ], and some machines have that directory: on the Linux box I ran it on, /nonexistent is nobody's home and exists, so the sh, dash and bash cases fail with exit 1 and an empty stderr. A path under tmp_path that the test knows is absent keeps the check without the dependency.

  7. Question - is RemotePath.display meant to reach the model? display (raven/ops/remote_path.py:127) renders a remote path from the registry row as user@host:path, or scp://user@host:port//path, and remote.shown (raven/acp_client/remote.py:401) calls it "what the file tools read back as a remote path". The same PR's remote.py says no address "reaches a config file, a log line or a model" (lines 18-19) and that the registry "keeps the address out of anything a model reads" (lines 74-75). Nothing calls either function here; in feat(*): read, write and deliver files on a registered machine by address #922 the file tools put display() into their results and errors (raven/agent/tools/remote_files.py, raven/agent/tools/deliver.py), and the remote agent's result note uses remote.shown (raven/acp_client/acp_agent.py), so the model would read registry addresses there. Which of the two is meant to hold?

Verification:

  • 17 test files at this head, with a copy of the dev environment whose editable install points at this head, so the relay's and the daemon's -I children import it too: tests/test_node_*.py (5 files), test_acp_client_teardown.py, test_acp_remote.py, test_acp_protocol.py, test_acp_journal.py, test_subagent_acp.py, test_ops_connection_add.py, test_ops_remote_path.py, test_ops_transport.py, test_cli_ops_connection_commands.py, test_scope_canon.py, test_l4_entrances.py, test_kernel_budget.py: 816 passed, 6 skipped, 3 failed (note 6).
  • lint-imports at this head: 10 kept, 0 broken.
  • The probes named in each note, run from this head on Linux with Python 3.12. No ssh to a real machine and no second account on my side.

Checked and deliberately not reported:

  • The debug gate: every way onto a machine passes remote.machine(), which refuses while RAVEN_DEBUG_MACHINES is off, and remote_path.target() then names no machine for any path; the gate is an environment variable, so the model cannot turn it on through raven_config.
  • The node pool is closed at the same three shutdown points as the ACP pool (gateway, TUI, RPC), after it.
  • find and grep, the Python fallback included, run off the event loop; fs/write compares expect and writes with no await in between.
  • The ACP backends under raven/agent/subagent construct no AcpClient themselves, so the endpoint seam reaches them unchanged, and test_subagent_acp.py passes. make_ssh_runner's new stdin=DEVNULL also covers exec(machine=...) (raven/agent/tools/machine_exec.py:344), whose remote command no longer inherits the caller's stdin.
  • feat(*): dispatch acp and cli agents on machines and tunnel openai endpoints #921 and feat(*): read, write and deliver files on a registered machine by address #922 are stacked on this commit and do not change raven/node/, raven/acp_client/remote.py or raven/ops/remote_path.py again, so these notes are filed here.

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

Requesting changes: 1 blocking finding, with 5 nonblocking notes and 1 question, is in the PR-level summary comment. None is filed inline, as no file in this PR is under raven/agent/**, the part of the tree I review; the summary also carries the scope statement and the verification log.

Handsome-wzw and others added 2 commits October 13, 2026 01:15
…when the machine has none

The install ran `python -m venv` and `python -m pip install` on the
machine, against the repository's rule that uv is the only Python package
manager. Everything Python now goes through uv: `uv venv` of the machine's
own Python 3.12 or of one uv keeps inside the node directory, `uv pip
install` for the pinned packages and the optional search package. A
machine without uv gets one under `<node_dir>/.uv` first, from uv's own
installer fetched with the machine's curl or wget and told to touch
nothing else; the probe looks for curl and wget, and for a uv an earlier
install put there; with no uv and no way to fetch one the install stops
and says so. The plan counts the room uv takes and says when it is fetched.

Co-authored-by: Claude (claude-fable-5-1) <noreply@anthropic.com>
…h argv goes as it is

The socket's fallback for a long node directory lay in the temp
directory itself, so the relay forwarded the host's lines to whatever
listened at that path, and a file there the user could not remove kept
the daemon from starting: the fallback now lies in a directory of the
user's own (0700, checked, never a link or another's), the relay
forwards only to a socket of the user's, and a daemon refused its
socket's place says so in one line and leaves. The task records -- the
agent's frames as they were -- were made with the default mode: tasks/,
each record and its three files are the owner's alone now, as the
host's journal is, and the installer makes the node directory so. A
page read, a digest, a chunk and a checked write ran on the daemon's
one loop, which also answers the heartbeat: they run in a thread now,
writers one at a time under a lock. The ssh argv went to the connection
as one shell-quoted line, which a Windows host's own grammar split into
twelve words: it goes as the argv, which no host reads again. A call on
its way to a node counts as use, so a link is not closed under it as
idle. A drive-relative or extended Windows path (C:file, \\?\C:\...)
no longer reads as a machine's. The daemon's tests skip where there is
no fcntl, the POSIX teardown tests lend a SIGKILL where the platform
has none, the login-shell test checks a directory of its own instead
of /nonexistent, a node is run with nothing importable beyond its
requirements, and remote.py says what does reach a model: a path on
the machine, written the way scp writes it.

Co-authored-by: Claude (claude-fable-5-1) <noreply@anthropic.com>
@Handsome-wzw

Copy link
Copy Markdown
Contributor Author

Thank you for the two reviews. Every point is in the commits now on this branch; the uv-only installer is the commit before them.

  1. Blocker, the socket's fallback: it now lies in a directory of the user's own under the temp directory (raven-node-<uid>/, 0700, checked before use: a directory of the user's, never a link, never another user's, narrowed when wider); run/ is checked the same way; the relay forwards only to a socket of the user's and starts no daemon beside anything else; a daemon whose socket's place holds a thing it cannot remove says so in one line in log/serve.log and leaves rather than listen beside it (own_dir, owned, socket_path in server.py; connect in relay.py; tests for a file, a link and another user's directory in the socket's place, for the daemon and for the relay).
  2. Records: tasks/, each record and task.json, frames.jsonl, stderr.log are made 0700 and 0600 (own_file), log/ and serve.log too, and the installer makes the node directory 0700 (test).
  3. Whole-file work: fs/read, fs/stat, fs/pull, fs/write and fs/edit run in a worker thread (_apart), writers one at a time under a lock so a write's check and its write stay one stretch; a ping is answered while a file's digest is being taken (test). NodePool.call reading its own timeout as a lost link is left as it is for now.
  4. Import budget: test_a_node_answers_every_file_call_with_only_its_requirements_importable runs the node with nothing importable beyond the standard library, its own code, loguru and pillow, and asks every file call.
  5. A call in flight counts as use: _Node.calls, and _close_idle leaves a link with one (test with a link that answers slowly while another machine is asked for).
  6. The login-shell test checks a directory under tmp_path.
  7. RemotePath.display is meant to reach the model: a path on a machine is written the way scp writes it (the design's ruling), and that is how the file tools and deliver_files take it. What stays out of models and logs is the way in -- the key path, ssh's options, ssh's own messages -- and an error names a machine by id and name only. The two docstrings in remote.py said more than that; they now say this.

Windows: remote.ssh_command returns the argv and AcpClient.launch takes an argv as it is, so no host grammar reads the remote command again (tests/test_acp_client_launch.py, and the node's argv checked at the connection in test_node_client.py); test_node_server.py and test_node_relay.py skip on win32 before importing the daemon, and the two POSIX teardown tests lend a SIGKILL where the platform has none.

@Handsome-wzw
Handsome-wzw requested a review from LivXue October 12, 2026 18:23

@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 new-head delta and its integration with the full PR diff, callers, and history. The earlier uv-only blocker is fixed: node setup now uses uv venv and uv pip install, obtains a private uv when possible, and refuses installation when uv cannot be obtained. The host-safe SSH argv, owner-only socket/records, off-loop whole-file work, in-flight call accounting, Windows collection guards, import-budget coverage, and remote-path documentation changes also match the failures previously reported.

One nonblocking test-portability issue is marked inline. It is introduced and reproducible under an ordinary umask 0002, but it affects only the assertion, has the umask 0022 workaround, and does not leave an operator without a working product path, so it does not meet the round-four blocker bar.

Verification: the affected suite produced 462 passed, 4 skipped, 1 failed on Python 3.12.3; the sole failure is the inline umask assumption. The isolated case failed the same way on Python 3.13.2 and passed under umask 022. ACP regressions passed (365). Ruff, all 10 import contracts, source-language and large-file gates, and git diff --check passed. make is unavailable in this environment, so I ran the two canonical gate scripts directly with their documented revision range.

Coverage included AGENTS.md/CLAUDE.md and CONTEXT routing, the diff and new commits, callers, history, backward compatibility, test weakening, and the stated architecture boundaries. The Windows module skips occur before POSIX imports and do not skip those tests on POSIX.

Comment thread tests/test_node_server.py
…test sets its own mode

The record opener handed os.fdopen a splatted keyword map, which no
overload of it matches under the type check: the binary and the text
mode are two calls now. The test of another user's directory asserted
the mode mkdir leaves, which is the umask's (0775 under 0002, where it
failed): the test sets 0755 first and asserts it is left so.

Co-authored-by: Claude (claude-fable-5-1) <noreply@anthropic.com>

@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 complete delta from the previously assessed head. The explicit chmod(0755) fixes the only outstanding suggestion under both tested umasks, and splitting the binary/text os.fdopen calls preserves runtime behavior while satisfying the type-check overloads. I found no new issue.

Verification: the isolated ownership test passed under umask 0002 and umask 022; all 90 node-server tests passed; the previously affected 12-file suite passed with 463 tests and 4 environment skips; focused Ruff and ty check raven/node/server.py passed; git diff --check passed.

Coverage for this revision included the new diff, its own_file callers, commit history, backward compatibility, test-strength preservation, and the already-reviewed AGENTS.md/CLAUDE.md, CONTEXT terminology, and architecture constraints. This delta changes no dependency, domain term, public interface, or architectural edge.

@LivXue

LivXue commented Oct 12, 2026

Copy link
Copy Markdown
Member

Windows compatibility re-review: the previously reported SSH argv and POSIX-import/teardown failures are addressed in the exercised paths; one test expectation needs updating.

Scope: all 35 changed files at 2979e5617a552dcfae5afdb588971baedc0e5702; API base 82c76a82a9a7d312ef6cd75e560404bccd81d292, merge base 829eb28e1db877dffdd3df4f835d707e1f049d3a. Rechecked the complete PR scope and revision delta, using blob identity for unchanged previously reviewed files and tracing the affected callers.

P2 - Update the unknown-machine fixture for Windows drive-relative paths. Reproduced. tests/test_ops_remote_path.py:101 still expects a:b.txt to raise RemotePathError. The new Windows branch intentionally recognizes it as a local drive-relative path, so this case fails with DID NOT RAISE. Keep that runtime correction; use a multi-character unknown host in this fixture, or make its POSIX expectation platform-specific. This is part of the shared #920 change.

The native launch probe confirmed that the real node preparation passes an argv list, preserves a key path containing spaces, and supplies exactly one remote-command argument to the intercepted connection. A real local file was readable through absolute, drive-relative and extended Windows spellings with the machine lane enabled. Both POSIX daemon test modules now skip before importing fcntl; the four teardown tests pass.

Native Windows / Python 3.13.12. Git fetching failed; the isolated fixed-SHA snapshots were reconstructed and every blob plus the complete Git tree was verified. A fresh environment was created with uv sync --frozen --python 3.13 --extra dev after the first environment-reuse attempt failed before tests ran. Tests used isolated user/Raven/temp/registry paths, PYTHONUTF8=1, UV_NO_SYNC=1, UV_PYTHON=3.13, and UV_PROJECT_ENVIRONMENT=$R/venv; PYTHONPATH selected the tested snapshot and plugin roots, with the loaded source asserted. $R = 'C:/Users/livxu/AppData/Local/Temp/raven-windows-review/2d5904b4d92f4f238e9db27e2ade2ddf'.

uv run pytest tests/test_node_bundle.py tests/test_ops_remote_path.py tests/test_acp_client_launch.py tests/test_acp_client_teardown.py tests/test_acp_remote.py tests/test_node_client.py tests/test_node_install.py tests/test_node_server.py tests/test_node_relay.py -k 'not path_that_names_no_registered_machine and not each_agent_gets_an_owner_only_ssh_log' -n 0 -x --tb=short --basetemp "$R/checks/920-final-host/pytest" --junitxml "$R/checks/920-final-host/junit.xml"
# 235 passed, 58 skipped, 13 deselected. POSIX daemon/shell/install and symlink-privilege cases skipped.
uv run pytest tests/test_ops_remote_path.py -k path_that_names_no_registered_machine -n 0 -x --tb=short --basetemp "$R/checks/920-final-path/pytest" --junitxml "$R/checks/920-final-path/junit.xml"
# 11 passed, 1 failed (a:b.txt), 79 deselected.
uv run pytest C:/Users/livxu/AppData/Local/Temp/raven-windows-review/2d5904b4d92f4f238e9db27e2ade2ddf/test_windows_resolved_paths.py -k 'not executable_spellings' -s -n 0 -x --tb=short --basetemp "$R/checks/920-final-native/pytest" --junitxml "$R/checks/920-final-native/junit.xml"
# 2 passed, 1 deselected; scratch argv/path probes, no SSH connection.
# Exact API-base control, with source imports pinned to that snapshot:
uv run pytest tests/test_acp_remote.py::test_each_agent_gets_an_owner_only_ssh_log -n 0 -x --tb=short --basetemp "$R/checks/920-platform-control/pytest" --junitxml "$R/checks/920-platform-control/junit.xml"
# 1 failed: the same POSIX 0700 mode assertion as at the inspected head.

The host selection excludes the separately reproduced path-error group and test_each_agent_gets_an_owner_only_ssh_log. The latter also failed on exact base 82c76a82...; it is an inherited Windows mode assertion, not a new finding. Earlier runs on the immediately preceding head stopped at those two assertions; their source is unchanged by the final two-file daemon/type-check delta.

The daemon ownership/umask discussion is already resolved and is not repeated here. Remote daemon hardening was code-reviewed; no live SSH, remote installation, remote daemon execution or full suite ran. Windows skips do not establish those POSIX behaviors.

@gloryfromca

Copy link
Copy Markdown
Member

No blockers; suggestions only, and they are marked inline.

The new Windows evidence supersedes my earlier clean stance. I verified the reported mismatch at 2979e5617a55: with the Windows branch selected, parse("a:b.txt", rows) correctly returns None, while the shared fixture still expects every listed value to raise RemotePathError. The native-platform tests/test_ops_remote_path.py suite remains green here (91 passed), confirming this is the platform-specific expectation rather than a runtime regression.

I am carrying this as the one nonblocking follow-up already identified in the Windows re-review; I am not duplicating it as another inline finding. The production correction is sound, and updating the fixture is straightforward.

…host reads as a drive

On a Windows host one letter before the colon is a drive, so `a:b.txt`
is this computer's there and the test that expects a refusal failed
(reviewed 2026-10-12); a two-letter name reads as a machine's everywhere.

Co-authored-by: Claude (claude-fable-5-1) <noreply@anthropic.com>

@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 complete delta from the previously assessed head. Replacing the ambiguous one-letter fixture with ab:b.txt fixes the Windows expectation without weakening coverage: a:b.txt remains exercised as a Windows drive-relative local path, while the two-letter name remains an unknown remote host on both platform branches. I found no new issue.

Verification: tests/test_ops_remote_path.py passed all 91 tests; direct probes exercised both _WINDOWS branches and preserved the intended distinction; focused Ruff and git diff --check passed.

Coverage for this revision included the changed test, the parser behavior it asserts, callers and history, backward compatibility, test-strength preservation, and the already-reviewed AGENTS.md/CLAUDE.md, CONTEXT terminology, and architecture constraints. This delta changes no production code, dependency, domain term, interface, or architectural edge.

@Handsome-wzw

Copy link
Copy Markdown
Contributor Author

The fixture is updated in 7b3757c, now on this branch: the colon-in-a-name case names a two-letter host (ab:b.txt), which reads as a machine's on every host, and the drive-relative runtime correction stays as it is.

@LivXue

LivXue commented Oct 12, 2026

Copy link
Copy Markdown
Member

Windows compatibility re-review: the drive-relative fixture is corrected; one intermittent test failure remains.

Scope: the complete 35-file PR at 7b3757c128b9a907e190e458ac5362242e34ccc4, API base 82c76a82a9a7d312ef6cd75e560404bccd81d292, merge base 829eb28e1db877dffdd3df4f835d707e1f049d3a. Rechecked the full scope, existing discussions and revision delta; all 4,375 source blobs in the isolated snapshot matched the head tree. Since the preceding reviewed head, only the unknown-machine fixture changed.

P2 - Do not require reattachment to be the last heartbeat request. Reproduced intermittently on native Windows. tests/test_node_client.py:901 checks again.asked[-1] after waiting for reconnection. This test runs heartbeats every 10 ms, while _until polls every 20 ms: a normal node/ping can follow the successful agent/attach before the assertion. Both the combined run and the standalone file run failed with node/ping != agent/attach; the selected test alone passed. The logs show successful reattachment in both failures. Assert that the expected attach occurred, or synchronize on that request, without requiring unrelated heartbeat traffic to stop. This is a test race, not evidence that production reconnection failed. The test is new relative to the PR base, but unchanged by this latest fixture correction; the same source/test blobs are carried by #921 and #922.

Native Windows, Python 3.13.12; fresh run-owned environment from uv sync --frozen --extra dev --dev, UV_NO_SYNC=1, PYTHONUTF8=1, isolated user/Raven/temp directories. Imports were verified against this snapshot. Exact test commands, from its root:

$R = 'C:\Users\livxu\AppData\Local\Temp\raven-windows-review\abf44f96254d43bc81b1be33079fb26b'
uv run pytest tests/test_ops_remote_path.py tests/test_acp_client_launch.py tests/test_acp_client_teardown.py tests/test_node_bundle.py tests/test_node_client.py tests/test_node_server.py tests/test_node_relay.py -n 0 -x --tb=short --basetemp "$R\checks\920-host\pytest" --junitxml "$R\checks\920-host\junit.xml"
# 116 passed, 32 skipped, 1 failed (heartbeat assertion).
uv run pytest tests/test_node_client.py::test_a_heartbeat_unanswered_loses_the_link_tells_its_agents_and_reconnects -n 0 -x --tb=short --basetemp "$R\checks\920-heartbeat\pytest" --junitxml "$R\checks\920-heartbeat\junit.xml"
# 1 passed.
uv run pytest tests/test_node_client.py -n 0 -x --tb=short --basetemp "$R\checks\920-node-client\pytest" --junitxml "$R\checks\920-node-client\junit.xml"
# 14 passed, 29 skipped, 1 failed (same assertion).

All 91 remote-path tests passed, including the corrected ab:b.txt fixture. The skips cover POSIX-only daemon/SSH fixtures and unavailable symlink privilege. Both failing runs stopped at the first failure; later node-client tests, live SSH, remote installation/daemon operation, and the full suite were not exercised.

@gloryfromca

Copy link
Copy Markdown
Member

No blockers; suggestions only, and they are marked inline.

The new Windows evidence supersedes my clean stance. I confirmed the race at 7b3757c128b9: the complete tests/test_node_client.py file passed here (55 tests), but a deterministic probe that waited one heartbeat after successful reconnection recorded the valid sequence agent/attach, node/ping, node/ping. Therefore again.asked[-1] == agent/attach depends on scheduler timing even though reattachment succeeded.

I am carrying the intermittent assertion as the one nonblocking follow-up already identified in the Windows re-review, without duplicating it as my own inline finding. Production reconnection behaved correctly in the probe, and the test can assert that the expected attachment occurred without requiring heartbeat traffic to stop.

…equests, not as its last

The test of a link that comes back read the new link's last request as
the attach, and its heartbeat, every 10 ms there, can follow the attach
before the check: it now looks for the attach among the requests
(reproduced on Windows, 2026-10-12).

Co-authored-by: Claude (claude-fable-5-1) <noreply@anthropic.com>
@Handsome-wzw

Copy link
Copy Markdown
Contributor Author

Fixed in 418923f: the reconnect test looks for the attach among the new link's requests instead of reading its last request, so a heartbeat that follows the attach no longer fails it.

@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 complete delta from the previously assessed head. The reconnect test now searches the replacement client's requests for the exact agent/attach method and payload instead of requiring it to remain last after heartbeat scheduling resumes. That fixes the reproduced race without weakening the behavior under test. I found no new issue.

Verification: all 55 tests/test_node_client.py tests passed with xdist disabled, focused Ruff passed, and git diff --check passed.

Coverage for this revision included the changed assertion, the heartbeat/reconnect ordering and callers it exercises, commit history, backward compatibility, test-strength preservation, and the already-reviewed AGENTS.md/CLAUDE.md, CONTEXT terminology, and architecture constraints. This test-only delta changes no production behavior, dependency, interface, domain term, or architectural edge.

A review date in a source comment is the task's context, not the
code's: the four comments that carried one keep their reason and drop
the day.

Co-authored-by: Claude (claude-fable-5-1) <noreply@anthropic.com>

@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 complete delta from the previously assessed head. It removes transient review-date wording while retaining the durable explanations for Windows argv handling and heartbeat interleaving. No executable behavior changed, and I found no new issue.

Verification: the directly associated ACP launch/remote and node-client suites passed with 145 tests and 4 unavailable-shell skips; focused Ruff, the source-language gate, and git diff --check passed.

Coverage for this revision included all changed lines, their documented callers and history, backward compatibility, test-strength preservation, and the already-reviewed AGENTS.md/CLAUDE.md, CONTEXT terminology, and architecture constraints. This comment-only delta changes no dependency, domain term, public interface, or architectural edge.

@LivXue

LivXue commented Oct 12, 2026

Copy link
Copy Markdown
Member

Windows compatibility re-review: the reported heartbeat-test race is fixed. No additional Windows compatibility issue was found in the inspected scope.

Reviewed the complete 35-file PR at 9fa6129cdcf77fa5448ee663e154c9bd6c555dac, against API base 82c76a82a9a7d312ef6cd75e560404bccd81d292. The isolated snapshot matched all 4,375 head-tree blobs. Since the preceding reviewed head, the functional change is the test's exact agent/attach membership assertion; the other edits remove dated wording from comments/docstrings. Runtime logic is unchanged. The assertion still checks the replacement connection, task and replay offset, while allowing a later heartbeat.

Native Windows / Python 3.13.12; fresh run-owned environment prepared with uv sync --frozen --extra dev --dev, UV_NO_SYNC=1, PYTHONUTF8=1, isolated user/Raven/temp directories, and imports checked against this snapshot. Exact command from the snapshot root:

$R = 'C:\Users\livxu\AppData\Local\Temp\raven-windows-review\0605c160aa864b11bad6b7d18f24455f'
uv run pytest tests/test_ops_remote_path.py tests/test_acp_client_launch.py tests/test_acp_client_teardown.py tests/test_node_bundle.py tests/test_node_client.py tests/test_node_server.py tests/test_node_relay.py -n 0 -x --tb=short --basetemp "$R\checks\920-host\pytest" --junitxml "$R\checks\920-host\junit.xml"
# 127 passed, 33 skipped.

The complete selected node-client file passed, including the previously failing reconnect test. Skips cover POSIX-only SSH/daemon fixtures and unavailable symlink privilege. No live SSH, remote installation/daemon operation, or full repository suite was run.

@LivXue

LivXue commented Oct 13, 2026

Copy link
Copy Markdown
Member

No blockers in the reviewed scope.

Scope: this re-review covers the six commits since 71f47d135497 and re-runs, at 9fa6129cdcf7, the checks behind my first-round notes. As in that round, none of this PR's 35 files is under raven/agent/**, the part of the tree I review, so nothing is filed inline; the node still runs raven/agent/tools on the machine, and the notes below are about the files that carry it there. The first round's Blocking note is fixed, so for this head the verdict above replaces that summary's.

First-round notes, at this head:

  1. Fixed - the socket fallback. Reproduced with two accounts on one Linux box (the relay ran as uid 0 and the other account was uid 65534; the new checks compare uids). At 71f47d135497 the other account bound the fallback path first and received the agent/launch line the relay forwarded, its env included. At this head the other account created raven-node-<uid>/ first, as a directory and then as a link, and listened inside it: the relay refused with "is not a directory only this user may open" and forwarded nothing, and --serve printed the same sentence and exited 1.
  2. Fixed - owner-only records. Under umask 022 a real daemon made run/, tasks/ and the task's directory 0700, and frames.jsonl, stderr.log and task.json 0600.
  3. Partly fixed - whole-file work and the event loop. The digest is off the loop now; the page read is not (note 8).
  4. Fixed - the import budget. The new test fails on a third-party import added at the top of raven/agent/tools/tree_walk.py, and on one added inside EditFileTool.execute. Under the same import hook, probe/machine, session/dir, probe/agent, agent/launch and task/list also ran.
  5. Fixed - a call in flight counts as use. With two stand-in links and an idle limit of 5 s, the 2 s call on link A returned its answer and A's link stayed open.
  6. Fixed - the login-shell test checks a directory under tmp_path.
  7. Answered - RemotePath.display is meant to reach the model. The docstrings in raven/acp_client/remote.py now say so.

New notes, all nonblocking:

  1. Nonblocking - keep the page read off the loop too, not only the digest. On one 1 GiB file, with runs of both heads alternated in one session: during fs/stat with hash, the longest unanswered ping was 0.08 s at this head (4.2-4.7 s at 71f47d135497), but during fs/read of the first 10 lines it was 7.9-9.4 s (17.0-18.6 s before). _apart (raven/node/server.py:1189) does move the call into a worker thread, but ReadFileTool.execute decodes and splits the whole file (raven/agent/tools/filesystem.py:264), and those two C calls hold the GIL until they finish, so the loop's thread cannot run in the meantime. Measured on the same file, one step at a time in a worker thread while the main thread wakes every 5 ms: read_bytes took 3.30 s with a longest main-thread gap of 0.09 s, bytes.decode 2.70 s with a 2.71 s gap, str.splitlines 4.80 s with a 4.80 s gap, and the sha256 0.88 s with a 0.10 s gap. The new test stands time.sleep in for _sha256, and time.sleep releases the GIL, so the test passes either way. A page read that does not decode the whole file, or a separate process for the whole-file calls, would keep the heartbeat answered. NodePool.call reading its own timeout as a lost link is left as it is, as the reply says.

  2. Nonblocking - keep a read from landing inside a write of the same file. Until this head the file handlers ran on the daemon's one loop without yielding to it, so a read and a write of one file never overlapped. Now each runs in a worker thread and only writers take _WRITE_LOCK (raven/node/server.py:1118-1119); _read reads the page and then reads the whole file again for sha256 (:241-254), and WriteFileTool writes in place (raven/agent/tools/filesystem.py:442). Reproduced by calling the daemon's handle on one loop, as a connection does: one fs/write of a 400,000-line file with fs/read calls of that file while the write ran, 12 trials per run, three runs. Per run, 47-59 reads landed during the write; 2-4 of them returned the old page with the new file's digest, and 1-4 returned a digest of neither version. At 71f47d135497 no read landed during a write. Nothing in this PR uses that digest yet; in feat(*): read, write and deliver files on a registered machine by address #922, raven/agent/tools/remote_files.py sends it as expect with the next write_file, so after a read that returned the old page with the new digest, that write replaces content the model never read. A lock that readers take as well (shared for reads, exclusive for writes), or a digest taken from the same bytes as the page, closes it.

  3. Nonblocking - give uv the package index the machine is configured with. The plan says the packages come "from the machine's package index" (raven/node/install.py:100-101), and at 71f47d135497 pip read that index from pip's own configuration; uv does not read pip's configuration. Simulated here with pypi.org unreachable and only a mirror, named in ~/.config/pip/pip.conf, reachable: the first round's install command (pip in the node's virtualenv) installed loguru==0.7.3 from the mirror; this head's uv pip install went to pypi.org and failed with "Failed to fetch" (exit 2); with UV_DEFAULT_INDEX set to the mirror, uv installed it. Passing uv the index pip would use (--default-index), or naming the index in the plan so the person can set it, keeps such a machine installable.

  4. Nonblocking - keep install-node inside the node directory, as its plan says. The plan a person approves ends "Nothing else on the machine changes" (raven/node/install.py:102; the module docstring says so at :7, and at :14-15 that uv's installer is "told to touch nothing else"), but two steps change things outside the node. On a machine without uv, the uv step (:220) runs uv's installer with UV_INSTALL_DIR, UV_NO_MODIFY_PATH and INSTALLER_NO_MODIFY_PATH, and the installer still writes an install receipt to ~/.config/uv/uv-receipt.json (or under $XDG_CONFIG_HOME): run with an empty HOME, the step left that receipt as the one file outside the node directory. The installer writes the receipt with >, so an existing receipt, from a uv the user installed with the same installer, is replaced; that part is code-reading. With UV_UNMANAGED_INSTALL="$dir/.uv" the installer puts the same binary there and writes no receipt and no PATH change; run the same way, it left nothing outside. The step also installs whichever uv is current (0.13.0 today); uv's per-version installer would pin it, as this Raven pins the packages it installs. Separately, chmod 700 "$dir" (:220, :225) narrows a --dir that already existed, a directory where prune_script expects the owner to keep other things (:256-258): the virtualenv step, run on an existing 0755 directory holding a file of the owner's, left it 0700. Making the directory owner-only only when the step creates it keeps note 2's fix, since run/, tasks/ and log/ are 0700 on their own.

Verification:

  • 18 test files at this head, run with a copy of the dev environment whose editable install points at this head, so the relay's and the daemon's -I children import it too: the first round's 17 plus tests/test_acp_client_launch.py: 839 passed, 6 skipped (no zsh, tcsh or csh here), 0 failed.
  • lint-imports at this head: 10 kept, 0 broken.
  • The probes named in each note, run from this head and from 71f47d135497 on Linux with Python 3.12. No ssh to a real machine.

Checked and deliberately not reported:

  • The fallback directory's name can be predicted, so another account can create it first and keep a long-directory daemon from starting; the relay and the daemon then refuse and say why.
  • Each worker-thread call runs its handler with asyncio.run on a loop of its own: the raven.agent modules the node loads hold no asyncio object bound to a loop, and each of the five whole-file calls answered through handle.
  • Writers still run one at a time under _WRITE_LOCK, so the expect check of fs/write and its write have no other write between them (code-reading).
  • feat(*): dispatch acp and cli agents on machines and tunnel openai endpoints #921 and feat(*): read, write and deliver files on a registered machine by address #922 carry patch-identical copies of these six commits and do not change raven/node/ again, so notes 8-11 apply to them too.

@LivXue

LivXue commented Oct 13, 2026

Copy link
Copy Markdown
Member

Windows compatibility re-review: no additional Windows compatibility issue found in the inspected scope. The long-line paging regression's new native tests pass.

Scope: the complete 37-file PR at 6a0ec2c12cf4ae928de4e4b6464c85e99516937b; API base 82c76a82a9a7d312ef6cd75e560404bccd81d292, merge base 829eb28e1db877dffdd3df4f835d707e1f049d3a. Rechecked the two-file delta and pager callers, with blob identity for previously inspected unchanged source. All 4,375 snapshot blobs matched the head tree; the full diff passed git diff --check.

Native Windows / Python 3.13.12, fresh per-head environment from uv sync --frozen --extra dev --dev, UV_NO_SYNC=1, PYTHONUTF8=1, isolated user/Raven/temp directories, and imports verified against the exact-head snapshot. Commands from that snapshot:

$R = 'C:/Users/livxu/AppData/Local/Temp/raven-windows-review/d1d0b660deeb48e3a2e4d079a3316cbc'
uv run pytest tests/test_filesystem_tools.py::test_a_page_is_the_lines_splitlines_gives_whatever_the_chunk_edge tests/test_filesystem_tools.py::test_a_line_longer_than_a_chunk_is_whole_on_its_page_and_only_counted_off_it tests/test_filesystem_tools.py::test_a_long_line_is_looked_at_once_whatever_its_length tests/test_filesystem_tools.py::test_read_file_pages_a_file_without_holding_all_of_it -n 0 -x --tb=short --basetemp "$R/checks/920-paging/pytest" --junitxml "$R/checks/920-paging/junit.xml"
# 12 passed; 1 advisory idle-ceiling warning.
uv run pytest tests/test_ops_remote_path.py tests/test_acp_client_launch.py tests/test_acp_client_teardown.py tests/test_node_bundle.py tests/test_node_client.py tests/test_node_server.py tests/test_node_relay.py -n 0 -x --tb=short --basetemp "$R/checks/920-host/pytest" --junitxml "$R/checks/920-host/junit.xml"
# 127 passed, 33 skipped.

Paging coverage includes CRLF/Unicode separators, chunk boundaries, long lines on and off the requested page, and the property that bytes are split once. Host checks cover Windows path classification, SSH argv, ACP teardown and node reconnect behavior. No native performance benchmark or remote daemon was run; skipped cases require POSIX fixtures or symlink privilege.

The already recorded node/installer follow-ups remain separate. No live SSH, installation, UI execution or full repository suite.

@LivXue LivXue 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: this full-PR review found five correctness issues (2 P1, 3 P2) in node recovery and daemon state management. The five inline comments describe the failing states and repair directions.

This review includes raven/node and supersedes any merge-readiness impression from my earlier agent-only and Windows compatibility notes. The earlier long-line paging finding remains resolved; its 12 regression cases pass.

Reviewed head: 6a0ec2c12cf4ae928de4e4b6464c85e99516937b. Verification used native Windows, Python 3.13.12, and a freshly synced isolated environment. All 4,375 blobs in the source archive were checked against the GitHub tree, and the source was checked again after the probes.

  • Relevant repository tests: 338 passed, 10 failed, 61 skipped. All 10 failing test IDs also failed in the same-machine merge-base control (829eb28e1db877dffdd3df4f835d707e1f049d3a); the comparison used JUnit test IDs, not just counts.
  • Controlled recovery probes: 4 failed, 1 passed. Daemon state probes: 2 failed, 2 passed. These are six violating states covering the five findings, with three positive controls.
  • uv run --frozen --no-sync lint-imports: 10 contracts kept.

Repository test command:

uv run --frozen --no-sync pytest tests/test_node_bundle.py tests/test_node_client.py tests/test_node_install.py tests/test_node_server.py tests/test_node_relay.py tests/test_acp_client_launch.py tests/test_acp_client_teardown.py tests/test_acp_remote.py tests/test_ops_remote_path.py tests/test_cli_ops_connection_commands.py tests/test_filesystem_tools.py tests/test_ops_connection_add.py tests/test_ops_transport.py -n 0 -q --tb=short

Verification limits: no real SSH, remote installation, or POSIX daemon end-to-end run. The socket finding uses the production path function with a fixed POSIX UID substitute; the launch-limit probe uses production request/launch logic with mocked subprocess creation and POSIX directory setup. An initial selection also included unchanged tests/test_read_file_image.py, whose unguarded os.geteuid call prevents Windows collection; that file was excluded from the completed selection, and the base has the same call.

The existing file-gate/index observations are not filed again. A separate error-redaction hypothesis was excluded because the real local uv failure did not establish the proposed credential leak.

Comment thread raven/node/client.py
Comment thread raven/node/client.py Outdated
Comment thread raven/node/client.py
Comment thread raven/node/server.py Outdated
Comment thread raven/node/server.py Outdated
…ry, a launch reserves its place

A full review of the link's recovery and the daemon's state found five
states they got wrong. Each is reproduced by a test of its own before the
fix, and three of them on a real machine as well: a dead link replaced by a
plain call left its agent parked for good; a cancel asked while the link
was dead never arrived and the task ran on; eleven launches at once
started nine against a limit of eight.

A link the heartbeat had not yet seen die was replaced by the next call
without its agents: the pool now attaches whatever waits for the machine
on every link it makes, and the call hears why when that link goes too. A
replay the new link could not carry escaped the reconnect with the frames
already dropped and the endpoints after it in no list: a frame leaves the
held list only once the link took it, the endpoint being replayed and
every one not yet reached are parked again with what they hold, and the
link is tried again. A cancel is wanted from the moment it is asked until
the node takes it or says there is nothing to take; a link that dies under
the request, or had died unnoticed, keeps it for the next link.

The daemon's long-path socket is named for the install as well as the
directory, as the socket under run/ is, so two installs on one long
directory listen apart. A launch takes its place against the agent limit
before its first await, so launches served together cannot pass the check
as one.

Co-authored-by: Claude (claude-fable-5-1) <noreply@anthropic.com>
@Handsome-wzw

Copy link
Copy Markdown
Contributor Author

All five are fixed in 093966f and answered on their threads, each with the test that reproduces your probe. The three a real machine can show were run there before and after the fix, from the same script (the relay killed and a plain call made before the heartbeat could notice; a cancel asked in that gap; eleven launches at once): the run before reproduced each as you described it, the run after passes. The node test groups pass with the seven new tests, and the fix is carried to #921, #922 and #934.

@Handsome-wzw
Handsome-wzw requested a review from LivXue October 13, 2026 17: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 delta from 6a0ec2c12cf4: ordinary link replacement now reattaches parked endpoints; replay removes frames only after delivery and reparks the remaining endpoints on failure; cancellation intent survives failed delivery; long socket fallbacks include the install identity; and launches reserve capacity before their first await. The new tests exercise each reported race or collision and do not weaken prior assertions.

I also checked the affected callers, reconnect and daemon lifecycle, relevant history and backward compatibility, repository rules, and the existing node architecture constraints. uv run pytest tests/test_node_client.py tests/test_node_server.py -q passes all 155 tests; Ruff and git diff --check github/main...HEAD pass. The previously named file-gate scope, authenticated-index argv, and invalid-index-port follow-ups remain nonblocking.

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

Reconfirmed the checked-out and live PR head are both 093966f8fa6d; the already-reviewed revision delta and its verification leave the three previously named maintainer-deferred follow-ups as nonblocking. This revision can merge as it stands.

@LivXue

LivXue commented Oct 13, 2026

Copy link
Copy Markdown
Member

Windows compatibility re-review: no additional Windows compatibility issue found in the inspected scope.

Scope: all 37 changed files at 093966f8fa6d107cbff0ad7ffd32c26067188531, base 82c76a82a9a7d312ef6cd75e560404bccd81d292, merge base 829eb28e1db877dffdd3df4f835d707e1f049d3a. Compared the complete PR scope with the previously reviewed tree 6a0ec2c12cf4ae928de4e4b6464c85e99516937b: 33 files are unchanged, and the four-file node client/server/test delta was re-inspected with its callers. Existing discussions and threads were read. git diff --check passed.

Native Windows / Python 3.13.12: 144 passed, 33 skipped. The selected tests cover Windows path classification, ACP argv/teardown, bundling, file paging and the new ordinary-call reconnect, failed replay and cancellation-retention cases. Those recovery cases use controlled client substitutes. Socket fallback namespacing and launch reservations were checked in the POSIX daemon code; its Unix-socket/process-group tests do not run on this host.

Exact verification commands

Fresh isolated uv environment and detached checkout; source imports verified against this head, with separate home/temp/token directories. Setup succeeded: uv sync --frozen --python 3.13 --all-packages --extra dev. Tests used PYTHONUTF8=1 and UV_NO_SYNC=1.

$R = 'C:/Users/livxu/AppData/Local/Temp/raven-windows-review/c281ddf96c3446c98e4f4127722b59c6'
$cases = @(
  'tests/test_ops_remote_path.py',
  'tests/test_acp_client_launch.py',
  'tests/test_acp_client_teardown.py',
  'tests/test_node_bundle.py',
  'tests/test_node_client.py',
  'tests/test_node_server.py',
  'tests/test_node_relay.py',
  'tests/test_filesystem_tools.py::test_a_page_is_the_lines_splitlines_gives_whatever_the_chunk_edge',
  'tests/test_filesystem_tools.py::test_a_line_longer_than_a_chunk_is_whole_on_its_page_and_only_counted_off_it',
  'tests/test_filesystem_tools.py::test_a_long_line_is_looked_at_once_whatever_its_length',
  'tests/test_filesystem_tools.py::test_read_file_pages_a_file_without_holding_all_of_it'
)
uv run pytest @cases -n 0 -x --tb=short --basetemp "$R/checks/920-host/pytest" --junitxml "$R/checks/920-host/junit.xml"

Limits: no real SSH/remote install, POSIX daemon end-to-end execution, full repository suite or Windows ACL audit. Platform skips do not establish equivalent Windows support.

…ackoff for the agents that wait

The previous fix had a link replaced by an ordinary call bring the parked
agents along, but only when the relaunch succeeded. When it failed -- the
machine unreachable at that moment -- the call raised and nothing tried
again: the agents stayed parked with no reconnect task, where the same
failure under the heartbeat would have been retried on the backoff. On a
real machine, the relay killed and the relaunch made to fail once, the
agent was still parked thirty seconds after the machine was reachable
again. A failed relaunch with agents waiting now schedules the same
reconnect the heartbeat loss does; the agent is attached again on the
first retry once the machine answers.

Co-authored-by: Claude (claude-fable-5-1) <noreply@anthropic.com>
@Handsome-wzw

Copy link
Copy Markdown
Contributor Author

One more commit, a6cc40d: the ordinary-call path of the first finding was fixed only for a relaunch that succeeds. A relaunch that fails with agents parked now schedules the reconnect the heartbeat loss does, so the agents come back on the backoff once the machine answers, as the module's contract says. Reproduced on a real machine before the fix (the relay killed, the relaunch made to fail once: parked = 1, no reconnect task, still parked 30 s after the machine was back) and gone after it (a reconnect task scheduled at once, the agent attached again on the first retry after the machine answered, nothing left running there); the unit test fails on the previous head and passes here. Carried to #921, #922 and #934.

@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 two-file delta from 093966f8fa6d, the surrounding acquisition/reconnect state transitions, the added regression case, and the existing project constraints. The failed ordinary-relaunch path now schedules the same guarded backoff recovery used after heartbeat loss, while an in-progress reconnect remains unique. uv run pytest tests/test_node_client.py -q passes all 61 tests, Ruff passes on both changed files, and git diff --check github/main...HEAD is clean. The three previously named maintainer-deferred node follow-ups remain nonblocking.

@LivXue

LivXue commented Oct 14, 2026

Copy link
Copy Markdown
Member

Windows compatibility re-review at a6cc40dddabaf71a4408de002c59cc5737a073ec, base 82c76a82a9a7d312ef6cd75e560404bccd81d292, merge base 829eb28e1db877dffdd3df4f835d707e1f049d3a.

Reconciled the complete 37-file PR diff with the previous review at 093966f8fa6d107cbff0ad7ffd32c26067188531: 35 files are unchanged; only raven/node/client.py and its test changed. Inspected the new failed-acquisition retry path, lock release, reconnect deduplication/backoff, shutdown guard and callers. Existing discussions/threads were read, and git diff --check passed.

No additional Windows issue found in this retry change. Native host/paging checks: 145 passed, 33 skipped. The new controlled-client regression confirms that a failed ordinary relaunch schedules a retry and reattaches the parked endpoint when a later launch succeeds. POSIX daemon and stand-in shell cases remain skipped.

Exact verification commands

Native Windows / Python 3.13.12, fresh isolated uv environment and detached exact-head checkout; imports verified, with separate home/temp/token directories. Setup succeeded: uv sync --frozen --python 3.13 --all-packages --extra dev. Tests used PYTHONUTF8=1 and UV_NO_SYNC=1.

$R = 'C:/Users/livxu/AppData/Local/Temp/raven-windows-review/615c117f238046cca9805459d4b37a77'
$cases = @(
  'tests/test_ops_remote_path.py',
  'tests/test_acp_client_launch.py',
  'tests/test_acp_client_teardown.py',
  'tests/test_node_bundle.py',
  'tests/test_node_client.py',
  'tests/test_node_server.py',
  'tests/test_node_relay.py',
  'tests/test_filesystem_tools.py::test_a_page_is_the_lines_splitlines_gives_whatever_the_chunk_edge',
  'tests/test_filesystem_tools.py::test_a_line_longer_than_a_chunk_is_whole_on_its_page_and_only_counted_off_it',
  'tests/test_filesystem_tools.py::test_a_long_line_is_looked_at_once_whatever_its_length',
  'tests/test_filesystem_tools.py::test_read_file_pages_a_file_without_holding_all_of_it'
)
uv run pytest @cases -n 0 -x --tb=short --basetemp "$R/checks/920-host/pytest" --junitxml "$R/checks/920-host/junit.xml"
# 145 passed, 33 skipped

Limits: no real SSH, remote installation, POSIX daemon end-to-end run, full repository suite or Windows ACL audit. Passing host tests does not establish equivalent Windows daemon support.

…node

# Conflicts:
#	raven/acp_client/client.py
@0xKT

0xKT commented Oct 14, 2026

Copy link
Copy Markdown
Member

Merge conflict: @Handsome-wzw the main branch tip has advanced to d106a5c and this PR no longer merges onto it cleanly. Re-running the merge check against the live tip:

git merge-tree --write-tree d106a5c6 a6cc40dd

reports a content conflict in raven/acp_client/client.py (the other files auto-merge). The judge has cleared the current head, so a rebase onto the latest main, resolving raven/acp_client/client.py, is the one step left before this PR can enter the acceptance lane. Posted by the review board after re-running the merge check.

@gloryfromca

Copy link
Copy Markdown
Member

Blocking: the branch must be rebased onto current main and the conflict resolved.

Confirmed against live github/main at d106a5c6: git merge-tree --write-tree HEAD github/main exits 1 with a content conflict in raven/acp_client/client.py. This is not a new code finding; it supersedes my nonblocking stance because the current revision cannot enter the merge lane as it stands.

@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 merge revision against live main, including the raven/acp_client/client.py conflict resolution, its process and remote-endpoint callers, and the retained PR diff. The resolution preserves the node endpoint/argv work while integrating main's process-tree teardown and leftover-agent bookkeeping. The live merge check and git diff --check github/main...HEAD pass; the focused ACP lifecycle and node client/server suites pass all 242 tests, and Ruff passes on the integrated files. This settles my merge-conflict blocker. The three previously named maintainer-deferred node follow-ups remain nonblocking.

@LivXue

LivXue commented Oct 14, 2026

Copy link
Copy Markdown
Member

Windows compatibility re-review at a9eabb049d4e4067ff9931f6b289adb1c8ebb49f, base d106a5c6ef329c6bc61f29be538818dff4afaa86.

Reconciled the complete 37-file base-to-head diff with the previous review: 29 files remain byte-identical; inspected the other 8 files against the new base and prior head, including upstream changes and the ACP endpoint/process-cleanup integration. All discussion pages and current threads were checked; git diff --check passed.

No Windows compatibility issue found in the inspected scope. Native checks: 147 passed, 33 skipped. The merged endpoint path awaits Windows tree cleanup, retains the launcher's exit guard, and keeps Linux-only leftover-agent bookkeeping guarded. Tests include real native ACP startup/close and the exited-launcher case.

Exact native Windows verification commands

Python 3.13.12; detached exact-head checkout, verified source imports, fresh uv environment and isolated home/temp/token paths. Setup succeeded: uv sync --frozen --python 3.13 --all-packages --extra dev. Tests used PYTHONUTF8=1, UV_NO_SYNC=1, and UV_PROJECT_ENVIRONMENT=$R/envs/920. Scratch probes stay outside the checkout.

$R = 'C:/Users/livxu/AppData/Local/Temp/raven-windows-review/2a3b9a778d94475783ca7bb0f0c52730'
$hostCases = @(
  'tests/test_ops_remote_path.py',
  'tests/test_acp_client_launch.py',
  'tests/test_acp_client_teardown.py',
  'tests/test_node_bundle.py',
  'tests/test_node_client.py',
  'tests/test_node_server.py',
  'tests/test_node_relay.py',
  'tests/test_filesystem_tools.py::test_a_page_is_the_lines_splitlines_gives_whatever_the_chunk_edge',
  'tests/test_filesystem_tools.py::test_a_line_longer_than_a_chunk_is_whole_on_its_page_and_only_counted_off_it',
  'tests/test_filesystem_tools.py::test_a_long_line_is_looked_at_once_whatever_its_length',
  'tests/test_filesystem_tools.py::test_read_file_pages_a_file_without_holding_all_of_it',
  'tests/test_subagent_acp.py::test_close_without_killpg_ends_the_tree_through_taskkill',
  'tests/test_subagent_acp.py::test_close_without_killpg_kills_nothing_by_the_pid_of_an_exited_launcher'
)
uv run pytest @hostCases -n 0 -x --tb=short --basetemp "$R/checks/920-host/pytest" --junitxml "$R/checks/920-host/junit.xml"
# 147 passed, 33 skipped

Limits: no real SSH/remote installation, POSIX daemon end-to-end execution, full repository suite or Windows ACL audit. Git HTTPS transport failed; GitHub's Git data API supplied missing objects, each SHA verified before checkout. This did not reduce the source diff.

@0xKT

0xKT commented Oct 14, 2026

Copy link
Copy Markdown
Member

Not a blocker (documentation). This PR adds a CONTEXT.md Avoid entry listing "server" as a name to avoid for the Raven node concept (the canonical term is "Raven node"), but the module docstrings at raven/node/main.py:1 and raven/node/protocol.py:1 name it "Raven Subagent Server". It is docstring prose only, zero runtime -- a within-PR domain-term self-inconsistency (CLAUDE.md section 6, "use the canonical term, not a synonym"). Not a merge blocker; noted so the wording can be reconciled in a follow-up. (The module file raven/node/server.py is a legitimate code/module name; this is only about the docstring prose naming the concept.)

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.

@LivXue

LivXue commented Oct 14, 2026

Copy link
Copy Markdown
Member

No blockers in the reviewed scope.

Scope: this re-review covers the three commits since 6a0ec2c12cf4, where my review requested changes with five notes on raven/node/. 093966f8f and a6cc40ddd answer those notes. a9eabb049 merges main and resolves its conflict in raven/acp_client/client.py, keeping this branch's Endpoint protocol and _ProcessEndpoint together with main's leftovers bookkeeping, with no conflict markers left. raven/agent/tools/filesystem.py, the one file of this PR under raven/agent/**, is byte-identical to 6a0ec2c12cf4, so my summary at that head still holds for it. The approval submitted with this comment replaces that review's request for changes.

The five notes, at this head:

  1. Fixed - parked agents come back when an ordinary call replaces a dead link. Reproduced with the production NodePool and a scripted stand-in for _launch: when the call's relaunch succeeds, the new link receives agent/attach and the agent is attached again; when it fails, the failed call now schedules the reconnect a heartbeat loss schedules (a6cc40ddd), and the first retry that reaches the machine attaches the agent. At 6a0ec2c12cf4 the agent stayed parked on both paths.
  2. Fixed - a replay that fails keeps the frames and every waiting endpoint. Two parked endpoints holding three frames, a second link that accepts agent/attach and fails on the first frame, then a healthy third link: here the third link receives the three frames in order and no endpoint is left parked; at 6a0ec2c12cf4 the reconnect task ended with AcpConnectionError, the first endpoint's frames were lost and the second endpoint was left with no reconnect.
  3. Fixed - a cancel waits for delivery. An agent ended after its link was parked, on a dead link not yet parked, and with the cancel failing during the reattach: here each cancel reached the next link; at 6a0ec2c12cf4 none did.
  4. Fixed - the long-path socket is per install. The production socket_path with two install names and a 91-character node directory gives two different sockets under raven-node-<uid>/; at 6a0ec2c12cf4 both names got the same path.
  5. Fixed - a launch reserves its place before it awaits. Daemon.launch now counts launches in progress toward MAX_AGENTS, takes the place before its first await and releases it in a finally (raven/node/server.py:889-927). Checked by reading that change and by test_a_launch_in_progress_counts_toward_the_limit, which passes at this head; the concurrent-launch probe of that review was not re-run.

Question:

  • Question - is a parked agent meant to wait for as long as its machine stays unreachable? While any agent is parked, _reconnect does not stop after the last backoff: it tries again every 16 s, the last delay of the default backoff, until the link comes back or close / close_all stops it (raven/node/client.py:694, :715). Each try starts ssh again, and status() reports the machine as lost meanwhile. That is what the docstring says, so this is a question rather than a finding. If it is the intended contract, a test that keeps _launch failing past the last backoff with an agent parked would pin it; I found no such test in tests/test_node_client.py. Code-reading only.

Still open, not filed against this change; the code is unchanged since 6a0ec2c12cf4, and the author has said these will be taken up apart from this PR:

  • _FileGate (raven/node/server.py:1133) is one gate for the whole machine rather than one per file.
  • The configured package index is passed in the ssh and sh -c argv (raven/node/install.py:278).
  • _shown_index (raven/node/install.py:358) raises ValueError on a port that is not an integer.

Verification:

  • Both revisions extracted with git archive and run on Linux, each printing the path of the module it imported; stand-in links with the production NodePool, ChannelEndpoint and socket_path. No ssh to a real machine.
  • 25 targeted cases from this head's tests/test_node_client.py and tests/test_node_server.py passed, among them test_the_long_path_socket_is_this_install_s_as_the_short_one_is and test_a_launch_in_progress_counts_toward_the_limit.
  • CI on this head: 21 checks passed, 2 skipped.

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

Approving at a9eabb049d4e: the five notes of my review at 6a0ec2c12cf4 are fixed, and the one question left is not blocking. The PR-level summary comment has how each note was re-checked and the question; this approval replaces that review's request for changes.

@0xKT
0xKT merged commit e3d0135 into EverMind-AI:main Oct 14, 2026
25 checks passed
Handsome-wzw added a commit that referenced this pull request Oct 14, 2026
…ress (#922)

## Summary

Third of four changes that let a Raven dispatch work to the machines in
its machine registry. #920 is merged; this branch is opened against main
ahead of #921, so the diff here includes #921's commits as well: review
from "read, write and deliver files on a registered machine by address"
on (the commits before it are #921's, carried here until it merges).

What this does:

- The file tools take a path on a registered machine as it is written
  for scp -- `user@host:/path`, or `scp://user@host:port//path` when the
  port is not 22 -- and answer it through the machine's daemon
  (`raven/agent/tools/remote_files.py`, hooked from `filesystem.py` and
  `file_search.py`): `read_file`, `list_dir`, `find` and `grep` run
  Raven's own tools there and bring back the answer, so a read of fifty
  lines of a large file moves those fifty lines; `write_file` and
  `edit_file` write there, with the digest of that agent's own last read
  -- the conversation's, or its sub-agent run's -- as the expected
  state, so a file changed since it read it is refused and nothing is
  written, and another agent's read does not stand in for its own. The
  path is resolved against the registry
  only, never this computer's ssh config, so one path names one machine
  on any computer; one that matches no row is refused, not guessed.
- What is shown back as an address is only what the tool says about
  paths -- its errors, its headers and footers, a write's account, a
  diff's two header lines; a read's numbered lines, a search's matches
  and context, and a diff's body come back as they are, so the model
  edits against the bytes the file holds. The shell's refusal of typed
  `ssh` to a registered machine (`raven/agent/tools/shell.py`,
  `machine_exec.py`) reads the program in any spelling -- a path, a
  backslash, and on a Windows host `ssh.exe`, `SSH.EXE` or a whole
  `C:\...\ssh.exe`, with the backslashes the shell there keeps and
  without the quotes it takes off (a quoted piece and a bare one next to
  it making one word), so a quoted destination or port is matched like a
  bare one -- and, with the lane on, names the file tools. `scp`, `sftp`
  and `rsync` to a registered machine stay what they are on main: a copy
  starts nothing there and passes exec's own question like any command,
  and the file tools cannot carry a directory, a binary file or a large
  one, so the copy stays the route for those. A drive-relative or
  extended Windows path (`C:file`, `\\?\C:\...`, UNC) is this
  computer's, not a machine nobody registered.
- Permissions (`raven/permissions/`): a path inside the directories
  Raven's agents work in on that machine -- its session directory, its
  `remoteCwd`, the row's listed `paths` -- is read without asking;
  elsewhere a read asks once per machine and directory for the session.
  A write there asks wherever it falls, as a write here does, and its
  grant is for the one file, apart from any read. The daemon checks the
  roots again on the machine, links included.
- `deliver_files` fetches one file by its address, whole and checked,
  under a 20 MB cap, into `<workspace>/pulled/<machine>/<key>/<name>`,
  the key a digest of the file's path on the machine, so two paths never
  share a landing: not two files of one name from two directories, and
  not two names this computer's filesystem reads as one (`Report.txt`
  and `report.txt`, a trailing dot). The name beside the key is written
  as one this computer keeps (a backslash, what Windows reserves in a
  name, control characters and the percent sign itself as
  percent-codes), and a landing that would not resolve under the
  machine's delivery directory is refused before anything is made; a
bigger file is named with its size and read there instead. Only a file
under the directories this Raven's agents work in
  on that machine is fetched: the tool asks nothing, and a read some
  conversation was allowed is no evidence for this one, so a file
  elsewhere is refused and read there instead. Nothing reclaims
  `pulled/`: a delivery is opened from its landing for as long as its
  record lasts, and a bound by size or age is left out of this change on
  purpose.

## Type

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

## Verification

- `uv run pytest tests/test_remote_file_tools.py
tests/test_permissions_remote_read.py tests/test_deliver_files_tool.py
tests/test_shell_machine_channel.py tests/test_ops_remote_path.py
tests/test_permissions_gate.py tests/test_import_cycle_budget.py
tests/test_node_server.py tests/test_node_client.py
tests/test_subagent_acp_remote.py tests/test_acp_remote.py
tests/test_cli_ops_connection_commands.py`
  -- all passed on this tree (703 in the run that covered the first
eight groups, the node's own suite and the real-node cases among them).
  Covered: each tool on a remote path answers as its local tool would,
  through a stood-in link and a real daemon; a path outside the roots is
  refused on both sides; a write with a stale digest is refused and
  writes nothing; the address form on what the tool says about paths,
  and a read's lines, a grep's matches and a diff's body untouched; the
  shell's refusal of typed ssh to a registered machine, by host, by
  alias, in a pipeline, as `ssh.exe` and `SSH.EXE`, and with a whole
  Windows path or a quoted destination or port when the host is Windows,
  a copy with scp, sftp or rsync not refused, and the file tools named
  only with the lane on; a fetched name holding the
  other platform's separators lands as one name under the machine's
  directory and nothing lands elsewhere, and a landing that would
resolve outside fetches nothing; a Windows path form read here with the
lane on; the read
  permission inside the roots and the question elsewhere, remembered per
  machine and directory, a write asked wherever it falls and its grant
  kept per file and apart from a read; a read's digest kept per agent,
  so another agent's read lets no stale overwrite through and an agent
  that never read the file expects nothing; a delivery fetched whole
  under a key of its path (two files of one name, two names a
  filesystem reads as one, and the percent-code pair all land apart),
  refused over the cap and refused outside the roots whatever was read
  before.
- `uv run pytest` on this tree after the merge with main -- 31210
passed,
  231 skipped, 2 xfailed, 4 failed, every one of the four in a test that
  needs what this machine lacks (`test_rpc_files.py`, the two simulation
  cases and the kimi-code case that fail the same way on main here); the
groups above also pass under `--idle-ceiling-strict`, CI's idle ceiling.
- Diff coverage of this change against main after the merge: 95.27% (805
of
  845 executable changed lines), against the 90% gate. The node suite,
the remote-path tests and the real-node cases among the groups above run
  on the merged tree.
- Real machines (two registered Linux machines): fifty lines from the
  middle of a 5 MB file read through the daemon in 30 to 35 ms, byte for
  byte what this computer reads; list, find and grep answered; a write
and an edit landed as the local tools would, and a write after the file
  changed was refused; a path outside the roots refused; a file written
  by an agent there fetched back by address. The shell's refusals are
  covered by the tests above.
- `scripts/check_source_language.py`, `scripts/check_commit_messages.py`
  and `scripts/check_large_files.py` on the branch range, `commitlint`,
  `ruff` -- all clean.

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

User docs follow once the lane is no longer debug-only.

## Risk

Behind the debug lane, as the two changes before it. With it on, a
remote
path reaches the machine only through its daemon, inside the roots each
call names and under the read and write questions above; the shell
cannot
run a command on a registered machine by address, and a copy to or from
it
with scp, sftp or rsync is the plain shell's behind exec's own question,
as
on main. A write refused for a stale
digest writes nothing. A delivery fetches at most 20 MB per file into
the
workspace, and only from the directories the agents work in on the
machine. Rollback is a plain revert.

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

## Related Issues

#920 (merged); depends on #921.

---------

Co-authored-by: Claude (claude-fable-5-1) <noreply@anthropic.com>
0xKT pushed a commit that referenced this pull request Oct 14, 2026
…er stops (#934)

## Summary

Registered machines are now available without a debug environment
switch.
`RAVEN_DEBUG_MACHINES` previously defaulted to off: it kept
machine-backed
agents out of the dispatch table, refused node/installer access, hid
remote
preset destinations, and treated file paths locally while off. It was a
trial feature gate, not a verbose-logging option. At the owner's
request,
this PR removes the variable and all of those checks. Normal startup and
restart recovery use the configured machine entries; even a leftover
value
of `0` is ignored. Machine registration, SSH authentication, file
permission
prompts, and concurrency limits still apply.

Fourth change in the series that lets a Raven dispatch work to the
machines in its machine registry. #920, #921 (merged through #922) and
#922 are on main; this change stands on main alone now, and the diff is
its own.

What this does: a turn running on a registered machine survives this
computer stopping, and the next start picks it up and reports it.

- While a turn runs on a machine, this computer remembers what the
  machine does not know (`raven/acp_client/remote_runs.py`): one
  owner-only file per run under `<raven home>/machines/runs/`, with the
machine's registry id, the task id its daemon runs the agent under, the
  agent, the session key, the instance handle, the dispatch id, the ACP
  session, the id of the prompt whose answer is awaited, the ids this
  side has used, how many of the agent's frames were read here, the
  agent's answer to `initialize` and the connection's launch key. Kept
  current after every frame the connection has dealt with, removed when
  the turn ends here. No address is in it.
- When this process stops -- the gateway, `serve`, the TUI, the page
  stack -- the
  agents on machines stay with their daemons
  (`remote.leave_agents_running()` before the drain): a cancelled turn
on a machine sends no `session/cancel`, closing its connection ends the
endpoint only, and the manager leaves the dispatch's record open rather
  than finishing it as cancelled. A hard kill or `/restart` leaves the
  same record, written as the turn ran.
- At the next start -- the gateway once its generation is bound; the
`serve`, TUI and page stacks once --
`raven/agent/subagent/resume_remote.py`
picks every run up: `task/list` on the machine, `agent/attach` from the
  frame last read (`NodePool.attach`), the connection adopted into the
  pool with request ids carried on past the other process's
  (`AcpConnectionPool.adopt`, `AcpClient.continue_ids_from`), the
  prompt's answer awaited (`AcpClient.await_response`), the reply built
  as a turn ended here is -- the partial notice when the agent stopped
  early, the machine note saying it was picked up after this computer
  restarted -- then the dispatch's record finished, the registry row and
  the node claim closed, and the result announced into the conversation
  that asked, as the manager announces a turn that ended here
  (`raven/acp_client/remote_resume.py`). A machine that cannot be asked
  keeps its record for a later start; a run the machine no longer has,
  one whose agent ended while this computer was away, one whose agent
  left the agent table, and one whose turn says nothing are announced as
  errors that say so. The adopted agent process serves later dispatches
  of its entry, as one started here would.
- Seams: `AcpClient.request(sent=)` hears the id of what goes out;
  `ChannelEndpoint.watchers` hear the frames the connection has dealt
  with (`consumed`, called by the read loop); `ChannelEndpoint.stays`
  reads the leaving flag.
- A cli entry's run on a machine is recorded while it runs (kind `cli`,
  no ACP part) and left the same way, the manager reading the dispatch
  as left; the entry picks it up (`CliAgentBackend.pick_up`): the daemon
  replays the command's whole output from its first line, the exit code
  ends it, and the reply closes with the machine note.
- The record carries the task as the model wrote it, and -- once the
  manager has read the run as left -- the dispatch's origin and summary
  (`remote_runs.annotate`): the announce after a restart names the
  instance, node and tool call as the announce here does and quotes the
  task as authored, never the rendered prompt on disk; a host killed
  outright gets an origin made up from the spawn record and the run.
- The record goes last, once the outcome has reached its conversation:
  a conclusion that fails, a stop under it, or an announce the scheduler
  refuses (`_announce_result` says so now) leaves the record, its claim
  released, for the next start to try again.
- A run that returned here keeps its record the same way, until the
  manager's announce of the reply was taken: the manager dispatches with
  `reported_by_caller=True`, the backend then leaves the record behind
  with the reply instead of removing it as it returns, and the manager
  removes it after the announce (`remote_runs.forget_dispatch`) -- or,
  when the draining scheduler refused the announce, annotates it with
  the dispatch's origin and summary for the next start to report. A host
  killed between the agent's answer and the announce therefore still has
  the way back to the run. A caller that takes the reply as its own
  result (a graph node, a probe, a direct chat) dispatches without the
  keyword, and the record goes as it did. The record's frame count stops
  at the prompt: the turn's own frames, the chunks before the answer as
  much as the answer, are never checkpointed, so a pick-up replays the
  turn whole and the reply it reports is complete; a record that had
  moved past the answer would have resumed after it and waited forever.

Not in this change: a run from the DAG lane has no spawn record to
finish; it is announced from the origin the manager added to its record
when it was left, or, for a host killed outright, from the run's record
alone. The Tasks page reads the finished record on its next load rather
than live.

## Type

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

## Verification

Machine availability and restart checks after removing the trial switch:

- New default-behavior regressions were run before the removal: 8
failed,
  with the enabled-value control passing. After removal, all 9 passed,
  including ACP/CLI recovery through the real AgentRegistry with the
  variable absent or set to `0`.
- `uv run --no-sync pytest tests/test_acp_remote.py
tests/test_node_client.py tests/test_node_server.py
tests/test_node_install.py tests/test_ops_remote_path.py
tests/test_permissions_remote_read.py tests/test_remote_file_tools.py
tests/test_deliver_files_tool.py tests/test_raven_config_tool.py
tests/test_rpc_subagents.py tests/test_shell_machine_channel.py
tests/test_subagent_acp_remote.py tests/test_subagent_cli_remote.py
tests/test_resume_remote.py tests/test_remote_resume.py
tests/test_remote_runs.py tests/test_subagent_manager.py
tests/test_cli_tui_commands.py tests/test_rpc_schema_match.py
tests/test_config_schema.py -n 4 --idle-ceiling-strict --cov=raven
--cov-branch --cov-report=json:<coverage-json> --cov-report= --tb=short`
  -- 1799 passed on the combined tree, including the latest published
result-retention fix. An earlier run had a missing stdio response in the
minimal-node dependency test and an idle-gate hit; both passed a focused
recheck, and this final complete selection passed with the strict gate.
- `uv run --no-sync python scripts/coverage_gate.py --coverage-json
<coverage-json> diff --base-ref origin/main --threshold 90`
-- 92.92% (656/706 changed executable lines) for this PR; the
gate-removal
commit alone covers 2/2 new executable lines (the remainder deletes
gates
  and updates descriptions).
- `UV_NO_SYNC=1 make lint-python lint-imports lint-deps lint-types`, the
commit/source-language/large-file scope gates and `git diff --check`
pass.
Both RPC generators' `--check` runs pass; `npm test --
scripts/gates/fixture-shape.test.mjs`
  from `ui-web` passes all five tests.
- Real Linux machine, isolated node installations, separate dispatch and
restart processes: before removal, a task started with the trial switch
on was reported as an absent agent and its recovery record deleted when
  restarted without it. After removal, both absent and old `0` settings
  recover the full CLI result and read its output file by address. The
absent-variable case kills the relay before host shutdown. The combined
  final tree was reinstalled and both scenarios repeated successfully.
- With the variable absent, 12 further timing/boundary probes pass:
early
  CLI exit and blank lines, relay loss before heartbeat with normal-call
recovery, tunnel loss, ACP/CLI/OpenAI cancellation during disconnection,
  11 launches admitting 8 and refusing 3, occupied tunnel port, and an
unwritable prompt file. Three attachment, POSIX-command and IPv6 checks
  also pass; Windows command parsing is simulated, not native Windows.
No test task or tunnel remains; the dedicated node directories are
removed.
- CONTEXT.md, configuration and RPC descriptions, transport/path
docstrings,
  both collaboration guides and the owner's design notes were checked
against the code. The guides explain the former trial switch and normal
startup without it. The existing SSH, permission and limit tests
continue
  to exercise their checks with no test-only switch setup.

- `env -u RAVEN_DEBUG_MACHINES uv run --no-sync pytest -n 4 --tb=short
--cov=raven --cov-branch --cov-report=json:<full-coverage-json>
--cov-report=`
-- 31345 passed, 230 skipped, 2 xfailed, 5 failed. A separate
current-main
  checkout reproduces four failures: CLI color rendering on this host,
LibreOffice detection, and the two simulation cases. The remaining
failure
  was the direct-chat test stub rejecting `reported_by_caller` from the
latest result-retention change; the published wrapper fix now forwards
only keywords declared by its fake backend. The full
suite was not rerun for that test-only correction. Final direct-chat and
manager recheck: 277 passed with strict idle accounting. Full-PR
changed-line coverage from this
  run is 96.18% (679/706).

Earlier verification:

- `uv run pytest tests/test_remote_runs.py tests/test_remote_resume.py
tests/test_resume_remote.py tests/test_acp_client_resume.py
tests/test_node_client.py tests/test_subagent_acp_remote.py
tests/test_subagent_cli_remote.py tests/test_cli_tui_commands.py
tests/test_subagent_manager.py tests/test_rpc_bootstrap.py
tests/test_cli_gateway_commands.py tests/test_acp_client_launch.py
tests/test_subagent_acp.py tests/test_scope_canon.py
tests/test_l4_entrances.py`
-- all passed (605 passed). Covered: the record written owner-only, kept
current
per frame and per prompt, removed when the turn ends here and kept when
  this process leaves; a dispatch left at shutdown neither finished nor
  announced by the manager; an endpoint ended while leaving leaves the
  agent with the daemon (real daemon); a task picked up from the frame
  last read, with the watchers hearing each frame; a pick-up the machine
  refuses or cannot carry; an answer awaited for a request another
  process sent, ids carried on, no `session/cancel` while leaving; the
  reply read off replayed frames with the other sessions' frames
  ignored, a turn cut short marked partial, an unreachable machine
  keeping the record, a run the machine no longer has, a prompt never
  sent, an agent that ended while this computer was away, an answer
  waited out, a turn that said nothing, a pick-up beside a live
  connection closing its own; the manager-side conclusion: record
  finished and announced, an error announced and the record failed, an
  unreachable machine keeping the record, an agent off the table, a
  failure inside one pick-up not stopping the next, a run with no
  conversation, the once-per-process scheduling; a cli run recorded
  while it runs and left at shutdown, picked up whole on the real daemon
  behind the stand-in ssh, its pick-up's errors said (a non-zero exit, a
  machine that no longer has it, a link down, this computer leaving), a
  cancel that is not a leave ending the command with no record left; the
  TUI's stop saying the agents stay before the cancel and its start
  scheduling the pick-up; the record annotated with the dispatch's
  origin and summary when the manager reads it as left, the announce
  quoting the task as authored and naming instance, node and tool call,
  with and without the manager's fields; the record outliving a refused
  announce and a failed conclusion, claim released; a cli record read
  without an ACP session, an older record read with defaults; the record
  moving with the frames before the prompt and not past it, a dispatch's
  record forgotten by the manager's id with its claim, the manager
  dispatching with `reported_by_caller` and forgetting the record after
  an announce that was taken and annotating it after one that was
  refused; on the real daemon behind the stand-in ssh, an acp reply and
  a cli output returned with the record kept and the host "stopped"
  before the announce, the next start reading the same reply again
whole, and a dispatch without the keyword leaving no record. End to end
on the real
  daemon behind the stand-in ssh: a turn dispatched, this process
  "stopped" the way the gateway stops, the agent still running on the
  daemon, then a fresh pick-up reading its answer, finishing the record
  and announcing it, with the agent's process the pool's connection
  again.
- `uv run pytest` on this tree, at an earlier revision of this change --
29270 passed, 121 skipped, 3 failed:
  `test_rpc_files.py::test_a_host_without_libreoffice_says_so`,

`test_simulation_scenario.py::test_a_fresh_trial_refreshes_the_subagent_homes_before_it_runs`
and
`test_simulation_suite.py::test_a_stopped_run_is_marked_in_its_record_and_finished_with_its_evidence`,
  which fail the same way on main on this machine; diff coverage of this
  change against its base: 97.45% (153 of 157 executable changed lines),
  and 94.67% for the four changes against main; `lint-imports` 10 kept,
  0 broken; `ty` clean.
- Real machine (a registered Linux machine with Claude Code signed in,
  node reinstalled with this tree): process A dispatched a 25 s task,
  saw the prompt out after 2.4 s and the record keep up with the frames
  (6, then 10), then stopped the way the gateway stops 5.4 s after the
  dispatch, leaving the record and the agent running there; process B,
  started fresh, found the record and the running task, picked it up
  in 2.4 s, announced the agent's reply to the conversation with the
  machine note, finished the record, removed the run's record, held the
  agent's process as the pool's connection, and closing the pool ended
  the agent on the machine. This revision, the cli lane with the code
  before and after, the node reinstalled for each: a cli entry
  `sh -c 'echo $$ > pid.txt; sleep 20; echo done; cat'` was dispatched
  and the host stopped the gateway's way 2.7 s later. Before: no
  record, the command left running on the machine, and the next start
  found nothing to pick up. After: the record kept with the task as
  authored and the manager's origin, the command running on, and the
  next start picked it up in 16.8 s (the sleep), announcing `done` and
  `hi` with the instance, node and tool call and the machine note. The
  same with the link killed before the stop: recorded, left, picked up.
  With the machine's daemon ended between the stop and the pick-up, it
  ended its groups on its way out, the task's record there said exit
  -9, and the pick-up announced that as the run's error in 0.1 s,
  leaving nothing on the machine.
- `scripts/check_source_language.py`, `scripts/check_commit_messages.py`
  and `scripts/check_large_files.py` on the branch range, `commitlint`,
  `ruff` -- all clean.

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

The English and Chinese agent-collaboration guides explain normal
startup,
registered-machine setup and recovery, and the removed trial switch.

## Risk

Configured machine entries are available by default; entries without a
`machine` remain local. A remote-looking file path is resolved against
the
registry even without an environment variable; use an explicit local
path
such as `./a:b` for a local filename containing a colon. A formerly
hidden
machine entry is now available for dispatch, and an old variable set to
`0`
no longer disables it. Stopping Raven no longer cancels a turn
running on a machine; it goes on there until the agent finishes or is
cancelled, and its answer arrives in the conversation after the next
start, as a sub-agent result that triggers a turn. One record per run in
the raven home, owner-only, removed when the run is concluded; a machine
that cannot be reached keeps it, so a run is reported at most once and
never lost quietly. The adopted connection answers requests with ids
past the ones the stopped process used, so a replayed frame resolves
nothing sent after the restart. Rollback is a plain revert; a record
left
behind is then an inert file.

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

## Related Issues

#920 and #922 (merged); #921 was carried by #922.

---------

Co-authored-by: Claude (claude-fable-5-1) <noreply@anthropic.com>
Co-authored-by: Codex <noreply@openai.com>
Handsome-wzw added a commit that referenced this pull request Oct 15, 2026
…ough its node (#947)

## Summary

Adds MCP lending for remote ACP agents on top of the machine and
recovery
services already merged in #920, #922 and #934. The branch includes
current
main and its default-on registered-machine behavior.

What this does: an acp entry on a registered machine is handed the MCP
servers its row grants, the way an entry on this computer is, and the
servers themselves stay here with their definitions and keys.

- The agent on the machine runs the node's own bridge as a stdio MCP
  server (`python -I -m raven.node --mcp --dir <node dir> --task <task>
  --lend <dispatch> --server <name>`, `raven/node/bridge.py`): two byte
  pumps around a connection to the daemon's socket, no MCP parsed, no
  credential held, the standard library only.
- The daemon carries the bridge's frames as a *stream* to the host
  holding the task (`mcp/open`, `mcp/frame`, `mcp/close`; protocol 6,
`raven/node/protocol.py`, `raven/node/server.py`). The daemon tells
which
  task a bridge is by the process that connected -- the peer pid off the
socket (`SO_PEERCRED` on Linux, `LOCAL_PEERPID` on macOS), walked up the
parent chain to the agent the daemon started, which the bridge is under
and no other task's process is; the kernel supplies that pid and a peer
cannot forge it. `mcp/open` carries only `{task, lend, server}`, and the
task it names must be the one the connection is a process of. Two tasks
on
  one machine run as one user, so no name or file a bridge could present
would tell them apart; the connecting process does. One stream per
bridge, at most 16 per task; a frame
  travels in pieces of at most 256 KiB characters, the last marked
(`protocol.pieces`), up to the 32 MiB the host's own bridge allows; the
  daemon relays and keeps nothing. A stream ends with either end, the
  host's link or the task, and the other end is told.
- The host opens the endpoints for a machine row exactly as for a row
here
(`McpEndpoints`: disabled tools withheld on the wire, stdio upstreams in
the host's sandbox, HTTP auth added here), binds them to the machine,
dispatch and acquired agent task
(`NodePool.lend`/`unlend`) before handing out bridge stanzas. On
`mcp/open`
the host checks the daemon-authenticated task against that binding
before
  connecting; it rechecks the grant and link after the socket opens. An
unlend, replacement grant or replaced link cannot authorize that pending
connection. The host then pumps the frames both ways
(`raven/node/client.py`).
`node/hello` names the bridge; a node serving without a daemon
(`--stdio`)
  names none, and the dispatch then goes without MCP and says so.
- The reply closes with the servers lent and the machine's node, or with
why none were. Remote lending requires Unix-socket support on the host:
on Windows it is withheld before endpoint setup, the task continues, and
the reply explains why. The roster's "injectable MCP" matches that
support
  (`session_mcp_delivered`). The cli lane on a machine still
  withholds its servers (the next change); the openai lane has no tools.

Not in this change: the cli lane's MCP file on the machine; a stream
that
survives the link going (a bridge sees its server end, the agent reports
the call failed, and the next dispatch works again); MCP for a run
picked
up after this computer restarted (#934): it reports its calls fail.

## Type

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

## Verification

Follow-up fix 05eaba6, after the c9de539 review:

- The nine tests the Windows review named now pin the host fact through
one
  fixture, `unix_socket_host` (`tests/conftest.py`), which sets
  `raven.mcp.bridge.unix_sockets_available` to true and re-points every
  `raven.*` module that imported it by name; their bodies are unchanged.
- Found complete, not just the nine: the whole suite was run with
  `asyncio.start_unix_server` and `asyncio.open_unix_connection` deleted
before collection (a pytest plugin, controller and xdist workers), at
the
  merge base `6b938a426` and at `c9de53947`, and the two failure sets
compared. 17 node ids failed at the head and not at the base: the nine,
and eight this change added that run the transport for real (the bridge
module, and two lending tests through the stand-in machine). Those eight
are guarded by host capability instead of pinned: the bridge module
skips
when `sys.platform == "win32" or not unix_sockets_available()`, the two
lending tests carry the existing `skipif(not unix_sockets_available())`.
After the fix the same run fails the same 66 node ids at the head as at
  the base: nothing this change adds or touches fails on a host without
  unix sockets.
- A bridge whose daemon side goes ends as streams end. The host can
close a
  stream while a frame of the agent's is on its way; the daemon writes
`mcp/close` and hangs up, and the frame meets the closed socket, or the
  read side is reset first. That exited 1 with `BrokenPipeError` or
`ConnectionResetError`; a connection-level end is now exit 0 with
nothing
said, a frame past the cap still exits 1 with its reason, both pumps are
  awaited before the socket closes, and a daemon gone before the stream
opens is one sentence, not a traceback. Four deterministic cases with a
  stand-in connection fail on `c9de53947` and pass here.
- The bridge test's stand-in agent outlives its bridge, as an agent
outlives
  one of its MCP servers, so the later parts of

`test_a_stream_the_host_closes_ends_the_bridge_and_a_bridge_of_no_task_or_of_another_s_never_begins`
no longer race its exit: 1 failure in 80 runs on `c9de53947`, 0 in 80
here.
- The focused set (node, MCP, remote, manager and roster files) -- 935
  passed; `.venv/bin/python -m pytest -n 6` -- 31602 passed, 3 failed
(`test_rpc_files.py::test_a_host_without_libreoffice_says_so` and the
two
  simulation cases, which fail the same way on main on this computer);
  diff coverage against main 97.67% (420/430). Real machine, node
reinstalled with this tree in a directory of the check's own: a stdlib
ACP
  agent's bridge reached a host-held server and 1 MiB and 8 MiB results
  crossed whole, bridge exit 0; nothing was left on the machine.

Follow-up fix c9de539, after the same-user token finding:

- The daemon no longer uses a token. `agent/launch` mints and writes
none,
  `mcp.token` and the `hmac` check are gone, and `Daemon.open_stream`
  identifies the bridge by the connecting process instead (`_peer_pid` +
  `Daemon.task_of`). The same-user path the reviewer kept open -- a peer
reading another task's `mcp.token` and opening its stream -- is closed,
  because identity is the connection the kernel reports, not a readable
  secret.
- Tests, each failing on the unfixed tree first: a bridge placed under
task
  B that names task A is refused (`the connection is a process of task
  'agent@box-2', not 'agent@box-1'`), as are a process under no task, an
  unplaced connection, and a task the daemon does not run
(`tests/test_node_server.py`); a real-OS test launches an agent, takes
its
real child, and asserts the child resolves to the task while the test's
own
  process resolves to none (runs on Linux in CI); `_peer_pid` is checked
against a real socketpair; the in-process and subprocess bridge tests
now
identify by the connecting process, and a bridge this test spawns
itself,
  or another task's agent spawns, never opens the stream.
- Windows withholding now covers a row on this computer too, not only a
  machine row: `session_mcp_delivered` drops the `machine and` guard and
answers no whenever `unix_sockets_available()` is false, so a local acp
row is no longer advertised as injectable and no longer raises at
dispatch
on a host without the transport; `_mcp_endpoints` yields nothing before
opening an endpoint and `_mcp_note` explains it. The two tests the
Windows
review named now guard on `unix_sockets_available()`, and the
withholding
  test is parameterized over a machine row and a local row.
- `scripts/coverage_gate.py diff --base-ref origin/main --threshold 90`
over the node/MCP/remote suite -- 97.63% changed-line coverage
(412/422).
  `ruff`, `ty` on the changed modules, and the import-cycle budget pass.
- Real machine (ravenx2, this head, node reinstalled, digest
fdbe132e76f0):
a stdlib ACP agent's bridge reached a host-held MCP server and a 1 MiB
and
  an 8 MiB tool result crossed whole (`CALL_OK`, bridge exit 0), so the
  connecting-process identity admits the legitimate bridge end to end on
  real Linux; the cross-task refusal on Linux is the real-OS test above,
  which CI runs there.

Earlier review fixes (4dc5b3e, the task-to-grant binding). Its
host-side
binding of each dispatch's endpoints to the acquired agent task is kept;
the
daemon-side token this run verified was since replaced by the
connecting-process
identity above (c9de539), so the token-named tests and reproduction
below no
longer describe the current tree.


- `uv run --no-sync pytest tests/test_node_client.py
tests/test_node_server.py tests/test_node_bridge.py
tests/test_node_relay.py tests/test_node_bundle.py
tests/test_node_install.py tests/test_subagent_acp_remote.py
tests/test_subagent_cli_remote.py tests/test_acp_per_session_mcp.py
tests/test_subagent_acp_mcp_lifecycle.py tests/test_mcp_endpoint.py
tests/test_subagent_mcp_grant.py tests/test_mcp_bridge.py
tests/test_acp_remote.py tests/test_remote_file_tools.py
tests/test_subagent_probe.py tests/test_subagent_acp.py
tests/test_import_cycle_budget.py tests/test_resume_remote.py
tests/test_remote_resume.py tests/test_subagent_manager.py
tests/test_subagent_third_party.py -n 4 --idle-ceiling-strict
--cov=raven --cov-branch --cov-report=json:<coverage-json> --cov-report=
--tb=short` -- 1495 passed and one minimal-node stdio response test
failed;
  strict idle accounting also flagged the assembly test. Both cases pass
together under `-n 0 --idle-ceiling-strict` (2 passed). No assertions or
timing limits were weakened. The new authorization, revocation and host
  compatibility tests all pass.
- The five focused cases failed on the unfixed implementation first: a
  different authenticated task selecting another grant, a stream with no
task, each missing Unix socket API, and an astral Unicode chunk over the
node's byte limit. Regressions also cover revocation, grant replacement
and link replacement while an endpoint connection is opening, and a real
  remote turn continuing when both host Unix socket APIs are absent.
- `uv run --no-sync python scripts/coverage_gate.py --coverage-json
<coverage-json> diff --base-ref origin/main --threshold 90`
  -- 97.94% (381/389 changed executable lines); this fix alone is 100%
  (40/40). Python lint, type, dependency
  and import checks pass; all 10 import contracts hold. The scope checks
  for commit messages, source language and file sizes pass. Both RPC
  generators pass `--check` after integrating current main.
- A real Linux node with two concurrent tasks and a host loopback
endpoint:
  before, the attacker's own task token plus the victim's public grant
  names returns the victim endpoint's marker; after, it returns no data
  and the endpoint is never reached. Its own grant works, unknown and
revoked grants fail, and the same attack remains denied after the relay
  is killed and an ordinary ping reattaches both tasks. A full chunk of
  262144 astral characters was lost/corrupted before and arrives intact
after. The final documented source tree was reinstalled and checked
again.
- Real ACP dispatch through the node's bridge reaches a host-held stdio
MCP tool and returns its marker; its grant is removed when the turn
ends.
  With the host Unix socket APIs removed to simulate Windows, the remote
  agent still completes, receives no MCP, explains the host limitation,
  and the host MCP ledger records no extra call. This is not a native
  Windows run. Dedicated test tasks, host endpoints and bridge processes
  are cleaned up.
- CONTEXT.md, module/field docstrings, both collaboration guides, the
  external design notes and this description were checked against the
  implementation. Main's no-debug-switch behavior and full-turn recovery
  documentation are retained.

Full combined-tree verification: `uv run --no-sync pytest -n 4
--tb=short
--cov=raven --cov-branch --cov-report=json:<full-coverage-json>
--cov-report=`
returned 31594 passed, 230 skipped, 2 xfailed and 4 failed. Each failure
reproduces on current main in a separate checkout: host CLI color
rendering,
LibreOffice detection and the two simulation cases. No other test fails.
The minimal-node response test that failed in the focused parallel
selection
passes in this full run. Diff coverage: 97.94% (381/389 executable
changed lines).

Earlier author verification:

- `uv run pytest tests/test_node_bridge.py tests/test_node_server.py
tests/test_node_client.py tests/test_node_relay.py
tests/test_node_bundle.py tests/test_node_install.py
tests/test_subagent_acp_remote.py tests/test_subagent_cli_remote.py
tests/test_acp_per_session_mcp.py
tests/test_subagent_acp_mcp_lifecycle.py tests/test_mcp_endpoint.py
tests/test_subagent_mcp_grant.py tests/test_mcp_bridge.py
tests/test_acp_remote.py tests/test_remote_file_tools.py
tests/test_subagent_probe.py tests/test_subagent_acp.py
tests/test_import_cycle_budget.py tests/test_resume_remote.py
tests/test_remote_resume.py tests/test_subagent_manager.py`
  -- 1182 passed. Covered: on the daemon, `node/hello` naming the bridge
  and a stdio node naming none, the token written owner-only beside the
  record, a stream opened with the task's token and refused with another
  or for a task not running, refused with no host holding the task,
  refused past 16 per task, one per bridge connection, the pieces of a
  frame crossing both ways as they are and a stranger carrying nothing,
  and every way a stream ends (the bridge closing, the host closing, the
  bridge's connection ending, the host's link ending, the agent ending)
  with the other end told; the bridge as a process against a real daemon
  over its socket: a frame to the host and one back whole, a large frame
  each way in pieces, the agent closing its stdin ending the stream and
the bridge leaving clean, the host closing the stream ending the bridge,
  a wrong token and a missing daemon each refused on stderr with exit 1;
  the stand-in machine with the real daemon and the real bridge: an acp
stub there that connects to its `mcpServers` and calls a tool reaches a
  stdio server here and answers with its result, the stanza handed to it
  naming the node's bridge, the task, the dispatch and the server and
  nothing of the server's definition or socket, the ledger here showing
  one call, no bridge outliving the dispatch; the reply's note and the
  roster predicate for a machine row.
- `uv run pytest` on this tree -- 31288 passed, 230 skipped, 2 xfailed,
4 failed:
  `test_rpc_files.py::test_a_host_without_libreoffice_says_so`,

`test_simulation_scenario.py::test_a_fresh_trial_refreshes_the_subagent_homes_before_it_runs`,

`test_simulation_suite.py::test_a_stopped_run_is_marked_in_its_record_and_finished_with_its_evidence`
and
`test_subagent_kimi_code.py::test_an_ask_that_runs_out_takes_what_its_kimi_started`,
which fail
  the same way on main on this machine.
- Real machine (a registered Linux machine with Claude Code signed in,
  the node reinstalled with the code before and after):
  A28, before (the
  code of #934's head and its node): the turn closed with "MCP servers
  'probe' were not lent", Claude Code said no tool of that name was
  connected, and the server here saw no call; after: `PROBE_OK:r2-lend`
  in 9.4 s, the server here recorded the call, and the turn closed with
"lent to acp agent ... through the Raven node on machine ...". A34:
after
the turn the machine's process list shows no bridge, a grep of its node
  directory finds nothing of the server's command or of this computer's
  socket directory, and no endpoint socket is left here. A29: with the
tool switched off here, Claude Code answered UNAVAILABLE and the server
saw no call. A30: a 40 s tool call under way, the bridge visible in the
  machine's process list, then cancelled: 3 s later no bridge there and
  no socket here. A31: the relay ssh killed during a 20 s call: the turn
ended 4.1 s later with "CALL_FAILED ... Connection closed", and the next
dispatch reached the server again in 7.6 s. A32: five calls in one turn
  all reached the server and came back; an 8 MiB answer: the server here
  produced it, and a plain ACP agent on the machine (a stdlib stub that
speaks the stanza's bridge directly) received it whole through the node
-- 1 MiB in 1.3 s, 8 MiB in 3.5 s, the bridge exiting 0 -- while Claude
  Code itself closed the server on that answer and reported the call
  failed: the agent's own cap on a tool result, not the link's.
- `ruff`, the commit-message and PR-body checks -- clean.

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

The English and Chinese collaboration guides describe lending,
task-bound authorization,
Windows withholding, and the existing CLI/restart limitations.

## Risk

Registered-machine support is enabled by configuration, without the
removed
trial environment switch. An ACP entry on a machine receives its granted
MCP servers on a Unix-socket-capable host; the host's normal MCP policy
still
applies. This intentionally exposes those tools to the selected remote
agent.
The server definition, credentials and endpoint paths remain on the
host.

The daemon identifies a bridge's task by the connecting process (the
peer
pid the kernel reports, walked to the agent it started), not by any
token or
name the bridge presents, so a peer task sharing the OS account cannot
open
another task's stream; the host then authorizes that task against the
dispatch/server grant. A system that reports no peer pid opens no
stream.
On Windows hosts remote lending is withheld explicitly, without aborting
the
remote task. Protocol 6 remains the MCP wire version; host and node must
also
have the same code digest, so updated nodes must be reinstalled.
Rollback is
a revert; the machine then carries no stream and receives no bridge
stanza.

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

## Related Issues

#934 (merged); #920 and #922 (merged).

---------

Co-authored-by: Claude (claude-fable-5-1) <noreply@anthropic.com>
Co-authored-by: Codex <noreply@openai.com>
Handsome-wzw added a commit that referenced this pull request Oct 15, 2026
…ode's own version (#963)

## Summary

Three fixes to installing a Raven node on a registered machine
(`raven ops connection install-node`), all in `raven/node/install.py`.
The
first two are nonblocking notes LivXue left on #920; the third is an
older
problem with the version a node names itself by.

- **The index's sign-in stays off every command line.** The probe reads
the
  package index pip is configured with on the machine, and the install
handed it to uv as `--default-index <url>` inside the step's command.
pip
takes a sign-in inside that URL (`https://user:password@host/simple`),
and
the command was in this computer's ssh argv, the machine's `sh -c` argv
and
uv's argv; `/proc/<pid>/cmdline` there is readable by every account on
the
  machine. The index now travels on the stdin of the two steps that run
`uv pip install` (`packages`, `search`) and is read into
`UV_DEFAULT_INDEX`,
  the variable uv reads as `--default-index`; a process's environment is
readable only by its own user. A step that finds no line on stdin fails
  rather than install from PyPI behind the owner's back. With no index
  configured nothing changes: no stdin, uv's default.
- **A port that is not a port is one sentence, not a traceback.**
`_shown_index`, which drops the sign-in from the URL for every sentence,
read `urlsplit(url).port`, which raises `ValueError` for a port that is
not
a number or is past 65535; `install-node` catches only `InstallError`,
so
the person saw a traceback. The display now cuts the sign-in from the
text
after the authority's last `@` (as `urlsplit` finds it) and never reads
`.port`, and the look at the machine refuses such an index before
anything
is installed: "the package index pip is configured with on machine ...
has
  a port that is not a number from 0 to 65535 (<url without sign-in>);
correct index-url in pip's configuration there, or PIP_INDEX_URL". An
empty
  port is the scheme's default (RFC 3986) and goes ahead.
- **A node names its own version.** A node runs this code from `lib/`,
not as
an installed distribution, so `importlib.metadata` found no `raven`
there:
`raven.__version__` fell back to `0.0.0+unknown`, and with it the
socket,
lock and pid the daemon keeps under `run/` (`bundle.install_name()`) and
the version the node reports at the handshake, which the machine listing
shows -- while the node's directory is named by the host's version. The
  `path` step now also writes a distribution record,
`lib/raven-<version>.dist-info/METADATA`, with the version the directory
is named by, so the node reads the same version and names itself as the
host named it. It sits outside `lib/raven`, so the digest is unchanged.
This needs no change to `raven/node/server.py` or
`raven/node/bundle.py`.

A node installed before this keeps its old names until it is
reinstalled;
any change to the code changes its digest and asks for a reinstall
anyway.

## Type

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

## Verification

- `.venv/bin/python -m pytest tests/test_node_install.py` -- 69 passed.
Each
  new or changed case failed on main first (12 failed there): no step
command names the index, its host or its sign-in, and `install()` feeds
the
  whole URL as one line on stdin to exactly the `packages` and `search`
steps (the `code` step still gets the tarball); under a real `/bin/sh`
with
a stand-in uv that records its argv and its `UV_DEFAULT_INDEX`, the
packages
step and both tries of the search package (the machine's index, then
beside
the public one) see the index in their environment and never in argv,
and
  with nothing on stdin the packages step fails before uv runs;
`_shown_index` drops the sign-in for a port that is not a number, one
past
  65535, an `@` inside the password and an IPv6 host, without raising;
`plan()` refuses an index whose port is not a number or is past 65535
with
the exact sentence above and runs nothing after the look, while a
numeric
  port, no port and an empty port go ahead; a plan holding such an index
  still describes itself and its failures; the `path` step passes the
  version the install name begins with; and the one real install on this
computer (a uv virtualenv of this interpreter, this code unpacked into
it)
now has the node report `raven.__version__` and `bundle.install_name()`
  equal to this computer's install name under `-I`.
- `.venv/bin/python -m pytest tests/test_node_install.py
tests/test_cli_ops_connection_commands.py tests/test_node_bundle.py
tests/test_node_client.py tests/test_node_server.py
tests/test_node_relay.py tests/test_subagent_acp_remote.py
tests/test_import_cycle_budget.py -n 6`
  -- 334 passed.
- `scripts/coverage_gate.py diff --base-ref origin/main --threshold 90`
--
100% (16/16 changed executable lines). `ty check raven/node/install.py`,
`ruff check` and `ruff format --check` clean;
`check_commit_messages.py`,
  `check_source_language.py` and `check_large_files.py` on
  `origin/main..HEAD` clean.
- `.venv/bin/python -m pytest -n 6` on this tree -- 31716 passed, 172
skipped,
2 xfailed, 3 failed:
`test_rpc_files.py::test_a_host_without_libreoffice_says_so`,

`test_simulation_scenario.py::test_a_fresh_trial_refreshes_the_subagent_homes_before_it_runs`
and
`test_simulation_suite.py::test_a_stopped_run_is_marked_in_its_record_and_finished_with_its_evidence`,
  which fail the same way on main on this machine.
- Real machine (a registered Linux machine whose pip is configured with
an
  HTTPS mirror, installed into a node directory of this check's own and
removed after): the machine's mirror URL was given a made-up sign-in, a
node
was installed, and the process lines on the machine and on this computer
were sampled every 0.3 s while it ran. With main's code: the sign-in was
in
a process line in 22 of 46 samples, in `sh` and `uv` on the machine and
in
`ssh` here; the node reported version `0.0.0+unknown` at the handshake
and
its `run/` held `0.0.0+unknown-<digest>.lock`, `.pid` and `.sock`. With
this change: 0 of 68 samples; the node installed, answered the handshake
  with this code's digest and version `0.2.4`, its `run/` held
`0.2.4-<digest>.*`, and its own `raven.__version__` and install name
matched
its directory. With the index pointed at a port nothing listens on, the
install failed at the `packages` step with "Connection refused" -- uv
took
the index from its environment and did not fall back to PyPI -- and the
sentence held no sign-in. Nothing of this check was left on the machine.

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

No user-facing text changes beyond the new refusal sentence; the
install's
module docstring and the `Plan.index` field say where the index travels
and
what the version record is for. `CONTEXT.md` (the node is installed "in
a
directory named by the code's version and digest") was checked and still
holds.

## Risk

The index now reaches uv only through its environment, which only the
machine user can read; nothing else about the install changes. uv has
read
`UV_DEFAULT_INDEX` since it gained `--default-index`, so a machine's uv
that understood the flag understands the variable. A machine whose pip
configuration names a port that is not a number now gets one sentence
instead of an install that would fail at the packages step. The version
record is a few lines beside the code, outside the digested package, so
a
node of this code reports the same digest. Rollback is a plain revert; a
node
installed with the record keeps working with older code, which does not
read
it.

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

## Related Issues

Follows the nonblocking notes on #920 (the install's package index on
the command line, and the traceback for a port that is not a number).

Co-authored-by: Claude (claude-opus-5-5) <noreply@anthropic.com>
Handsome-wzw added a commit that referenced this pull request Oct 16, 2026
## Summary

`bind_roles_here` writes the four EverOS roles into `os.environ`
directly, records them in `raven_everos.config._BOUND_HERE` and
publishes that record as `RAVEN_EVEROS_BOUND`. `monkeypatch` undoes none
of it: it records only what it changed itself, and `delenv(...,
raising=False)` on a name that is not there records nothing. The everos
backend tests have put the `EVEROS_*` variables back since #489, but not
the record or the set, and the binding is not theirs alone: every
runtime assembled with the memory plugin on binds through the plugin's
start. A run of the whole suite with a watcher on these three things
(2026-10-15) found fourteen files binding -- the design and ppt
launchers, eval hooks, plugin tools, remote file tools, direct chat,
onboarding, the TUI command tests. So one test's binding was the next
test's environment, and which test paid depended on how the suite was
dealt into shards: ten `tests/test_rpc_settings.py` save cases refused
with "decided by the `EVEROS_LLM__*` environment variables" in shard 4/4
of #920 (whose tree predated #489), and nowhere else.

What this does, tests only:

- `tests/_everos_env.py`: `confined_everos_binding()`, a block that puts
the variables (`EVEROS_ROOT` included), the provenance variable and the
contents of the provenance set back when it ends, exception or not; and
`forget_everos_binding(monkeypatch)`, which starts a reader with none of
them and swaps the set rather than clearing it.
- `tests/conftest.py`: one autouse fixture, `_no_leaked_everos_binding`,
around every test, next to the other process-global restores
(`_unbind_the_permission_turn`, `_no_leaked_usage_sink`). It replaces
the everos backend file's own fixture, which covered the variables
alone.
- `tests/test_rpc_settings.py` and `tests/test_config_update_everos.py`:
the two readers of `role_is_env_managed` start with no role of any kind
exported, not only no embedding, through the helper.
- `tests/test_everos_env_confinement.py`: the helper's contract (a
binding made inside the block is undone; what was there before comes
back; so on an exception; the forget half), and one slow test per
representative file that, in a child interpreter started clean and
running under the suite's own conftest, checks that the file leaves no
variable, no record and an empty set behind. Red on the tree before this
change for all three targets (the backend file left the record; the
launcher case left twelve empty role variables, `EVEROS_ROOT` under a
`tmp_path` that is gone, and the record).

The board's own reproduction (the named everos case, then
`tests/test_rpc_settings.py`, one process) does not fail on main any
more because of #489's fixture; it was kept as a check and passes.

Third commit, after review: the block restored the contents of whichever
set the attribute held when it ended. That is the wrong object for a
test that binds and then swaps the attribute with `monkeypatch`, because
`monkeypatch`'s undo runs after an autouse fixture's teardown here
(`_isolate_tracing_state_dir` asks for it and is set up earlier; autouse
fixtures of one conftest are set up in name order): the swapped-in set
got cleaned and the module's own came back with everything bound into it
-- a probe handed the next test twelve keys. No test in the suite binds
before it swaps, so nothing failed, but the helper's docstring claimed
the reverse order. The block now keeps the object it began with and
restores that object's contents and the attribute's binding to it,
whatever the attribute holds by then; the docstring says why, and a test
that binds, swaps with a MonkeyPatch undone only after the block, and
reads a clean set is red before the change.

## Type

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

## Verification

- A throwaway pytest plugin recording, per test, every change to
`EVEROS_*`, `RAVEN_EVEROS_BOUND` and `_BOUND_HERE`, over the whole suite
(`-n 6`): before this change 63 tests in 16 files changed them (14
binding, 2 clearing); after it, the watcher wrote nothing: no test
changed them (31719 passed, same run).
- `pytest tests/test_everos_env_confinement.py -n 0`: 8 passed, with and
without `CI=1`; the three child-interpreter cases fail on the tree
before this change with the leftovers named above. (The first push had
them print the report after `pytest.main`, which on CI never returns --
the suite's conftest hard-exits the interpreter in `pytest_unconfigure`
when `CI` is set -- so shard 1/4 failed on "the child did not report"
while the targets had passed; `CI=1` reproduces that locally. The child
now writes the report from a `pytest_sessionfinish` plugin into a file
the parent reads.)
- `tests/test_everos_backend.py` 191 passed,
`tests/test_agents_ppt_launcher.py` 61 passed,
`tests/test_rpc_settings.py` 153 passed,
`tests/test_config_update_everos.py` 50 passed, each on its own; the
board's reproduction order 154 passed; the fourteen binding files
followed by every reader in one process, 1261 passed.
- Full suite on the merged tree (`-n 6`): after the third commit, 31720
passed, 173 skipped, 2 xfailed, 3 failed -- the same three that fail on
main on this machine (a LibreOffice case and two simulation cases); the
related group (`test_everos_backend`, `test_rpc_settings`,
`test_config_update_everos`, `test_everos_config`, `test_everos_server`,
the two launcher/eval-hook files) 651 passed.
- `ruff check`/`ruff format`, `scripts/check_source_language.py`,
`scripts/check_large_files.py`, `scripts/check_commit_messages.py`:
pass. No production code changes, so the diff coverage gate has no
changed executable lines to measure.

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

## Risk

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

Tests only. A test that asserted on a binding left by an earlier test
would now see it gone; the full-suite run says none does. Rollback is
reverting the commit.

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