Skip to content

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

Open
volen-silo wants to merge 18 commits into
mainfrom
feat/remote-tailnet-foundation
Open

feat(remote): serve a model on a tailnet GPU machine#333
volen-silo wants to merge 18 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-198attach 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-1425serve_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:155ROCM_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-240ConnectTimeout=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:130ROCM_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:173remove_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-113run_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-368free_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:269scp_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-294create_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.

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.

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. So
nothing runs locally, the endpoint outlives the command that created it,
and it answers from any of the user's machines rather than only the one
that started it.

That breaks an assumption elsewhere, so this fixes it too. `serve` grants
an API key only to non-loopback binds, on the 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. `serve` gains
`--require-api-key` for exactly this: whoever widens the reach asks for
the credential. Remote sessions always set it, and the key travels on
stdin, never in a command line that both machines expose in their process
tables.

Two lifecycles are tracked, both on the remote, because either can fail
alone: the model server and the publish pointing at it. They are reported
as separate facts — a live model with no endpoint is re-published, a dead
one has to be restarted, and collapsing them hides which.

Teardown is the sharp edge. 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. Withdrawal is confirmed rather than
assumed, ownership is established before a port is claimed or released so
a session never takes over or tears down another's forward, and a teardown
that cannot confirm both halves keeps the session listed rather than
dropping the only record of what is still running. `--force` exists for a
machine that is gone for good, and is deliberately louder than a clean
stop.

Provisioning never copies this machine's binary. That only worked when
both machines shared an OS and CPU, and when they did not the copy still
landed and still looked installed. The remote fetches its own build with
the project's installer instead; 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. `install.sh`
gains the download-only and install-from-archive modes that makes possible
— splitting the trust chain across two machines must not shorten it.

Installing ROCm is opt-in and gated on the failure catalog: a machine the
catalog recognises as needing a person is refused, since 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 need almost no new logic: gathering facts already produces a
plain snapshot, and scoring it reads nothing else, so the fetch runs on the
remote and the scoring here. Suggested fixes are rewritten to name the
target — a command repairing a machine you are not sitting at is not one
you can paste, and printed bare it invites being run against your own.

`rocm services list --json` is added as the machine-readable listing the
remote orchestration reads back, applying the same liveness filter as the
table so the two cannot disagree about what is running.

Signed-off-by: Eugene Volen <Eugene.Volen@amd.com>
`rocm remote` drives ssh, scp, and the remote machine's own tooling. Unit
tests use a scripted stand-in, which proves control flow but assumes those
tools behave a certain way — and the assumptions are where this could
quietly be wrong. Three layers close that.

A container standing in for a GPU machine: real OpenSSH, with `rocm` and
`tailscale` as stubs that keep state in files, so publishing and
withdrawing are genuinely stateful rather than canned. No GPU, no ROCm, no
tailnet needed.

`run.sh` checks the tool contracts against it — argument handling, a
non-zero remote exit arriving as an exit code, a credential delivered on
stdin and absent from the command line, file copy taking its port as -P,
batch mode refusing rather than hanging, and that withdrawing a published
endpoint actually removes it. Ports are keyed as strings in the serve
config, so a parser looking for a number would match nothing and call
every live endpoint absent.

The batch-mode check has an account that really does ask for a password.
With password auth off everywhere the connection failed for lack of a key
either way, and the check proved nothing; now batch mode is refused
immediately while the same connection without it waits for input nobody
will type.

