- Rewrote em dash prose across the whole tree outside docs/reviews/ (the historical reviews stay verbatim, matching the alktunnels and alktty precedent): src doc comments and inline comments (587), docs/architecture (319), docs/research + sdd_process (484), tests (361), tasks (899), fuzz (28), README/CHANGELOG/Cargo.toml (77), AGENTS.md (85), and the .opencode/agents prompts (109) - Fenced content follows the precedent: comments, ASCII-diagram label separators, and template placeholders rewritten; wire data, JSON/BAST documents, byte diagrams, and transcripts survive verbatim; assert, expect, panic, error-display, and format message literals rewritten as prose; no code, fixture, or test data change - src/rfc1928/LICENSE: the two dashes in alksocks' own provenance header rewritten; the MIT legal text untouched - AGENTS.md adds Project Convention 18: no em dashes in prose; varied restructure, no spaced-hyphen substitutes, fenced prose rule, en dash numeric ranges exempt, docs/reviews/ exempt from the greps - tasks/*.md frontmatter: the name values rewritten by the sweep are now quoted (a raw colon inside an unquoted YAML scalar parsed as null); taskgraph validate reports 37 tasks, no issues Verification: cargo test; cargo clippy --all-targets -D warnings; cargo fmt --check; cargo doc --no-deps; fuzz corpus replay (cargo test --manifest-path fuzz/shared/Cargo.toml); wasm check and clippy for wasm32-unknown-unknown with -D warnings; em dash grep over the repo returns matches only in docs/reviews/ and the bast.md BAST JSON data fence
23 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, sincetry_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, 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.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), meaning 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-socks5usestokio::netandsocket2unconditionally as published; the posture is the targeted fork (OQ-SK-04, resolved 2026-09-20, convention 17; extraction pinned 2026-09-21, ADR-011 §Amendment; fork 2026-09-22, ADR-013): theT-generic subset was extracted in-tree (the kernel-net handlers are not copied), so the RFC 1928 core (src/rfc1928/) is wasm-clean by construction with nonet-shaped feature; socket I/O rideslocalalone. Run the wasm check whenever a non-backend module changes; it is a hard gate (no dependency-graph failure is expected, since the core contains notokio::net). -
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, whose
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, because 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), so use it; do not hand-roll the two-pump shape. The channels layer drops all per-channel senders on transport EOF, so 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: 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, i.e. 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.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.1"and bump deliberately. The establishment surface (alkcall ADR-049 + amendments) 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, whose payloads must beSend + Sync; 0.8.0 adds the optional reply projection,with_reply_field(s)from ADR-049 amendment 3). 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 follows the 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 (the producer half, the 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: alkcall is ours to shape; fast-socks5 is forked in-tree. alkcall is ours (the actively co-developed substrate): 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; the graduation sweep landed in alkcall 0.8.0 / alktunnels 0.2.0).
fast-socks5is NOT ours and never will be; it is third-party upstream (/workspace/fast-socks5is a read-only reference checkout, v1.0.0, MIT), but theT-generic subset we depend on is now ours, as a targeted fork (OQ-SK-04 resolved 2026-09-20 as the inline-vendor extraction; extraction pinned 2026-09-21, ADR-011 §Amendment; fork posture pinned 2026-09-22, ADR-013): the subset lives insrc/rfc1928/as fully owned code, and project conventions apply to it unconditionally (no vendor carve-outs, no lint-allow posture, no verbatim preservation). The extraction cut (ADR-011's table) is the hand-port map: if a future need surfaces (e.g. the kernel-net region for a standalone vanilla-server example), port the piece from the reference checkout, adapt it to the conventions, and let the differential suite guard it. A future upstream fix that matters is a hand-port too; nothing is absorbed silently, and nothing waits on upstream. Keep/workspace/fast-socks5for differential testing of RFC edge cases. Provenance is permanent: the module docs and the bundled license notice (src/rfc1928/LICENSE) always name the origin (v1.0.0, the extraction range, the fork point): extend, never replace, the notice. -
No em dashes in prose. Do not use the em dash (U+2014) in source doc comments, markdown docs (including all of
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,fast-socks5,alk/socks5) are untouched. The one exemption isdocs/reviews/: reviews are archived historical records, so their prose is preserved verbatim and is excluded from the em-dash greps.
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 test --manifest-path fuzz/shared/Cargo.toml # corpus replay (the fuzz gate)
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
Release steps (after the version bump + verification)
- Commit the release (bump + CHANGELOG/README/AGENTS pins).
- Tag it:
git tag -a vX.Y.Z -m "release: vX.Y.Z (<the tag>)"annotated, always (v0.1.0/v0.2.0 are lightweight; annotated since the v0.3.0 release, 2026-10-19). git push origin main && git push origin vX.Y.Z.cargo publish(no--allow-dirtyonce the tree is clean).- Confirm with
cargo search/cargo info.
The corpus replay is the fuzz gate (the alkcall §7.9 posture): it
replays every committed seed through the same invariant functions the
fuzz targets run, on stable, without nightly. Campaigns (nightly,
cargo-fuzz) run manually via fuzz/run-detached.sh when
src/wire.rs, src/rfc1928/, or src/params.rs change, never as a
foreground child of an agent session (docs/research/fuzzing.md §5).
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. It is a hard gate: the RFC 1928 core is an extraction with no kernel-net code (ADR-011 §Amendment), so no dependency-graph failure is expected.
Architecture Context
docs/research/phase-0.mdholds 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.9.x, crates.io). The substrate: call protocol + channels multiplexing. This crate consumesalkcall::coretypes and the channelsChannelCore/ChannelClient/register_openable_with_establishersurface, same as alktty/alktunnels. 0.8.0 adds the establisher reply projection (with_reply_field, ADR-049 amendment 3), flavor-form open-op ids in discovery (ADR-047 amendment 3), and the in-treeChannelRelay/HubLegTemplate(ADR-051), the hub re-produce machinery the SOCKS5 hub story composes on. - alktunnels (v0.2.0, ADRs 007/008): the direct op
(
channels/tunnel/direct, dynamic-target egress; OQ-SK-01 Option A's vehicle), the bind-first establisher + forwarded op (the composed BIND substrate; OQ-SK-08 variant 2), andTunnelListener. - fast-socks5 (
/workspace/fast-socks5, v1.0.0, MIT, third-party upstream, NOT ours and never will be). The SOCKS5 implementation the RFC 1928 core forked: 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). The posture is the targeted fork (OQ-SK-04 resolved 2026-09-20 as the inline-vendor extraction; extraction pinned 2026-09-21, ADR-011 §Amendment; fork 2026-09-22, ADR-013, convention 17): theT-generic subset lives insrc/rfc1928/as owned code, no kernel-net code and nonetfeature; BIND and thenew_replypublication are the two carried changes; the reference checkout stays for differential testing of RFC edge cases. - alknet's SOCKS5 client at
/workspace/@alkdev/alknet/crates/alknet-client/src/socks5.rs(ADR-090), the client-side example: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, which hasnoq::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, i.e. 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, in which the hub terminates and re-produces per-hop ACL (a SOCKS5 resource is the same shape, "further downstream", per 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.