- Rewrote the remaining em dashes outside docs/reviews/ (preserved as historical record, matching the alktty precedent): tests/ (313), fuzz/ (34 plus one shell comment), tasks/ (671 plus 12 kept in fences), docs/research + sdd_process + plans (525 plus 25 in fences), docs/architecture/overview.md fence labels (13), README, CHANGELOG, AGENTS.md, fuzz/README.md (176), and the .opencode/agents prompts (110) - Rewrote fenced content too: ASCII tree and module-map label separators, Rust snippet comments, and template placeholders; wire data, JSON/BAST examples, and transcripts survive verbatim - Rewrote 6 assert/expect message literals in tests/ and 2 format strings in fuzz/shared/src; no test data changed - Added Project Convention 18: no em dashes in prose (doc comments, markdown including docs/ and tasks/, agent prompts, commit messages); fence content rule, en-dash clause rule, and the no-spaced-hyphen prohibition follow alktty's convention 15 Follows6b20924(src doc comments) andcd0afe0(docs/architecture). Verification: cargo test (141 pass); cargo clippy --all-targets -D warnings; cargo fmt --check; cargo doc --no-deps; wasm check and clippy for wasm32-unknown-unknown with -D warnings; em dash grep over the repo returns matches only in docs/reviews/
18 KiB
AGENTS.md
Operating instructions for opencode agents working in this repo. opencode
auto-loads this file as instructions, overriding the built-in defaults for
this project. Custom agents in .opencode/agents/ inherit these rules
unless their own prompts say otherwise.
Git Workflow
Commit and push when reasonable. When a change is complete and
verified (build + lint + tests pass), commit and push to origin/main
without asking. This overrides the built-in default of "only commit when
explicitly asked."
The workflow:
- Make the change
- Verify:
cargo test,cargo clippy --all-targets -- -D warnings,cargo fmt --check,cargo doc --no-depsif docs changed - Inspect
git statusandgit diffbefore staging; stage only the intended files, never secrets - Write a concise commit message matching the repo style (see
git log --oneline -10). For multi-point changes, use a summary line plus a body with bullet points and a verification block. git push origin main- Report the commit hash and the verification summary
Exceptions (do not commit or push without asking):
- The change is exploratory / speculative (you're not sure the user wants it kept)
- The user is actively reviewing the diff and may ask for changes
- The change touches the wire format or a trait shape that backends or consumers implement (one-way doors; see "Wire formats are stable" and "Backend trait shapes are one-way doors" below; once consumers exist, those signatures are wire-stable contracts)
- You'd be force-pushing, amending a published commit, creating an empty commit, or skipping hooks
Never commit secrets, keys, or credentials. If a commit fails or hooks reject it, fix the issue and create a new commit; do not amend the failed one.
Git identity is preconfigured (glm-5.3-flash <glm-5.3-flash@alk.dev>). Do
not change git config, skip hooks, or use git commit -i.
Project Conventions (Rust / tunnel protocol crate)
This is the tunnels protocol crate: arbitrary bidirectional tunnels in
the ssh -L / ssh -D sense (TCP, UDP, unix sockets, and other stream
or datagram substrates), riding alkcall channels the same way alktty
does. It is the sibling of alktty (alk/tty, terminal sessions) and
alkcall (call + channels, the substrate both build on). Where alktty
multiplexes one service with a fixed five-stream channel structure, this
crate generalizes the tunnel handler shape: the producer side dials or
accepts on some local substrate and pumps bytes to/from a channels data
channel; the consumer side bridges its own substrate onto that channel.
The conventions below apply to all work in src/ and tests/. They
mirror .opencode/agents/implementation-specialist.md §Project
Conventions and are repeated here so they apply to every session, not
just spawned implementation agents.
-
No comments in code unless the user explicitly asks. This is a project-wide convention. Doc comments (
///,//!) are fine and expected on public API. Inline//comments only when the user asks or when a non-obvious safety/correctness constraint would otherwise be missed (e.g., "a two-pump tunnel must shut down the opposite sink on pump completion, sincetry_join!alone deadlocks; see alkcall ADR-050'spump_bidi/ alknet ADR-078"). -
Error handling:
thiserrorfor library error types (TunnelError;HandlerError/StreamErrorcome fromalkcall::core). No panics in library code. Nounwrap()orexpect()outside tests. If you reach forunwrap, the error path wasn't specified, so stop and decide what should actually happen. For poisonedRwLock/Mutex, useunwrap_or_else(|e| e.into_inner())so a panic in one operation does not cascade to other operations. -
tokiois the async runtime: all I/O is async. The tunnel pumps, the channels integration, and the consumer session type are all async. Usetokio::syncprimitives (oneshot,mpsc) for lifecycle correlation and per-direction data flow. Any non-wasm backend module (local TCP/UDP sockets, process-spawned listeners) lives behind a feature flag like alktty'slocal. -
WASM target is load-bearing (by default), meaning the default crate (protocol-only) should compile to
wasm32-unknown-unknown, following alktty's precedent. This is what keeps the downstream TS/Python adapter story viable (a wasm-compiled alktunnels is the protocol layer for a sandboxed adapter). Use the wasm-clean tokio subset (rt,sync,io-util,macros,time); do NOT usefeatures = ["full"]; and keep socket/platform I/O feature-gated. If a design decision ever forces the default crate off wasm (e.g. the wire format embeds a substrate-specific type), that is an ADR-worthy decision, not an accident. -
Wire format is stable: the tunnel wire format is a one-way door once consumers exist. Follow the alknet ADR-093/alktty ADR-008 precedent: the tunnel payload rides inside channels data channels as raw bytes (channels strips its 8-byte header transparently; the tunnel protocol owns whatever framing it puts inside the
BiStream). If a negotiation/setup frame is needed (like alktty'sNegotiateRequest), make it self-contained per alktty ADR-006: length-prefixed JSON, not alkcall'sEventEnvelopeframing. Any wire-format ADR must be written before the first consumer exists; after that, changes are additive-only. -
Producer/consumer, not server/client, because both sides of a channels connection can initiate. A producer exposes tunnels (registers openable channels via
ChannelCore::register_openable); a consumer opens tunnel channels (ChannelClient). Both sides can be both simultaneously; connection direction (who opened it) is independent of tunnel direction (who dials the target, who serves it). Avoid "server" and "client" framing in docs and API names; use "producer" and "consumer," or "accept side" / "connect side" for the connection-establishment half specifically. See alkcall ADR-022, ADR-037. -
Substrate-agnostic by construction: the protocol layer must not know whether the bytes come from TCP, UDP, a Unix socket, or a stdio pipe. The handler sees a
Connection/BiStreamfrom alkcall core and pumps bytes; the substrate-specific types (TcpStream,UdpSocket, etc.) are confined to backend modules behind feature flags, injected at the assembly layer, the same inversion-point pattern as alktty'sTtyBackend. "Arbitrary tunnels" means the bookkeeping (target addressing, direction, lifecycle) must not hardcode a substrate. See alknet ADR-078 §Where the pattern lives and the channels-adapter spec. -
Two-pump shutdown-on-completion is a contract: a tunnel is the canonical two-pump handler (one pump per direction). Each pump MUST shut down the opposite sink when it completes;
tokio::try_join!alone deadlocks. This was POC-validated in the alknet-channels POC (Target 3), pinned as alknet ADR-078, and the helper is pinned upstream as alkcallchannels::pump_bidi(ADR-050, review 007 R-03), so use it; do not hand-roll the two-pump shape. The channels layer drops all per-channel senders on transport EOF; rely on that for teardown, and emit EOF sentinels (zero-length chunks) on clean sink shutdown. Awaitpump_bidiinline inside theOpenHandler's task, because the returnedJoinHandlemust track the data-plane lifetime (R-02; early return = teardown-at-birth). -
No forced local binding: a tunnel must not require the producer (or consumer) to bind a local port. The POC's TCP-tunnel shape dialed a target from the handler; SSH
-Rstyle flows need the reverse (the remote side asks the local side to dial or listen). The API surface must support both directions and unbound/listen- optional flows; the binding decision belongs to the caller (assembly layer), not the protocol crate. This is a spec-level requirement for the architecture docs, not just an implementation preference. -
Backpressure and limits are inherited, not redefined: bounded per-channel buffers, the 256-channel per-connection cap, monotonic channel IDs, and the zero-length-sentinel EOF convention are alkcall channels invariants (see alkcall ADR-040/041/034). The tunnel crate consumes them via
ChannelCore/ChannelClient; do not build a second demux/mux or re-derive limits. Datagram substrates (UDP) map onto the chunk stream with the same backpressure; datagram-boundary preservation, if required, is a wire-format decision for an ADR, not an accident. -
Vendored core types come from alkcall:
Connection,ProtocolHandler,BiStream,BidiStreamSource,AuthContext,Identity,IdentityProvider,AccessControl,OwnershipProvider,HandlerError,StreamErrorcome fromalkcall::core. Do not vendor copies into this crate. alkcall is v0.10.x, where breaking changes are expected at this major-zero stage; this is an early consumer, so we find and fix issues upstream rather than working around them. Pinalkcall = "0.10.0"and bump deliberately. The 0.6.0 establishment surface (ADR-049 + amendment 2) is load-bearing for this crate: tunnel opens useregister_openable_with_establisherso a refused target dial is a typedchannel:open_failedcall error, never a phantom channel, and the establisher returns the dialed handle viaEstablishment::new(plan)(typed-opaqueChannelPlan, whoseSend + Syncbound constrains plan payloads; socket handles carry+ Sync); no side-channel handoff. The 0.7.0 identity surface (ledger CF-005/CF-006) is load-bearing for the serving side: a connect-side serving op resolves the caller identity in precedence order (payloadauth_token>ServingConfig.identity> transport identity (set viaConnection::set_identitybefore dialing), and the establisher/ pump handler receive the per-call opener identity, not the install-time context. -
BAST document for the wire format: when the tunnel wire format gains binary framing (if any beyond pass-through), it carries a BAST (Binary Abstract Syntax Tree) document as its machine-readable spec under
docs/architecture/, conforming to the BAST meta-schema athttps://alk.dev/bast/v1/schema. BAST is plain JSON; no dependency required to author or consume it. alktunnels does not depend on alktype; hand-rolled codecs are fine for trivial formats, with the BAST doc as the contract. Do not roll your own offset map or validator for complex formats: use alktype (as an optional dep) when that surfaces. -
Access control: the producer's openable channels get authorization for free via
ChannelCore::register_openable, which wiresAccessControlinto the operation spec (the registry runs the ACL before the open handler). Scope-gate tunnel opens (e.g.TUNNEL_OPEN_SCOPE, shape following alktty'sTTY_OPEN_SCOPE) and optionally consultOwnershipProviderfor resource ownership (provider.owns(id_ref, kind, &id, "tunnel"), the 4-arg shape;OwnershipStore::recordis the 3-arg shape). Tunnels reach local networks; treat the open gate as the security boundary. See alknet ADR-024 (registry layering, alkcall ADR-019), alknet ADR-050 (ownership, alkcall ADR-011). -
Feature flags: substrate backends may be feature-gated if the need arises. The base crate should compile lean (no socket/platform deps unless the feature is on). Verify both
cargo test(default) andcargo test --all-featurespass if features are added. -
Naming: Rust standard,
snake_casefor functions/variables/ modules,PascalCasefor types/traits,SCREAMING_SNAKE_CASEfor constants. -
Module structure: one module per file under
src/, re-exported fromsrc/lib.rs. Public API surface islib.rsre-exports. The shape is pinned by the architecture spec (ADR-004, ADR-005): a producer half (adapter / open-handler foralk/tunnel-family ALPNs), a consumer half (a typed session/client that opens tunnel channels), and shared wire/target-addressing modules. Backend modules are feature-gated and never imported from the shared/producer/consumer modules. -
ALPN naming: this crate owns the
alk/tunnel-family ALPN(s). One ALPN per protocol per alkcall ADR-004; keep thealk/prefix convention. If multiple tunnel flavors need distinct ALPNs (e.g. stream vs datagram), decide via ADR before the first consumer; ALPN strings are wire-stable once published. -
No em dashes in prose. Do not use the em dash (U+2014) in source doc comments, markdown docs (including
docs/andtasks/), agent prompts under.opencode/agents/, or commit messages. Restructure instead: split the sentence, use parentheses, commas, a colon, or a semicolon, whichever reads naturally, and vary the choice rather than converting every instance the same way. A spaced hyphen (-) is not an acceptable substitute; it reads as a different but equally recognizable writing tell. Inside fenced code blocks the rule applies to prose-y content only: line comments, ASCII-diagram label separators, and template placeholders are rewritten too, while wire data, JSON/BAST examples, and tool transcripts survive verbatim. The same rule covers en dashes used as clause separators, though en dashes inside numeric ranges keep their normal meaning. Hyphens in compound words (wasm-clean,tty-wire,alk/tunnel) are untouched.
Verification Commands
Run these before committing. All must pass.
cargo test # full suite
cargo clippy --all-targets -- -D warnings
cargo fmt --check
cargo doc --no-deps # if docs changed
cargo test --all-features # if features are added
cargo check --target wasm32-unknown-unknown # default crate stays wasm-clean
cargo clippy --target wasm32-unknown-unknown -- -D warnings
cargo publish --dry-run --allow-dirty # before a release
The wasm check is the structural guard for the "default crate is wasm-clean" invariant (same as alktty), and run it whenever a non-backend module changes.
Architecture Context
docs/architecture/holds the authoritative spec (Phase 1 of the SDD process, complete): overview/producer/consumer/wire spec docs, ADRs 001..006 (all Accepted), the wire-format BAST document, andopen-questions.md(the authoritative OQ tracker). Read it before non-trivial changes. Phase 0 research remains indocs/research/(its OQ ledger is superseded by the tracker).- The prior art for this crate:
- alknet-channels POC, with its summary at
/workspace/@alkdev/alknet/docs/research/alknet-channels/poc-summary.md(§POC Target 3: tunnel handler). Validates the demux→Connection→ handler→mux path and the two-pump tunnel shape over a stand-in transport. POC code at/workspace/alknet-channels-poc/. - alkcall (
/workspace/@alkdev/alkcall, v0.10.x, crates.io). The substrate: call protocol + channels multiplexing. The tunnel crate consumesalkcall::coretypes and the channelsChannelCore/ChannelClient/register_openablesurface. - alktty, found at
/workspace/@alkdev/alktty(v0.1, crates.io). The sibling pattern to follow: a producer/consumer protocol crate on alkcall channels, with the backend inversion point, wasm-clean default crate, and feature-gated local backend.
- alknet-channels POC, with its summary at
- Key upstream ADRs that inform this crate's design (alknet numbers;
alkcall's ports live in
/workspace/@alkdev/alkcall/docs/architecture/ decisions/):- alknet ADR-093 / alkcall ADR-035: channels pure channel
multiplexing (8-byte header, no
stream_type); the tunnel payload is raw bytes inside theBiStream - alknet ADR-078 / alkcall ADR-050: two-pump shutdown-on-completion
(the tunnel handler pattern); the helper is pinned upstream as
alkcall::channels::pump_bidi(use it, do not hand-roll) - alkcall ADR-049 (amendment 2): the establishment phase; the
establisher returns the dialed handle via
Establishment::new(typed-opaqueChannelPlan), replacing the POC's side-channel handoff - alknet ADR-074 / alkcall ADR-038:
ChannelConnectionas aBidiStreamSource; every handler (TTY, tunnel, call) receives aConnection - alknet ADR-075 / alkcall ADR-039:
ChannelsAdapter/ChannelManager(substrate-agnostic demux;paramsis ALPN-specific, i.e. for tunnels the target resource) - alknet ADR-071 / alkcall ADR-034: channels wire format (one-way door)
- alkcall ADR-037: channel lifecycle operations (
channel/openon channel 0;paramscarries the tunnel target) - alkcall ADR-042: hub relay (byte-for-byte data-channel forwarding with ID rewrite, so tunnels traverse relays transparently)
- alkcall ledger CF-005/CF-006 (0.7.0), the connect-side serving
identity seam: caller identity precedence (token >
ServingConfig.identity> transport), per-call opener identity on the open-op hooks (the reverse-flow POC's W1, resolved) - alknet ADR-085, workspace scope:
alknet/tunnelwas flagged "POC-validated, minimal spec needed, not yet specced"; this crate is that spec
- alknet ADR-093 / alkcall ADR-035: channels pure channel
multiplexing (8-byte header, no
- What the POCs settled (Phase 0 complete as of 2026-09-07): both POCs
ran clean (forward UDP POC + reverse-flow POC; see
docs/research/poc-summary.mdandreverse-poc-summary.md). Phase 1 is complete (2026-09-07): the OQ ledger is promoted, and the params, ALPN, codec, and API-surface decisions are pinned by ADR-001..006 (including consumer-session teardown ownership from reverse POC W3). Phase 2 (implementation) is decomposed intasks/. - If a TODO references a design direction that an ADR has since decided against, the TODO is stale; remove it and align with the ADR. Do not implement the rejected design.