Skip to content

feat(remote): serve a model on a tailnet GPU machine - #333

Merged
volen-silo merged 6 commits into
mainfrom
feat/remote-tailnet-foundation
Sep 28, 2026
Merged

volen-silo merged 6 commits into
mainfrom
feat/remote-tailnet-foundation

Conversation

@volen-silo

@volen-silo volen-silo commented Sep 1, 2026 •

Copy link
Copy Markdown
Collaborator
  • If this PR fixes a bug, searched tests/e2e-cucumber/expectations.toml for the fixed ticket ID and removed/narrowed any now-stale xfail rows. — n/a, no bug fix; no xfail rows affected.

Summary

Adds rocm remote: discover GPU machines on your tailnet, check their health, install what they are missing, serve a model on one, and reach it from any of your machines.

$ rocm remote targets --tag gpu
$ rocm remote serve gpu-box qwen2.5-7b-instruct
✓ endpoint: http://gpu-box.tailnet.ts.net:8000/v1

SSH is the control channel, not the data path. Everything that inspects or changes the remote goes over SSH. The inference traffic does not: rocm serve binds loopback on the GPU machine as it always has, and the machine then tells its own Tailscale daemon to forward a tailnet port to it. Nothing runs locally, so the endpoint outlives the command that created it and answers from any of your machines rather than only the one that started it.

Ready for review, not for merge. Two things still need a real tailnet and a real GPU to confirm — see "Not verified" below.

Why this shape

The alternative was a local ssh -L tunnel. Publishing from the remote instead means no local process to supervise, no tunnel PID to track, and an endpoint that survives the terminal that made it. The cost is a hard dependency on Tailscale for serving, and an endpoint that is tailnet-wide rather than point-to-point — which is what drove the one change to existing behaviour below.

Changes existing behaviour

rocm serve --require-api-key. serve grants an API key only to non-loopback binds, reasoning that loopback means "only this machine can reach it". Publishing the port makes that false while leaving the bind address unchanged — which would put an unauthenticated model endpoint on the tailnet. The new flag makes a loopback bind authenticated anyway; remote sessions always set it. Local serving is unchanged.

The key travels to the remote on stdin, never in a command line, since both machines expose command arguments in their process tables.

install.sh download-only and install-from-archive modes. Provisioning never copies the local binary — that only works when both machines share an OS and CPU, and when they do not the copy still lands and still looks installed. The remote fetches its own build; if it cannot reach the release host, this machine fetches one for the remote's platform and pushes it with its checksum and signature so the remote repeats every check. Splitting the trust chain across two machines must not shorten it.

rocm services list --json — the machine-readable listing the remote orchestration reads back, applying the same liveness filter as the table.

Non-obvious decisions

  • Two lifecycles, reported separately. The model server and the publish pointing at it can fail alone. A live model with no endpoint is re-published; a dead one is restarted. Collapsing them hides which.
  • Teardown is confirmed, not assumed. A publish is configuration rather than a process, so it survives reboots — a forgotten one is a GPU endpoint on the tailnet with nothing tracking it. Ownership is established before a port is claimed or released, so a session never takes over or tears down another's forward. A teardown that cannot confirm both halves keeps the session listed rather than dropping the only record of what is still running.
  • Installing ROCm is opt-in and gated on the failure catalog. A machine the catalog recognises as needing a person is refused — the wizard that walks someone through those cannot run over a connection nobody is watching. Passwordless sudo is checked first, because a prompt the control channel will never answer hangs rather than fails.
  • Health checks add almost no logic. Gathering facts already produces a plain snapshot and scoring reads nothing else, so the fetch runs on the remote and the scoring here, against the same catalog. Suggested fixes are rewritten to name the target.
  • Signing-key selection matches install.sh exactly. Both resolve _PATH before _PEM, and both treat an empty value as unset. What they choose between is the trust root a signature is verified against, so the two disagreeing would let a remote provision accept a build a local install would reject — surfacing as a rejected artifact rather than a key mismatch. A forwarded key also blanks the remote's own _PATH, so a value the far side exports for itself cannot beat the one we sent.
  • ROCM_REMOTE_SSH_CONFIG names an alternative ssh config. ssh resolves ~/.ssh/config from the account database rather than from HOME, so there was otherwise no way to point the CLI at a different one. Added while building the end-to-end harness, which could not run without it; independently useful for anyone with a per-project ssh config.

Test plan

  • cargo test --workspace --all-targets, cargo clippy --workspace --all-targets -- -D warnings, cargo fmt --check, prek run --all-files, scripts/smoke_local.py — all pass.
  • 14 scenarios in tests/e2e-cucumber/features/remote.feature. Eight cover discovery, refusals and the session list. Six need a host on the other end of a real SSH connection, so they carry a @requires-docker gate and skip with a reason where no container runtime exists.
  • tests/remote-ssh/run.sh — 21 checks of the tool contracts against a real OpenSSH server in a container: argument handling, exit-code propagation, a credential delivered on stdin and absent from the command line, file copy, batch-mode refusal, the shape Tailscale Funnel takes in the serve config, and that withdrawing a published endpoint actually removes it.
  • tests/remote-ssh/run-e2e.sh — 25 checks driving the built binary through the whole flow: discover, probe, serve, publish, reconcile status, re-publish after an out-of-band withdrawal, tear down, and refuse to publish over a Funnel-exposed port.
  • Both run on the remote control channel (containerised) CI lane, gated on the heavy path filter.

On a network that intercepts TLS, the container lanes need plain-HTTP package mirrors — docs/testing.md documents the ROCM_TEST_APK_REPOS escape hatch.

Not verified

  • The endpoint carrying traffic. tailscale is a stand-in on both sides of every harness, so publish/withdraw are exercised but no inference request crosses a tailnet. Needs a real two-node tailnet.
  • The tailscale serve command surface. Shapes follow Tailscale's documented CLI and the ServeConfig struct, and the parsing contract is pinned against a stateful stub — but nothing here has spoken to a real daemon.
  • A real GPU. No model is ever loaded; the remote's rocm is a stub.

Open question for review

The Funnel guard is unreachable at the default port. Tailscale Funnel serves only 443, 8443 and 10000. The default tailnet port is 8000, so PublishState::FunnelAllowed — a state variant, four refusal arms, a status line, six unit tests and two container lanes — can only be reached by someone passing --tailnet-port 443 (or 8443/10000). That is not a hole: Funnel exposure is per-port, so a Funnel on 443 does not expose a session published on 8000. But it is a lot of machinery behind an opt-in flag, and Funnel is not mentioned in any user-facing doc. Worth deciding whether to document it, default differently, or drop it.

Risk

Medium. The rocm remote surface is entirely new and additive. The two touch points with existing behaviour are serve's new opt-in flag (loopback serving is unchanged when it is absent) and the installer's new modes (the existing path is untouched). Reviewers may reasonably want the installer change looked at separately given its place in the signed-release trust chain — happy to split it out.

@volen-silo
volen-silo force-pushed the feat/remote-tailnet-foundation branch 4 times, most recently from f2dca4f to 78bf68b Compare September 1, 2026 12:57
Comment thread apps/rocm/src/remote/session.rs Fixed
Comment thread apps/rocm/src/remote/session.rs Fixed
@volen-silo
volen-silo force-pushed the feat/remote-tailnet-foundation branch from 78bf68b to e186e9a Compare September 1, 2026 13:18
@tomastola

Copy link
Copy Markdown
Collaborator

The red E2E tests (GPU) here is shared-runner state, not your change.

That job landed on mi300x-0, whose shared pre-warm tree had torch-2.11.0+rocm7.14.0 sitting inside the ROCm 7.13.0 runtime — a cross-wiring from an earlier run. Multi-arch wheels are published stripped of device code by design, so every vLLM start on that tree dies the same way:

RuntimeError: Engine core initialization failed. See root cause above.

which is exactly the three unexpected failures in this run (93 scenarios (85 passed, 8 failed), 5 of them the known xfails). Full diagnosis in #314.

The runner is repaired — torch is back to 2.11.0+rocm7.13.0 and a real kernel verified on it — and I have re-run the job, so nothing needed from you. E2E tests (Strix Halo, Ubuntu) is a separate failure that I have not looked at.

@volen-silo
volen-silo force-pushed the feat/remote-tailnet-foundation branch 4 times, most recently from 7285717 to 24e057b Compare September 7, 2026 08:13
@volen-silo
volen-silo marked this pull request as ready for review September 7, 2026 08:20
@volen-silo
volen-silo requested a review from a team as a code owner September 7, 2026 08:20

@siloteemu siloteemu left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🔴 Automated review · pr-review-watcher · 24e057b

Summary

Adds rocm remote (~7.8k lines, 31 files): provisions the CLI onto a GPU machine over SSH, serves a model there, and publishes the port onto a Tailscale tailnet, plus a containerised SSH test harness and CI lane. Needs work — the security design is genuinely good, but four added tests cannot fail when the code they guard breaks, and one harness line can expose a baked-in password beyond loopback. Verified: the trust model is loopback bind + per-session API key + tailnet-scoped tailscale serve — I confirmed the remote server is pinned to --host 127.0.0.1 --require-api-key (mod.rs:478-493), that funnel is never invoked (only serve --bg --tcp=, publish.rs:176-186), that the key travels on ssh stdin not argv and is stored 0600 with a path-traversal-guarded session id, that shell_quote covers every user value and is proven against a real sh, and that host trust is delegated to the user's own ssh config with BatchMode=yes (fails closed, no silent TOFU) — the README states all of this plainly, so code and documented model match; on the revert question I checked every added test individually and four fail it (below), while the rest are tied to real functions via a ScriptedTransport that hard-errors on unmatched commands; I refuted a reported "remote skips signature verification" concern by reading install.sh (a pinned release key is present, so public_keys is non-empty and the signature gate fires on the remote too); I ran cargo fmt --check (clean) as my one permitted check, so the red check is not formatting — the two plausible candidates I can argue from the source are the unguarded readiness loop in run-e2e.sh:141-144 and the first-ever activation of @requires-docker scenarios on the required e2e lane via E2E_INCLUDE_DOCKER: "1", but I could not read the CI logs and will not call it flake without them. Blocking: 4 · Non-blocking: 5.

🚫 Blocking (must fix before merge)

tests/remote-ssh/run.sh:84-85 — the container is started with docker run -d -p "127.0.0.1:${PORT}:22" ... || docker run -d -p "${PORT}:22" .... The fallback drops the loopback prefix and publishes sshd on all interfaces, and the image ships a real password account (Dockerfile:39-45: a fixed username/password with PasswordAuthentication yes) whose password is committed to this public repository. On any host where the 127.0.0.1: publish form fails, this silently exposes a guessable-password shell on the LAN for the container's lifetime — on a CI runner or a contributor's laptop. run-e2e.sh:84 correctly uses the loopback bind with no fallback. Fix: drop the || fallback so a failed loopback bind is a hard error.

