Files
alkssh/tasks/implementation/w3-tty-session.md
T
glm-5.3-flash f4b5f896b4 docs: sweep em dashes from all markdown prose
Renames the written-usage rule into practice across the contract surface:
docs/ (architecture, research, plans, sdd), tasks/ (all 43 task files,
taskgraph validate passes), AGENTS.md, README.md, CHANGELOG.md, and the
.opencode/agents/ prompts. Every replacement is a sense-preserving
rewrite (colons, semicolons, commas, parentheses, sentence splits, varied),
never a mechanical swap; no spaced hyphens were substituted. Enum fields,
frontmatter, tables, wire data, and cited identifiers survive verbatim;
en dashes inside numeric ranges keep their meaning.

Verification: taskgraph validate (43 tasks), repo grep clean (single
exempt hit: the verification command's own search pattern in AGENTS.md),
cargo test, clippy --all-targets -D warnings, fmt --check, doc all green.
2026-11-04 20:28:00 +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, since ssh -t host "cmd" sends pty-then-exec and must serve in PTY-mode; a shell_request without a preceding pty serves pipe-mode tty = None, and whether such a shell serves or declines is for the task to decide 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, which is 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 with 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, where the backend's stderr stream is None, so the translation is universal with 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, with the 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, so reach them into the spawned bridge through a per-channel control surface (a bounded mpsc; decide plus 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, with 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 serves through the alkssh-side TtyBackend shim double, since alktty's MockBackend/testing.rs are pub(crate) + cfg(test) and so 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 (the 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, covering 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 and 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), because 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, which is 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), and simply closes.

  • 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, so 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, matching vanilla sshd's ordering).

  • The derivation contract (mapping.md rules 1–6, in code + unit rows + wire rows): derive_exit, where 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, letting alktty's ADR-056 kill-on-drop contract clean up the cancelled session, 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, because 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, with the shim's resource_id guaranteeing 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.