Files
alksocks/AGENTS.md
T
glm-5.3-flash e196ed43e3 docs: remove em dashes from repo prose and forbid them going forward
- 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
2026-11-03 19:31:53 +00:00

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:

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

  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; map SOCKS5 reply codes (fast_socks5::ReplyError) faithfully per RFC 1928. 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. fast-socks5 is tokio-native; the wrapper's protocol layer, pumps, and client session types are all async. Use tokio::sync primitives (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.

  4. 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 use features = ["full"]; and keep socket/platform I/O feature-gated. Note: fast-socks5 uses tokio::net and socket2 unconditionally 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): the T-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 no net-shaped feature; socket I/O rides local alone. 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 no tokio::net).

  5. 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 params is 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.

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

  7. 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 over T: AsyncRead + AsyncWrite + Unpin (Socks5ServerProtocol<T, ...>, transfer(inbound, outbound)); the wrapper must preserve that genericity: the channels adapter feeds the SOCKS5 state machine a BiStream (which is AsyncRead + AsyncWrite), the local backend feeds it a TcpStream. Substrate-specific types are confined to feature-gated backend modules, injected at the assembly layer, the same inversion-point pattern as alktty's TtyBackend and alktunnels' pump halves.

  8. 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 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, so 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: 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 -D front 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.

  10. 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 u32 wire 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 via ChannelCore/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.

  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.1" and bump deliberately. The establishment surface (alkcall ADR-049 + amendments) is load-bearing for this crate: SOCKS5 session opens use register_openable_with_establisher so a refused/auth-failed session is a typed channel:open_failed call error, never a phantom channel; the establisher returns the negotiated handle via Establishment::new(plan) (typed-opaque ChannelPlan, whose payloads must be Send + 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.

  12. 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 SOCKS5 opens (e.g. SOCKS5_OPEN_SCOPE, shape following alktty's TTY_OPEN_SCOPE) and optionally consult OwnershipProvider for 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).

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

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

  15. Module structure: one module per file under src/, re-exported from src/lib.rs. Public API surface is lib.rs re-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.

  16. ALPN naming: this crate owns a alk/-prefixed ALPN (provisional alk/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.

  17. 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-socks5 is NOT ours and never will be; it is third-party upstream (/workspace/fast-socks5 is a read-only reference checkout, v1.0.0, MIT), but the T-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 in src/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-socks5 for 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.

  18. No em dashes in prose. Do not use the em dash (U+2014) in source doc comments, markdown docs (including all of 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, fast-socks5, alk/socks5) are untouched. The one exemption is docs/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)

  1. Commit the release (bump + CHANGELOG/README/AGENTS pins).
  2. 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).
  3. git push origin main && git push origin vX.Y.Z.
  4. cargo publish (no --allow-dirty once the tree is clean).
  5. 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.md holds 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 in docs/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 consumes alkcall::core types and the channels ChannelCore/ChannelClient/register_openable_with_establisher surface, 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-tree ChannelRelay/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), and TunnelListener.
    • 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 over T: AsyncRead + AsyncWrite + Unpin; run_tcp_proxy / run_udp_proxy_custom / transfer are the interception points; client side has Socks5Stream (generic over the backing socket) and Socks5Datagram (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): the T-generic subset lives in src/rfc1928/ as owned code, no kernel-net code and no net feature; BIND and the new_reply publication 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: Socks5UdpSocket implements quinn::AsyncUdpSocket so 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 has noq::AsyncUdpSocket, Endpoint:: new_with_abstract_socket exist but with a different surface shape; see OQ-SK-06).
  • 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's BiStream
    • 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-opaque ChannelPlan, Send + Sync payload constraint)
    • alknet ADR-074 / alkcall ADR-038: ChannelConnection as a BidiStreamSource; every handler receives a Connection
    • alknet ADR-075 / alkcall ADR-039: ChannelsAdapter / ChannelManager (substrate-agnostic demux; params is 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/open on 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
  • 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 -D capability alktunnels deferred to composition). alknet-client/src/socks5.rs and 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.