tests/e2e-cucumber/tests/e2e/remote_steps.rs:283-305 (scenario at features/remote.feature:58-64) — remote-09 is titled "Checking a machine's health never installs anything on it", but its When step deliberately uses no container, so ssh fails at the transport layer before remote_doctor reaches bootstrap::locate_cli (the step's own comment says so). then_doctor_installed_nothing accepts "could not reach" as a pass, and then_doctor_points_at_serve wraps its only assertion in if said.contains("only reads"), which never holds here — a permanently dead assertion. If remote_doctor were changed to call ensure_ready_with(...) and silently provision on a health check, this scenario would pass unchanged. Fix: give it a reachable container with no rocm binary so locate_cli's refusal actually fires, and make then_doctor_points_at_serve unconditional.

tests/remote-ssh/run-e2e.sh:195-198 — attach is the one stateful step whose effect is never checked. serve (line 167) and stop (line 206) both cross-check the container's real tailscale serve status --json; attach only asserts the printed strings "Endpoint re-published" and "not restarted". The preceding step withdraws the endpoint out of band, so this is precisely where re-publishing matters — yet an attach that printed those lines without re-publishing would go undetected, because the following stop reports success either way and the final expect_absent '"8000"' passes trivially. Fix: add serve_config="$(in_container tailscale serve status --json)"; expect_contains "the endpoint is back" '"8000"' "${serve_config}" right after the attach call.

apps/rocm/src/remote/mod.rs:1413-1425 — serve_sends_the_key_over_stdin_when_it_starts_the_model never calls serve(). It invokes transport.exec_with_stdin(..., Some("k")) itself, then asserts ScriptedTransport recorded the Some("k") it was just handed — exec_with_stdin pushes stdin unconditionally, so the assertion cannot fail. Its comment claims to guard "the caller actually supplies it", but reverting the real call site at mod.rs:270 from Some(&api_key) to None leaves this green. The command-shape half is already covered by mod.rs:1023. Fix: make serve() accept a &dyn Transport so the real orchestration can be driven through ScriptedTransport, or delete the test rather than leave a false guarantee on the credential path.

Non-blocking

  • apps/rocm/src/remote/provision.rs:130-131 — the comment "the remote can repeat every check this machine made" is overstated: ROCM_CLI_SIGNING_PUBLIC_KEY_PATH/PEM is not forwarded, so an operator using a private-mirror key gets the remote verifying against the pinned production key instead — a hard failure, not a downgrade, but a confusing one. Forward the key vars, or narrow the comment.
  • apps/rocm/src/remote/provision.rs:155 — ROCM_CLI_ARCHIVE={remote_dir}/{asset} is the only unquoted interpolation into a remote command in the whole module; asset comes from parsing the installer's downloaded: line. Not exploitable today, but it breaks the otherwise-uniform shell_quote discipline.
  • apps/rocm/src/remote/transport.rs:24-28 and tailnet.rs:248-252 — both #[cfg_attr(not(test), allow(dead_code))] comments say "remove this attribute in the change that adds the serve path"; this PR is that change. Leaving them will mask genuinely dead code added later.
  • apps/rocm/src/remote/transport.rs:238-240 — ConnectTimeout=10 bounds only the handshake; there is no ServerAliveInterval/ServerAliveCountMax and no wall-clock bound on wait_with_output(), so a connection that drops mid-command hangs the CLI indefinitely, including in status's polling loop.
  • tests/remote-ssh/run-e2e.sh:141-144 — the sshd readiness loop falls through after 15s with no success check, unlike the equivalent loop in run.sh:108-113 which hard-fails with a clear message. A slow container start surfaces as a confusing discovery-assertion failure instead; this is my leading in-diff candidate for the red check.

@volen-silo

Copy link
Copy Markdown
Collaborator Author

Addressed all 4 blocking findings and 4 of 5 non-blocking findings from the automated review; skipping one non-blocking item as a follow-up.

Blocking

  • run.sh:84 — removed the || fallback to an all-interfaces bind. A failed loopback bind is now a hard error, so the password account never reaches the LAN.
  • remote-09 (remote_steps.rs / remote.feature) — added an INCLUDE_ROCM_CLI build arg so the fixture image can be built without the rocm binary, and gave remote-09 a Given step that starts that variant. The scenario now reaches locate_cli's refusal for real, and then_doctor_points_at_serve's assertion is unconditional rather than permanently skipped.
  • run-e2e.sh:195 — added a tailscale serve status --json check right after attach, so a re-publish that doesn't actually happen fails the harness instead of only checking printed strings.
  • mod.rs stdin test — split serve() into serve_with_transport() so the test drives the real orchestration through a ScriptedTransport, instead of calling exec_with_stdin directly and asserting on its own input. Reverting the real call site's Some(&api_key) back to None now fails this test (checked by reverting it locally and confirming the failure, then restoring it).

Non-blocking

  • provision.rs:130 — ROCM_CLI_SIGNING_PUBLIC_KEY_PATH/_PEM are now forwarded to the remote's install.sh, shell-quoted, so a private-mirror signing key actually reaches the remote instead of falling back to the pinned production key.
  • provision.rs:155 — the archive path is now shell_quoted like every other interpolation in the module.
  • transport.rs / tailnet.rs — dropped both stale dead_code attributes; this PR is the change their own comments said to remove them in.
  • run-e2e.sh:141 — the sshd readiness loop now hard-fails with a message instead of falling through silently after 15s.
  • ServerAliveInterval/ServerAliveCountMax (transport.rs:238) — left out of this pass; tracked as a follow-up rather than folded in here.

Verification

  • cargo fmt --check, cargo clippy --workspace --all-targets -- -D warnings, and cargo test --workspace --all-targets all pass clean.
  • Ran the e2e-cucumber remote-09 scenario against a real container built with no rocm binary — passes, 4/4 steps.
  • Ran tests/remote-ssh/run-e2e.sh end to end against a real container — all checks pass, including the new post-attach publish check.
  • Not verified: a real tailnet or a real GPU. Both harnesses remain the same container-based stand-ins used elsewhere in this PR.

Also replied to and resolved the two CodeQL threads: alerts #778/#779 already report state: fixed on this head.

@volen-silo
volen-silo force-pushed the feat/remote-tailnet-foundation branch from 9af9830 to 7327243 Compare September 11, 2026 09:29
@siloteemu

siloteemu commented Sep 11, 2026 •

Copy link
Copy Markdown

🔴 Automated review · pr-review-watcher · 486054f

This automation posts comments only. It never files a GitHub approval, so no approving review will appear here whatever the outcome — the merge decision stays with a human reviewer.

Summary

Adds rocm remote — provision, serve, publish and tear down a model on a tailnet GPU machine over ssh — with unit, cucumber and container-backed e2e coverage. Outcome: Needs work — both prior blockers are genuinely fixed, but three new issues surfaced, two of them repeats of the same two defect classes one layer away from where they were fixed. Verified: ran cargo test -p rocm --bin rocm remote:: (130 passed, 0 failed) and read install.sh and provision.rs side by side — the signing-key precedence now genuinely matches (_PATH > _PEM > pinned in both, pinned by a_path_wins_over_a_pem_because_that_is_what_install_sh_does, which fails if reverted), the forwarded fragment really does blank the remote's _PATH, the head commit's doc walk-back is accurate to the code, transport.rs really does check ssh's 255 before the writer-thread result (an_unreachable_host_says_so_even_when_a_payload_was_being_written fails if reverted, and its 1 MiB payload makes the EPIPE deterministic rather than racy); leak scan across the diff is clean, all 19 commits are signed and carry a matching DCO sign-off, no prompt-injection content anywhere in the checkout. Blocking: 3 · Non-blocking: 5.

🚫 Blocking (must fix before merge)

1. apps/rocm/src/main.rs:5972 — the --require-api-key guard is passed a hardcoded false, disabling it at this call site.

ensure_public_service_has_endpoint_key(host, endpoint_key_file.is_some(), false)?;

ensure_public_service_has_endpoint_key (main.rs:5701) has two branches: a public bind without a key, and requires_api_key && !key_present. The second is the one this PR adds for exactly the threat it introduces — a loopback bind is no longer "only this machine" once the remote republishes the port onto the tailnet. But record.requires_api_key is computed 40 lines earlier (main.rs:5931) and is in scope, and this call passes the literal false instead. The other three call sites (restart_internal_managed_service at main.rs:15805, and both sites in apps/rocmd/src/lib.rs) pass the real value; this one is the outlier.

It is reachable, not merely theoretical, because the two values are computed by different tests: record.requires_api_key comes from file existence, while endpoint_key_file is filtered by validity (endpoint_api_key_from_file) — a distinction the comment immediately above spells out as the reason an "empty or malformed key file would otherwise satisfy the guard". So an existing-but-invalid key file yields requires_api_key = true, key_present = false: precisely the case the new branch exists to catch, and the hardcoded false lets it spawn an unauthenticated listener for a service the user explicitly asked to require a key.

This also repeats prior finding 1's shape: the comment on the line above ("enforce the invariant here too rather than relying on every future caller having done so") claims the invariant is enforced, and only half of it is.

Fix: ensure_public_service_has_endpoint_key(host, endpoint_key_file.is_some(), record.requires_api_key)?; and add a test that a present-but-empty key file on a --require-api-key loopback service refuses to spawn.

2. apps/rocm/src/remote/mod.rs:685 — a definite remote failure is collapsed into "the machine could not be asked".

publish: publish::publish_state(transport, record.tailnet_port, record.remote_port).ok(),

publish_state (publish.rs:132-148) returns Err in two materially different cases: the transport failed, or the remote was reached and tailscale serve status --json exited non-zero, in which case the error carries the exit code and the remote's own stderr (e.g. tailscale: command not found). .ok() discards both into None, which render_status (mod.rs:770) prints as "unknown — the machine could not be asked" — telling the user the machine was never asked when in fact it answered with a concrete, actionable reason.

This is the same defect class as prior blocking finding 2, one layer up and still present. The inconsistency is visible within the same function: forty lines earlier the very same code path carefully separates ServerHealth::Error (reached, failed) from ServerHealth::Unreachable (never reached), and the module docs at mod.rs:17-22 make that separation the stated design. Nothing in the test suite covers it — no test scripts a successful services list followed by a failing serve status.

Fix: carry the error rather than dropping it — make the field Result<PublishState, String> (or add a PublishState::Unreachable(String)), have render_status print the captured stderr, and add a ScriptedTransport test pinning that a reached-but-failed serve status is reported differently from an unreachable host.

3. tests/e2e-cucumber/tests/e2e/remote_steps.rs:297-305 — a Then step that asserts nothing when its guard does not match.

async fn then_doctor_points_at_serve(world: &mut E2eWorld) {
    let said = said(world);
    if said.contains("only reads") {
        assert!(said.contains("rocm remote serve"), "{said}");
    }
}

If the output does not contain "only reads", the step passes having verified nothing. Its partner step at remote_steps.rs:291-294 has the matching escape hatch (said.contains("only reads") || said.contains("could not reach")), and the preceding assert_ne!(cli_rc, Some(0)) is satisfied by any failure. Together they mean scenario remote-09 can go fully green while asserting only "the command exited non-zero somehow" — CI stays green as the coverage silently disappears.

This is the standing test-vacuity failure mode, and it is in the remediation itself: commit 6f3a81e (test(remote): give remote-09 a reachable container to check) made the container reachable precisely so the real branch is taken, but left the tolerance for unreachability in place. With reachability now guaranteed by given_reachable_machine_without_cli, the fallbacks are dead permissiveness.

Fix: drop both escape hatches — assert said.contains("rocm remote serve") unconditionally, and drop || said.contains("could not reach") — so an unexpected path fails loudly instead of passing quietly.

Non-blocking

  • apps/rocm/src/remote/provision.rs:173 — remove_dir_all(&staging) is only reached on success; every ?/bail! above it leaks the staging dir, so repeated failed --install-rocm runs accumulate under $TMPDIR (0700 + nonce-named, so not a security issue — but the asymmetry reads as an oversight, not the deliberate keep-it-for-debugging choice it might be).
  • apps/rocm/src/remote/provision.rs:96-113 — run_remote_installer, the path tried first, never forwards the signing override, so the "the key we send wins" guarantee only engages on the fallback; behaviour is fail-safe but nothing says so, and a reader of install_cli would reasonably assume it applies throughout. One comment line fixes it.
  • apps/rocm/src/remote/provision.rs:160-166 — only the pure fragment builder is tested; nothing pins that {signing_env} is actually prefixed onto the remote command, so dropping it in a refactor would pass the whole suite.
  • apps/rocm/src/remote/transport.rs:339-366 — the stdin/stdout deadlock fix is correct by inspection (writer thread + immediate wait_with_output), but no test exercises a reachable host that both consumes a large stdin and floods stdout; every current test passes if the fix is reverted.
  • tests/e2e-cucumber/tests/e2e/remote_steps.rs:362-368 — free_port() binds, reads the port, drops the listener, then hands it to docker run; under the 64-way max_concurrent_scenarios this lane uses, that TOCTOU is a plausible intermittent-flake source. Note the CI state here is 19 success / 1 failure / 1 skipped: I cannot see which lane is red and am not claiming this is the cause — that would be an inference I have no way to confirm from the checkout.

@volen-silo

Copy link
Copy Markdown
Collaborator Author

Addressed the second review round.

Blocking finding 1 — publish.rs AllowFunnel blind spot. Fixed. AllowFunnel (keyed host:port) is now parsed into RawServeConfig; when set for the target port, classify() returns a new PublishState::FunnelAllowed, and both publish() and withdraw() bail loudly naming tailscale funnel --tcp=<port> off. SERVE_CONFIG_KEYS now has a comment distinguishing parsed keys from document-shape-only keys. Corrected the module doc's "visible to the whole tailnet, scoped only by its ACLs" claim, which Funnel already contradicted.

Blocking finding 2 — bootstrap.rs install-before-tailscale-check. Fixed. The tailscale_present check is now hoisted above the rocm_present branch, so a machine without Tailscale is refused before any install runs, regardless of what else is missing. Extended a_machine_without_tailscale_is_refused_before_a_model_is_started with an install_rocm = true, neither-present fixture; confirmed it fails without the hoist.

Non-blocking items — none skipped, all five addressed:

  • provision.rs:164 — now reads _PATH locally and forwards its content as _PEM; an explicit _PEM still wins, matching install.sh's own resolution order. Split into a pure, dependency-injected helper with unit tests for all four cases (neither set, explicit-PEM-wins, PATH-read-and-forwarded, PATH-read-failure-reported).
  • publish.rs:34,144 — the Services key had the same blind spot as AllowFunnel; fixed alongside finding 1.
  • transport.rs:291 — fixed. The stdin write now happens on its own thread instead of being sequenced before wait_with_output(), removing the deadlock risk for a payload larger than the OS pipe buffer paired with remote output.
  • provision.rs:234 — the staging dir is now salted with a nanosecond nonce and created with create_dir (not _all), so a pre-staged/symlinked path can't be silently adopted; restricted to 0700.
  • transport.rs:269 — scp_argv now rejects a local_path/remote_path starting with -, mirroring validate_destination's existing guard for the ssh destination.

Verification (local, on top of the branch's current merge with main):

  • cargo fmt --all -- --check — clean
  • cargo clippy --workspace --all-targets -- -D warnings — zero warnings
  • cargo test --workspace --all-targets — every test result: line workspace-wide reports 0 failed

Commits: 0e7adb38, e3613fe2, 2cc06956, de914c0c.

One open item, not part of this review round's findings: origin/main advanced again after these commits were prepared (one new commit, a9937493), and merging it into this branch is currently blocked by pre-existing staged, uncommitted changes in this worktree unrelated to this review (including staged deletions of two source files). That's being sorted out separately and isn't a gap in this review response.

@siloteemu siloteemu left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🔴 Automated review · pr-review-watcher · de914c0

This automation posts comments only. It never files a GitHub approval, so no approving review will appear here whatever the outcome — the merge decision stays with a human reviewer.

Summary

Adds rocm remote (serve/attach/stop/status/doctor/targets) driving a tailnet GPU machine over SSH, plus a containerised SSH test lane — Needs work. Verified: ran cargo test -p rocm --bin rocm remote:: (126 passed, 0 failed); confirmed both prior blocking findings are genuinely fixed — the Funnel classifier now checks AllowFunnel before the forward lookup and both publish and withdraw bail naming tailscale funnel --tcp=<port> off, and the tailscale prerequisite is hoisted above the install branch with a revert-sensitive test; also confirmed the credential is delivered over stdin (never argv) and the diff carries no internal leaks or injected instructions. Two new defects in the remediation commits, both verified against source. Blocking: 2 · Non-blocking: 5.

🚫 Blocking (must fix before merge)

1. apps/rocm/src/remote/provision.rs:195-218 — signing-key precedence is the inverse of install.sh, and the doc comment claims otherwise.
signing_env_fragment_from matches pem_env first and only falls back to path_env. install.sh:99-110 (resolve_public_keys) does the opposite: it returns ROCM_CLI_SIGNING_PUBLIC_KEY_PATH if set and only falls through to _PEM when it is not. The doc comment at provision.rs:184-186 asserts "an explicit _PEM is forwarded as-is and takes precedence, matching install.sh's own resolution order" — that is factually false, on the selection of a signature trust root. With both variables set locally, a remote provision verifies against a different key than a local install.sh run would; the failure surfaces as "the remote rejected the build we fetched for it", which points at the artifact rather than at the key.

There is a second, sharper edge in the same function: std::env::var(..).ok() yields Some("") for a variable set to the empty string, whereas install.sh's [ -n ... ] treats empty as unset. So ROCM_CLI_SIGNING_PUBLIC_KEY_PEM="" together with a real _PATH makes this code forward an empty _PEM and silently drop the operator's explicit key, and the remote then falls back to the pinned production keys — the operator's chosen trust root is discarded with no diagnostic.

Fix: check path_env before pem_env (or, if the inversion is deliberate, correct the comment and say why), and treat an empty value as unset on both branches. The new test an_explicit_pem_is_forwarded_as_is_and_wins_over_a_path (provision.rs:407-421) currently encodes the wrong order, so it must change with the code — it is why this slipped through. Add a case asserting the order that install.sh actually implements.

2. apps/rocm/src/remote/transport.rs:359-370 — the deadlock fix consults the stdin-writer error before the outcome it now has in hand, discarding ground truth.
wait_with_output() returns first and output already holds ssh's exit code, stdout and stderr. The code then does writer.join()...?? before the SSH_TRANSPORT_FAILURE (255) check at :379, so a write error on the payload aborts the call and throws the captured outcome away. The relevant write error is BrokenPipe: if the child exits and closes stdin before the writer thread is scheduled, write_all gets EPIPE. Moving the write onto a thread widened that window rather than narrowing it — previously the write happened inline immediately after spawn, whereas now the main thread blocks in wait_with_output while the writer waits to be scheduled.

The consequence lands on the one caller that uses this path, serve_with_transport (mod.rs:286): instead of the purpose-built "could not reach {dest} over ssh: {stderr}", an unreachable host can produce "failed to send input to {dest}: Broken pipe", which mod.rs:288-300 then wraps as "lost contact ... so it may or may not be running" and clears the freshly minted key — telling the user the model's state is unknown when the transport in fact reported 255 and nothing started. The call-site comment "this fails on a broken pipe while sending the key ... so the model's state is genuinely unknown from here" was true before the fix and is now stale.

Fix: evaluate the 255 check and build RemoteOutcome from output first; only surface a writer error when the process outcome does not already explain the failure (treat ErrorKind::BrokenPipe as advisory once output is in hand). While there, join the writer on the wait_with_output error path too — today it is dropped and detached.

Non-blocking

  • apps/rocm/src/remote/transport.rs:201-216 and :655-672 — the remote-path guard's stated rationale is wrong: the remote argument is built as format!("{destination}:{remote_path}"), so it can never start with - and scp cannot read it as an option; keep the check but fix the comment and the test comment, or a future reader re-derives the same wrong mechanism.
  • apps/rocm/src/remote/provision.rs:284-294 — create_dir followed by set_permissions(0o700) leaves a umask window, contradicting the adjacent comment's "0700 keeps the contents unreadable"; this repo already has the atomic pattern in apps/rocm/src/dash.rs:255-266 (DirBuilder::new().mode(0o700)), which even documents why.
  • .github/workflows/ci.yml:652-691 — the new remote-ssh job runs cargo build -p rocm with no actions-rust-lang/setup-rust-toolchain step and no rust cache, unlike every other cargo job in this workflow; a cold uncached build of this workspace against the 30-minute timeout is a plausible cause of the single failing check, but I am inferring that from the workflow source and cannot confirm it — no lane names were available to me, and the un-merged base may equally explain it.
  • tests/remote-ssh/fake-tailscale.sh — the fake only ever emits {"TCP": ...}, never AllowFunnel, Foreground or Services, so the exposure classifier's safety branches (the subject of the prior blocking finding) are proven only against ScriptedTransport fixtures, not against anything shaped like the real daemon.
  • apps/rocm/src/remote/mod.rs:747 — the FunnelAllowed status line leads with "no", but that state is also reached when our own forward is live (Funnel is checked first and short-circuits); phrase it as an exposure warning rather than a "not published" answer.

@volen-silo
volen-silo force-pushed the feat/remote-tailnet-foundation branch from de914c0 to 486054f Compare September 11, 2026 15:26
@volen-silo

Copy link
Copy Markdown
Collaborator Author

Addressed the review on de914c0c, plus a rebase onto main. Force-pushed, so the review's line references point at commits that no longer exist — summary of what moved:

Both blocking findings fixed, each reproduced first.

  • Signing-key precedence. _PATH now resolves before _PEM, matching resolve_public_keys, and an empty value counts as unset on both sides. The test that encoded the old order is replaced rather than kept. Verified by reverting each half separately: the old order yields pem-content where install.sh would use path-content, and without the empty-value filter an empty _PEM forwards PEM='' and silently discards the operator's real key.
  • Transport ordering. The outcome is built and the 255 check runs before the writer result, which is now demoted to a symptom only when the command also failed. The regression test needed a payload larger than a pipe buffer — a short one lands in the buffer and returns success with no reader, so the bug is timing-dependent at that size and the test passed against the broken code. At 1 MiB it fails 3/3 before and passes 3/3 after.

All five non-blocking items fixed, including the scp guard rationale (the remote argument is prefixed with the destination, so that half is a shape check rather than a safety one) and the Funnel status line.

One finding I did not take. The CI lane suggestion assumed the missing toolchain explained the red check. It did not — remote control channel (containerised) was already passing; the failure was clippy, from a semantic conflict with main over resolve's signature. Fixed by rebasing and updating the three call sites. The toolchain step is still added, as a consistency fix.

Beyond the review, worth flagging:

  • Forwarding a signing key now also blanks the remote's own ROCM_CLI_SIGNING_PUBLIC_KEY_PATH. Without it a key could be forwarded correctly and still lose, since resolve_public_keys reads _PATH first and /etc/environment reaches non-interactive sshd sessions.
  • The container fake accepted funnel --tcp=8000, but Funnel serves only 443/8443/10000 — it was encoding a state tailscaled cannot emit. Fake and fixtures now use real Funnel ports. That surfaced the open question now in the PR description: the guard is unreachable at the default tailnet port.

Local: full workspace tests, clippy -D warnings, fmt, prek, and both container lanes (21 and 25 checks) all pass on the rebased tree.

@volen-silo

Copy link
Copy Markdown
Collaborator Author

CI status: 20 of 21 checks green, including clippy (the one that was red before the rebase), remote control channel (containerised), build-and-test, windows-build-and-test and Commit signatures + sign-off.

The one red check, E2E tests, is inherited from main and not from this branch:

  • It fails on exactly two scenarios, dash-gen-tps-held-after-scrape-failure and dash-gen-tps-expiry-boundary.
  • The same two fail on main's own HEAD (a6fa76db) with an identical reconciliation line. The commit before it (a9937493) was green, so the regression arrived with feat(therock): support ROCm 10 "next" install layout, additive (EAI-8431) #329.
  • Re-ran the job to rule out a flake — same two scenarios, deterministically.
  • This branch touches no dash code. git diff --name-only origin/main...HEAD returns nothing matching dash. The only file this branch shares with that area is expectation.rs, and the three call sites changed here are inside #[cfg(test)], so they cannot affect scenario resolution at runtime.

Flagging rather than fixing: a dash regression is unrelated to this PR and belongs in its own change. Happy to pick it up separately if that is useful.

@siloteemu
siloteemu dismissed their stale review September 11, 2026 16:06

pr-review-watcher · de914c0 — superseded, withdrawn.

Both blocking findings from that round are genuinely fixed at the current head, and each was re-verified here rather than taken from the summary: the signing-key precedence now matches the install script in both order and empty-value handling, pinned by a test that fails when the order is reverted; and a cleanly failed transport is now reported as a failure rather than as indeterminate, pinned by a regression test whose payload size makes the condition deterministic instead of timing-dependent.

On the earlier CI-lane point: the author is right and that finding was wrong. It asserted a cause for a red check that this automation had no way to confirm, and should have been stated as an inference or not at all.

A fresh review of the current head is posted separately.

@siloteemu siloteemu left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🔴 Automated review · pr-review-watcher · 486054f

This automation posts comments only. It never files a GitHub approval, so no approving review will appear here whatever the outcome — the merge decision stays with a human reviewer.

Both findings from the previous round are genuinely fixed, and the earlier change request has been withdrawn. Blocking: 3 · Non-blocking: 5. Three new issues at this head, two of them the same two defect classes one layer away from where they were fixed. Full detail is in the review comment on this PR.

1. The API-key guard is bypassed at one of its four call sites. ensure_public_service_has_endpoint_key is passed a hardcoded false for requires_api_key in apps/rocm/src/main.rs, while the real value is already in scope and the other three call sites pass it. Reachable, because the guard's two inputs are computed by different tests — one by file existence, one by file validity — so an existing-but-invalid key file is exactly the case the new branch was added to catch, and it spawns an unauthenticated listener instead. The comment above the line says the invariant is enforced here; only half of it is. Fix: pass the real value, and add a test for a present-but-empty key file on a service that requires one.

2. A definite remote failure is reported as "the machine could not be asked." In apps/rocm/src/remote/mod.rs, publish_state(...).ok() discards two materially different errors into one: transport failure, and the remote answering with a non-zero exit and its own stderr. The user is told the machine was never reached when it in fact replied with an actionable reason. This is the same class as the finding just fixed in the transport layer, one layer up — and the same function separates reached-but-failed from never-reached forty lines earlier, which is also what the module docs describe. Fix: carry the error instead of dropping it, and pin the distinction with a test.

3. A test step asserts nothing when its guard does not match. In tests/e2e-cucumber/tests/e2e/remote_steps.rs, the step passes unconditionally unless the output contains a particular phrase, and its partner step carries a matching escape hatch. Together the scenario can go green having checked only that the command exited non-zero. The commit in this round that made the container reachable removed the reason those fallbacks existed but left them in place, so they are now dead permissiveness that will hide the coverage disappearing. Fix: drop both escape hatches so an unexpected path fails loudly.

@volen-silo
volen-silo force-pushed the feat/remote-tailnet-foundation branch from 486054f to b014f52 Compare September 14, 2026 09:02
@volen-silo

Copy link
Copy Markdown
Collaborator Author

History rewritten: 19 commits squashed to 3, and rebased onto current main. The tree is byte-identical to the pre-squash one (same tree hash) — only the history changed. Line anchors in the older review comments no longer resolve; the reasoning is preserved in this thread.

The three commits are feature / tests / docs. Further review rounds will amend these rather than stack more fix commits on top.

The three blocking findings — all verified, and all older than the last round. git log -L puts each on a 2026-09-01 commit, i.e. the PR's original work, not the remediation. They were in the tree at the previously reviewed head and were not reported then, so this is the review surfacing a deeper slice rather than a regression.

  1. The managed-spawn guard was passed a literal false. Fixed to pass record.requires_api_key. The test is the part that mattered: a unit test on the guard function cannot catch this — the function was always correct, the wiring was not. The new test plants an empty key file, which is the only state where the guard's two inputs disagree (existence says a key is required, validity says none is present), and drives spawn_managed_engine_child on a loopback host so the public-bind branch cannot be what refuses it. Confirmed it fails with the literal restored.
  2. publish_state(..).ok() collapsed reached-but-failed into never-reached. The same function makes exactly that distinction 28 lines earlier for services list, with a comment saying why. Added PublishObservation mirroring the ServerHealth split; publish_state keeps its signature so the four callers in publish.rs are untouched. status now prints the remote's own words. Confirmed the test fails when the collapse is restored.
  3. Two dead escape hatches in remote-09. Both steps belong to that scenario alone, whose Given starts a real container, so the unreachable branch was unreachable. Dropped; the scenario still passes, which is what shows the hatches were carrying nothing.

All five non-blocking items are also fixed, including a Drop guard so the staging directory is removed on the error paths too, and a real test for the stdin/stdout deadlock fix. That last one is worth a note: my first attempt ran the child's output and input concurrently, and it passed with the fix reverted — i.e. it tested nothing. Sequencing the flood before the read reproduces the hang, and it now times out at 10s reverted and passes in 0.03s fixed.

Beyond the named findings, I swept the diff for each defect class rather than just the sites reported. That turned up one more instance of the same collapse: the session-listing path built a SessionObservation with ServerHealth::Error after SshTransport::new failed — but Error means "answered unreadably" and nothing had been sent, so it is Unreachable, and the constructor's reason was being discarded. Both fixed. The comment sweep and the literal-argument sweep came back clean.

Verified locally on the rebased tree: full workspace tests, clippy -D warnings, fmt, prek, both container lanes (21 and 25 checks), and all 14 remote cucumber scenarios with E2E_INCLUDE_DOCKER=1.

@siloteemu
siloteemu dismissed their stale review September 14, 2026 09:44

Dismissing this as superseded. Re-reviewed at b014f52: all three counts are resolved and I verified them rather than taking the summary on trust - the managed-spawn guard now receives the record's own flag (confirmed load-bearing by reverting the call site in a scratch copy, which makes the new test fail), the reached-but-failed versus never-reached split is restored and pinned by a test asserting the two render differently, and all 23 then-steps were walked for any input under which no assertion runs. A separate, newly-found instance of the same fail-open class is filed as a fresh change request; this older one is retired so only one objection is live.

@siloteemu siloteemu left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🔴 Automated review · pr-review-watcher · b014f52

This automation posts comments only. It never files a GitHub approval, so no approving review will appear here whatever the outcome — the merge decision stays with a human reviewer.

Summary

Adds rocm remote: discover GPU machines on a tailnet, provision them over SSH, serve a model there and publish its port, plus a containerised SSH test lane and docs. Needs work — all three counts of the standing objection are genuinely resolved, but the author's own sweep for the "collapse a failure into a weaker state" class missed one instance, and it sits on the same security gate as count 1. Verified: read all three commit messages whole from the raw objects (57/52/28 lines, signatures intact, company identity and DCO sign-off present); ran a scratch-copy revert experiment reverting the apps/rocm/src/main.rs guard call site to the hardcoded false and ran cargo test -p rocm --bin rocm a_managed_spawn_refuses_an_invalid_key_file — it FAILED, so that test is genuinely load-bearing rather than passing either way; confirmed publish_state(..).ok() is gone and its replacement is pinned by a test that asserts the two outcomes render differently; walked all 23 #[then] steps in remote_steps.rs and found no remaining vacuous-pass path; leak scan over the diff clean (no internal hostnames, gateways, cluster names or registry paths; ROCM_TEST_APK_REPOS defaults empty and the documented example uses the public Alpine CDN); no prompt-injection content found. Blocking: 1 · Non-blocking: 5.

🚫 Blocking (must fix before merge)

apps/rocmd/src/lib.rs:3239 — the API-key requirement is silently dropped, and written back to disk, when a record read fails. supervise_service rebuilds the record from scratch (ManagedServiceRecord::new starts requires_api_key false) and restores the flag from disk with load_managed_services(paths).unwrap_or_default(). That call returns Err on a real, informative failure — read_dir, the per-entry ?, or fs::read on any single record file (note it skips unparseable JSON, so Err means an I/O failure, not a corrupt record). .unwrap_or_default() turns that into "no service ever required a key". The next line falls back to key-file presence, which is absent precisely when a service has been stopped — the case the code's own comment three lines above calls out. Both signals then read false, ensure_public_service_has_endpoint_key at :3250 passes, and the service comes back up on a loopback bind with no authentication even though the user launched it with --require-api-key.

Why this blocks rather than being a nit: it is reachable without exotic conditions (a record file deleted by a concurrent rocm services stop between read_dir and fs::read is enough — ENOENT), it fails open on a security gate, and record.write() at :3256 persists the weakened record, so the damage is permanent: every later rocm services restart is disarmed too. That is verbatim the outcome the adjacent comment says must not happen — "rebuilding a record here without restoring it would not just skip the check now — it would write the weakened record back and disarm every later rocm services restart as well." In user-facing terms this is the same sentence as count 1 of the standing objection: a service the user asked to protect starts unprotected. The PR introduces these lines, so it is not pre-existing.

Fix: propagate instead of defaulting — load_managed_services(paths)?. The function already returns Result<()> and uses ? freely a few lines up (paths.ensure()?, fs::create_dir_all(...)?), and failing closed is this file's own stated preference ("an unreachable service is recoverable, an anonymous public one is not"). Add a test that drives the real call site — supervise_service checks the guard before record.write() and before command.spawn(), so the same technique a_managed_spawn_refuses_an_invalid_key_file_on_a_service_that_requires_one uses in the rocm copy works here — asserting both that it refuses and that the on-disk record is not rewritten with requires_api_key: false.

Non-blocking

  • .github/workflows/ci.yml:669 — the new lane is the only job-level if: needs.changes.outputs.heavy == 'true' in the file; every other heavy gate is step-level, and the file documents at :1066 why job-level gating stalls the merge queue for a required check. Harmless while this lane is not a required check; worth confirming that is the intent before it becomes one.
  • apps/rocm/src/remote/mod.rs:903 — stop() collapses a transport error and a non-zero remote exit into one bool, and the bail names no reason, unlike the withdraw branch immediately above which interpolates {error}. Nothing is misreported (unlike count 2), but the remote's own words are available and dropped.
  • apps/rocm/src/remote/mod.rs:839,867 — no test ever calls attach() or stop(); only render_stopped is tested, with hand-picked booleans. Deleting the early bail!s so the record is removed on an unconfirmed teardown would pass every test in the file.
  • apps/rocmd/src/lib.rs:5228,7571 — the guard tests here call the function directly with literals; neither real call site is driven, so a future miswiring in this copy would go undetected. The rocm copy does it properly and is the model to follow.
  • apps/rocm/src/remote/transport.rs:195 — ~14 literal spaces mid-sentence in a user-facing error message ("would be    read as an option"), an editing artefact.

On the standing objection

Count 1 — "a service the user asks to protect with an API key can start UNPROTECTED when the key file is empty or unreadable"; originally "ensure_public_service_has_endpoint_key is passed a hardcoded false for requires_api_key in apps/rocm/src/main.rs". RESOLVED. Both call sites (apps/rocm/src/main.rs:5978 and :15818) now pass record.requires_api_key, and key_present is validity-filtered through endpoint_api_key_from_file rather than mere file existence. The requested test exists and I verified it is load-bearing rather than taking the claim on trust: on a scratch copy with the call site reverted to the hardcoded false, a_managed_spawn_refuses_an_invalid_key_file_on_a_service_that_requires_one fails.

Count 2 — "a real remote failure is reported to the user as merely unreachable"; originally "publish_state(...).ok() discards two materially different errors into one". RESOLVED. The .ok() is gone; observe now calls publish::observe(...), which preserves the reached-but-failed versus never-reached split the same function already made forty lines earlier. Pinned by a_remote_that_answers_about_publishing_is_not_reported_as_one_that_was_never_asked, which asserts the remote's own stderr reaches the status line and that the two cases do not render identically — it would fail against the old .ok().

Count 3 — "one end-to-end test passes without checking anything"; originally "the step passes unconditionally unless the output contains a particular phrase, and its partner step carries a matching escape hatch". RESOLVED. Both named steps now assert unconditionally, with inline comments recording why the guard was removed. I walked all 23 #[then] steps individually looking for any input under which no assertion runs — conditional asserts with no else, early returns, silently-falling-through matches, defanging unwrap_or — and found none.

The block is withdrawn on all three counts. It is replaced by the single new blocking finding above.

On the author's sweep claim. The claim that the diff was swept for this defect class beyond the reported sites, finding one further instance, does not hold: apps/rocmd/src/lib.rs:3239 is a fourth instance, in the same subsystem as count 1. A second, milder instance sits at apps/rocm/src/main.rs:18075, where the uninstall plan's remote-session warning is defaulted away on an I/O error — that one mirrors the pre-existing style on the line above it and is informational only, so it is not called out separately.

Why this will recur (and the cheap prevention). The cause is the codebase inviting the wrong conclusion, not reviewer error: load_managed_services(paths).unwrap_or_default() appears twice in this diff with identical shape, once feeding a security gate and once feeding a printed warning, and nothing at the call site distinguishes them. A competent reader sweeping for this class will keep classifying the security-gate use as benign best-effort, exactly as the author's sweep did. The prevention is one line: at :3239, use ? and add a comment saying this read may not be best-effort because its result arms the guard below — sitting next to the comment already there that explains why the flag must be restored at all.

On CI. The check-run conclusions at this head are counts only (failure 2, pending 4, skipped 1, success 21) with no lane names available, so no outcome is attributed to any named job here; the workflow observation above is read from the YAML, and I cannot confirm from this checkout whether any particular lane is red or why.

Check-run conclusions at this head were failure 2, pending 4, skipped 1, success 21 when the review started, and failure 2, pending 3, skipped 1, success 22 when this was filed. The earlier change request on this PR has been dismissed as superseded, so this is the only objection of ours that is live.

Adds `rocm remote`: discover GPU machines on a tailnet, check their
health, install what they are missing, serve a model on one, and reach it
from any machine on the tailnet.

SSH is the control channel, not the data path. Everything that inspects
or changes the remote goes over SSH; the inference traffic does not.
`rocm serve` binds loopback on the GPU machine as it always has, and the
machine then tells its own Tailscale daemon to forward a tailnet port to
it. Nothing runs locally, so the endpoint outlives the command that
created it and answers from any machine rather than only the one that
started it.

Two touch points with existing behaviour:

- `rocm serve --require-api-key` makes a loopback bind authenticated
  anyway. Publishing the port makes "loopback means only this machine"
  false while leaving the bind address unchanged, which would otherwise
  put an unauthenticated model endpoint on the tailnet. The key travels
  to the remote on stdin, never in a command line, since both machines
  expose command arguments in their process tables.
- `install.sh` grows download-only and install-from-archive modes.
  Provisioning never copies the local binary — that only works when both
  machines share an OS and CPU, and when they do not the copy still lands
  and still looks installed. The remote fetches its own build; if it
  cannot reach the release host, this machine fetches one for the
  remote's platform and pushes it with its checksum and signature so the
  remote repeats every check. Signing-key selection matches install.sh's
  own order exactly, and a forwarded key blanks the remote's own path so
  the two machines cannot end up on different trust roots.

`rocm services list --json` is the machine-readable listing the
orchestration reads back, applying the same liveness filter as the table.

Signed-off-by: Eugene Volen <Eugene.Volen@amd.com>
The unit tests drive a scripted stand-in, which proves the control flow
but assumes the real tools behave a certain way. These check that
assumption against a real OpenSSH server in a container — no GPU, no
ROCm, no tailnet, since the remote's `rocm` and `tailscale` are
stand-ins.

- `tests/remote-ssh/run.sh` checks the tool contracts: argument handling,
  exit-code propagation, a credential delivered on stdin and absent from
  the command line, file copy, batch-mode refusal, the shape Tailscale
  Funnel takes in the serve config, and that withdrawing a published
  endpoint actually removes it.
- `tests/remote-ssh/run-e2e.sh` drives the built binary through the whole
  flow: discover, probe, serve, publish, reconcile status, re-publish
  after an out-of-band withdrawal, tear down, and refuse to publish over
  a Funnel-exposed port.
- 14 cucumber scenarios in `features/remote.feature`; the six needing a
  host on the other end of a real SSH connection carry `@requires-docker`
  and skip with a reason where no container runtime exists.
- Both scripts run on a new `remote control channel (containerised)` CI
  lane, gated on the `heavy` path filter.

The Funnel fixtures use 443, not the default tailnet port: Funnel serves
only 443, 8443 and 10000, so an AllowFunnel entry on any other port is a
document the daemon cannot produce and a test against it proves nothing.

`resolve` takes an `Included` struct rather than a row of same-typed
bools, so a mis-ordered argument cannot silently change which set runs.

Signed-off-by: Eugene Volen <Eugene.Volen@amd.com>
Covers the `rocm remote` surface, and in docs/testing.md how to run the
two container-backed lanes — including the ROCM_TEST_APK_REPOS escape
hatch a network that intercepts TLS needs to build the fixture image.

Signed-off-by: Eugene Volen <Eugene.Volen@amd.com>
@volen-silo
volen-silo force-pushed the feat/remote-tailnet-foundation branch from b2c5dd9 to f24b76d Compare September 24, 2026 06:59
@volen-silo
volen-silo force-pushed the feat/remote-tailnet-foundation branch from f24b76d to b0657e7 Compare September 24, 2026 07:18
@volen-silo

Copy link
Copy Markdown
Collaborator Author

@juhovainio flagging this since the ; to && fix changed shape after your approval.

Tightening the chain broke remote serve outright. IFS= read -r returns non-zero at EOF when the input has no trailing newline, even though it does assign the variable. The key was sent bare, so the chain short-circuited and rocm serve never ran. The containerised lane caught it at the serve stage.

Fixed by sending the key as a complete line. That is also what gives the short circuit its meaning: a truncated write delivers no newline, so the read fails and nothing starts holding half a key. Without the terminator the chain refuses every start.

My first test used closed stdin only, so it passed with the bug present. It now covers three cases (nothing, unterminated, complete line), plus an assertion at the real call site that the stdin actually sent ends with a newline.

The other minors are in as well: staging dir 0700, remote-ssh gated on clippy and prek, and the teardown check-then-act window recorded in withdraw as a known limitation. Rebased onto main, all checks green.

Worth a re-read of the fix commit if you want it, given it is the credential path.

@siloteemu

siloteemu commented Sep 24, 2026 •

Copy link
Copy Markdown

🔴 Automated review · pr-review-watcher · cc276ef

This automation never files a GitHub approval, so no approving review will appear here whatever the outcome — the merge decision stays with a human reviewer.

Summary

Adds the rocm remote command family — provision a tailnet GPU machine over SSH, start a loopback-bound model on it, publish that port to the tailnet, then status/attach/stop — together with a new rocm serve --require-api-key flag, services list --json, split download/install modes in the installer, a Gherkin feature, and a containerised SSH harness. Outcome: needs work. Both findings raised last round are genuinely fixed, and I confirmed each by mutation on a scratch copy rather than by reading: deleting the new reuse guard makes spawn_managed_engine_child_refuses_to_reuse_an_unauthenticated_service fail, and the reworked staging-directory test is now falsifiable from both directions — dropping umask 077 fails on the parent at 0755, dropping chmod 700 fails on the stale leaf at 0755, which is exactly the half that could not fail before. Beyond that I mutation-tested the publish-state classifier (both the foreign-forward and funnel-allowed arms go red when removed), the && chain and the newline-terminated key (each has its own red test), the peer-ordering parse, the docker skip gate, and the session credential-claim path; I also read the serve, teardown and unwind flows end to end against their callers. Three problems remain, two of them tests that cannot fail for the thing they name, and one correctness gap on the path this round just remediated. Not verified here: the full suite was not run, and nothing was exercised against a real tailnet or real GPU hardware — the container stand-ins are the same ones the change ships. Checks at review time stand at 22 success, 3 failure, 3 pending, 1 skipped; two of the failures are also red on the base branch's own tip and so are not attributable to this change, while the third is a static-analysis lane that is green on the base tip and may be this change's own — I could not confirm which job that is, so treat the attribution as inference, not fact. Blocking: 3 · Non-blocking: 8.

🚫 Blocking (must fix before merge)

1. apps/rocm/src/remote/tailnet.rs:404-469 — the peer-ordering test passes with the sort deleted. parsing_orders_peers_and_strips_the_magicdns_trailing_dot asserts the peer list comes back as ["gpu-box-1", "gpu-box-2", "phone"], and its comment states the property under test: an unsorted list would shuffle between runs. But RawStatus::peers is a BTreeMap, so into_values() already yields entries in key order, and the fixture's keys are nodekey:aaa → gpu-box-1, nodekey:bbb → gpu-box-2, nodekey:ccc → phone. The map's natural order is therefore identical to the sorted order the test asserts, so peers.sort_by at lines 225-230 contributes nothing to the result. Removing that call entirely leaves the whole remote::tailnet test module green — confirmed on a scratch copy, 15 passed. The production sort is correct and wanted; the test simply provides no coverage for it, which is the failure mode this review treats as a hard blocker. Fix: permute the fixture's map keys so insertion order and host order disagree — e.g. key the phone peer nodekey:aaa, gpu-box-1 nodekey:bbb, gpu-box-2 nodekey:ccc — and keep the assertion on ["gpu-box-1", "gpu-box-2", "phone"]. The sort then becomes load-bearing to the assertion. While in there, the secondary dns_name tie-break is likewise unexercised, since no two fixture peers share a host name.

2. apps/rocm/src/remote/mod.rs:345-489 and :667-695 — remote serve prints and stores an API key the endpoint does not accept, when the remote reuses an already-authenticated service. The new guard added this round (apps/rocm/src/main.rs:6302-6331) refuses reuse only when the caller asked for --require-api-key and the running service has none. The symmetric case is untouched: if the running service on that port does require a key, reuse is satisfied, drop_orphaned_endpoint_key_on_already_running discards the key the remote was just handed on stdin, and the engine keeps enforcing the one it was launched with — the same "a running server cannot be given a key it did not start with" reasoning that motivated the refusal. The controlling side never learns this. It minted api_key, wrote it to the session key file, and reaches render_started, which prints api key: {api_key} unconditionally and follows it with "The API key above is what stops anyone else calling it." The remote's stdout is not surfaced — only start.success is consulted at line 406 — so the user is told the command succeeded and handed a credential that will be rejected, with the only stored copy on disk being the wrong one. The local serve path already avoids exactly this, at apps/rocm/src/main.rs:5871-5890, where launched_key is forced to None on already_running precisely so the CLI does not print a key it did not install; the remote path has no equivalent. This is reachable along a route the code itself advertises: rocm remote stop … --force clears the session key and removes the record even when the model could not be stopped (mod.rs:1076-1077), and both non-force failure messages recommend it by name — after which the model is still running and authenticated on that port, and the next rocm remote serve for the same model and port lands in this case. Fix: make the remote path fail closed the way the local path stays honest. The cheapest version with the data already in hand is for serve_with_transport to record a timestamp before exec_with_stdin and require discover_started_service to return a record created after it, bailing with the same shape of message as the reuse refusal (name the service and tell the user to stop it) when the port is held by something this command did not start. Alternatively, have the remote report reuse explicitly so the controlling side can refuse rather than infer. Either way the invariant to establish is that render_started only ever prints a key that the running endpoint actually enforces.

3. tests/remote-ssh/run.sh:185-207 — the batch-mode counterfactual reports pass on every outcome, including the one its comment calls unusable. The comment above it states the purpose plainly: this is "what makes the check above mean anything" — without batch mode, the same account and server should sit waiting for input, which is the hang batch mode exists to prevent. The check runs the command under a pty and expects a timeout (rc=124). When it gets one it passes; when it does not, the else arm also passes, with a message that says the host "does not refuse cleanly". So no result can fail it, and the harness reports a green check for a run that demonstrated nothing. The adjacent branch three lines below already handles the same situation correctly — when script is unavailable the harness prints skip, because the environment cannot show the behaviour. Fix: use skip in the else arm too, so an environment that cannot reproduce the hang is reported as undemonstrated rather than verified. That keeps the check non-flaky (the stated reason for tolerating the other outcome) while making the pass mean what it says.

Non-blocking

  • apps/rocm/src/remote/mod.rs:636-659 — discover_started_service lists with --all and picks the newest record on the port, so a stopped record created after the live one is a candidate, and ties on created_at_unix_ms resolve by iteration order rather than by anything meaningful. Filtering to live records, and stating the tie-break assumption, would make the selection say what it means.
  • apps/rocm/src/remote/session.rs:276-278 — clear_key's path-traversal guard is not constrained by any test. Removing the validate_id check leaves all 12 tests in the module green, including an_unusable_id_cannot_place_a_credential_outside_the_sessions_directory, because that test calls clear_key(&paths, "../../escaped") and asserts nothing afterwards: an unguarded removal of a file that does not exist is indistinguishable from a guarded no-op. Planting a sentinel file at the traversal target and asserting it survives would make the guard testable. The store side of that test was not mutation-tested here, so this observation is scoped to the clear_key half.
  • apps/rocm/src/remote/session.rs:61-77 — id_for maps every character outside [A-Za-z0-9-] to -, so gpu.box, gpu_box and gpu-box collapse to one session id on the same port. store_key's exclusive create bounds the damage to a confusing refusal rather than a credential overwrite, but the refusal will name a machine the user is not using. Folding a short hash of the untransformed host into the id would remove the collision.
  • .github/workflows/ci.yml:863-886 — the remote-ssh job carries needs.changes.outputs.heavy == 'true' in its job-level if:, while every sibling heavy job (build-and-test, test, windows-build-and-test) keeps only the dispatch condition at job level and pushes the heavy filter down to its steps, specifically so the job always reports. The job's own comment says it is "gated like every other heavy job here", which is what sent me looking for a merge-queue hazard that is not there: the changes job forces every category true on push and in the queue, so this job always runs where a required check matters. The comment is the problem, not the gating. Naming the difference and why it is safe — one clause — would stop the next reader retracing this.
  • apps/rocm/src/remote/transport.rs:123-145 — the control-socket length guard computes candidate.len() + 40 < 100, where candidate already contains the two literal bytes of the %C placeholder that the 40-byte expansion replaces. The guard is conservative by two bytes, never optimistic, so it is safe as written; subtracting the placeholder first would make the arithmetic match the comment.
  • tests/e2e-cucumber/features/remote.feature:90-97 with tests/e2e-cucumber/tests/e2e/remote_steps.rs:663-668 — the endpoint-restore scenario is the one whose premise is that the machine and the CLI have diverged, yet its final assertion reads only the CLI's own printed strings, where its siblings (then_machine_publishing, then_publishing_nothing) assert against real container state. It is not vacuous — "Endpoint re-published" is printed only after publish::publish returns Ok — but a tailscale serve status --json check in then_endpoint_restored, mirroring the sibling steps, would match the scenario's own premise.
  • apps/rocm/src/remote/mod.rs:1028-1043 — when neither the withdraw nor the model stop succeeds and --force was not given, the bail reports only the withdraw failure; the stop failure is already known in stop_failure at that point and is dropped. Nothing is lost (the session and key are kept either way), but the user retries without knowing both halves failed.
  • apps/rocm/src/main.rs:6413-6425 — when a service launched with both --allow-public-bind and --require-api-key later loses its key, the remediation text names only --require-api-key for the relaunch, which would not restore a non-loopback bind. Reachable only through that redundant flag pairing, which the remote path never produces.

@siloteemu siloteemu left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

pr-review-watcher · b0657e7

One blocking item, and it is a one-line fix -- nothing else here gates the merge, and the credential-path work itself holds up under mutation.

The comment that justifies demoting a broken pipe on the remote transport names its tripwire test by a name that does not exist anywhere in the tree. The test that actually pins the property is the one asserting that only a complete key lets the command reach serve. This is the single comment telling a future maintainer that the read-before-serve coupling is enforced by the shell rather than merely asserted in prose -- and it sits on the credential path, in a commit whose whole point is that loosening that chain broke remote serve outright. Someone who greps for the named test finds nothing, and the most available conclusion is that the guard is not real, which is exactly the licence to loosen the chain again.

The guard itself is genuinely there and genuinely tested: loosening the chain, dropping the newline terminator, and reverting the staging-directory command each turn the relevant test red. Only the pointer is wrong. Correcting the referenced name clears this.

Raised as blocking rather than as a note because a wrong cross-reference on a credential path that has already regressed once is the kind of thing nobody returns to after merge.

@volen-silo
volen-silo force-pushed the feat/remote-tailnet-foundation branch from b0657e7 to fcee6e7 Compare September 25, 2026 09:54
@volen-silo

Copy link
Copy Markdown
Collaborator Author

Fixed in fcee6e79. The finding was correct and the cause was a rename: the test started life as a_key_that_never_arrives_stops_the_command_before_it_serves, covering only closed stdin. That version passed with the newline bug present, because closed stdin and a key with no terminator look different to read. Rewriting it into the three-case version (nothing, unterminated, complete line) is what caught the real defect, and the rename went in without the cross-reference following. The comment now points at only_a_complete_key_lets_the_command_reach_serve.

Swept the rest of the remote module for other dangling references while I was in there. One apparent hit, ensure_rocm_command_is_read_only, is a false positive: it lives in apps/rocmd, outside the path I first searched.

Your reasoning for raising it above a note is fair for this particular comment. It is the only place recording that the read-before-serve coupling is enforced by the shell rather than asserted in prose, it sits on a path that regressed once already, and a dead pointer reads as evidence the guard was never real.

Noting the mutation testing specifically, because it is the part that made this review worth reading: loosening the chain, dropping the newline terminator, and reverting the staging-directory command each turning the relevant test red is a check on my work rather than a restatement of it. That is a different thing from reading the test bodies and concluding what they would do.

@siloteemu siloteemu left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🔴 Automated review · pr-review-watcher · fcee6e7

This automation never files a GitHub approval, so no approving review will appear here whatever the outcome — the merge decision stays with a human reviewer.

Summary

Adds rocm remote — SSH as control channel, with the GPU machine publishing its own loopback-bound model port to the tailnet — plus a new rocm serve --require-api-key flag and split download/install modes in the installer. The work is careful and unusually well-reasoned, but two guarantees stated in prose are not backed by the code or by a test that can fail for them.

Verified: the three mutation claims in the round description all hold — loosening the && chain, dropping the newline terminator, and reverting the staging-directory command each turn the named test red. The comment in transport.rs now names a test that exists and asserts what it promises, and a sweep of every backticked identifier in the remote module found no other dangling reference. The 32 new high-severity "cleartext logging" alerts were read per site: 29 fall inside #[cfg(test)] mod tests (which begins at apps/rocm/src/remote/mod.rs:1177) and are fixture strings — false positives; of the three production sites, :856 (render_status) and :1074 (render_stopped) never receive or emit a credential, only a key-file path — false positives; :489 is a real taint flow but by design, a one-time display of a freshly minted key to the operator who asked for it, with the key also written at mode 0600. No true positive reaches production. The full test suite was not run here, and nothing was exercised against a real tailnet or a real GPU. One claim from a deeper pass was checked and refuted: the container examination fixture omits three Option fields, but it deserializes and the unit test pinning it passes, so that is not a defect.

Checks at review time: 23 success, 2 failure, 3 pending, 1 skipped.

Blocking: 2 · Non-blocking: 5.

🚫 Blocking (must fix before merge)

apps/rocm/src/remote/provision.rs:164 — the test passes with umask 077 removed.

staging_dir_command is (umask 077 && mkdir -p {dir}) && chmod 700 {dir}, and the doc comment above it states the umask half is load-bearing: "umask 077 covers the creation itself — a mode applied afterwards leaves a window at whatever the remote's umask happens to be". Mutating the function to mkdir -p {dir} && chmod 700 {dir} — deleting only that half — leaves the_remote_staging_directory_is_restricted_to_its_owner (line 590) green, 1 passed. The test asserts the mode of the leaf directory only, and the trailing chmod sets the leaf either way. Deleting the chmod half is caught, via the stale-directory case, so it is specifically the branch the comment calls essential that nothing can fail for. This is a credential staging directory on someone else's machine, so the window is the point of the change.

Fix: assert the parent directory's mode as well. mkdir -p creates the parent and the chmod never touches it, so under the harness's umask 022 it is 0o700 with the umask and 0o755 without — confirmed by direct experiment. One extra assertion on the parent makes the umask branch fail-able.

apps/rocm/src/main.rs:6296 — --require-api-key is silently dropped when a managed service is reused.

spawn_managed_engine_child returns ManagedSpawn::AlreadyRunning at the existing_live_managed_service check, which matches on engine and canonical model only and whose sole mismatch guard is the engine recipe. That early return happens before record.requires_api_key = require_api_key (line 6355) and before ensure_public_service_has_endpoint_key(...) (line 6404). So when rocm remote serve reuses a model already served managed on the requested port, the flag is accepted and ignored: the endpoint stays unauthenticated, the caller's freshly minted key is dropped, and render_started (apps/rocm/src/remote/mod.rs:667) nevertheless prints the key alongside "The API key above is what stops anyone else calling it." That sentence is then false about a tailnet-reachable endpoint. Nothing in the changed tests varies require_api_key across the reuse branch — spawn_managed_engine_child_blocks_reuse_with_mismatched_recipe only varies the recipe.

Reachability is narrower than it first looks and worth stating honestly: the default remote port is 11434 while a local rocm serve defaults to 11435, and the publish step discovers by exact port, so an all-defaults collision fails closed with "the remote started no service on port 11434; nothing to publish" rather than publishing. A second rocm remote serve at the same target and port is refused by the session guard. The exposure needs a service already running for that model on the requested port — e.g. someone had run rocm serve MODEL --port 11434 on the box, 11434 being a familiar choice. Narrow, but the failure mode is an unauthenticated model endpoint on the tailnet under an explicit printed assurance that it is protected.

Fix: treat the reuse branch as a decision point rather than a shortcut. Either refuse reuse when require_api_key is set and the existing record has requires_api_key == false (symmetric with the existing recipe-mismatch refusal, and the message can tell the user to stop the existing service), or upgrade the record and re-run ensure_public_service_has_endpoint_key before returning AlreadyRunning. Add a test that reuses a service with requires_api_key == false while requesting the flag, and asserts the call does not return a satisfied AlreadyRunning — that is the assertion that can fail for this defect.

Non-blocking

  • apps/rocm/src/remote/provision.rs:158 — the doc comment sends the reader to the_staging_path_still_expands_on_the_remote_shell for the unquoted $HOME, but that test exercises the archive-install command, not this one; $HOME expansion is unproven for this function.
  • apps/rocm/src/remote/mod.rs:489 — the one by-design credential print leaves the next reader re-triaging 32 alerts; a targeted suppression with a one-line reason at this single site, and nowhere else, makes the triage legible once instead of every round.
  • tests/remote-ssh/run.sh:198 — both branches of the batch-mode counterfactual call pass, so it cannot fail yet counts toward the stated 21 checks; make the environment-dependent branch a skip so the total reflects what was actually asserted.
  • apps/rocm/src/remote/transport.rs:142 — the control-socket path length guard counts the two-byte %C placeholder as if it were literal, so it refuses paths that would in fact fit. Over-conservative only.
  • apps/rocm/src/remote/session.rs:61 — RemoteSessionRecord::id_for strips non-alphanumerics, so two distinct all-punctuation peer hosts collapse to the same session id for a given port.

The remote serve command joined the API-key `read`, the export, and
`rocm serve` with `;`, so the compound's exit status was whatever
`serve` returned. `run_with_piped_io` demotes a broken-pipe write error
to a symptom whenever the command also failed, and that is only sound
if a failed read cannot be followed by a successful serve. The ordering
held by prose, not by the shell: reorder the command and a truncated
key would classify as success on the one path guarding the model. `&&`
makes the shell hold it instead.

Tightening the chain also means the key has to arrive as a complete
line. `IFS= read -r` returns non-zero at EOF without a terminator even
though it did assign the variable, so the bare key the caller used to
send now short-circuits the chain and nothing starts. Send it with a
trailing newline, which is also what gives the short circuit meaning: a
write truncated part-way delivers no newline, so the read fails and no
model comes up holding half a key.

Both halves are pinned against a real shell — the command's exit status
for the chain, and the recorded stdin at the real call site for the
terminator. A string assertion can see neither.

Also create the remote staging directory 0700, matching the local
`create_restricted_dir`. Its test asserts the parent's mode as well as
the leaf's, because `chmod` names the leaf and would set it either way:
checking the leaf alone passed with `umask 077` deleted, leaving the
half the doc comment calls load-bearing unable to fail. A parent that
`mkdir -p` created is covered by the umask and by nothing else. The archive, checksum, and signature sit there
before any of them is verified, so a permissive umask on a shared box
let another local user read or race them. `umask 077` closes the window
at creation and the chmod repairs a directory an earlier run left open.

Gate the remote-ssh CI job on clippy and prek and skip it on manual
dispatch, as every other heavy job in the workflow already does, so a
fast lint failure short-circuits before the containerised build.

Record the teardown check-then-act window in `withdraw` as a known
limitation: tailscale offers no compare-and-swap on a port, so only a
daemon holding the claim could close it, and re-reading state before
the exec would narrow the window while implying it was shut.

Signed-off-by: Eugene Volen <Eugene.Volen@amd.com>
`spawn_managed_engine_child` treats a live managed service for the same
engine and model as satisfying the request. That early return happens
before `record.requires_api_key` is set and before the endpoint key is
checked, so `--require-api-key` was accepted and discarded: the caller
got a server that never had a key, and no error.

`rocm remote serve` is what makes that dangerous. It publishes the port
onto the tailnet and prints the key it just minted under "the API key
above is what stops anyone else calling it". Reusing an unauthenticated
service made that sentence false about an endpoint every machine on the
tailnet can reach.

Refuse instead of upgrading the record. The engine reads its key once,
at launch, so a server already running without one keeps serving
anonymously whatever is written afterwards; flipping the flag and
writing a key file would leave the record claiming auth that nothing
enforces, which is worse than the original bug. The refusal names the
service to stop, and is the same shape as the recipe-mismatch refusal
beside it.

Reachability was narrow: the remote path defaults to a different port
than local serving, and publish discovers by exact port, so an
all-defaults collision already failed closed. It needed a service
already serving that model on the requested port.

Signed-off-by: Eugene Volen <Eugene.Volen@amd.com>
@volen-silo
volen-silo force-pushed the feat/remote-tailnet-foundation branch from fcee6e7 to cc276ef Compare September 25, 2026 12:22
@volen-silo

Copy link
Copy Markdown
Collaborator Author

Both blocking findings fixed. The staging assertion in 15025a52, the reuse refusal in cc276ef8.

The umask half that could not fail. Correct, and the suggested fix was the right one. The leaf assertion passed with umask 077 deleted because chmod names the leaf and sets it either way, so exactly the branch my own doc comment called load-bearing was the one nothing could fail for.

The parent assertion needed one correction before it worked. Applied to both cases it fails for the wrong reason: the stale case pre-creates its parent inside the test, so mkdir -p never touches it and its mode reflects the test process's umask rather than anything the command did. The assertion is now scoped to the case where mkdir -p creates the parent itself. Both halves are independently falsifiable now, which was the point: deleting umask 077 fails on the parent at 0755, deleting chmod 700 fails on the stale leaf at 0755. Before, only the second was catchable.

--require-api-key dropped on reuse. Real, and the analysis of reachability matched what I found. Fixed by refusing, which is the first of the two options offered. I did not take the second, and the reason is worth recording rather than leaving as a silent choice.

ensure_public_service_has_endpoint_key only validates. It cannot give a key to a process that is already running, because the engine reads its key once, at launch, from the environment built further down that same function. So upgrading the record and re-running the guard would leave the record claiming authentication that the live server does not enforce, with the guard passing on the strength of a key file nothing reads. That is worse than the original defect, which at least leaves the record honest about the state of the endpoint. Refusing is the only option that fails closed, and it matches the shape of the recipe-mismatch refusal beside it. The message names the service to stop.

The test drives spawn_managed_engine_child rather than the guard's arguments, since the defect was the early return and only the real call site can fail for it. Disabling the guard makes it fail with "a satisfied reuse leaves the endpoint open while the caller is told it is not".

This one is in its own commit rather than amended into the remediation commit. An unauthenticated-endpoint fix filed under "let the shell enforce the read-before-serve invariant" is not something anyone would find later.

On the CodeQL triage. Your per-site reading matches mine and went further than mine did: 29 inside the test module, render_status and render_stopped emitting only a key-file path, and :489 as the single real taint flow, by design, with the key also written at 0600. The suggestion of a targeted suppression at that one site, with a reason, is better than re-triaging 32 alerts every round, and I would rather it be a deliberate one-line decision than a recurring exercise. Leaving that to the repo owner since it is a security-annotation change rather than a code change.

Not addressed here, and neither is caused by this branch: the Strix Halo WSL2 lane 404s on llama-b9752-bin-ubuntu-rocm-7.14-x64.tar.gz, where the tag is live but only ships a rocm-7.13 build, and the Ubuntu lane fails at GPU preflight before any test runs.

Verification: cargo fmt --check, cargo clippy --workspace --all-targets, cargo test --workspace clean at 42 suites, and prek. One comfyui failure on a first full run reproduced as a port-race flake, passing in isolation and on a clean rerun, with nothing in this diff touching comfyui or ports.

@siloteemu
siloteemu dismissed their stale review September 25, 2026 14:00

Superseded by a fresh review at the current head.

@siloteemu siloteemu left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🔴 Automated review · pr-review-watcher · cc276ef

This automation never files a GitHub approval, so no approving review will appear here whatever the outcome — the merge decision stays with a human reviewer.

Summary

Adds the rocm remote command family — provision a tailnet GPU machine over SSH, start a loopback-bound model on it, publish that port to the tailnet, then status/attach/stop — together with a new rocm serve --require-api-key flag, services list --json, split download/install modes in the installer, a Gherkin feature, and a containerised SSH harness. Outcome: needs work. Both findings raised last round are genuinely fixed, and I confirmed each by mutation on a scratch copy rather than by reading: deleting the new reuse guard makes spawn_managed_engine_child_refuses_to_reuse_an_unauthenticated_service fail, and the reworked staging-directory test is now falsifiable from both directions — dropping umask 077 fails on the parent at 0755, dropping chmod 700 fails on the stale leaf at 0755, which is exactly the half that could not fail before. Beyond that I mutation-tested the publish-state classifier (both the foreign-forward and funnel-allowed arms go red when removed), the && chain and the newline-terminated key (each has its own red test), the peer-ordering parse, the docker skip gate, and the session credential-claim path; I also read the serve, teardown and unwind flows end to end against their callers. Three problems remain, two of them tests that cannot fail for the thing they name, and one correctness gap on the path this round just remediated. Not verified here: the full suite was not run, and nothing was exercised against a real tailnet or real GPU hardware — the container stand-ins are the same ones the change ships. Checks at review time stand at 22 success, 3 failure, 3 pending, 1 skipped; two of the failures are also red on the base branch's own tip and so are not attributable to this change, while the third is a static-analysis lane that is green on the base tip and may be this change's own — I could not confirm which job that is, so treat the attribution as inference, not fact. Blocking: 3 · Non-blocking: 8.

🚫 Blocking (must fix before merge)

1. apps/rocm/src/remote/tailnet.rs:404-469 — the peer-ordering test passes with the sort deleted. parsing_orders_peers_and_strips_the_magicdns_trailing_dot asserts the peer list comes back as ["gpu-box-1", "gpu-box-2", "phone"], and its comment states the property under test: an unsorted list would shuffle between runs. But RawStatus::peers is a BTreeMap, so into_values() already yields entries in key order, and the fixture's keys are nodekey:aaa → gpu-box-1, nodekey:bbb → gpu-box-2, nodekey:ccc → phone. The map's natural order is therefore identical to the sorted order the test asserts, so peers.sort_by at lines 225-230 contributes nothing to the result. Removing that call entirely leaves the whole remote::tailnet test module green — confirmed on a scratch copy, 15 passed. The production sort is correct and wanted; the test simply provides no coverage for it, which is the failure mode this review treats as a hard blocker. Fix: permute the fixture's map keys so insertion order and host order disagree — e.g. key the phone peer nodekey:aaa, gpu-box-1 nodekey:bbb, gpu-box-2 nodekey:ccc — and keep the assertion on ["gpu-box-1", "gpu-box-2", "phone"]. The sort then becomes load-bearing to the assertion. While in there, the secondary dns_name tie-break is likewise unexercised, since no two fixture peers share a host name.

2. apps/rocm/src/remote/mod.rs:345-489 and :667-695 — remote serve prints and stores an API key the endpoint does not accept, when the remote reuses an already-authenticated service. The new guard added this round (apps/rocm/src/main.rs:6302-6331) refuses reuse only when the caller asked for --require-api-key and the running service has none. The symmetric case is untouched: if the running service on that port does require a key, reuse is satisfied, drop_orphaned_endpoint_key_on_already_running discards the key the remote was just handed on stdin, and the engine keeps enforcing the one it was launched with — the same "a running server cannot be given a key it did not start with" reasoning that motivated the refusal. The controlling side never learns this. It minted api_key, wrote it to the session key file, and reaches render_started, which prints api key: {api_key} unconditionally and follows it with "The API key above is what stops anyone else calling it." The remote's stdout is not surfaced — only start.success is consulted at line 406 — so the user is told the command succeeded and handed a credential that will be rejected, with the only stored copy on disk being the wrong one. The local serve path already avoids exactly this, at apps/rocm/src/main.rs:5871-5890, where launched_key is forced to None on already_running precisely so the CLI does not print a key it did not install; the remote path has no equivalent. This is reachable along a route the code itself advertises: rocm remote stop … --force clears the session key and removes the record even when the model could not be stopped (mod.rs:1076-1077), and both non-force failure messages recommend it by name — after which the model is still running and authenticated on that port, and the next rocm remote serve for the same model and port lands in this case. Fix: make the remote path fail closed the way the local path stays honest. The cheapest version with the data already in hand is for serve_with_transport to record a timestamp before exec_with_stdin and require discover_started_service to return a record created after it, bailing with the same shape of message as the reuse refusal (name the service and tell the user to stop it) when the port is held by something this command did not start. Alternatively, have the remote report reuse explicitly so the controlling side can refuse rather than infer. Either way the invariant to establish is that render_started only ever prints a key that the running endpoint actually enforces.

3. tests/remote-ssh/run.sh:185-207 — the batch-mode counterfactual reports pass on every outcome, including the one its comment calls unusable. The comment above it states the purpose plainly: this is "what makes the check above mean anything" — without batch mode, the same account and server should sit waiting for input, which is the hang batch mode exists to prevent. The check runs the command under a pty and expects a timeout (rc=124). When it gets one it passes; when it does not, the else arm also passes, with a message that says the host "does not refuse cleanly". So no result can fail it, and the harness reports a green check for a run that demonstrated nothing. The adjacent branch three lines below already handles the same situation correctly — when script is unavailable the harness prints skip, because the environment cannot show the behaviour. Fix: use skip in the else arm too, so an environment that cannot reproduce the hang is reported as undemonstrated rather than verified. That keeps the check non-flaky (the stated reason for tolerating the other outcome) while making the pass mean what it says.

Non-blocking

  • apps/rocm/src/remote/mod.rs:636-659 — discover_started_service lists with --all and picks the newest record on the port, so a stopped record created after the live one is a candidate, and ties on created_at_unix_ms resolve by iteration order rather than by anything meaningful. Filtering to live records, and stating the tie-break assumption, would make the selection say what it means.
  • apps/rocm/src/remote/session.rs:276-278 — clear_key's path-traversal guard is not constrained by any test. Removing the validate_id check leaves all 12 tests in the module green, including an_unusable_id_cannot_place_a_credential_outside_the_sessions_directory, because that test calls clear_key(&paths, "../../escaped") and asserts nothing afterwards: an unguarded removal of a file that does not exist is indistinguishable from a guarded no-op. Planting a sentinel file at the traversal target and asserting it survives would make the guard testable. The store side of that test was not mutation-tested here, so this observation is scoped to the clear_key half.
  • apps/rocm/src/remote/session.rs:61-77 — id_for maps every character outside [A-Za-z0-9-] to -, so gpu.box, gpu_box and gpu-box collapse to one session id on the same port. store_key's exclusive create bounds the damage to a confusing refusal rather than a credential overwrite, but the refusal will name a machine the user is not using. Folding a short hash of the untransformed host into the id would remove the collision.
  • .github/workflows/ci.yml:863-886 — the remote-ssh job carries needs.changes.outputs.heavy == 'true' in its job-level if:, while every sibling heavy job (build-and-test, test, windows-build-and-test) keeps only the dispatch condition at job level and pushes the heavy filter down to its steps, specifically so the job always reports. The job's own comment says it is "gated like every other heavy job here", which is what sent me looking for a merge-queue hazard that is not there: the changes job forces every category true on push and in the queue, so this job always runs where a required check matters. The comment is the problem, not the gating. Naming the difference and why it is safe — one clause — would stop the next reader retracing this.
  • apps/rocm/src/remote/transport.rs:123-145 — the control-socket length guard computes candidate.len() + 40 < 100, where candidate already contains the two literal bytes of the %C placeholder that the 40-byte expansion replaces. The guard is conservative by two bytes, never optimistic, so it is safe as written; subtracting the placeholder first would make the arithmetic match the comment.
  • tests/e2e-cucumber/features/remote.feature:90-97 with tests/e2e-cucumber/tests/e2e/remote_steps.rs:663-668 — the endpoint-restore scenario is the one whose premise is that the machine and the CLI have diverged, yet its final assertion reads only the CLI's own printed strings, where its siblings (then_machine_publishing, then_publishing_nothing) assert against real container state. It is not vacuous — "Endpoint re-published" is printed only after publish::publish returns Ok — but a tailscale serve status --json check in then_endpoint_restored, mirroring the sibling steps, would match the scenario's own premise.
  • apps/rocm/src/remote/mod.rs:1028-1043 — when neither the withdraw nor the model stop succeeds and --force was not given, the bail reports only the withdraw failure; the stop failure is already known in stop_failure at that point and is dropped. Nothing is lost (the session and key are kept either way), but the user retries without knowing both halves failed.
  • apps/rocm/src/main.rs:6413-6425 — when a service launched with both --allow-public-bind and --require-api-key later loses its key, the remediation text names only --require-api-key for the relaunch, which would not restore a non-loopback bind. Reachable only through that redundant flag pairing, which the remote path never produces.

The reuse guard added for `rocm serve` covers one direction: a caller
asking for a key when the running service has none. The mirror was left
open. When the running service does have a key of its own, the remote
reuses it, discards the key it was just sent on stdin, and exits 0. The
controlling side reads only that exit status, so it publishes the port
and prints the key it minted under "the API key above is what stops
anyone else calling it" -- a credential the engine will reject, stored
as the only copy the user gets.

Reachable along a route the code recommends by name: `rocm remote stop
--force` clears the session key and drops the record even when the
model could not be stopped, and both non-force failures suggest it.
The model is still running and authenticated on that port afterwards.

Snapshot the live service ids on the port before starting, and refuse
when the service discovered afterwards is one of them. Ids rather than
timestamps because `created_at_unix_ms` is stamped by the remote's
clock, so comparing it against ours would turn ordinary skew between
two machines into a spurious refusal or a missed one. The snapshot
omits `--all`: only a live service can be reused, and a stopped record
sharing the port is not something the remote could hand back instead of
a fresh start.

Also make two checks able to fail for what they claim.

`parsing_orders_peers_and_strips_the_magicdns_trailing_dot` asserted a
sorted peer list, but `Peer` deserialises into a `BTreeMap` and the
fixture's node keys ran in the same order as its host names, so the
sort contributed nothing and deleting it left the module green. The
keys are now permuted against the host names.

`run.sh`'s batch-mode counterfactual called `pass` on both branches,
including the one whose message says the host did not refuse cleanly,
so no outcome could fail it. An environment that cannot reproduce the
hang now reports `skip`, matching the adjacent branch that already does
this when no pty is available.

Signed-off-by: Eugene Volen <Eugene.Volen@amd.com>
@volen-silo

Copy link
Copy Markdown
Collaborator Author

Re-triaged the 32 CodeQL alerts against the current head rather than from memory, since the line numbers moved with the last few commits. Same 32, same rule (rust/cleartext-logging), all in apps/rocm/src/remote/mod.rs, and none of them added by the recent changes. No true positive reaches production.

The test module starts at line 1253, which splits them cleanly:

29 are inside #[cfg(test)] mod tests (lines 1574 through 2228). Fixture strings such as "the-key" passed to the renderers. Nothing there runs in a shipped binary.

3 are production, and two of those never touch a credential. render_status (line 938) and render_stopped (line 1187) do not take an API key as a parameter at all, so the flow CodeQL follows into them is record.session_id, not a secret. That id is format!("remote-{host}-{remote_port}"), so a value like remote-gpu-box-11434: a tailnet hostname and a port, both of which the user typed. The rule fires on the identifier's name, not on anything sensitive reaching the sink. The listing behaviour is pinned by a test that asserts a status listing contains key file: and specifically does not contain the key itself.

One is a real taint flow, and it is the intended behaviour. Line 513, println!("{}", render_started(paths, &record, &api_key)), prints the key once to the operator who just asked for it. That is the only way they receive it. The same key is written to disk at mode 0600, which is asserted separately, so the printed copy is not the only one and the stored one is not world-readable.

So: 29 test fixtures, 2 non-secret identifiers, 1 deliberate one-time display. False alarms in substance.

Two things worth recording so nobody repeats this exercise.

First, these alerts do not block anything. CodeQL is not in this repository's required status checks for main, so the red mark is advisory. What is required is Analyze (rust), which is the analysis job itself, and that passes.

Second, the reason all 32 appeared at once is worth knowing. This check reported "No new alerts" two commits earlier, and the only change between those heads was a single comment line. CodeQL's own summary explains it: "Alerts not introduced by this pull request might have been detected because the code changes were too large." The entire remote module is new in this PR, so it is re-attributing the whole file rather than reacting to anything that changed.

The suggestion from the automated round still stands as the better long-term answer: a targeted suppression with a one-line reason at line 513 only, and nowhere else, so the single by-design flow is marked once instead of re-triaged every round. That is a security-annotation change rather than a code change, so I am leaving the call to the repo owner rather than making it here.

@volen-silo
volen-silo added this pull request to the merge queue Sep 28, 2026
Merged via the queue into main with commit c5fc507 Sep 28, 2026
27 of 30 checks passed
@volen-silo
volen-silo deleted the feat/remote-tailnet-foundation branch September 28, 2026 18:50
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.

5 participants