- Bump alkcall 0.8.0 -> 0.8.1 (duplicate adopt/open channel-state destruction fix from alkcall's fuzz campaign; AGENTS.md stale-pin fix). - Adopt fuzzing per docs/plans/fuzzing.md (mirrors alkcall's docs/research/fuzzing.md as-built layout): fuzz/ workspace with own [workspace] table, nightly pinned for the subtree only, invariant logic in a stable-toolchain shared crate. - Targets: chunk_frame (5-byte chunk codec), negotiation_frame (negotiation framing + NegotiateRequest + error_response_bytes + the cross-codec peek-disambiguation seam), control_json (ControlMessage JSON + signal_from_name). src/local/ out of scope (not wire-attacker-shaped). - 328 committed seeds (deterministic generator); grown corpora gitignored. Corpus replay on stable is the standing fuzz gate (cargo test --manifest-path fuzz/shared/Cargo.toml). - Detached-runner rule (fuzz/run-detached.sh): campaigns never run in the foreground of an agent session - OOM in a target must cost the fuzzer, never the session host. - Root Cargo.toml gained [workspace] members/exclude (MSRV vs fuzz nightly dev-deps footgun) and fuzz/ in the publish exclude. Verification: cargo test (113), cargo test --all-features (156), clippy stable + wasm32-unknown-unknown, fmt, doc, publish dry-run, corpus replay 328 seeds - all green. 10-min detached campaigns on all three targets: 0 crashes/hangs/OOMs/leaks (chunk_frame cov-saturated at 710 edges; negotiation_frame 3.69M execs cov 3157; control_json 8.6M execs cov 2035). Corpus replay caught three harness-model mismatches pre-campaign (payload-slice shape; the negotiation framing layer is length-prefix-only - Json unreachable from read_frame; zero-length frames admitted by the reader, rejected by the adapter's parse). Campaigns recorded in docs/plans/fuzzing.md section 8.
328 lines
18 KiB
Markdown
328 lines
18 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 the `TtyBackend` trait shape
|
||
(one-way doors — see "Wire formats are stable" and "`TtyBackend` trait
|
||
shape is a one-way door" below; the 5-byte chunk header, the
|
||
negotiation-frame layout, the `NegotiateRequest` JSON shape, and the
|
||
trait signature are wire-stable contracts once consumers exist)
|
||
- 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.2 <glm-5.2@alk.dev>`). Do not
|
||
change `git config`, skip hooks, or use `git commit -i`.
|
||
|
||
## Project Conventions (Rust / TTY protocol crate)
|
||
|
||
This is the `alk/tty` terminal-session protocol crate — a
|
||
producer/consumer protocol crate on top of alkcall channels. It folds
|
||
the old `alknet-tty` (wire/adapter/backend-trait half) and
|
||
`alknet-tty-local` (`portable_pty` + `tokio::process` half) from the
|
||
alknet mono-repo into a single crate with a `local` feature. 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., "zero-length stdin is the EOF sentinel — the codec does
|
||
not special-case it; the adapter interprets `length == 0` chunks").
|
||
|
||
2. **Error handling** — `thiserror` for library error types
|
||
(`TtyError`, `NegotiateError`, `WireError`, `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 adapter's
|
||
per-session pumps, the channels integration, and `TtySession` are all
|
||
async. Use `tokio::sync` primitives (`oneshot`, `mpsc`) for
|
||
request/exit correlation and stdout/stderr streams. The PTY bridge in
|
||
`src/local/pty.rs` is the one exception: it uses three dedicated std
|
||
threads feeding tokio mpsc/oneshot channels (the `portable_pty` API
|
||
is blocking) — and it's behind the `local` feature.
|
||
|
||
4. **WASM target is load-bearing** — the default crate (no `local`
|
||
feature) MUST compile to `wasm32-unknown-unknown`. This is what makes
|
||
the downstream TS/Python adapter story work (a wasm-compiled alktty
|
||
is the protocol layer for a sandboxed adapter). `Cargo.toml` uses
|
||
`tokio = { default-features = false, features = ["rt", "sync",
|
||
"io-util", "macros"] }` (the wasm-clean subset alkcall uses) and
|
||
`local` adds `tokio/process` + `tokio/rt-multi-thread`. **Do NOT use
|
||
`features = ["full"]`** — it pulls in `signal`/`fs`/`net` which break
|
||
`wasm32-unknown-unknown`. The adapter's `tokio::spawn` for per-session
|
||
pumps is fine on wasm (the wasm tokio runtime supports `spawn`); the
|
||
`local` module's std threads, `tokio::process::Command`, and
|
||
`libc::kill` are the non-wasm parts, and they're feature-gated.
|
||
`libc` stays under `cfg(unix)` (it's already there for
|
||
`signal_from_name` and the pipe-mode `kill` path — neither runs on
|
||
wasm because the only callers are in `local`).
|
||
|
||
5. **Wire formats are stable** — two wire formats live in this crate,
|
||
both one-way doors:
|
||
- **5-byte chunk header** (`[stream_type: u8][length: u32 BE]
|
||
[payload]`) — the raw chunk codec in `wire.rs`. Five stream types:
|
||
`STREAM_STDIN=0`, `STREAM_STDOUT=1`, `STREAM_STDERR=2`,
|
||
`STREAM_CTRL_IN=3`, `STREAM_CTRL_OUT=4`. Zero-length data chunks are
|
||
sentinels (zero-length stdin = EOF from client; zero-length stdout
|
||
= "drained" from server); control chunks are never zero-length.
|
||
Changing the header, the stream-type values, or the sentinel
|
||
semantics breaks all peers. See ADR-052 (ported as alktty ADR-001).
|
||
- **Negotiation frame** (4-byte BE length prefix + UTF-8 JSON
|
||
`NegotiateRequest` body) — self-contained per ADR-057 (ported as
|
||
alktty ADR-006), not reused from alkcall's `EventEnvelope` framing
|
||
(the payload is `NegotiateRequest`, not `EventEnvelope`; alkcall's
|
||
`FrameFramedReader` is hardcoded to deserialize `EventEnvelope`).
|
||
The `NegotiateRequest` JSON shape is wire-stable once consumers
|
||
exist.
|
||
|
||
Per ADR-093 (ported as alktty ADR-008): TTY always uses its 5-byte
|
||
format, even inside channels. The channels layer strips its 8-byte
|
||
header and hands TTY the payload transparently — the same `wire.rs`
|
||
code runs in both direct (`alk/tty`) and channels (`alk/channels`)
|
||
modes; only the `BiStream` source differs.
|
||
|
||
6. **`TtyBackend` trait shape is a one-way door** — the trait
|
||
(`src/backend.rs`) is the inversion point between the wire-format
|
||
adapter and the backend crates (alktty's own `local` feature module,
|
||
future `alknet-docker`, `alknet-ssh`). alktty defines the trait; the
|
||
backends implement it. Changing the trait shape after backends exist
|
||
is a rewrite across crates. The adapter holds a
|
||
`HashMap<String, Arc<dyn TtyBackend>>` keyed by the negotiation
|
||
frame's `backend` string and pumps the `TtyHandle` fields
|
||
bidirectionally; backends produce handles, they do not write to the
|
||
wire. `TtyError` is `#[non_exhaustive]` so new variants are additive
|
||
(two-way-door extension within the one-way trait shape). See ADR-053
|
||
(ported as alktty ADR-002).
|
||
|
||
7. **Producer/consumer, not server/client** — inherited from alkcall.
|
||
Both sides of a `alk/tty` or `alk/channels` connection can initiate.
|
||
A producer exposes TTY (direct ALPN via `TtyAdapter::ProtocolHandler`,
|
||
or via `channels::register_openable`); a consumer opens a session
|
||
(`TtySession::connect_direct` / `open_via_channels`). Both sides can
|
||
be both simultaneously — connection direction (who opened it) is
|
||
independent of TTY direction (who's the shell, who's the client).
|
||
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.
|
||
|
||
8. **Module structure** — one module per file under `src/`, re-exported
|
||
from `src/lib.rs`. Public API surface is `lib.rs` re-exports. The
|
||
crate has three concerns:
|
||
- **Producer half** — `src/adapter.rs` (`TtyAdapter` + `drive_session`
|
||
for direct `alk/tty`) and `src/channels.rs` (`register_openable`
|
||
helper + `TtyOpenHandler` for the `alk/channels` multiplexed path).
|
||
- **Consumer half** — `src/session.rs` (`TtySession` typed client
|
||
with `connect_direct` and `open_via_channels` constructors).
|
||
- **Shared** — `src/wire.rs` (chunk codec), `src/control.rs`
|
||
(control-message enum + `signal_from_name`), `src/negotiation.rs`
|
||
(negotiation frame + `NegotiateRequest`), `src/backend.rs`
|
||
(`TtyBackend` trait, `TtyHandle`, `TtyParams`, `TtyError`).
|
||
The `src/local/` module (the folded `alknet-tty-local`) is behind the
|
||
`local` feature and never imported from the shared/producer/consumer
|
||
modules — only re-exported from `lib.rs` under
|
||
`#[cfg(feature = "local")]`.
|
||
|
||
9. **`local` feature isolation** — the `local` module is the only
|
||
non-wasm part of the crate. The shared/producer/consumer modules
|
||
(`wire`, `control`, `negotiation`, `backend`, `adapter`, `channels`,
|
||
`session`) MUST NOT import from `crate::local` or reference any
|
||
`local`-only type (`LocalTtyBackend`, `portable_pty`,
|
||
`tokio::process::Command`). The producer's `TtyAdapter` takes a
|
||
`HashMap<String, Arc<dyn TtyBackend>>` — the local backend is
|
||
injected at the assembly layer, not wired in by the adapter. A
|
||
regression where a non-`local` module pulls in `portable_pty`,
|
||
`tokio::process`, `std::thread`, or `libc` (outside the existing
|
||
`cfg(unix)` calls in `control.rs`/`pipe.rs`) silently breaks the
|
||
downstream TS/Python adapter story.
|
||
|
||
10. **BAST document for the wire format** — `docs/architecture/tty-bast.md`
|
||
(Phase 4, not yet written) will be the machine-readable spec for the
|
||
`alk/tty` wire format, 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. alktty does **not**
|
||
depend on alktype; the hand-rolled `ChunkReader`/`ChunkWriter` in
|
||
`wire.rs` is the runtime codec, the BAST document is the
|
||
human-readable contract that describes what those types round-trip.
|
||
If runtime validation against the BAST becomes desirable later,
|
||
alktype becomes an optional dep and the BAST document is already
|
||
there to feed it. Do not roll your own offset map or validator for
|
||
complex formats — use alktype (as an optional dep) when that
|
||
surfaces.
|
||
|
||
11. **Access control** — the producer's direct-ALPN path keeps the
|
||
existing ad-hoc scope check (`has_scope(identity, TTY_OPEN_SCOPE)`)
|
||
for the scope gate and optionally consults `OwnershipProvider` for
|
||
the resource-ownership check (`provider.owns(id_ref, kind, &id,
|
||
"tty")` — the 4-arg shape alkcall adopted). The channels path gets
|
||
the scope gate for free via `ChannelCore::register_openable`
|
||
(which wires `AccessControl` into the operation spec — the
|
||
registry runs the ACL before the wrapper) and runs the ownership
|
||
check in the establisher (ADR-010 — it is part of the
|
||
establishment phase since alkcall 0.5.0 ADR-049, so a denial is
|
||
`channel:open_failed`, not an in-band frame; since alkcall 0.6.0
|
||
ADR-010 §2A the establisher also allocates — the pump handler
|
||
only pumps). Terminal sessions are resources per
|
||
ADR-050 (ported as alktty ADR-003); `OwnershipStore::record(&self,
|
||
identity, resource_type, resource_id)` is the 3-arg shape (alkcall
|
||
dropped the old `action` arg).
|
||
|
||
12. **Feature flags** — the only feature is `local` (default = `[]`).
|
||
`local` is inherently non-wasm (`portable-pty` + `tokio::process`
|
||
need a real OS); enabling `local` on wasm is a build error by
|
||
design. Verify both `cargo test` (default) and
|
||
`cargo test --all-features` pass. Do not add a feature flag that
|
||
pulls non-wasm deps into the default crate.
|
||
|
||
13. **Naming** — Rust standard: `snake_case` for functions/variables/
|
||
modules, `PascalCase` for types/traits, `SCREAMING_SNAKE_CASE` for
|
||
constants (`STREAM_STDIN`, `TTY_OPEN_SCOPE`, `CHUNK_HEADER_LEN`).
|
||
|
||
14. **No `unsafe`** — the crate has zero `unsafe` blocks outside the
|
||
`local` feature module's signal-forwarding calls. The PTY bridge's
|
||
`libc::kill(-pgid, sig)` and `libc::kill(pid, sig)` calls (and the
|
||
pipe-mode `libc::kill(pid, sig)` / `libc::kill(pid, SIGKILL)`
|
||
fallback) are safe `libc` crate APIs wrapped in `unsafe { ... }`
|
||
blocks because `libc::kill` is an `unsafe fn`; they are the
|
||
documented signal-forwarding path, not `unsafe` in the crate's own
|
||
logic. Bounds-checked slice access via `get(..)`/`ok_or_else` is
|
||
the pattern. Do not introduce `unsafe` for performance, and do not
|
||
add `unsafe` outside the `local` module's `libc::kill` calls.
|
||
|
||
## Verification Commands
|
||
|
||
Run these before committing. All must pass.
|
||
|
||
```bash
|
||
cargo test
|
||
cargo clippy --all-targets -- -D warnings
|
||
cargo fmt --check
|
||
cargo doc --no-deps
|
||
cargo test --all-features
|
||
cargo test --manifest-path fuzz/shared/Cargo.toml # fuzz corpus replay — the standing fuzz gate
|
||
cargo check --target wasm32-unknown-unknown # the 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 — run it whenever a non-`local` module changes.
|
||
`cargo test --all-features` exercises the `local` backend (PTY mode is
|
||
`#[cfg(unix)]`; pipe mode is cross-platform).
|
||
|
||
The fuzz corpus replay (`docs/plans/fuzzing.md`) runs every committed
|
||
seed through the same invariant functions the cargo-fuzz targets run —
|
||
on stable, no nightly needed. Run detached campaigns
|
||
(`fuzz/run-detached.sh <target>` — never foreground fuzzing) before a
|
||
release and after touching `src/wire.rs`, `src/negotiation.rs`,
|
||
`src/control.rs`, the adapter, or the session pump.
|
||
|
||
## Architecture Context
|
||
|
||
- `docs/plans/project-setup.md` — the current plan (phases 0–5). All
|
||
five phases are landed: Phase 0 (scaffold hygiene), Phase 1 (port
|
||
core types), Phase 2 (channels integration + `TtySession`), Phase 3
|
||
(`local` backend), Phase 4 (architecture docs + BAST schema +
|
||
renumbered ADRs), and Phase 5 (tests, including the integration
|
||
tests in `tests/` at the crate root).
|
||
- `docs/architecture/` is created in Phase 4. The alknet ADRs
|
||
referenced below (052, 053, 054, 055, 056, 057, 077, 093) are ported
|
||
and renumbered into alktty's ADR range (001..009) in
|
||
`docs/architecture/decisions/`. ADR-009 (the channels open op's
|
||
`input` is the negotiation) is alktty-native — it resolves review
|
||
#001 L1 and has no alknet original. The alknet originals at
|
||
`/workspace/@alkdev/alknet/docs/architecture/decisions/` remain the
|
||
authoritative source for any ADR not yet ported — read them before
|
||
non-trivial changes to the wire format, trait, or adapter.
|
||
- Key ADRs that inform this crate's design (alknet numbers → ported
|
||
alktty numbers, 1:1 in the order the plan lists them):
|
||
- ADR-052 → 001 — two-carriage wire format (JSON negotiation + raw
|
||
chunks); the 5-byte chunk header. The Phase 7 control-channel
|
||
split (`STREAM_CTRL_IN` = 3 client→server, `STREAM_CTRL_OUT` = 4
|
||
server→client) is an amendment inside this ADR, mirroring alknet
|
||
(it was not a standalone ADR there either).
|
||
- ADR-053 → 002 — `TtyBackend` trait (the inversion point between
|
||
wire-format adapter and backend crates)
|
||
- ADR-054 → 003 — local backend placement. Ported with the
|
||
single-crate rewrite: alktty folds the local backend in behind a
|
||
`local` feature (resolves the alknet cyclic-dep workaround that
|
||
motivated the original sibling-crate decision; the ADR records both
|
||
the original alknet decision and the alktty consolidation).
|
||
- ADR-055 → 004 — exit-code reporting (`{"type":"exit","code":N}` on
|
||
`ctrl_out`; `code: -1` on wait-failure)
|
||
- ADR-056 → 005 — backend cleanup on session cancel (drop of
|
||
`exit_code` future kills the session target; the
|
||
kill-on-`Drop` contract on the `TtyBackend` trait)
|
||
- ADR-057 → 006 — negotiation framing is self-contained (not reused
|
||
from alkcall's `EventEnvelope` framing)
|
||
- ADR-077 → 007 — TTY inside channels (reversed by ADR-093/008; kept
|
||
for historical context with its reversal notice pointing to 008)
|
||
- ADR-093 → 008 — TTY always uses its 5-byte format inside channels
|
||
(reverses ADR-077; channels strips its 8-byte header transparently)
|
||
- (alktty-native) ADR-009 — the channels open op's `input` IS the
|
||
negotiation (no alknet original; resolves review #001 L1)
|
||
- ADR-050 (dynamic resource ownership) is an alkcall/alknet-core ADR,
|
||
not a tty-specific one — it is not ported into alktty's ADR range.
|
||
The access-control work that declares against the ADR-050 model
|
||
(scope-gate at negotiation, backend-driven `resource_id()` ownership
|
||
check) is described in `tty-adapter.md` and the ADR-001/002 ported
|
||
docs, which reference ADR-050 by its alknet number.
|
||
- If a TODO references a "Phase 7" note or 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.
|
||
- The crate was ported from `/workspace/@alkdev/alknet/crates/
|
||
alknet-tty/src/` (wire, control, negotiation, backend, adapter) and
|
||
`/workspace/@alkdev/alknet/crates/alknet-tty-local/src/` (local
|
||
backend, pty, pipe). The source architecture docs at
|
||
`/workspace/@alkdev/alknet/docs/architecture/crates/tty/` are the
|
||
port origin for the Phase 4 spec docs.
|
||
- alkcall (`/workspace/@alkdev/alkcall`) is the upstream dependency.
|
||
All types formerly in `alknet-core` (`Connection`, `ProtocolHandler`,
|
||
`BiStream`, `BidiStreamSource`, `AuthContext`, `Identity`,
|
||
`IdentityProvider`, `AccessControl`, `OwnershipProvider`,
|
||
`OwnershipStore`, `InMemoryOwnershipStore`, `HandlerError`,
|
||
`StreamError`) come from `alkcall::core`. alkcall is v0.8.x —
|
||
breaking changes are expected at this major-zero stage; this is the
|
||
first real consumer, so we find and fix issues upstream rather than
|
||
working around them. Pin `alkcall = "0.8.1"` (0.8.1 fixes the
|
||
duplicate adopt/open channel-state destruction found by alkcall's
|
||
fuzz campaign) and bump deliberately. |