Files
alktunnels/AGENTS.md
T
glm-5.3-flash 0ceecf582b docs: remove em dashes from repo prose and forbid them going forward
- 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

Follows 6b20924 (src doc comments) and cd0afe0 (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/
2026-11-03 14:12:46 +00:00

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:

  1. Make the change
  2. Verify: cargo test, cargo clippy --all-targets -- -D warnings, cargo fmt --check, cargo doc --no-deps if docs changed
  3. Inspect git status and git diff before staging; stage only the intended files, never secrets
  4. 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.
  5. git push origin main
  6. 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.

  1. 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, since try_join! alone deadlocks; see alkcall ADR-050's pump_bidi / alknet ADR-078").

  2. Error handling: thiserror for library error types (TunnelError; HandlerError/StreamError come from alkcall::core). No panics in library code. No unwrap() or expect() outside tests. If you reach for unwrap, the error path wasn't specified, so stop and decide what should actually happen. For poisoned RwLock/Mutex, use unwrap_or_else(|e| e.into_inner()) so a panic in one operation does not cascade to other operations.

  3. tokio is the async runtime: all I/O is async. The tunnel pumps, the channels integration, and the consumer session type are all async. Use tokio::sync primitives (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's local.

  4. 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 use features = ["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.

  5. 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's NegotiateRequest), make it self-contained per alktty ADR-006: length-prefixed JSON, not alkcall's EventEnvelope framing. Any wire-format ADR must be written before the first consumer exists; after that, changes are additive-only.

  6. 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.

  7. 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/BiStream from 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's TtyBackend. "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.

  8. 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 alkcall channels::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. Await pump_bidi inline inside the OpenHandler's task, because the returned JoinHandle must track the data-plane lifetime (R-02; early return = teardown-at-birth).

  9. 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 -R style 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.

  10. 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.

  11. Vendored core types come from alkcall: Connection, ProtocolHandler, BiStream, BidiStreamSource, AuthContext, Identity, IdentityProvider, AccessControl, OwnershipProvider, HandlerError, StreamError come from alkcall::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. Pin alkcall = "0.10.0" and bump deliberately. The 0.6.0 establishment surface (ADR-049 + amendment 2) is load-bearing for this crate: tunnel opens use register_openable_with_establisher so a refused target dial is a typed channel:open_failed call error, never a phantom channel, and the establisher returns the dialed handle via Establishment::new(plan) (typed-opaque ChannelPlan, whose Send + Sync bound 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 (payload auth_token > ServingConfig.identity > transport identity (set via Connection::set_identity before dialing), and the establisher/ pump handler receive the per-call opener identity, not the install-time context.

  12. 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 at https://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.

  13. Access control: the producer's openable channels get authorization for free via ChannelCore::register_openable, which wires AccessControl into the operation spec (the registry runs the ACL before the open handler). Scope-gate tunnel opens (e.g. TUNNEL_OPEN_SCOPE, shape following alktty's TTY_OPEN_SCOPE) and optionally consult OwnershipProvider for resource ownership (provider.owns(id_ref, kind, &id, "tunnel"), the 4-arg shape; OwnershipStore::record is 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).

  14. 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) and cargo test --all-features pass if features are added.

  15. Naming: Rust standard, snake_case for functions/variables/ modules, PascalCase for types/traits, SCREAMING_SNAKE_CASE for constants.

  16. Module structure: one module per file under src/, re-exported from src/lib.rs. Public API surface is lib.rs re-exports. The shape is pinned by the architecture spec (ADR-004, ADR-005): a producer half (adapter / open-handler for alk/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.

  17. ALPN naming: this crate owns the alk/tunnel-family ALPN(s). One ALPN per protocol per alkcall ADR-004; keep the alk/ 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.

  18. No em dashes in prose. Do not use the em dash (U+2014) in source doc comments, markdown docs (including docs/ and tasks/), 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, and open-questions.md (the authoritative OQ tracker). Read it before non-trivial changes. Phase 0 research remains in docs/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 consumes alkcall::core types and the channels ChannelCore/ChannelClient/register_openable surface.
    • 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.
  • 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 the BiStream
    • 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-opaque ChannelPlan), replacing the POC's side-channel handoff
    • alknet ADR-074 / alkcall ADR-038: ChannelConnection as a BidiStreamSource; every handler (TTY, tunnel, call) receives a Connection
    • alknet ADR-075 / alkcall ADR-039: ChannelsAdapter / ChannelManager (substrate-agnostic demux; params is 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/open on channel 0; params carries 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/tunnel was flagged "POC-validated, minimal spec needed, not yet specced"; this crate is that spec
  • 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.md and reverse-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 in tasks/.
  • 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.