Files
alkssh/tasks/implementation/w3-tty-session.md
T
glm-5.3-flash b1e569d1a8 feat(session): land the session mapping — the translation bridge + the exit derivation (w3-tty-session)
The biggest single-row surface of the spine: pty/shell/pipe-exec serve
through the published alktty seam (drive_session_pre_negotiated) over a
translation bridge, re-landing POC-3 stage D as contract:

- src/tty_map.rs (session feature): the bridge (SSH data → Chunk::stdin
  through a bounded mpsc + framing task; SSH EOF → the zero-length
  stdin sentinel — verified against alktty's pump semantics; stdout →
  data; stderr → extended_data(1); exit chunk → the derivation
  contract), the locally-built negotiation (OQ-08: shell argv verbatim,
  wrapper ++ [cmd], cwd/env session-side, ttymodes → the reserved {}
  modes JSON), and the spawned serving path.
- serve_tty_row: driven chunks→SSH inline so the exit report is the
  path's final act (status before close, ADR-006 decision 4); two
  cancel signals (per-channel watch fired by channel_close, connection
  teardown fired by SessionHandler::Drop) abort the alktty session
  task — the ADR-056 kill-on-drop contract is the cancelled session's
  cleanup.
- Derivation contract rules 1–6 in derive_exit + the crate-local
  signum→name table: −N → exit-signal(name), −1 → exit-status(255),
  core_dumped never, unmapped negatives → 255, the PTY-path rule-5
  fidelity limit documented; wire rows pin the shapes.
- Negotiation-error frames (alktty's 0x00-length-prefixed error
  framing) → negotiation-failed serving decline + exit-status(255) +
  close — the one could-not-start claim this door makes (nothing ran).
- Control rows: window_change/signal → the per-channel BridgeSurface
  (bounded mpsc, try_send, never awaited on the packet loop); signal
  names verbatim (no decline shape — fire-and-forget); pre-serve
  resizes update the stored pty params (final size wins).
- Unset-declines (OQ-08) keep the git row's serving-path shape
  (failure then close, spawned): shell-unconfigured /
  wrapper-unconfigured / seam-unconfigured; pipe-less shell SERVES
  pipe-mode (the recorded vanilla-sshd posture); env_request decline
  unchanged (OQ-09).
- tests: the hand-rolled TtyBackend shim + ownership witness doubles;
  serving rows through the harness, the unset-field decline matrix,
  derivation rows on the wire, negotiation-failure rows, the identity
  ride asserted live at the seam, the kill witness; the real-tool
  smoke — OpenSSH -tt against a real PTY via dev-dep alktty local
  (test graph only), skip-with-reason posture. Wave-2 stub rows
  migrated to the new decline reasons (dispatch_core, observe).

Verification: cargo test (113) / --features session (149) / git (142) /
tunnel (115) / --all-features (161) — all green; cargo clippy
--all-targets -- -D warnings ×5 feature configs; cargo fmt --check;
cargo doc --no-deps ×3 builds clean; taskgraph validate.
2026-10-28 20:29:59 +00:00

15 KiB
Raw Blame History

id, name, status, depends_on, scope, risk, impact, level
id name status depends_on scope risk impact level
w3-tty-session Wave 3 — session → alktty (translation bridge + exit derivation) completed
w3-git-mapping
moderate medium phase implementation

Description

