Files
alkssh/docs/research/poc-3-mapping-bakeoff.md
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

21 KiB
Raw Permalink Blame History

POC-3 findings: the mapping bake-off, channel bridge through real sibling seams

Status: Complete (phase 0). POC source: /workspace/alkssh-poc3 (standalone crate, disposable, never merges). All four stages ran against vanilla clients (OpenSSH 9.6 ssh -L/-R, real git 2.43, real curl 8.5) and the published crates.io forms of every sibling, with no path dependencies and no in-family forks. The doc records the evidence: three corrections to mapping-table.md, the russh 0.64.1 runtime facts phase 1 must code around (the inline-dispatch rule), the identity/scope live check, and the OQ seeds.

Setup exercised

  • russh 0.64.1 (crates.io, ring backend; the same published tree POC-1/POC-2 pinned; re-verified still the newest release). Same dependency tree as both prior POCs across all four stages.
  • Sibling seams, published forms: alkgit 0.1.0 + alkgit-substrate 0.1.0 (the shipped backend composition, with alkstore-mem engine + alkblobs::Store::mem_only() object pool, the substrate's own mem-assembly shape, SubstrateAssembly::mem), alksocks 0.3.1 (local feature, the shipped tcp_dial), alktunnels 0.4.1 (local feature, the shipped local_dial), alktty 0.8.1 (local feature, the shipped LocalTtyBackend over portable-pty), alkcall 0.10.1 (channels + identity types).
  • Servers per stage were built as feature-shaped builds: stage A's server serves only git-parseable exec (a git-only build), stage B's only SOCKS-on-session (a dynamic-forward shape), stage C's only direct-tcpip (an egress-tunnel shape that also declines the -R global request), stage D's only pty/shell/pipe-exec (a session shape). The auth loopback is POC-1's verbatim shape (fingerprint registry fed from ssh-keygen -lf, auth_publickey_offered accept-and-decide, all other methods declined).
  • POC mechanics: single binary, stage functions returning check reports (POC-1's harness); full clean runs reproduced twice.

Stage A: git exec mapping (24/24 PASS)

1. Vanilla git works through the seam with zero seam fixups

git push → git clone → rev parity → second push (have/star negotiation on the populated repo) all succeeded through:

vanilla git (GIT_SSH_COMMAND) → OpenSSH → russh server
  → exec_request parse (the alkssh-shaped grammar)
  → serve_duplex_resolved(repo_id, identity, service, channel.into_stream(),
                           Limits, registry, refs, packgen, ingest, false, pipeline)

with the real alkgit-substrate mem composition as the backend: registry/ref-rows/packgen/ingest, an in-memory alkstore engine, and an in-memory object pool. The published alkgit seam needed no wrapper, no adapter glue beyond the command parse, and no dialect nudges. The hypothesis that phase-1's mapping ADR gated on (stage A gates the ADR work) is confirmed.

2. The SSH exec-command grammar (the alkssh-side parse)

What real OpenSSH git actually sends: git-upload-pack '/owner/demo', a single-quoted repo path with a leading / (the parse strips quotes and the leading slash; no \0host= rides the SSH exec, so the mapping-table's "same grammar as preamble.rs, without \0host=" is exactly right). The POC grammar table (all rows verified):

exec command verdict
git-upload-pack '/r' / git-receive-pack '/r' / git-upload-pack /r git parse, dispatched
git-send-pack '/r', git-upload-archive '/r' declined (unknown service, honest surface)
git-upload-pack (service-only, no repo) declined: SSH exec must carry the repo
uptime (non-git) declined parse-then-fail, no resource alloc

3. Subsystem mapping row (corrected at the detail level)

mapping-table.md says git subsystem names "arrive as either" (exec or subsystem). On the SSH wire a subsystem request carries only the name, and no repository is available to resolve. The POC proves the forced decline: subsystem("git-upload-pack") and subsystem("sftp") both fail cleanly with a channel failure, with the finding recorded: over SSH, git always uses exec; a subsystem git request can never dispatch (no repo id), so the correct mapping is decline-with-reason. The mapping-table row needs the detail fix.

4. russh runtime facts phase 1 must code around (new, load-bearing)

  • Server handler callbacks run inline inside the session's packet loop. Blocking inside exec_request/pty_request/shell_request (or any handler) suspends packet processing for the entire connection: the awaited channel data is only routed by that same loop, so an inline serve deadlocks (receive-pack pushes bytes immediately after the exec request). Confirmed by construction: the serving entry is spawned and the handler returns immediately. This is the binding implementation constraint for the alkssh adapter: every serving path is tokio::spawn + handler returns fast.
  • The spawned task gets exit reporting through Session::handle(), a cloneable server-side Handle carrying data / extended_data / eof / close / exit_status_request / exit_signal_request. The exit-status mapping row is implementable exactly as the table prescribes: send exit-status before close from the serving task.
  • The accepted-but-held Channel can be stored in the handler and taken later in the request callback (channels.remove(&id) → into_stream() in the spawned task), with no wait() anywhere (POC-1's caveat holds server-side too).

5. Exit-status fidelity, denial fidelity, vanilla diagnostics

  • Vanilla git paths exit 0 (the exit-status request rode the wire); an unknown repo produces the collapsed generic denial before any protocol byte (no existence oracle) and vanilla git reports fatal: remote error: access denied: owner/absent, exit 128.
  • A russh client exec against the absent repo observed ExitStatus{1} after the denial band; the exit-status flow is proven in both directions, including the failure leg.
  • The git-only build's decline matrix held under vanilla ssh: ssh host uptime failed with the parse-then-fail decline logged; pty_request/shell_request/env_request declined with failure replies (russh client observed ChannelMsg::Failure); the fail- closed feature matrix of the mapping table is live end to end.

Stage B: SOCKS5-in-channel (6/6 PASS)

1. serve_socks5_over works over a russh session channel

Construction: server accepts the session channel, spawns the SOCKS-detect (first-byte sniff via BufReader::fill_buf: 0x05 = RFC 1928 greeting), and routes to serve_socks5_over(stream, dial, None, None, false) with the shipped alksocks::tcp_dial() (the local feature's DialFn, resolving domain-form targets). Client side: a local bridge listener pumps each accepted TCP socket bidirectionally into one authenticated session channel. Real curl --socks5-hostname through the whole chain returned the endpoint body byte-identically: CONNECT over the SSH wire, dialed at the server through the family seam.

2. OpenSSH's own -D never speaks SOCKS on the wire (mapping row corrected)

This is a mapping-table correction: vanilla ssh -D performs the SOCKS5 handshake client-side and then opens plain direct-tcpip channels per connection. The session → SOCKS-in-channel row is therefore the family-native client shape (an embedder/alktty-style client speaking SOCKS5 on the channel), not what a vanilla ssh -D emits; vanilla -D traffic takes the stage-C row (direct-tcpip → egress). Both rows are real; the table should say which client shape each serves. (POC evidence: this stage's proxy uses the session channel; stage C's -L-style row serves forwarded and SOCKS-originated direct-tcpip alike.)

3. The dispatch split is real and needs a bounded detect window

Parse-then-route (stage A) and SOCKS-detect (this stage) are the two session-channel dispatch modes the mapping table predicted, and both work. The detect runs in a spawned task with a bounded window (500 ms in the POC): the first byte decides the route; a silent channel is declined. Two consequences for the ADR: (a) the detect must not block inline (the §A.4 deadlock rule applies: the sniffed byte arrives via the same loop that awaits it); (b) the detect window is a design shape to pin, an OQ seed: whether the dispatch is a first-byte sniff, an in-band preamble, or an exec-request marker chosen by the assembly.

4. Refusals are RFC-faithful through the seam

A CONNECT to a dead port: the dial seam returns SocksDialError::Refused → alksocks replies the mapped RFC code → curl exits 97 (proxy-connection-not-allowed class) with no data crossing. Backpressure/throughput: 8 MiB through the session-channel proxy in ~585 ms (in-process route; the 1 MiB channels-reassembly leg is not on this path). Non-SOCKS first byte on a session channel → declined, logged; no HTTP serving happens on the SOCKS route.

Stage C: direct-tcpip via alktunnels route 1 (8/8 PASS)

1. The bridged route works end to end with the published crates

Route 1 (adapter-side bridge) exercised as the mapping table's route note describes: the russh server holds a real ChannelClient into a family fabric, an in-process alk/channels connection whose serving side (the ChannelsAdapter accept end) registers ChannelOperations + register_tunnel_direct_openable with the shipped alktunnels::local::local_dial() injected. Per vanilla ssh -L channel:

channel_open_direct_tcpip → TunnelSession::open_direct(
      &client, SubstrateAddr::HostPort{host, port}, Substrate::Tcp)
  → take_halves() → pump_bidi(channel.into_stream(), read, write)

Vanilla ssh -L + curl to the bridged port returned the endpoint body byte-identically; 8 MiB through the bridge in ~333 ms. The direct_egress precedent in alksocks' composed feature is the same shape; the family precedent survives contact with the SSH door.

2. Failure shapes at both ends of the bridge

  • Fabric open failure → the SSH channel is rejected explicitly (ChannelOpenHandle::reject(ConnectFailed)); accept is possible only after open_direct succeeds because accept(mut self) consumes the handle, so the handle moves into the spawned task. A vanilla client dials a dead target and gets a clean curl failure (exit 56) instead of a hang.
  • The -R ingress direction is declined on this egress-only build: Handler::tcpip_forward returns false (the global request refuses), vanilla ssh -R dies with remote port forwarding failed for listen port (exit 255), and the event log records the refusal: honest advertisement, gate-before-wire.
  • Unfeatured session-channel opens drop (auto-reject) unchanged from POC-1 on this and the other stage builds.

3. Identity/scope propagation observed live

The direct establisher's identity witness recorded u-writer; the SSH-authenticated identity rides the transport connection into the channels layer (CF-005 propagation), and the establisher's per-call auth (CF-006) sees it. The visible-surface invariant's alkcall half (opener identity resolved from the authenticated transport) is confirmed inside the mapping, not just documented.

4. Wiring recipe worth carrying into phase 1

The in-process fabric leg (worth recording because it was the POC's only real lift, and the alkforge assembly will build exactly this): OperationRegistry + InstallChannelZero hook (spawn: accept channel 0's accept_bi, split_single_stream, set_identity(caller), CallConnection::new_single_stream, register ops + openables, Dispatcher::serve_single_stream) on the accept side; CoreConnection::from_bidi on both ends of a tokio::io::duplex transport; ChannelClient::from_connection on the alkssh side with the identity attached before dialing.

Stage D: session/pty mapping (13/13 PASS)

1. The mapping is a translation bridge, not a pump (mapping-table note confirmed with a shape)

alktty's data plane is chunk-framed in both directions (STREAM_STDIN/STDOUT/STDERR/CTRL_IN/CTRL_OUT, 5-byte header + payload; alktty::wire). The SSH mapping therefore wraps the channel with two small adapters: SSH data/EOF → stdin chunks (+ zero-length EOF sentinel); alktty stdout chunks → SSH data, stderr chunks → SSH extended_data(1), ctrl-out exit chunk → SSH exit-status request + close. drive_session_pre_negotiated(writer, reader, NegotiateRequest built locally, backends, ownership, identity), the exact producer construct the mapping table names, then runs the real session. Proven against a real PTY (LocalTtyBackend over portable-pty):

  • pty_request args → TerminalParamsWire (term/cols/rows/ pixels; SSH modes → the reserved {} JSON, an alktty-side reservation, alktty OQ-44; modes are deferred there); live: bash on the PTY answered stty size with the requested 24×80.
  • Keystrokes ride in, PTY echo and shell output ride out (real bash prompt captured in the run output).
  • window_change_request → ControlMessage::Resize as a CTRL_IN chunk → the PTY resized: stty size flipped to 40 120 after the client sent window_change(120, 40), the mapping row proven behaviorally.
  • Pipe-mode exec_request: stdout rode SSH data, stderr rode SSH extended_data(1) (client received both distinctly), exit chunk → exit-status(0). The PTY/pipe stderr split the mapping table describes (PTY merges, pipe separates) is exactly what the local backend produces.
  • signal(TERM) → ControlMessage::Signal CTRL_IN chunk → the backend killed the session; the exit chunk carried the negative code and the SSH reply was an exit-signal request, matching the mapping-table's "only reachable in signal-termination cases" row, now with live wire evidence.
  • The git-parseable exec declined in the session-only build (parse-then-route: the git parse is the git feature's gate); the feature-split failure matrix holds in this build too.

2. Signal/exit fidelity over the alktty exit-code convention

The exit chunk is {"type":"exit","code":N} (alktty ADR-004, ported from alknet ADR-055). Findings of this stage, verified against the published sources:

  • Pipe path: the negative code is the signal encoding. Pipe-mode signal-terminated children arrive as the negative signal number verbatim from std's ExitStatusExt (pipe.rs::exit_code_from maps ExitStatus::signal() to −sig; the alknet ADR-055 convention). So SSH exit-signal needs no wire change: the adapter's check mapped −15 → exit-signal("TERM"); the name derivation is a server-side number→name table lookup, not an alktty ask.
  • PTY path: the signal number does not survive the backend. portable-pty 0.9's ExitStatus conversion records code = status.code().unwrap_or(1) → 1 for signal-terminated children, keeping the signal only in a separate humanized (strsignal-text) string field that alktty's PTY waiter discards (pty.rs reads exit_code() only; the number is unrecoverable through portable-pty 0.9's trait surface). Alktty's own PTY signal tests assert code != 0, never the encoding. Consequence: faithful exit-signal for signal-killed PTY sessions needs the alktty backend to conform to its own ADR's negative-code convention, an alktty/portable-pty ask with tty/pty-grounded justification (upstream trackers; the alkssh mapping adapts automatically, no hedge needed).
  • The −1 sentinel (backend wait failure) collides with a real SIGHUP termination (signal 1), documented in alktty ADR-004 §4, with an additive exit_error control type as its recorded extension seam. Alkssh renders −1 as exit-status(255), fabricating no signal for a wait failure.
  • Pipe-mode signals are pid-only (a SIGTERM to sh -c "sleep 60" leaves the orphaned sleep holding the stdout pipe, with no exit chunk until the whole group dies); PTY-mode signals reach the process group (correct terminal semantics). The signal row's fidelity depends on the tty/pipe mode split the table already notes.

3. Client-visible mechanics (russh 0.64 API facts from this stage)

  • russh client has Channel::exec() (there is no request_exec); pty/subsystem request failures surface as ChannelMsg::Failure via wait() (not as Err from the request call); the caller must read the channel until Success/Failure.
  • window_change/signal are fire-and-forget (no want-reply), and must be sent before into_stream() (after the conversion the Channel is consumed); server-side they carry no SSH reply, so the handler must not send one.
  • The server handler loop dispatches CHANNEL_SUCCESS-style replies only through explicit handler calls; request-level failure replies in stage A/D all used Session::channel_failure.
  • The channel message loop client-side delivers interleaved Data/ExtendedData/Success/ExitStatus on one queue; drain to the expected message, continue past the rest (the POC hit the break-on-first-message trap twice; noted so the alkssh consumer half doesn't).

Hypothesis verdicts (pocs.md §POC-3)

  • "one-seam-per-mapping hypothesis" is confirmed across all four seams with zero seam fixups; the mapping glue is adapter-side (command parse, chunk translation, handle plumbing).
  • "parse-then-route dispatch" is confirmed (stage A), with the SOCKS-detect variant confirmed in stage B plus the bounded-window and spawn-not-block constraints.
  • "EOF/close/signal fidelity end to end" is confirmed: exec → serve → exit-status (0 and 1 legs), exit-signal for signal termination, close mapping, refusal diagnostics at both bridge ends.
  • "stderr semantics in pipe vs PTY mode" is confirmed exactly as the table describes (separate extended_data in pipe; merged under PTY).
  • "throughput sanity" is confirmed with margin: 8 MiB in ~0.3–0.6 s per bridged route.
  • "stage D may surface PTY-merge stderr surprises that reshape the request-level mapping rows": partially. No surprises in the PTY/pipe split, but the signal/exit-fidelity details and the translation-bridge shape (two adapters, not a pump) do reshape the session row's implementation notes.

Corrections recorded against phase 0 research

  1. mapping-table.md §channel-type rows: the git subsystem claim ("git-upload-pack etc. arrive as either") is wrong over SSH: subsystem cannot carry the repo id, so even a git service name must decline. Git over SSH is exec-only.
  2. mapping-table.md: ssh -D from a vanilla client does not produce SOCKS5-in-channel traffic (OpenSSH does SOCKS client-side and opens direct-tcpip). The dynamic-forward row serves family-native clients; vanilla -D rides the tunnel row.
  3. mapping-table.md session/pty row implementation notes: the alktty bridge is a translation bridge (chunk framing both directions), not a byte pump; plus the exit/signal fidelity findings above (pipe-path −N encoding holds; PTY path reduces signal termination to a plain code, portable-pty's exit surface).

OQ seeds carried forward

  1. Session-channel dispatch shape for SOCKS (first-byte sniff vs in-band preamble vs exec marker; the detect window's bound).
  2. Exit/signal fidelity over the alktty exit-code convention (the −N encoding holds on the pipe path, and the name derivation is server-side; the PTY path's signal-identity loss is an alktty/portable-pty matter; pipe-mode pid-only vs PTY process-group scope).
  3. Shell command convention (already seeded; the POC's stand-in, bash for shell and sh -c <cmd> for exec, is an explicit assembly policy, not wire-derived).
  4. The -R listen flow (forwarded-tcpip mapping): untouched by the POC beyond the refusal shape; route choice (ingress via register_tunnel_forwarded_openable) is phase 1 work with this stage's route-1 recipe as the template.
  5. The identity-attachment seam (SSH fingerprint → alkcall Identity → transport identity → establisher witness) worked in-POC; whether the assembly composes these as one configuration surface is an assembly-layer question to keep explicit.

Reference run

Full clean run at findings time: **51 checks across 4 stages: 24 + 6

  • 8 + 13, all PASS, overall=true, exit 0** (reproduced twice; second run byte-identical PASS set with different ephemeral ports). Server-side event logs captured per stage in the run output (the diagnostics quoted above are verbatim from that run).

References

  • pocs.md §POC-3 (plan), mapping-table.md (the rows under test, three corrections recorded above), poc-1-auth-bridge.md (the auth loopback everything rides), russh.md (API inventory)
  • POC source: /workspace/alkssh-poc3 (src/stage_{a_git,b_socks, c_tunnel,d_tty}.rs; the fabric/serving recipes and the translation bridge are the reusable shapes)
  • russh 0.64.1, alkgit 0.1.0, alkgit-substrate 0.1.0, alksocks 0.3.1 (local), alktunnels 0.4.1 (local), alktty 0.8.1 (local), alkcall 0.10.1 (crates.io, ring backend); git 2.43.0, OpenSSH 9.6p1, curl 8.5.0 (vanilla tooling)