Files
alkssh/tasks/implementation/w4-op-rows.md
T
glm-5.3-flash 2371c2e169 feat(client): land the op rows — exec/pty, direct-tcpip, git exec, sftp placeholder (w4-op-rows)
The family op → vanilla translation surfaces on SshConnection, per
client.md §op rows:

- session: exec (pipe) / pty_exec / shell → RemoteSession — the
  alktty chunk plane both directions, the exit consumed in-band as
  the family exit chunk (exit-chunk-is-last); the POC-3 §D.3
  mechanics pinned (exec() + wait()-drain, split-halves pump for
  exit-consuming rows, typed-open-then-straight-into_stream for the
  exit-less tunnel rows)
- tunnel: tunnel_direct / tunnel_direct_unix → TunnelChannel — typed
  opens, rejections mirrored to OpError::OpenDeclined
  (OpenDeclineReason), the connection stays usable after every
  decline (row-pinned per class); the OQ-11 runtime-Unix gate mirrored
- git: git_service(Service, repo_id) → GitPackSession — the shared
  grammar's construction arm in dispatch.rs (parse round-trips
  unit-pinned), the raw pack duplex pumped, the exit consumed
- sftp: the every-build OQ-13 placeholder (OpError::SftpNotServed,
  Infallible Ok type), before any wire op
- shared exit-currency module src/exit_code.rs (extracted from
  tty_map.rs): SIGNAL_NAMES + derive_exit + sig maps two-direction,
  new signal_code_from_name client arm
- OpError taxonomy (closed, family-mirrored) in error.rs; the fake
  sshd double grown with the op-row script + observations;
  tests/client_ops.rs — 18 rows + the alkssh↔alkssh exec smoke
  (real-vanilla-sshd smoke named wave-5 e2e territory)

Verification: cargo test (default + per-feature + all-features) all
green; clippy ×2 -D warnings clean; fmt clean; cargo doc --no-deps
warning-free.
2026-10-29 16:21:25 +00:00

18 KiB
Raw Blame History

id, name, status, depends_on, scope, risk, impact, level
id name status depends_on scope risk impact level
w4-op-rows Wave 4 — the client op rows (exec/pty, direct-tcpip, git exec, sftp decline) completed
w4-dial
moderate medium phase implementation

Description

