Files
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

279 lines
15 KiB
Markdown
Raw Permalink 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.
---
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.