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.
279 lines
15 KiB
Markdown
279 lines
15 KiB
Markdown
---
|
||
id: w3-tty-session
|
||
name: "Wave 3: session → alktty (translation bridge + exit derivation)"
|
||
status: completed
|
||
depends_on: [w3-git-mapping]
|
||
scope: moderate
|
||
risk: medium
|
||
impact: phase
|
||
level: 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
|
||
|
||
- [x] 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
|
||
- [x] `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
|
||
- [x] 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)
|
||
- [x] 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
|
||
- [x] 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
|
||
- [x] The real-tool pty smoke row (posture decided + recorded)
|
||
- [x] `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. |