Files
alktunnels/AGENTS.md
glm-5.3-flash 508fc6fc28 docs: complete oq-promotion-sync — AGENTS.md current with Phase 1, add TN-12 tracker
- AGENTS.md architecture bullet: spec exists (present tense), OQ tracker
  authoritative; convention 16 de-hedged (shape pinned by ADR-004/005);
  alkcall version drift fixed (v0.4.x/v0.5.x -> v0.7.x per Cargo.toml);
  Phase 1-work bullet -> Phase 1 complete, Phase 2 in tasks/
- phase-0-findings.md: promotion note atop the Open Questions section
  pointing at docs/architecture/open-questions.md; convergence
  checklist's final item checked
- tasks: add architecture/oq-tn-12-tracker (external-trigger tracker for
  OQ-TN-12 deferred(scope), per sdd_process.md deferred-OQ two-halves
  rule); OQ-TN-12 Blocked-on text now references the tracker id
- tasks: oq-promotion-sync completed (scope grew: 3 folded stale
  AGENTS.md refs found during review + the TN-12 tracker)

Verified: taskgraph validate (13 tasks), grep sweep clean for stale
'not exist yet'/version-drift language, cargo test + cargo fmt --check
2026-09-08 06:18:55 +00:00

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

  2. Error handlingthiserror 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 — 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) — 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 — 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) — 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 — 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 alkcallConnection, ProtocolHandler, BiStream, BidiStreamSource, AuthContext, Identity, IdentityProvider, AccessControl, OwnershipProvider, HandlerError, StreamError come from alkcall::core. Do not vendor copies into this crate. alkcall is v0.7.x — 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.7.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 — its 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.

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) — run it whenever a non-backend module changes.

Architecture Context

  • docs/architecture/ — 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/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.7.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/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 — 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 — 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 — 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 — 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.