Land the op rows — family op → vanilla translation per client.md's table, pointed upstream at a vanilla server (the reverse-runner shape: the hub drives every channel open). Type symmetry at the seam is the contract: family-protocol types in, vanilla wire ops out, no vanilla-concept leaks up. The POC-3 §D.3 mechanics are the body of knowledge this task lands in code:

  • Session exec/pty rows (under session) — channel_open_session
    • the exec/pty-req/shell-req translations onto the alktty session types (pipe vs PTY; exit semantics): Channel::exec() — no request_exec at this pin; Success/Failure arrive on the channel queue via wait()-drain, not as Err from the request call (drain until the expected message, continue past the rest); window_change/signal are fire-and-forget and must be sent before into_stream() (the Channel is consumed by conversion); never consume the channel queue before into_stream() (a wait() on a fresh channel breaks it — POC-1 §6, both sides). The data pump + the exit consumption mirror the server direction's derivation contract (the pipe-mode pid-only signal caveat rides the alktty exit-chunk semantics — mapping.md's contract, consumed not re-derived).
  • direct-tcpip typed dial (under tunnel) — the row the -L leg and the alksocks DialFn compose over: channel_open_direct_tcpip resolves to Err(ChannelOpenFailure) on server reject (POC-1 §6) — the typed open, no wait() before into_stream; the connection stays usable after a declined open (pinned by row). The direct-streamlocal twin with the runtime-Unix gate (OQ-11 symmetry: universal compile, clean off-Unix decline).
  • Git exec consumption (under git) — the same exec grammar the server side parses (git-{upload,receive}-pack '/repo'), reused, not forked — pointed upstream at a vanilla sshd: parse → exec → the pack duplex pumped → exit-status consumed. The per-length limits and service-only decline classes ride the shared grammar's verdicts.
  • The sftp subsystem placeholder decline (every build, OQ-13) — the client-side half of the tested decline placeholder: a subsystem "sftp" open declines cleanly in every build (the feature exists, the route doesn't until alkfs lands). The server-side rows already landed in wave 2; this lands the client mirror so both directions are contract-tested.
  • Contract rows through the server double, per build — the w4-dial fake-sshd vehicle grown with the op-row surfaces: featured + working rows (exec exit-status, pty window-change ordering, direct-tcpip pump, git exec round trip), decline rows (server-rejected opens, the sftp decline, off-Unix streamlocal), the connection-stays-usable row after each decline class. The real-tool smoke posture decided + recorded (a real sshd exec is cheap where tooling permits — check-gated like the wave-3 smokes; else named wave-5 e2e territory explicitly).

Acceptance Criteria

  • Exec/pty rows land under session with the POC-3 §D.3 mechanics pinned in code + rows (exec() not request_exec; wait()-drain Success/Failure; window_change/signal before into_stream; queue never consumed before conversion)
  • direct-tcpip (+ streamlocal twin, runtime-Unix gate) lands under tunnel: typed open, reject → Err(ChannelOpenFailure), the connection stays usable after a decline (row-pinned)
  • Git exec consumption lands under git over the shared grammar (no forked parser); the pack duplex pumps; exit-status consumed
  • The sftp subsystem placeholder declines in every build (the client-side OQ-13 mirror)
  • Per-build contract rows green through the server double; the smoke posture decided + recorded
  • cargo test (+ per-feature + --all-features), clippy ×2 (-D warnings), fmt, cargo doc --no-deps pass

References

  • docs/architecture/client.md §Op rows (the table), §Correctness facts, §What the crate does not include (job supervision is assembly work — the crate gives channels + exit semantics)
  • docs/architecture/mapping.md §The exit derivation contract (the mirror the client consumes), docs/architecture/decisions/003-feature-graph.md (the feature rows), docs/architecture/decisions/006-mapping-bridge-and-dispatch.md (exec-only git, both directions)
  • docs/research/poc-3-mapping-bakeoff.md §D.3 (the client mechanics — the body of knowledge), docs/research/poc-1-auth-bridge.md §6 (the open-call Err semantics), docs/research/reverse-runner.md (the per-connection surface table — the consumer evidence)
  • src/git_map.rs (the grammar reused), src/tty_map.rs (the exit-chunk semantics consumed), russh 0.65 client::Handle (channel_open_session / channel_open_direct_tcpip / channel_open_direct_streamlocal)

Notes

What landed (2026-10-29)

  • src/client_ops.rs (new, every build; rows feature-gated inside): the op rows — the family op → vanilla translation surfaces, all on SshConnection (dial's handle; sibling-module method impls over the crate accessors russh_handle() / budgets()):
    • session: exec (pipe), pty_exec (ssh -t), shell(Option< TerminalParamsWire>) (pty with params; the pipe shell with None, mirroring the server direction's decided pty-less shell parity) → RemoteSession, the family chunk plane (§below).
    • tunnel: tunnel_direct(host, port) / tunnel_direct_unix(path) → TunnelChannel (a plain byte duplex — the alksocks DialFn composes it; the -D leg's composition needs no dependency, ADR-003 §1a).
    • git: git_service(alkgit::Service, repo_id) → GitPackSession (raw pack duplex + stderr duplex + the consumed-exit oneshot).
    • every build: sftp() → Err(OpError::SftpNotServed) (the Ok type is Infallible — the feature ships but the route doesn't, OQ-13, now tested in both the featureless and the featured build).
  • src/exit_code.rs (new, extracted from tty_map.rs — the wave's structural edit): the derivation contract's machinery (SIGNAL_NAMES / ExitReport / derive_exit / sig_from_name / sig_control_name) is now two-direction territory — the module gained the client arm's signal_code_from_name (the wire exit-signal name → the family −signum code, −1 for the unmapped/the sentinel collision) and the module docs carry the client arm's rules as the same contract's inverse. tty_map.rs consumes it (no fork anywhere); handler.rs's signal row follows the moved path.
  • src/error.rs: [OpError] — the op rows' closed taxonomy (OpenDecline(OpenDeclineReason) — the family-mirrored copy of the typed open's wire class, Other{code,reason} verbatim, nothing collapsed / RequestRefused (the wait()-drain's Failure reply — POC-3 §D.3's not-an-Err fact surfaced as the op's decline class) / ChannelEnded (the drain saw the channel's end; nothing fabricated) / CommandTooLong (the exec-length budget's client-mirror verdict, pre-wire) / InvalidRepoId (the git validation gate mirrored, pre-wire) / UnixUnavailable (the OQ-11 gate, pre-wire) / SftpNotServed (the placeholder) / Wire(russh::Error) (transparent); the From<russh::Error> maps the typed open's ChannelOpenFailure into the family mirror — one conversion point, unit rows pin every class).
  • src/dispatch.rs: the shared grammar's construction arm — build_git_exec_command(route, repo_id) beside parse_exec, with unit rows proving the construction round-trips the parse for both services (the grammar's gate is the Service type: the construction arm can emit no other token, so the client rows are structurally unreachable by unknown-service/service-only — recorded).
  • src/client.rs: the crate accessors (russh_handle/budgets, feature-gated; no public-surface change); src/lib.rs: the client_ops module + the per-feature surface re-exports (RemoteSession / TunnelChannel / GitPackSession, OpError/OpenDeclineReason public).
  • tests/common/sshd_double.rs: the fake sshd grown with the op-row script — the session requests' reply knob, the scripted program (stdout/stderr/exit — exit-then-close, the vanilla discipline; a no-output/no-exit script launches nothing, keeping interactive sessions open), the direct opens' echo-or-reject behavior, a counted-refusal knob for the stays-usable-after-a-decline rows, and the observation surface (SshdObserved: recorded execs/ptys/windows/signals — the wire translations' witnesses); tests/client_ops.rs: the contract rows (below).

Decided + recorded

  1. The family plane (the session rows) is the alktty chunk framing in both directions — the exact plane the server direction's bridge speaks, consumed not re-derived: stdin chunks + CTRL_IN (sentinel/Resize/Signal/Eof) toward the wire; stdout/stderr chunks + the in-band exit chunk, last toward the hub. The exit ride is the family exit-currency (ControlMessage::Exit's i32): wire ExitStatus(N) → N, wire ExitSignal(name) → −signum via the table's inverse read (unmapped names ride the −1 sentinel — including a genuine SIGHUP, the same collision rule 3 consumes server-side). A session ending with no report delivers no exit chunk (the no-exit-chunk close family's mirror).
  2. The exec/pty/git rows never into_stream(); the tunnel rows never wait. The mechanics facts pin the shapes: the conversion's reader swallows exactly the non-Data messages the exit reports ride on, so an exit-consuming row is structurally a split-halves wait-pump (Channel::split() + spawned drain of wait() — the same shape w4-git-exit-close pinned server-side; the write half stays alive, so the fire-and-forget window_change/signal rows ride mid-session — the ordering fact's constraint, that controls precede into_stream(), is respected by construction across both row kinds). The tunnel rows go typed-open → straight into_stream(), no queue consumption ever (POC-1 §6's fresh-wait() caveat, honored by construction); client.md §Correctness facts records the landed shape.
  3. The hub→door plane reads without the error-frame disambiguation (ChunkReader::read_chunk, not tty_map::read_bridge_frame): that plane's chunk types are stdin 0 / CTRL_IN 3; a negotiation-error frame never exists toward the door — and a 0x00-leading read there is a legitimately valid zero-length stdin chunk, so read_bridge_frame's 0x00-disambiguation (whose home is the alktty→hub direction, where error frames live) would misparse it. The wave-3 harness's mistake became this module's first live-row bug, caught by the contract rows before landing; recorded so the disambiguation rule ("the server never sends 0 or 3", alktty ADR-052 §5) is understood as directional.
  4. The pumps' terminal/kill structure (the drop-is-wire-clean path): every pump pair shares a kill watch the output pump's terminal paths fire (the exit chunk / the channel's end / a hub write failure); the input pump closes the wire channel on it. stdin-EOF (the sentinel, a CTRL_IN Eof, or the hub's input half dropping) sends the wire EOF once and parks the pump in the idle-for-kill state (the session keeps pumping — stdin closure never ends the session). Dropping the surface (both halves) closes the channel — wire-clean, nothing dangling outside the dial's own wall clock.
  5. The launch drains are message-capped (DRAIN_MESSAGE_CAP, 1024): a peer answering only with non-reply messages cannot loop the drain unboundedly (bounded resources; the drain's idle waits are bounded by the dial's always-set inactivity timeout). A refusal closes the channel behind itself (drain_launch_request — the row owned it; never half-serves).
  6. The pty translation: family TerminalParamsWire → wire pty_request verbatim (term/cols/rows/pixels, term: None → the empty-terminal-string value; the modes JSON never rides the wire — always the empty set, the OQ-44 reserved posture mirrored: the door never invents wire terminal modes); a refused pty request refuses the whole row (never a pty-less fall) — the never-half-capability row.
  7. The git row's gates before any wire op (the shared grammar's verdicts): validate_repo_id → InvalidRepoId; the built command's length → CommandTooLong. The other grammar decline classes are structurally unreachable client-side (the Service-typed input carries only the two serving services). stderr rides a split raw duplex (the hub may drop it); the pack duplex is the protocol's own byte plane, untouched.
  8. The tunnel rows' originator: the door's label ("alk-ssh", port 0) — nothing invented, nothing leaked; peer logs name the door (recorded; a real originator would be the assembly's local-listener fact, and the DialFn shape carries the target only).
  9. No client-side in-crate channel cap admits ops: the server direction's cap is a wire-battery defense; the client rows are hub-driven (the family app chose the dial). Bounded resources on this direction: the always-on wall clock/keepalives, the russh windows, the bounded duplexes/mpscs, the drain cap.
  10. No crate events on the client op rows: the outcomes ride the typed surfaces (Result/channels) — the decline* event vocabulary takes a wire channel id, and a client-side declined open has none to name (the typed open carries no id back); the events remain the server-direction (and dial-skeleton) rows' surface. Recorded so the asymmetry is a decision, not an omission.
  11. The smoke posture (decided + recorded, per the task's item): the session rows' cheap real-stack smoke is the alkssh-client-op-row ↔ alkssh-server loopback (the client exec row against the server direction's real session rows — both chunk-bridge arms over one live wire), landed as the row smoke_client_exec_row_against_the_server_directions_session_rows (gated on sh like the wave-3 smokes, skipped-with-reason when absent). A real-vanilla-sshd dial smoke is wave-5 e2e territory explicitly — nothing silently dropped.

Test rows (tests/client_ops.rs, one per decline/contract class; per-build binaries)

  • every build: the sftp placeholder's decline (before any wire op, no channel opened at the peer) + the featured-build variant (under sftp) — the connection-stays-usable close on both.
  • session (10 rows + the smoke): the exec family-plane row (command rides the wire verbatim; stdout/stderr chunks distinct; the exit chunk in-band, last; the stdin sentinel = wire EOF), the refused-exec RequestRefused row, the pre-wire budget row (no channel opened), the pty-shell param-translation row (the family TerminalParamsWire verbatim, the reserved modes never riding), the pipe-shell parity row (no pty request), the live-pump control rows (CTRL_IN Resize → wire window_change; Signal → wire signal, name verbatim), the exit-signal mirror row (terminal exit-signal("TERM") → the family code −15 in-band), the never-half-capability row (a refused pty refuses the whole row — no exec behind it ever rode the wire), the server-rejected-open decline row (OpenDeclined(Prohibited)) with the stays-usable-after-the-decline launch on the same connection, and the smoke.
  • tunnel: the direct-tcpip pump row (raw byte duplex round-trips; poll_shutdown EOF rides), the reject-maps-the-class + stays-usable row (ConnectFailed mirror; the streamlocal twin then opens on the same connection on-Unix; #[cfg(not(unix))]'s OQ-11 decline row compiles off-Unix only — the universal-compile/clean-decline gate mirrored from the server side), the twin's on-Unix pump served in the same row.
  • git: the grammar's construction riding the wire verbatim + the pack duplex pump + the exit-status(0) consumed into the family code, the pre-wire InvalidRepoId / budget rows (no channel opened), and the receive-pack-refused RequestRefused row.
  • The PumpBuf/drain caps and the exit-currency mirror are unit rows inside client_ops.rs / exit_code.rs / error.rs (taxonomy) / dispatch.rs (the construction arm's round-trip).

Verification

cargo test (default), per-feature (--features {session, git, tunnel, sftp}), pairs, and --all-features — all 15 test binaries green in the full builds (the client_ops binary: 18 rows under --all-features, the per-build sets: session 12 / git 5 / tunnel 3 / featureless 1); cargo clippy --all-features --all-targets and cargo clippy --all-targets both -D warnings clean; cargo fmt --check clean; cargo doc --no-deps warning-free.

Summary

The client op rows landed: family ops in (alktty session shapes / alkgit Service), vanilla wire ops out, no vanilla concepts riding up (OpError's family-mirrored taxonomy; OpenDeclineReason as the typed-open decline's one-to-one mirror). The session rows give the hub the alktty chunk plane in both directions with the exit consumed in-band as the family exit chunk (the derivation contract's client arm, extracted into the shared two-direction exit_code.rs module — table and rules never forked); the tunnel rows are the typed direct-tcpip/direct-streamlocal opens into raw byte duplexes (rejects mirror to OpenDeclineReason, the connection stays usable, row-pinned per decline class); the git row builds the vanilla exec command through the shared grammar's new construction arm and pumps the raw pack duplex with the exit consumed; the sftp placeholder declines in every build before any wire op. The mechanics facts are pinned in code (the wait()-drain shapes, the split-halves pump for exit-consuming rows, the typed-open-then-straight-conversion for the exit-less ones), the fake-sshd double grew the scripted op-row surfaces + observations, and 18 contract rows per-build + one smoke landed through it.