Land the session branch — the biggest single-row surface of the spine (mapping.md's session rows): pty/shell/pipe-exec become a real serving path through the published alktty seam, as a translation bridge, not a byte pump (POC-3 §D.1). Everything POC-3 stage D proved, re-landed as contract:

  • The per-channel state + the store-and-take reuse — the channel store w3-git-mapping lands (channels.remove(&id) → into_stream() in the spawned serving task) gains the session rows' per-channel state: the PTY params a pty_request delivered (consumed by the later shell_request/exec_request on the same channel — ssh -t host "cmd" sends pty-then-exec and must serve PTY-mode; a shell_request without a preceding pty serves pipe-mode tty = None — decide whether such a shell serves or declines and record).
  • Negotiation, built locally, never wire-derived (OQ-08 — the serve-config is the whole truth): pty_request args → TerminalParamsWire{term, cols, rows, pixel_w/h, modes} (SSH ttymodes → the reserved modes JSON — alktty OQ-44's reservation, deferred there); shell_request → NegotiateRequest{carriage: "raw", cmd: shell_argv (the serve-config shell, verbatim), cwd: session_cwd, env: session_env, backend: the seam selector} → drive_session_pre_negotiated(writer, reader, req, backends, ownership, identity) spawned; exec_request (pipe row) → cmd = wrapper ++ [cmd] (the OQ-08 argv append — vanilla quoting semantics; never naively split), tty = None pipe mode. Unset-declines (OQ-08): shell_argv unset → shell_request declines; exec_wrapper_argv unset → the pipe row declines — never a fallback. Decide the wire shape per row (capability decline — channel stays usable — vs the serving-path failure+close shape) and record.
  • The translation bridge — two small adapters around a duplex the serving task drives (the POC-3 §D.1 shape): SSH data/EOF → Chunk::stdin (decide the EOF translation — the zero-length stdin-chunk sentinel vs alktty's ControlMessage::Eof CTRL_IN — verify what alktty's ChunkReader/backend actually consumes and record); stdout chunks → SSH data; stderr chunks → extended_data(1) (PTY mode merges upstream — the backend's stderr stream is None there, so the translation is universal, no mode-switch); the exit chunk (STREAM_CTRL_OUT) → the derivation contract. No unbounded buffering in either adapter (convention 9).
  • Control rows — window_change_request → ControlMessage:: Resize CTRL_IN; signal → ControlMessage::Signal (alktty's uppercase-name vocabulary; name accepted verbatim — decide the decline shape for names alktty's backend semantics reject and record). Mechanics: the requests arrive on the packet loop after the serving task owns the wire — reach them into the spawned bridge through a per-channel control surface (bounded mpsc — decide + record; a silent unbounded proxy is a bounded-resources bug).
  • The exit derivation contract (mapping.md rules 1–6, normative): the alktty exit chunk ({"type":"exit","code":i32}, exit-chunk-is-last) is the sole fidelity input, never widened: code ≥ 0 → exit-status(N); negative −N, N ≥ 2 → exit-signal(name) via the server-side Unix signum→name table (crate-local; not derived from alktty's signal_from_name inverse unless that is public and sound — decide the table's form and record; core_dumped never asserted, the bool stays false); −1 → exit-status(255), never a signal claim (rule 3 — the SIGHUP collision accepted symmetrically, per the mapping.md rationale); non-Unix adds no other negatives (rule 4); the PTY-path rule-5 fidelity limit documented in the module docs (signal- terminated PTY sessions arrive exit-status(1) — portable-pty's exit surface, upstream); unmapped negatives → 255 (rule 6). Sent before close, from the spawned task.
  • env_request stays declined (OQ-09 — unchanged from wave 2); session_cwd/session_env ride NegotiateRequest only.
  • Contract rows through the harness, per build — the wave-2 stub rows (pty_row_stub/shell_row_stub/ pipe_row_stub) replaced; the decline matrix's unset-field rows — feature on + handle unset → the rows decline; featured+set serve through the alkssh-side TtyBackend shim double (alktty's MockBackend/testing.rs are pub(crate) + cfg(test) — never importable; alkssh hand-rolls the shim: a backend that records TtyParams, emits scripted stdout/stderr/exit chunks and consumes stdin/resizes/signals — per the TtyHandle field shapes). The identity-landing item (the parked wave-2 note) asserted through the row that observes the identity at drive_session_pre_negotiated.
  • The real-tool smoke — one OpenSSH pty session through the harness against the shim (or against a real PTY via a dev-dep alktty local composition — decide which posture and record; the real bash/stty size fidelity was POC stage D's, wave 5 re-lands it as e2e): the smoke owns the wire rows a plain rustsh-client row can't pin (echo paths, stty size-grade fidelity are wave-5's matrix; here the pty lifecycle rows only).

Acceptance Criteria

  • pty/shell/pipe-exec rows serve through drive_session_pre_negotiated (spawned, handler fast); the store-and-take + per-channel PTY state reused from the git task's handoff; pipe-less shell decided + recorded
  • pty_request → TerminalParamsWire (ttymodes → the reserved JSON), shell_request → shell argv, exec → wrapper ++ [cmd], cwd/env session-side; unset-declines on both OQ-08 rows with recorded wire shapes
  • The bridge translated per row (stdin chunks + EOF shape verified against alktty; stdout→data; stderr→extended_data(1); resize/signal CTRL_IN reach the serving path through a bounded per-channel surface)
  • The derivation contract rules 1–6 in code with the signum→name table; −1→255; core_dumped never; PTY-path rule-5 documented; exit-status before close from the spawned task — pinned by rows
  • The decline matrix per build green through the harness (unset- field rows included); identity ride asserted; the env_request decline unchanged; skeleton stub rows migrated
  • The real-tool pty smoke row (posture decided + recorded)
  • cargo test (+ per-feature + --all-features rows), clippy ×2 (-D warnings), fmt, cargo doc --no-deps pass

References

  • docs/architecture/mapping.md (the session channel-type row, request-level rows, §Exit chunk → SSH exit reporting — rules 1–6)
  • docs/architecture/decisions/006-mapping-bridge-and-dispatch.md (decision 4 — spawn-and-return: status before close from the spawned task), docs/architecture/decisions/003-feature-graph.md
  • docs/architecture/auth.md §Server direction step 4 (the identity ride), §Dispositions (OQ-03)
  • docs/research/poc-3-mapping-bakeoff.md §Stage D (the bridge shape, control rows, the signal/exit fidelity findings, §D.3 mechanics)
  • docs/research/reverse-runner.md (pipe-mode pid-only signal caveat — the fidelity note)
  • alktty 0.8.1: adapter.rs:234-241 (drive_session_pre_negotiated), negotiation.rs (NegotiateRequest/TerminalParamsWire), wire.rs (chunks, readers/writers), control.rs (ControlMessage), backend.rs (TtyHandle/TtyParams/TtyBackend shapes)

Notes

Decisions recorded (implementation, 2026-10-28)

  • The module (src/tty_map.rs, feature session): the session rows live in one module following the git_map pattern — the bridge, the locally-built negotiation, the derivation contract, and the serving path. serde_json (the OQ-44 modes JSON + the exit chunk/error-frame reads) and bytes (the chunk plane) joined as session-gated optional deps at alktty's own pins.

  • The negotiation (locally built, never wire-derived — OQ-08): pty_request args → TerminalParamsWire with SSH ttymodes → the reserved {} modes JSON (alktty OQ-44's reservation, deferred there; the map is honest — nothing is carried). Shell argv rides the serve-config verbatim; exec rides wrapper ++ [cmd] (never naively split); cwd/env come from session_cwd/session_env only. backend_params stays empty (no backend-specific selector fields on this door; the seam selector is the backend string). Pipe-less shell decided: a shell_request without a preceding pty SERVES pipe-mode (tty = None) — vanilla sshd serves a shell without a terminal too (ssh -T is legitimate vanilla).

  • Unset-declines (OQ-08) — wire shape decided + recorded: the serving-path shape (channel_failure then close, from a spawned task) for shell_argv-unset (shell-unconfigured), exec_wrapper_argv-unset (wrapper-unconfigured), and the session_seam-unset rows (seam-unconfigured — the git row's recorded shape); the pty row checks the seam first (a doomed allocation is declined before its params are stored — decline first, alloc never). Field checks precede the seam check. The unvanilla guards keep the git row's shapes: channel-not-held → inline request-level failure; no-identity → the spawned serving-path decline. A routed request answers channel_success inline (the POC-3 §A.5 shape).

  • The translation bridge (the POC-3 §D.1 shape, two adapters around a duplex pair): SSH data → Chunk::stdin through a bounded mpsc (bound 64) feeding a framing task; SSH channel EOF → the zero-length stdin-chunk sentinel — decided and verified against alktty 0.8.1's pump_client_to_backend: a zero-length stdin chunk shuts the backend's stdin, identical in effect to ControlMessage::Eof, and rides the data path the SSH EOF already chose (no extra control frame). stdout chunks → SSH data; stderr chunks → extended_data(1); the exit chunk → the derivation contract. All buffers bounded (convention 9): mpsc 64, duplexes 64 KiB, frames bounds-checked against alktty's MAX_CHUNK_LEN.

  • The negotiation-error frame is a serving-path decline class (decided + recorded): alktty re-runs validation/allocation/ownership inline even on the pre-negotiated path and answers failures with a length-prefixed error frame (first byte 0x00 — alktty's ADR-052 §5 disambiguation invariant). The bridge reads it via a 4-byte first-window disambiguation and maps it to negotiation-failed + exit-status(255) + close — the one place this door claims 255: the session never started, nothing ran, so 255 is the honest could-not-start semantic (contrast the git row, whose service runs and honestly reports 1; 255 is never fabricated for a real outcome). A bridge read ending without an exit chunk reports nothing (sole-fidelity-input posture: absence is neither 0 nor 255) — plain close.

  • The control rows: window_change_request → ControlMessage::Resize, signal → ControlMessage::Signal, forwarded through the per-channel BridgeSurface (handler state while the row serves): a bounded mpsc whose try_send is never awaited on the packet loop — a full/closed surface drops the control event (fire-and-forget rows carry no SSH reply to decline with; bounded resources, convention 9). Signal names are accepted verbatim — no decline shape: the backend owns the rejection semantics, nothing on the wire can answer. A pre-serve resize updates the stored pty params instead (the final size wins — vanilla sshd's ordering).

  • The derivation contract (mapping.md rules 1–6, in code + unit rows + wire rows): derive_exit — code ≥ 0 → exit-status(N); negative −N, N ≥ 2 → exit-signal(name) via the crate-local [SIGNAL_NAMES] signum→name table (1–31, server-side work, deliberately not alktty's signal_from_name inverse); −1 → exit-status(255) (rule 3, the SIGHUP collision accepted symmetrically); core_dumped never asserted (the bool stays false on every exit-signal row); unmapped negatives (and i32::MIN's checked-abs failure) → 255 (rule 6); the PTY-path rule-5 fidelity limit documented in the module docs. Report sent before close from the spawned task; names ride russh's Sig (known variants verbatim, Sig::Custom for the rest — RFC 4254 §6.10).

  • Cancellation (bounded resources + alktty's kill-on-drop): the serving path watches two signals — the per-channel cancel (fired by channel_close via the surface's watch) and the connection teardown (fired by the handler's Drop; the russh session ended however it ended). On either: the serving task aborts the alktty session task, dropping its not-yet-resolved exit_code future — alktty's ADR-056 kill-on-drop contract is the cancelled session's cleanup — then closes the channel and ends. Witnessed live by the shim's kill log row.

  • The contract tests (tests/tty_mapping.rs, session-gated; tests/common/tty_doubles.rs): the shim double (hand-rolled per the TtyHandle field shapes — alktty's MockBackend is pub(crate) and never importable) records TtyParams, consumes stdin/EOF/resizes/signals, emits scripted chunks and exit codes, and carries the kill-on-drop witness; the ownership witness double observes the identity at the seam. The wave-2 stub rows migrated: dispatch_core.rs's session-build rows now pin the unset-decline reasons (seam-unconfigured / wrapper-unconfigured, same serving-path wire shape); observe.rs's two session rows updated (and a one-line pre-existing clippy lint in its git-build cap row fixed). env_request decline unchanged (OQ-09).

  • The identity ride (the parked wave-2 item, discharged): asserted through alktty's ADR-050 ownership check — the shim's resource_id guarantees the check evaluates, and the ownership double records the resolved identity; the deny shape also pins the negotiation-error wire shape.

  • Real-tool smoke posture (recorded): one OpenSSH -tt pty session (ssh -tt … smoke-rider@127.0.0.1 with piped stdin) through a TCP-mounted adapter against a real PTY via a dev-dep alktty local composition — the decided posture: a shim row cannot show what vanilla OpenSSH really sends, and POC-3 stage D proved exactly this composition. The dev-dep features = ["local"] unification applies to the test graph only (the published lib never enables it; src/ never names a local shape). Requires ssh + sh; absent tooling is skipped with a printed reason — never silently green. The smoke pins the pty lifecycle (serve, stdin ride, clean exit → exit-status(0) before close); the stty size-grade fidelity matrix is wave 5's.

  • Verification: cargo test (default), cargo test --features session (incl. the smoke + shim rows), cargo test --features git, cargo test --features tunnel, cargo test --all-features; cargo clippy --all-targets -- -D warnings ×5 (default / session / git / tunnel / all-features); cargo fmt --check; cargo doc --no-deps (default + session + all-features) — all green.