Files
alkssh/docs/research/reverse-runner.md
T
glm-5.3-flash 263f91fdb7 docs(architecture): review-pass cleanup — inconsistencies, stale refs, scaffolding refresh
- OQ-02: rewritten as a clean resolved statement; contradictory
  deferred-table row removed
- OQ-05: 'old-code anti-pattern' framing corrected — ephemeral-fleet
  accept-and-continue is a legitimate case-2 HostKeyPolicy; the crate
  rule is 'no hard-coded trust model', not 'accept-all forbidden';
  dangling 'expected ADR-007' pointer replaced with the real contract
  locations (auth.md, client.md)
- README: OQ counts corrected (6 resolved / 3 open / 4 deferred);
  sibling references normalized to real ADR IDs (alkcall 010 is alkhttp
  014's analog; 'ADR-016-analog' was a wrong number)
- client/auth: OQ-07 status corrected to resolved-at-adapter-level;
  TOFO typo; TOFU framing
- ADR-004: dangling 'ADR-008' -> serve-config defaults
- ADR-006: SOCKS5 typo
- mapping: alktty typo; '(OQ-… note below)' placeholder -> the real
  POC-3 note reference
- research: cross-repo numbers qualified (alktty OQ-44, alkvault
  ADR-021); reverse-runner lesson 5 reframed per correction
- scaffolding: Cargo.toml description/comments and src/lib.rs doc
  comment updated against ADR-002/003/006 (dynamic-forward retired,
  git exec-only, russh pin decided); CHANGELOG logs POC completion and
  ADRs 001-006

Verification: cargo test, cargo clippy --all-targets -- -D warnings,
cargo fmt --check, cargo check — all pass
2026-10-27 10:00:11 +00:00

118 lines
7.2 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Reverse runner — client-direction pattern extraction (alkminer)
Status: phase 0 post-POC extraction. **Reading aid only — concepts and
wire shapes, never code** (the source is not open-sourced and stays
that way; per repo license hygiene, reference code is for reading and
facts). Source:
`/workspace/@alkminer/alkminer` — specifically
`crates/alkminer-host/src/remote/{ssh,exec,forward,manager,sftp}.rs`
and `src/oai/mod.rs`. Also old (russh 0.62 era) and POC-grade; its
weak spots are as instructive as its strengths, and both are recorded
below as facts to design from, not precedents to copy.
## The direction
The reverse-runner pattern inverts the typical runner flow: the
**hub/coordinator is the SSH client** and dials out to vanilla `sshd`
on rented containers (vast.ai, runpod — any box that exposes SSH). The
runner exposes nothing but the wire; the hub is the active party and
pushes all work and all channel opens.
Security shape (why this beat the alternatives in practice):
- **No inbound surface on runners** — only `sshd` with one key.
- **Secrets never leave the hub** — the pattern's killer use was an
OAI-compatible proxy served *by the hub* (`oai/mod.rs`, alkminer
ADR-016): the instance points its LLM tooling at a localhost URL
that rides the tunnel to the hub's proxy, which injects the real
provider key. The runner authenticates to the proxy with a scoped
dev token — least privilege, no upstream-secret custody.
- **Reachback both ways, deliberately**: hub→runner services via `-L`,
runner→hub services via `-R` (the two legs below were both used,
sometimes chained: instance port ‑L→ hub port ‑L→ dev workstation).
## Per-connection surface (what the hub actually drove)
One authenticated SSH connection per instance carried:
| Surface | alkminer use | alkssh client-direction translation |
|---|---|---|
| blocking `exec` | provisioning commands, health checks | session channel open + `exec()`; alktty pipe-mode seam when the runner is family-built |
| background jobs (`start_job`/`job_status`/`job_logs`/`stop_job`) | mining worker lifecycle; pid files on the runner, job rows in hub-side storage | assembly-level job supervision over exec channels; the alkminer pid-file approach is the ad-hoc analog of POC-3 §D's pipe-mode signal gap (alktty exit-chunk signal identity OQ) |
| SFTP | binary deploy to the instance | **future alkfs client half** — real consumer evidence that the `sftp` feature's deferred implementation needs both directions (alkforge CI artifact deploy is the consumer) |
| `-L` client-side tunnel | opencode dev tunnel (instance:4096 → hub) | client opens `direct-tcpip` per accepted local connection and pumps (POC-1's `into_stream` plumbing, reversed) |
| `-R` client-side tunnel | hub-service reach tunnel (instance → hub's HTTP/OAI proxy) | `tcpip_forward` global request (returns the bound port — no mpsc sink param, matching `russh.md`'s client inventory) then **accept the server-initiated `forwarded-tcpip` opens** and relay each to the hub-local target |
## Client-direction facts that phase 1 designs from
1. **The acceptor row is unavoidable and its disposition is ours.** russh's
client handler receives `server_channel_open_forwarded_tcpip` for
`-R` data. In alkminer's 0.62-era code (and unchanged in kind at
0.64.1), an accepted forwarded channel with no handler installed is
**accept-and-drop** — a fail-open-ish default. alkssh's client
contract: only channel types/features the assembly registered are
served; otherwise decline cleanly. This is the client side of the
feature-graph symmetry rule; unlike the server auth defaults (which
0.64.1 already has rejecting), the client acceptor default is
*ours to enforce*.
2. **The routing sink installs post-connect.** alkminer's shape: the
handler exposes a late-bound handler slot
(`install_forward_sink`) set after `connect` by the consumer that
owns the forward. The family translation is an assembly-injected
acceptor-handlers registry (per channel type / per feature), not a
static config — but the *late-install* dynamic is real: consumers
turn capabilities on/off over a live connection.
3. **Keepalives are load-bearing on cloud runners.** The hub needed
`keepalive_interval` 15 s + `keepalive_max` 3 (OpenSSH
`ServerAliveInterval=15`/`ServerAliveCountMax=3` parity) — with
russh's default (no keepalives), dead NAT'd connections sat
unnoticed until a health-check exec failed. Evidence for the
client-side bounded-resources defaults (the server side has
symmetric knobs; cloud edges need both).
4. **Reconnect-and-restore semantics.** Tunnel ports were persisted
(hub storage) and re-bound identically on reconnect; forwards were
tracked per connection with idempotent stop handles. This is
assembly policy (session/forward lifecycle), not wire contract —
kept in view so phase 1 doesn't accidentally bake reconnect policy
into the crate.
5. **Host-key acceptance for ephemeral fleets — the pattern the fleet
model demands.** alkminer's `check_server_key` returned accept-all
for rental fleets. That was the correct pattern for that problem,
not a lapse: a fresh container per rent mints a fresh host key, so
no pin can exist beforehand — strict TOFU/"pin everything" is
unusable there, and the churn is exactly why accept-all won. The
security anchor is elsewhere: hub-held per-instance dial keys make
a captured hub credential worthless, plus what rides the tunnels.
The family rule is therefore not "pin everything" — it is "no
trust decisions inside the crate": default fail-closed at the
crate, with an assembly-provided policy seam so each fleet model
gets the control that fits it — host pinning where anchors can
exist (family-built runners, whose host key the assembly mints and
therefore knows beforehand), permissive accept where they can't
(rental fleets). The fleet churn is the reason the two-case policy
seam (OQ-05) is the correct shape rather than "pin everything".
6. **Per-runner key registration.** Keys were minted per instance and
registered hub-side (their provisioning flow, including a real
gitea client for dev workspaces) — the deployment precedent for
alkforge's per-runner `IdentityProvider` rows.
## Not carried
- No code extraction in either direction (concepts/wire shapes only).
- The job-supervision storage model (hub-side SQLite job ledger, pid
files on the runner) belongs to the assembly layer in a family
deployment; the crate's surface is channels and tunnels.
- No per-target scoping of proxied traffic existed (dev token proved
"a valid dev instance", nothing more) — a real least-privilege gap
the family model fixes via per-identity ACLs on the service side.
## References
- `pocs.md` §Client-side scope (the scope item this doc grounds)
- `mapping-table.md` (server-direction rows; POC-3 stage C is the
server side of the same bridge legs), `poc-1-auth-bridge.md` §7
(the channel↔stream plumbing both leg styles reuse),
`poc-3-mapping-bakeoff.md` §D.3 (client-request mechanics)
- `auth-bridge.md` §Host keys and `check_server_key` (the trust-policy
seam this doc's ephemeral-fleet acceptance pattern motivates)
- alkminer ADR-016 (the OAI proxy pattern — secrets stay at the hub)