`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 — catching orchestration mistakes a scripted
stand-in cannot. It does not prove the endpoint carries traffic; that
needs a real two-node tailnet.

Behavioural coverage lands where the project requires it. Nine scenarios
cover discovery, refusals and the session list with a stand-in for the
network client, so they do not depend on the developer's own machine being
on one. Five more cover the paths that succeed — serve, status, attach,
stop, doctor — which need a host on the other end of a real SSH
connection, so a new `@requires-docker` gate stands the container up and
skips with a reason where no container runtime exists. Both run on the new
containerised CI lane.

The stub's `examine` answers with a complete document generated from the
type's own defaults and pinned by a unit test. It previously returned a
fragment that looked plausible and could never have deserialized; nothing
noticed, because no test ran that code against it.

Signed-off-by: Eugene Volen <Eugene.Volen@amd.com>
The README gains the command surface and, more importantly, states who can
reach a published endpoint. The loopback intuition from local serving does
not carry over: the endpoint is reachable by every machine on the tailnet
that the tailnet's own rules allow, and the API key is what stops anyone
else using it. A reader who assumes otherwise never thinks to check their
access rules.

Also documents `ROCM_REMOTE_SSH_CONFIG`, since ssh resolves its config
from the account database rather than from HOME and there is otherwise no
way to point the CLI at a different one.

docs/testing.md describes the two container harnesses, what each proves,
and what neither does — the endpoint carrying traffic still needs a real
two-node tailnet.

Signed-off-by: Eugene Volen <Eugene.Volen@amd.com>
serve_with_transport() takes the transport and paths serve() builds, so a
scripted-transport test can drive the exact code path that starts the
model and see the same stdin write, instead of only checking
exec_with_stdin and remote_serve_command in isolation with nothing
pairing them.

Forward ROCM_CLI_SIGNING_PUBLIC_KEY_PATH/_PEM to the remote's own
install.sh, shell-quoted, so a machine trusting an alternate signing key
does not fall back to the pinned production keys and reject an archive
this machine already trusted.

Drop two dead_code attributes on resolve_peer and the transport module
that have had callers since the serve path landed.

Signed-off-by: Eugene Volen <Eugene.Volen@amd.com>
remote-09 asserted a doctor refusal without a live connection, so the
refusal it wanted to prove was never actually reached before the command
failed for lack of a connection. Build a second stand-in image with no
rocm binary at all (Dockerfile's INCLUDE_ROCM_CLI build arg) and give the
scenario a Given step that starts it, so the assertion is reached against
a machine that is reachable but genuinely missing the CLI.

Harden the SSH fixture harness while in there: run.sh no longer falls
back to publishing on all interfaces when the loopback bind fails, since
the image ships a password account for the batch-mode check and that
fallback would put it on the LAN. run-e2e.sh treats a readiness timeout
as a failure instead of racing on regardless, and checks that the tailnet
publish actually reports the endpoint after attach.

Signed-off-by: Eugene Volen <Eugene.Volen@amd.com>
A test added on main called resolve() with the old positional bool
signature. This branch had already refactored resolve() to take an
Included struct, so merging main back in left two call sites that no
longer matched the function signature, breaking the build.

Signed-off-by: Eugene Volen <Eugene.Volen@amd.com>
serve status --json's AllowFunnel key (keyed host:port) was listed in
SERVE_CONFIG_KEYS but never parsed, so a Funnel-enabled port classified
as Absent and publish() would complete a public-internet exposure with
no warning. Parse it into RawServeConfig and, when set for the target
port, return a distinct FunnelAllowed state so both publish() and
withdraw() bail loudly naming the exact command to turn it off.

Apply the same fix to the Services key, which had the identical blind
spot. Comment SERVE_CONFIG_KEYS to distinguish keys we parse from ones
that are only there to round-trip the document shape. Correct the
module doc's "visible to the whole tailnet, scoped only by its ACLs"
claim, which Funnel already contradicts.

Signed-off-by: Eugene Volen <Eugene.Volen@amd.com>
With --install-rocm, the rocm_present branch returned before the
tailscale_present bail was reached, so on a machine with neither
present, ROCm install would run before the Tailscale check refused the
machine. Hoist the tailscale_present check above the rocm_present
branch so a machine without Tailscale is always refused first,
regardless of what else is missing.

Extend the existing
a_machine_without_tailscale_is_refused_before_a_model_is_started test
with an install_rocm = true, neither-present fixture; it fails without
the hoist.

Signed-off-by: Eugene Volen <Eugene.Volen@amd.com>
push_matched_artifact forwarded ROCM_CLI_SIGNING_PUBLIC_KEY_PATH to the
remote verbatim, but that path names a file on this machine — the
remote shell has nothing at that location, so the variable was set but
useless. Read the file locally and forward its content as _PEM
instead; an explicit _PEM still wins, matching install.sh's own
resolution order. The lookup is split into a pure, dependency-injected
helper so both branches are covered by unit tests without touching the
real filesystem or environment.

Also replace the PID-keyed staging directory with one salted by a
nanosecond nonce and created with create_dir (not _all), so a
pre-staged or symlinked path at a reused PID can no longer be silently
adopted, and restrict it to 0700 so its contents aren't readable by
other local users even if the name leaked.

Signed-off-by: Eugene Volen <Eugene.Volen@amd.com>
exec_with_stdin wrote the payload to the child's stdin synchronously
before calling wait_with_output. ssh does not have to drain stdin
before producing output, so a payload larger than the OS pipe buffer
paired with any remote output could deadlock: us blocked writing
stdin, ssh blocked writing a full stdout/stderr pipe, and
wait_with_output — which would drain both — never reached. Write the
payload on its own thread instead, so the main thread reaches
wait_with_output immediately; join the writer afterward and propagate
a write failure or panic.

Also give scp_argv the same leading-`-` rejection guard
validate_destination already applies to the ssh destination: scp has
the identical option-vs-argument ambiguity for its two path arguments.

Signed-off-by: Eugene Volen <Eugene.Volen@amd.com>
signing_env_fragment_from checked _PEM first and fell back to _PATH.
install.sh's resolve_public_keys does the opposite: _PATH wins, and _PEM
is only consulted when it is unset. The doc comment claimed the two
agreed.

What they disagreed about is the trust root a signature is verified
against, so with both variables set a remote provision accepted a build
that a local install would have rejected. It surfaced as "the remote
rejected the build we fetched for it", which points at the artifact
rather than at the key.

An empty value now counts as unset on both sides too. install.sh tests
these with `[ -n ... ]`, but env::var yields Some("") for `FOO=`, so an
empty _PEM alongside a real _PATH forwarded the empty one and silently
discarded the operator's chosen key.

The test asserting the old order is replaced rather than kept: it is why
this shipped.

Also closes a umask window in the staging directory the signing key is
written into, using the DirBuilder::mode pattern already in dash.rs.

Signed-off-by: Eugene Volen <Eugene.Volen@amd.com>
Moving the stdin write onto its own thread fixed a deadlock but left the
join before the 255 check, so a write error aborted the call and threw
away the outcome already in hand.

The write error that matters is EPIPE. ssh exits without reading stdin
when it cannot reach the host, which breaks the pipe, so an unreachable
host produces two errors describing one event. Consulting the echo first
reported "failed to send input" where ssh had plainly said 255, and
serve_with_transport wrapped that as "may or may not be running" —
telling the user a model might be up on a machine never contacted.

The outcome is now built first and the write error consulted after, and
demoted to a symptom only when the command itself also failed. A broken
pipe on a *successful* command still errors: the payload there is an API
key, and a model started without the key meant to guard it is not a
success. The writer is also joined on the wait error path, which
previously detached the thread.

The regression test uses a payload larger than a pipe buffer. A short
one lands in the buffer and returns success with no reader, so the race
decides whether the bug appears; overflowing makes the EPIPE certain and
exercises the overflow the thread exists to survive.

Also corrects the scp path guard's rationale — the remote argument is
prefixed with the destination, so scp can never read it as an option and
that half is a shape check, not a safety one — and rephrases the Funnel
status line, which led with "no" to a question it had not looked at.

Signed-off-by: Eugene Volen <Eugene.Volen@amd.com>
The container fake only ever emitted {"TCP": ...}, so the exposure
classifier's Funnel branch was proven against hand-written fixtures and
nothing else. A fake that wrote AllowFunnel in the wrong place would
have left the CLI looking correctly cautious while reading nothing.

The fake now understands `funnel --tcp=N on|off` and writes AllowFunnel
where the real daemon does, keyed host:port. run.sh asserts that shape,
and run-e2e.sh drives the binary at an exposed port and checks it
refuses, names the way out, and publishes nothing anyway. The CLI never
runs funnel itself — it only reads the key — so this exists purely to
put the remote in the state that must be refused.

Also installs the pinned Rust toolchain on the remote-ssh job. It was
the one cargo job in the workflow building against whatever the runner
image shipped, uncached, against a 30-minute timeout.

Signed-off-by: Eugene Volen <Eugene.Volen@amd.com>
Funnel serves 443, 8443 and 10000 only. The fake accepted `--tcp=8000`
and wrote an AllowFunnel entry tailscaled could never emit, so the new
coverage proved the classifier reads the right field against a document
that cannot occur. The fake now refuses the ports the daemon refuses,
and both lanes exercise 443.

Worth stating plainly, because the fixture was hiding it: at the default
tailnet port of 8000 the Funnel guard can never fire, since Funnel
cannot listen there. It is reachable only via `--tailnet-port 443`
(or 8443/10000). That is not a hole — a Funnel on 443 does not expose
8000 — but it does mean the guard is dead weight at the default, which
is a design question worth its own look.

Also from review:

- The FunnelAllowed status line had no test at all, despite being
  rewritten last commit; it now has one, and interpolates the real port
  instead of printing a literal `<port>` the user has to substitute.
- create_restricted_dir's comment claimed a signing key is staged there.
  It is not — the archive, checksum, signature and installer are, all
  public. The mode guards integrity, not confidentiality; say so.
- The writer-panic arm is only reached when the two earlier returns did
  not fire. Note that rather than claiming an outcome cannot explain it.
- Record that the BrokenPipe demotion is safe because of where
  remote_serve_command puts its `read`, not because of anything this
  function enforces.
- Add the Funnel checks to the coverage lists in docs/testing.md and the
  CI job comment, which are maintained by hand.

Signed-off-by: Eugene Volen <Eugene.Volen@amd.com>
… send

Forwarding the key was only half of it. install.sh's resolve_public_keys
reads ROCM_CLI_SIGNING_PUBLIC_KEY_PATH before _PEM, so a path the remote
exports for itself — /etc/environment through pam_env reaches
non-interactive sshd sessions — beat the key we had just sent, and the
two machines verified against different trust roots. Which is the same
divergence the precedence fix closed, arriving from the other side.

The provisioning command now sets an empty _PATH alongside the _PEM.
install.sh tests it with `[ -n ... ]`, so an explicitly-empty export
reads as unset there, and the forwarded key wins.

Verified at the shell: with an ambient _PATH=/etc/other.pem exported,
the old fragment resolves to /etc/other.pem and the new one to the
forwarded key.

Signed-off-by: Eugene Volen <Eugene.Volen@amd.com>
Same drift as the earlier pass: this branch changed `resolve` to take an
`Included` struct so a mis-ordered bool cannot silently pick a different
set, and main has since grown three more call sites using the old
six-argument form. Neither side conflicts textually, so it only appears
once the two are built together — which is what CI does and a clean
local build does not.

Reproduced by rebasing onto main: clippy fails with three E0061s at
expectation.rs:1148, :1155 and :1162. All three passed `false, false,
false`, so `Included::default()` is the equivalent.

Signed-off-by: Eugene Volen <Eugene.Volen@amd.com>
…port move

The rewritten comment on create_restricted_dir was still wrong about the
mechanism, in the opposite direction from before. There is no
download-then-check window in that directory: install.sh's download-only
mode fetches and verifies inside its own mktemp -d and only copies the
proven artifacts out. Nor can the installer be swapped there — it is
rewritten from the compiled-in constant immediately before the push.

What 0700 actually buys is the window between the verified artifacts
landing and the scp: a second local user could swap the archive together
with a matching .sha256, and the remote re-verifies the pair it is given.
A signature would stop that, but install.sh only requires one on the
release channel with pinned keys, or when a key is named explicitly, so
on any other channel the checksum is all that stands there.

The "impossible document" argument applied to the shell fake but not to
the Rust fixtures, which still keyed AllowFunnel at :8000 and :9000. They
now use 443 and 8443, and the render_status test builds a record on 443 —
it was asserting a remedy string no user could ever see, since Funnel
cannot listen on the default tailnet port.

Also: run.sh now asserts the fake's refusal *message* rather than a
non-zero exit, since `remote` is an ssh wrapper and ssh fails non-zero
for its own reasons; verified it fails when the port gate is removed. The
duplicate signing test is folded into the stronger assert_eq above it,
and the remaining hand-maintained coverage comment in ci.yml is caught up
with docs/testing.md.

Signed-off-by: Eugene Volen <Eugene.Volen@amd.com>
The empty _PATH rides along with a forwarded key, so it only ever
guarantees that the key we send wins — not that the remote's own path is
always switched off. Forward nothing, which is the default, and a _PATH
the remote exports for itself still stands while this machine verifies
against the pinned keys.

Blanking it unconditionally would close that too, but it would also
override a remote operator's deliberate mirror-key config in exactly the
case where this machine has no opinion. The divergence fails loud rather
than trusting the wrong root, so it stays a decision rather than an
assumption, and the comment now says so instead of implying otherwise.

Signed-off-by: Eugene Volen <Eugene.Volen@amd.com>
@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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants