chore: add .gitignore and AGENTS.md; fix implementation-specialist conventions
The scaffold was missing basic repo hygiene, which led to drift: the implementation-specialist agent's Project Conventions section was a verbatim copy from alkcall (referencing OperationEnv, EventEnvelope, Capabilities, alktype as a dep, call.aborted, vendored core types — none of which exist in alktty), so spawned agents got alkcall conventions injected. - .gitignore: target/, node_modules/, .worktrees/ (matches alkcall/alktype) - AGENTS.md: alktty-specific operating instructions — Git Workflow, 14 Project Conventions (5-byte chunk header wire format, TtyBackend trait one-way door, local feature isolation / WASM invariant, BAST-as-doc-not-dep, 3-arg/4-arg ownership shapes, alknet ADR→alktty ADR renumbering plan), Verification Commands (incl. wasm checks), Architecture Context (phase status, ADR index, port origin) - .opencode/agents/implementation-specialist.md: replace stale alkcall Project Conventions section with alktty-appropriate rules that mirror AGENTS.md Verification: cargo test (80 passed), cargo fmt --check, cargo build --target wasm32-unknown-unknown all clean. No source changes.
This commit is contained in:
@@ -0,0 +1,3 @@
|
|||||||
|
target/
|
||||||
|
node_modules/
|
||||||
|
.worktrees/
|
||||||
@@ -210,55 +210,101 @@ This is especially important for complex tasks that span many file operations.
|
|||||||
Read `AGENTS.md` at project root for full details. Key rules:
|
Read `AGENTS.md` at project root for full details. Key rules:
|
||||||
|
|
||||||
1. **No comments in code** — Per project convention. Doc comments (`///`, `//!`)
|
1. **No comments in code** — Per project convention. Doc comments (`///`, `//!`)
|
||||||
are fine and expected on public API.
|
are fine and expected on public API. Inline `//` comments only when the user
|
||||||
2. **Error handling** — `thiserror` for library error types. No panics in
|
asks or when a non-obvious safety/correctness constraint would otherwise be
|
||||||
library code. No `unwrap()` or `expect()` outside tests. For poisoned
|
missed (e.g., "zero-length stdin is the EOF sentinel — the codec does not
|
||||||
`RwLock`/`Mutex`, use `unwrap_or_else(|e| e.into_inner())` so a panic in one
|
special-case it; the adapter interprets `length == 0` chunks").
|
||||||
operation does not cascade to other operations.
|
2. **Error handling** — `thiserror` for library error types (`TtyError`,
|
||||||
3. **`tokio` is the async runtime** — all I/O is async. Use `tokio::sync`
|
`NegotiateError`, `WireError`; `HandlerError`/`StreamError` come from
|
||||||
primitives (`oneshot`, `mpsc`) for request correlation and subscription
|
alkcall::core). No panics in library code. No `unwrap()` or `expect()`
|
||||||
channels; `parking_lot` for short-held internal locks (`PendingRequestMap`).
|
outside tests. For poisoned `RwLock`/`Mutex`, use
|
||||||
4. **No secret material on the wire** — `call.requested`/`call.responded`
|
`unwrap_or_else(|e| e.into_inner())`.
|
||||||
payloads and `OperationContext.metadata` carry no private keys, API keys, or
|
3. **`tokio` is the async runtime** — all I/O is async. The adapter's
|
||||||
decrypted credentials. Outbound credentials flow through `Capabilities`
|
per-session pumps, the channels integration, and `TtySession` are all async.
|
||||||
injected at the assembly layer → `HandlerRegistration.capabilities` →
|
Use `tokio::sync` primitives (`oneshot`, `mpsc`) for request/exit
|
||||||
`OperationContext.capabilities` → handler.
|
correlation and stdout/stderr streams. The PTY bridge in `src/local/pty.rs`
|
||||||
5. **No-env-vars invariant** — no handler reads outbound credentials from any
|
is the one exception: three dedicated std threads feeding tokio mpsc/oneshot
|
||||||
source other than `OperationContext.capabilities`. This is a spec-level
|
channels (the `portable_pty` API is blocking) — behind the `local` feature.
|
||||||
invariant, not a runtime convention.
|
4. **WASM target is load-bearing** — the default crate (no `local` feature)
|
||||||
6. **`OperationEnv` must remain a trait** — the trait-based design enables
|
MUST compile to `wasm32-unknown-unknown`. This is what makes the downstream
|
||||||
registry layering (session overlays, connection overlays, peer-keyed
|
TS/Python adapter story work. `Cargo.toml` uses
|
||||||
composition). Do not make it concrete or hardcode the global registry.
|
`tokio = { default-features = false, features = ["rt", "sync", "io-util", "macros"] }`
|
||||||
7. **Wire formats are stable** — `EventEnvelope` (`{ type, id, payload }` +
|
(the wasm-clean subset alkcall uses); `local` adds `tokio/process` +
|
||||||
length-prefixed JSON framing) and the channels 8-byte chunk header
|
`tokio/rt-multi-thread`. **Do NOT use `features = ["full"]`** — it pulls in
|
||||||
(`[channel_id:u32 BE][length:u32 BE][payload]`) are one-way doors. New event
|
`signal`/`fs`/`net` which break `wasm32-unknown-unknown`. The adapter's
|
||||||
types may be added; existing shapes must not change.
|
`tokio::spawn` is fine on wasm; the `local` module's std threads,
|
||||||
8. **Producer/consumer, not server/client** — both sides of a call or channels
|
`tokio::process::Command`, and `libc::kill` are the non-wasm parts, and
|
||||||
connection can initiate. Use "producer"/"consumer" or "accept side"/"connect
|
they're feature-gated.
|
||||||
side," not "server"/"client."
|
5. **Wire formats are stable** — two one-way doors:
|
||||||
9. **Vendored core types** — `Connection`, `ProtocolHandler`, `BiStream`,
|
- **5-byte chunk header** (`[stream_type: u8][length: u32 BE][payload]`) —
|
||||||
`BidiStreamSource`, `AuthContext`, `IdentityProvider`, `Identity`,
|
the raw chunk codec in `wire.rs`. Five stream types: `STREAM_STDIN=0`,
|
||||||
`AuthToken`, `Capabilities`, `OwnershipProvider`, `HandlerError`,
|
`STREAM_STDOUT=1`, `STREAM_STDERR=2`, `STREAM_CTRL_IN=3`,
|
||||||
`StreamError` live in this crate. Do not add a separate `alkcore` dependency.
|
`STREAM_CTRL_OUT=4`. Zero-length data chunks are sentinels (zero-length
|
||||||
Keep them lean (no TLS, no transport coupling, no endpoint/accept-loop).
|
stdin = EOF from client; zero-length stdout = "drained" from server);
|
||||||
10. **`alktype` dependency** — use `alktype` for binary layout (channels chunk
|
control chunks are never zero-length. Changing the header, the
|
||||||
header, future binary payload schemas) and JSON payload schema validation
|
stream-type values, or the sentinel semantics breaks all peers.
|
||||||
(`OperationSpec`'s `input_schema`/`output_schema`). Do not roll your own.
|
- **Negotiation frame** (4-byte BE length prefix + UTF-8 JSON
|
||||||
11. **Feature flags** — transports may be feature-gated if the need arises. The
|
`NegotiateRequest` body) — self-contained, not reused from alkcall's
|
||||||
base crate should compile lean (no `quinn`, no `iroh` unless the feature is
|
`EventEnvelope` framing. The `NegotiateRequest` JSON shape is
|
||||||
on). Verify both `cargo test` (default) and `cargo test --all-features` pass
|
wire-stable once consumers exist.
|
||||||
if features are added.
|
Per ADR-093: TTY always uses its 5-byte format, even inside channels. The
|
||||||
12. **Abort cascades to descendants** — `call.aborted` for a parent cascades to
|
channels layer strips its 8-byte header transparently — the same `wire.rs`
|
||||||
all non-terminal descendants. Default `abort-dependents`;
|
code runs in both direct (`alk/tty`) and channels (`alk/channels`) modes;
|
||||||
`continue-running` opt-in. The composing handler decides the child's policy,
|
only the `BiStream` source differs.
|
||||||
not the wire caller.
|
6. **`TtyBackend` trait shape is a one-way door** — the trait in
|
||||||
13. **Peer authorization via `AccessControl`** — a remote peer's call is
|
`src/backend.rs` is the inversion point between the wire-format adapter and
|
||||||
authorized by `AccessControl::check(peer_identity)`. No `remote_safe` flag,
|
the backend crates (alktty's own `local` feature module, future
|
||||||
no `trusted_peer` bypass. `Visibility::Internal` ops are never wire-callable.
|
`alknet-docker`, `alknet-ssh`). alktty defines the trait; the backends
|
||||||
14. **Naming conventions** — Rust standard: `snake_case` for functions/variables/
|
implement it. The adapter holds a `HashMap<String, Arc<dyn TtyBackend>>`
|
||||||
modules, `PascalCase` for types/traits, `SCREAMING_SNAKE_CASE` for constants.
|
keyed by the negotiation frame's `backend` string; backends produce
|
||||||
15. **Module structure** — one module per file under `src/`, re-exported from
|
`TtyHandle`s, they do not write to the wire. `TtyError` is
|
||||||
`src/lib.rs`. Public API surface is `lib.rs` re-exports.
|
`#[non_exhaustive]` so new variants are additive.
|
||||||
|
7. **Producer/consumer, not server/client** — both sides of a `alk/tty` or
|
||||||
|
`alk/channels` connection can initiate. Use "producer"/"consumer" or
|
||||||
|
"accept side"/"connect side," not "server"/"client."
|
||||||
|
8. **`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 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.
|
||||||
|
9. **Module structure** — one module per file under `src/`, re-exported from
|
||||||
|
`src/lib.rs`. Producer half: `src/adapter.rs` + `src/channels.rs`. Consumer
|
||||||
|
half: `src/session.rs`. Shared: `src/wire.rs`, `src/control.rs`,
|
||||||
|
`src/negotiation.rs`, `src/backend.rs`. The `src/local/` module is behind
|
||||||
|
the `local` feature and never imported from the shared/producer/consumer
|
||||||
|
modules — only re-exported from `lib.rs` under `#[cfg(feature = "local")]`.
|
||||||
|
10. **BAST document, not alktype dependency** — `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`. 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. If runtime
|
||||||
|
validation against the BAST becomes desirable later, alktype becomes an
|
||||||
|
optional dep — do not roll your own offset map or validator for complex
|
||||||
|
formats.
|
||||||
|
11. **Feature flags** — the only feature is `local` (default = `[]`). `local`
|
||||||
|
is inherently non-wasm; enabling it 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.
|
||||||
|
12. **Access control** — the direct-ALPN path keeps the ad-hoc scope check
|
||||||
|
(`has_scope(identity, TTY_OPEN_SCOPE)`) and optionally consults
|
||||||
|
`OwnershipProvider` (`provider.owns(id_ref, kind, &id, "tty")` — the 4-arg
|
||||||
|
shape). The channels path gets both for free via
|
||||||
|
`ChannelCore::register_openable`, which wires `AccessControl` into the
|
||||||
|
operation spec. `OwnershipStore::record(&self, identity, resource_type,
|
||||||
|
resource_id)` is the 3-arg shape (alkcall dropped the old `action` arg).
|
||||||
|
13. **Naming conventions** — 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. The PTY bridge's
|
||||||
|
`libc::kill(-pgid, sig)` and `libc::kill(pid, sig)` calls are safe `libc`
|
||||||
|
crate APIs (not `unsafe` blocks in this crate). Do not introduce `unsafe`
|
||||||
|
for performance.
|
||||||
|
|
||||||
## Key Principles
|
## Key Principles
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,292 @@
|
|||||||
|
# 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-008), 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-007): 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
|
||||||
|
both for free via `ChannelCore::register_openable`, which wires
|
||||||
|
`AccessControl` into the operation spec — the registry runs the ACL
|
||||||
|
before the wrapper, so the `OpenHandler` only needs to validate
|
||||||
|
params and spawn the protocol. 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. The PTY
|
||||||
|
bridge's `libc::kill(-pgid, sig)` and `libc::kill(pid, sig)` calls
|
||||||
|
are safe `libc` crate APIs (not `unsafe` blocks in this crate);
|
||||||
|
bounds-checked slice access via `get(..)`/`ok_or_else` is the
|
||||||
|
pattern. Do not introduce `unsafe` for performance.
|
||||||
|
|
||||||
|
## 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 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).
|
||||||
|
|
||||||
|
## Architecture Context
|
||||||
|
|
||||||
|
- `docs/plans/project-setup.md` — the current plan (phases 0–5). Phase 0
|
||||||
|
(scaffold hygiene), Phase 1 (port core types), Phase 2 (channels
|
||||||
|
integration + `TtySession`), and Phase 3 (`local` backend) are landed.
|
||||||
|
Phase 4 (architecture docs + BAST schema + renumbered ADRs) and
|
||||||
|
Phase 5 (tests, including integration tests in `tests/` at the crate
|
||||||
|
root) are not yet done.
|
||||||
|
- `docs/architecture/` does not exist yet — it's created in Phase 4.
|
||||||
|
The ADRs referenced below (alknet ADR-052, 053, 054, 055, 056, 057,
|
||||||
|
077, 093) will be ported and renumbered into alktty's ADR range
|
||||||
|
(001..008) at that time. Until then, the alknet originals at
|
||||||
|
`/workspace/@alkdev/alknet/docs/architecture/decisions/` are the
|
||||||
|
authoritative source — read them before non-trivial changes to the
|
||||||
|
wire format, trait, or adapter.
|
||||||
|
- Key ADRs that inform this crate's design (alknet numbers → planned
|
||||||
|
alktty numbers):
|
||||||
|
- ADR-052 → 001 — two-carriage wire format (JSON negotiation + raw
|
||||||
|
chunks); the 5-byte chunk header
|
||||||
|
- ADR-053 → 002 — `TtyBackend` trait (the inversion point between
|
||||||
|
wire-format adapter and backend crates)
|
||||||
|
- ADR-050 → 003 — dynamic resource ownership for runtime-spawned
|
||||||
|
terminal sessions
|
||||||
|
- ADR-054 → 004 — single crate with `local` feature (resolves the
|
||||||
|
alknet cyclic-dep workaround; the local backend is folded in)
|
||||||
|
- ADR-055 → 005 — exit-code reporting (`{"type":"exit","code":N}` on
|
||||||
|
`ctrl_out`; `code: -1` on wait-failure)
|
||||||
|
- ADR-056 → 006 — control-message split (`ctrl_in` client→server,
|
||||||
|
`ctrl_out` server→client; the bidirectionality fix)
|
||||||
|
- ADR-093 → 007 — TTY always uses its 5-byte format inside channels
|
||||||
|
(reverses ADR-077; channels strips its 8-byte header transparently)
|
||||||
|
- ADR-057 → 008 — negotiation framing is self-contained (not reused
|
||||||
|
from alkcall's `EventEnvelope` framing)
|
||||||
|
- 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.1.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.1.1"` and bump deliberately.
|
||||||
Reference in New Issue
Block a user