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

420 lines
23 KiB
Markdown

# 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.
```bash
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.