channel_id is u32 on the wire (~4B ids, monotonic counter, ADR-040); 256 is the default per-connection max_channels (memory bound, 256x1MiB reassembly buffers) and the default per-identity DoS cap (ADR-041) — both configurable without a wire change. AGENTS.md convention 10 and the OQ-SK-03 composed-egress note now carry the qualifier.
19 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 / SOCKS5 protocol crate)
This is the SOCKS5 protocol crate — a wrapper around fast-socks5 that
offers the SSH -D (dynamic / SOCKS5 proxy) capability alongside
alktunnels' -R and -L, composed with alkcall channels ("ALPN as a
service"). It sits in the alk* family: alkcall (call + channels — the
substrate), alktty (terminal sessions), alktunnels (arbitrary -L/-R/
tunnels). The distinguishing posture of this crate: the SOCKS5 server
never binds a port unless explicitly configured to — the protocol is
served inside alkcall channels data channels (a produced, ACL-scoped
resource), not on a kernel socket. A local bind is one optional
assembly-layer shape, not the protocol's home. 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 —try_join!alone deadlocks; see alkcall ADR-050'spump_bidi/ alknet ADR-078"). -
Error handling —
thiserrorfor library error types; map SOCKS5 reply codes (fast_socks5::ReplyError) faithfully per RFC 1928. No panics in library code. Nounwrap()orexpect()outside tests. If you reach forunwrap, the error path wasn't specified — 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.fast-socks5is tokio-native; the wrapper's protocol layer, pumps, and client session types are all async. Usetokio::syncprimitives (oneshot,mpsc) for lifecycle correlation. Any non-wasm backend module (local TCP listener for a "real"socks5://endpoint) lives behind a feature flag like alktty/alktunnels'local. -
WASM target is load-bearing (by default) — the default crate (protocol-only) should compile to
wasm32-unknown-unknown, following the alktty/alktunnels precedent. Use the wasm-clean tokio subset (rt,sync,io-util,macros,time) — do NOT usefeatures = ["full"]— and keep socket/platform I/O feature-gated. Note:fast-socks5itself usestokio::netandsocket2unconditionally; the posture is conditional (OQ-SK-04): if wasm matters for this crate, the path is fork-first (vendor theT-generic subset, gate native-net pieces — see OQ-SK-04 Case 1); if wasm does not matter, ride the nativerouter.rspath with a plain dependency (Case 2 — the alkhttp precedent). The root question — does wasm make sense here — is the OQ to settle; do not carry a fork for an invariant's sake. Run the wasm check whenever a non-backend module changes. -
Wire format is stable — the SOCKS5 protocol itself is RFC 1928 (fixed); this crate's wire surface is the ALPN + the channel open-op params that carry it. Those params are a one-way door once consumers exist. Follow the alktunnels precedent: params are a self-contained JSON object identifying the produced resource (alkcall ADR-039 —
paramsis ALPN-specific, interpreted by the open handler). Any params-format ADR must be written before the first consumer exists; after that, changes are additive-only. -
Producer/consumer, not server/client — both sides of a channels connection can initiate. A producer exposes the SOCKS5 service (registers openable channels via
ChannelCore::register_openable); a consumer opens SOCKS5 channels and speaks RFC 1928 inside them. Both sides can be both simultaneously — connection direction (who opened it) is independent of service direction (who dials targets, who serves). 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. (SOCKS5's own client/server roles are RFC vocabulary and keep their RFC names inside the protocol layer.) See alkcall ADR-022, ADR-037. -
Substrate-agnostic by construction — the protocol layer must not know whether the far side of a SOCKS5 connection is a kernel TCP socket, a channels
BiStream, a unix socket, or an in-process pipe.fast-socks5's explicit server API is generic overT: AsyncRead + AsyncWrite + Unpin(Socks5ServerProtocol<T, ...>,transfer(inbound, outbound)); the wrapper must preserve that genericity — the channels adapter feeds the SOCKS5 state machine aBiStream(which isAsyncRead + AsyncWrite), the local backend feeds it aTcpStream. Substrate-specific types are confined to feature-gated backend modules, injected at the assembly layer — the same inversion-point pattern as alktty'sTtyBackendand alktunnels' pump halves. -
Two-pump shutdown-on-completion is a contract — the SOCKS5 CONNECT data plane is the canonical two-pump shape (one pump per direction). Each pump MUST shut down the opposite sink when it completes;
tokio::try_join!alone deadlocks. The helper is pinned upstream as alkcallchannels::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. Awaitpump_bidiinline inside theOpenHandler's task — the returnedJoinHandlemust track the data-plane lifetime (R-02; early return = teardown-at-birth). -
No forced local binding — the defining requirement of this crate. The SOCKS5 server must run without binding any port: the RFC 1928 conversation is carried inside a channels data channel, and target dials happen on the producing side (or hop further through alktunnels/alksocks). Local binds exist only as explicit, optional assembly-layer shapes on either side: the producer may serve from a real listener (a feature-gated local backend), and the consumer may expose the resource on a local port (the ssh
-Dfront door that vanilla clients — curl, browsers, tun2proxy — point at). The protocol itself never binds. UDP ASSOCIATE is the hard case: RFC 1928 replies with a UDP relay address and the client sends datagrams to it, which seems to demand a bind — how the relay address is virtualized (or the associate shape adapted) is a Phase 0 question (OQ-SK-03). Binding decisions belong to the caller (assembly layer), never the protocol crate. -
Backpressure and limits are inherited, not redefined — bounded per-channel buffers, the 256-channel default caps (per-connection memory bound, ADR-040; per-identity DoS defense, ADR-041 — policy knobs, configurable without a wire change; the
u32wire space itself is ~4 billion), monotonic channel IDs, and the zero-length-sentinel EOF convention are alkcall channels invariants (see alkcall ADR-040/041/034). The crate consumes them viaChannelCore/ChannelClient; do not build a second demux/mux or re-derive limits. SOCKS5 CONNECT is a single stream per open (no sub-demux inside the channel) — one channel per SOCKS5 session, no protocol-level flow key. -
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.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. Pinalkcall = "0.7.1"and bump deliberately. The establishment surface (alkcall ADR-049 + amendment 2) is load-bearing for this crate: SOCKS5 session opens useregister_openable_with_establisherso a refused/auth-failed session is a typedchannel:open_failedcall error, never a phantom channel; the establisher returns the negotiated handle viaEstablishment::new(plan)(typed-opaqueChannelPlan— payloads must beSend + Sync). The 0.7.0 identity surface (ledger CF-005/CF-006) is load-bearing for auth mapping: the per-call opener identity arrives on the open-op hooks — map it to SOCKS5 credentials/ACL there, not via any in-band SOCKS auth unless clients need RFC-facing username/password. -
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 SOCKS5 opens (e.g.SOCKS5_OPEN_SCOPE, shape following alktty'sTTY_OPEN_SCOPE) and optionally consultOwnershipProviderfor resource ownership. SOCKS5 is arbitrary-egress by nature — treat the open gate and the target policy as the security boundary; per-target allowlists (if any) are a producer-side policy question for Phase 1 (OQ-SK-05). See alknet ADR-024 (registry layering, alkcall ADR-019), alknet ADR-050 (ownership, alkcall ADR-011), and alktunnels' resolved posture (target selection lives in the SOCKS5 protocol at the producing side; whatever ACL governs the socks5 resource governs everything reachable through it). -
Feature flags — substrate backends are 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 expected shape (pending Phase 1 pinning): the SOCKS5 protocol wrapper (server state machine + client session), the channels adapter (producer half — open handler for this crate's ALPN), the consumer half (a typed client that opens SOCKS5 channels and speaks RFC 1928 through them), and feature-gated backend modules (the optional local-bind listener). Backend modules are feature-gated and never imported from the protocol/adapter/client modules. -
ALPN naming — this crate owns a
alk/-prefixed ALPN (provisionalalk/socks5; final naming per alkcall ADR-004/ADR-006 convention). One ALPN per protocol. The ALPN string is wire-stable once published — decide via ADR before the first consumer. -
Upstream is ours — fork-first where it pays, asks for alkcall. We own
fast-socks5(/workspace/fast-socks5, v1.0.0, MIT) and alkcall. For fast-socks5, the posture is conditional (OQ-SK-04): if wasm matters for this crate (Case 1), make the change as a minimal fork and use it — offer it upstream as a PR and merge upstream if they want it, but never wait on approval; if wasm does not matter (Case 2), ride the native path with a plain dependency, no fork. For alkcall (the actively co-developed substrate) the alk* precedent still applies: file asks early and land them there rather than working around them locally — the alktunnels precedent (the E-01/E-02 sweep, filed from its Phase 0 and landed in alkcall 0.5.0 within a day). Keep/workspace/fast-socks5as the reference checkout; if the vendored subset diverges from upstream, note the fork point in the research docs.
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/alktunnels) — run it whenever a non-backend module changes. Until OQ-SK-04 (fast-socks5 wasm posture) is resolved, the wasm check may fail on the dependency graph; that failure is expected and is exactly what the OQ tracks.
Architecture Context
docs/research/phase-0.md— the Phase 0 (Exploration) document and the current state of this repo: vision, prior art, open questions (OQ-SK-01..NN), and POC candidates. Read it before non-trivial work. The SDD process lives indocs/sdd_process.md(Phase 0 in progress;docs/architecture/does not exist yet).- The prior art for this crate:
- alktunnels —
/workspace/@alkdev/alktunnels(v0.1, the closest sibling): the producer/consumer tunnel crate on alkcall channels. Its Phase 0 findings (docs/research/phase-0-findings.md) settled the hub-owns-the-connection model, the two-pump shape, the establishment story (alkcall ADR-049), and the-D-composes-at-the- assembly-layer conclusion — this crate is that conclusion's realization. Its Phase 1 ADRs (params shape, ALPN strategy, access-control posture) are the template for this crate's Phase 1. - alktty —
/workspace/@alkdev/alktty(v0.4): the first producer/consumer protocol crate on alkcall channels; the backend inversion point, wasm-clean default crate, and feature-gated local backend precedents. - alkcall —
/workspace/@alkdev/alkcall(v0.7.1, crates.io). The substrate: call protocol + channels multiplexing. This crate consumesalkcall::coretypes and the channelsChannelCore/ChannelClient/register_openable_with_establishersurface, same as alktty/alktunnels. - fast-socks5 —
/workspace/fast-socks5(v1.0.0, MIT, we own upstream). The SOCKS5 implementation to wrap: explicit typestate server API (Socks5ServerProtocol<T, states::*>), generic overT: AsyncRead + AsyncWrite + Unpin;run_tcp_proxy/run_udp_proxy_custom/transferare the interception points; client side hasSocks5Stream(generic over the backing socket) andSocks5Datagram(UDP associate). - alknet's SOCKS5 client —
/workspace/@alkdev/alknet/crates/alknet-client/src/socks5.rs(ADR-090): the client-side example need —Socks5UdpSocketimplementsquinn::AsyncUdpSocketso QUIC rides SOCKS5 UDP ASSOCIATE. Validated by the quinn-proxy POC (/workspace/@alkdev/alknet/docs/research/quinn-quic-proxy/ findings.md). The open question for this crate's client story: the same trait impl against noq (iroh's quinn fork,/workspace/noq—noq::AsyncUdpSocket,Endpoint:: new_with_abstract_socketexist but with a different surface shape; see OQ-SK-06).
- alktunnels —
- 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 SOCKS5 session rides inside the channel'sBiStream - alknet ADR-078 / alkcall ADR-050 — two-pump shutdown-on-completion;
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 negotiated handle via
Establishment::new(typed-opaqueChannelPlan,Send + Syncpayload constraint) - alknet ADR-074 / alkcall ADR-038 —
ChannelConnectionas aBidiStreamSource; every handler receives aConnection - alknet ADR-075 / alkcall ADR-039 —
ChannelsAdapter/ChannelManager(substrate-agnostic demux;paramsis ALPN-specific — for this crate, the SOCKS5 service identity/policy) - alknet ADR-071 / alkcall ADR-034 — channels wire format (one-way
door); alkcall ADR-036 — channel 0 is pre-negotiated
alk/call; alkcall ADR-037 — channel lifecycle operations (channel/openon channel 0) - alkcall ADR-042 — hub relay; alktunnels' hub-proxy refinement — the hub terminates and re-produces per-hop ACL (a SOCKS5 resource is the same shape, "further downstream" — alktunnels phase-0-findings §Prior art)
- 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
- alknet ADR-093 / alkcall ADR-035 — channels pure channel
multiplexing (8-byte header, no
- The suite decomposition context: alknet was the massive POC ("vpn-like
without being a vpn" + transport agnosticism) being rewritten; this
crate extracts and improves its SOCKS5 story (client side: ADR-090's
proxy support; server side: the
-Dcapability alktunnels deferred to composition).alknet-client/src/socks5.rsand the quinn-proxy POC findings are the direct ancestors of this crate's client work. - If a TODO references a design direction that a later ADR has decided against, the TODO is stale — remove it and align with the ADR. Do not implement the rejected design.