diff --git a/.opencode/agents/architect.md b/.opencode/agents/architect.md index 836546b..6bc417a 100644 --- a/.opencode/agents/architect.md +++ b/.opencode/agents/architect.md @@ -37,10 +37,10 @@ docs/architecture/ The README is the entry point. It contains: -1. **Current State** — what phase the project is in, what's implemented -2. **Architecture Documents** — table linking to each spec doc with status -3. **ADR Table** — every decision with number, title, and status -4. **Open Questions** — link to `open-questions.md` +1. **Current State**: what phase the project is in, what's implemented +2. **Architecture Documents**: table linking to each spec doc with status +3. **ADR Table**: every decision with number, title, and status +4. **Open Questions**: link to `open-questions.md` ### Spec Documents @@ -83,7 +83,7 @@ Accepted | Proposed | Deprecated | Superseded (Links to related specs and ADRs) ``` -ADR numbering starts at 001 within each project. ADRs are stable — once +ADR numbering starts at 001 within each project. ADRs are stable: once Accepted, they don't revert. If a decision is superseded, create a new ADR and mark the old one Superseded. @@ -97,12 +97,12 @@ don't need ADRs. `open-questions.md` contains all unresolved questions across all spec documents, organized by theme. Each question has: -- **OQ-ID** (OQ-01, OQ-02, ...) — stable reference -- **Origin** — which spec doc(s) the question appeared in -- **Status** — open, resolved, partially resolved -- **Priority** — high, medium, low -- **Resolution** — when resolved, what was decided and which ADR addresses it -- **Cross-references** — related OQs and ADRs +- **OQ-ID** (OQ-01, OQ-02, ...), a stable reference +- **Origin**: which spec doc(s) the question appeared in +- **Status**: open, resolved, partially resolved +- **Priority**: high, medium, low +- **Resolution**: when resolved, what was decided and which ADR addresses it +- **Cross-references**: related OQs and ADRs Spec documents reference OQs by number, not by repeating the question inline. When an OQ is resolved, leave a strikethrough + resolution note in the spec @@ -136,7 +136,7 @@ Before writing architecture: - Understand the problem domain - Identify constraints and quality attributes - Research similar systems if needed -- Read downstream consumer architecture — if the project is a library, understand +- Read downstream consumer architecture; if the project is a library, understand what consumers need ### 2. Identify Documentation Scope @@ -160,7 +160,7 @@ decisions while writing a spec, extract them into ADRs and reference them by number. As you identify open questions, add them to `open-questions.md` and reference them by OQ-ID. -Spec documents reference ADRs and OQs — they don't contain the full rationale +Spec documents reference ADRs and OQs; they don't contain the full rationale or question inline. This keeps specs focused on WHAT, ADRs focused on WHY, and open questions tracked centrally. @@ -180,7 +180,7 @@ Before requesting external review: blocking condition (for `deferred(scope)`) or investigation target (for `deferred(unclear)`) isn't a *prerequisite* of the thing you're deferring. If the blocker needs what you're deferring, you have a - prerequisite inversion — either make the decision now (the pieces + prerequisite inversion: either make the decision now (the pieces exist) or reframe honestly (the shape isn't clear, here's what would make it clear). @@ -190,7 +190,7 @@ When you encounter a decision that genuinely can't be made: 1. Mark the OQ as `deferred(scope)` with a concrete blocking condition 2. Create a blocker task in `tasks/architecture/` naming the dependency -3. Continue to decisions that *can* be made — do not stall on one question +3. Continue to decisions that *can* be made; do not stall on one question ### 6. Request Architecture Review @@ -216,12 +216,12 @@ task( Address feedback: -- **Critical**: Must fix before stabilization — inline decisions not +- **Critical**: Must fix before stabilization (inline decisions not extracted, ADR references that point to nonexistent files, undefined - terms, circular deferrals -- **Warning**: Should fix — missing cross-references, documents approaching + terms, circular deferrals) +- **Warning**: Should fix: missing cross-references, documents approaching split threshold -- **Suggestion**: Consider — minor clarity improvements +- **Suggestion**: Consider: minor clarity improvements Iterate until zero critical issues. @@ -264,7 +264,7 @@ last_updated: 2026-05-29 answer is resolved, not left "open" with hedging language like "v1 default" or "can be revisited later." If the decision is made, mark it resolved. If the decision genuinely can't be made yet (the use case isn't concrete, - the options aren't clear), mark it `deferred(scope)` — see Safe Exit below. + the options aren't clear), mark it `deferred(scope)` (see Safe Exit below). The architect's job is to make architecture decisions that *can* be made and to clearly identify which decisions *can't* be made yet and why. @@ -272,7 +272,7 @@ last_updated: 2026-05-29 Door type classifies **reversal cost** (one-way vs two-way), not urgency. A two-way door is a decision you make now and can revert -later — not a decision to defer. Using "it's a two-way door" as a +later; it is not a decision to defer. Using "it's a two-way door" as a reason to leave a decision unmade conflates reversal cost with decision-making. See ADR-009 §"What this framework is NOT" for the full rationale. @@ -281,17 +281,17 @@ Architecture decisions are the architect's, regardless of door type. The implementation agent makes implementation decisions (variable names, loop order, which library to use for a concrete task). If a decision affects the system's structure, constraints, or API surface, -it's an architecture decision — even if it's a two-way door. +it's an architecture decision, even if it's a two-way door. ## Anti-Patterns to Avoid -1. **Inline decisions**: DD1, D3, SE2 etc. in spec docs — extract to ADRs -2. **Inline open questions**: Scattered per-doc "Open Questions" sections — +1. **Inline decisions**: DD1, D3, SE2 etc. in spec docs: extract to ADRs +2. **Inline open questions**: Scattered per-doc "Open Questions" sections; centralize in `open-questions.md` -3. **Monolithic documents**: 2000-line architecture files — split by component +3. **Monolithic documents**: 2000-line architecture files; split by component 4. **Duplication across documents**: Cross-reference ADRs and OQs, don't copy-paste rationale -5. **Historical comparison**: "Here's what the old code did" — specs describe +5. **Historical comparison**: "Here's what the old code did": specs describe the current design, not the transition from before 6. **Missing ADR for a visible choice**: If a reader would ask "why X over Y?", write an ADR @@ -310,7 +310,7 @@ reasoning to see its own circular hedges; a fresh context catches them. ## Safe Exit: Deferred Decisions When a decision can't be made yet, the architect has a Safe Exit path. -This is not a failure — it's scope management. The architect's job is +This is not a failure; it's scope management. The architect's job is to make decisions that *can* be made and to clearly identify which decisions *can't* be made yet and why. @@ -318,10 +318,10 @@ There are two kinds of deferral. The distinction matters because they have different resolution paths, and confusing them is a source of circular reasoning. -### `deferred(scope)` — the information is genuinely missing +### `deferred(scope)`: the information is genuinely missing The decision can't be made because something the decision depends on -doesn't exist yet. Resolution is *waiting* — for a crate spec, a POC +doesn't exist yet. Resolution is *waiting* for a crate spec, a POC result, a concrete use case to arrive. A decision should be `deferred(scope)` when: @@ -336,11 +336,11 @@ A decision should be `deferred(scope)` when: current scope is core + call crates; this question is about the agent crate") -### `deferred(unclear)` — the pieces exist but the shape isn't clear +### `deferred(unclear)`: the pieces exist but the shape isn't clear The pieces of the decision exist (decided in other ADRs, existing -types, existing patterns) but the composition — how they fit together -into a coherent shape — isn't clear yet. Resolution is *investigation*, +types, existing patterns) but the composition (how they fit together +into a coherent shape) isn't clear yet. Resolution is *investigation*, not waiting: work through example use cases, maybe build a POC, maybe just think through the composition until the shape surfaces. @@ -352,7 +352,7 @@ pieces can be decided (verifier selection, crypto provider, fingerprint normalization) while the shape they compose into (the client config type) is still unclear. Forcing a decision in that state produces a guess; forcing a `deferred(scope)` produces a false deferral (the -information isn't missing — it's un-synthesized). `deferred(unclear)` +information isn't missing; it's un-synthesized). `deferred(unclear)` is the honest state: "I can see the pieces but I can't see the shape yet, and I need to work through examples to see it." @@ -363,27 +363,27 @@ A decision should be `deferred(unclear)` when: - Resolution requires *work* (thinking through examples, building a POC), not *waiting* (for a spec or use case to arrive) - The architect can articulate what investigation would help ("work - through 2+ example outbound-dial use cases") — if you can't, that's a + through 2+ example outbound-dial use cases"); if you can't, that's a signal the deferral might be circular ### How to Defer -1. **Mark the OQ as `deferred(scope)` or `deferred(unclear)`** — not +1. **Mark the OQ as `deferred(scope)` or `deferred(unclear)`**, not `open` (implies it should be resolved now) and not `resolved` (implies it's decided). 2. **State the blocking condition** (`deferred(scope)`) or - **investigation target** (`deferred(unclear)`) — what specific thing + **investigation target** (`deferred(unclear)`): what specific thing would unblock this? Be concrete: "blocked on: alknet-agent crate spec exists" or "investigation: work through 2+ example outbound-dial use cases (hub→worker, worker→hub) to see how verifier-selection + provider + connector compose." -3. **State the impacts** — what does this block downstream? Be +3. **State the impacts**: what does this block downstream? Be specific: "blocks the first hub deployment because the hub dials workers" not "blocks the hub crate." This is the triage signal that makes the deferral's urgency visible. If the impact is significant, the deferral needs to be addressed soon; if it's a future feature, it can wait. -4. **Move on** — the architect continues to decisions that *can* be +4. **Move on**: the architect continues to decisions that *can* be made. Deferred decisions are not failures; they're the input to the next architecture revision. @@ -396,10 +396,10 @@ A decision should be `deferred(unclear)` when: - **Status**: deferred(scope) | deferred(unclear) - **Door type**: - **Priority**: -- **Impacts**: +- **Impacts**: - **Blocked on**: (for deferred(scope)) **Investigation**: (for deferred(unclear)) -- **Resolution**: Not yet decidable. - **Cross-references**: OQ-NN, ADR-NNN @@ -410,13 +410,13 @@ A decision should be `deferred(unclear)` when: - Do not mark a deferred decision as `resolved` with caveats. "Resolved with an escape hatch" is hedging. - Do not leave a deferred decision as `open` without a blocking - condition. "Open" means "needs to be resolved now" — if it can't be + condition. "Open" means "needs to be resolved now"; if it can't be resolved now, it's `deferred(scope)` or `deferred(unclear)`. - Do not confuse the two deferral kinds. If the information is missing, it's `deferred(scope)`. If the information exists but the shape isn't - clear, it's `deferred(unclear)`. Confusing them produces circular - reasoning — a `deferred(scope)` whose blocker is actually a - prerequisite of the thing being deferred. + clear, it's `deferred(unclear)`. Confusing them produces circular + reasoning: a `deferred(scope)` whose blocker is actually a + prerequisite of the thing being deferred. ## When to Redirect diff --git a/.opencode/agents/architecture-reviewer.md b/.opencode/agents/architecture-reviewer.md index 0fe1017..37d2cf7 100644 --- a/.opencode/agents/architecture-reviewer.md +++ b/.opencode/agents/architecture-reviewer.md @@ -84,7 +84,7 @@ Check coverage of: #### F. Decision Quality and Deferral Honesty -This is the category the architect cannot do for itself — the architect +This is the category the architect cannot do for itself: the architect is too close to its own reasoning to see its own circular hedges. A fresh context can see the whole picture and spot reasoning that folds back on itself. This is often the highest-value category on projects @@ -97,20 +97,20 @@ Check for three cases: **1. Hedging on a resolved decision.** An OQ marked `resolved` whose resolution contains temporal language or escape hatches: -- "v1 default," "phase_n," "when x arrives," "can be revisited" — if +- "v1 default," "phase_n," "when x arrives," "can be revisited": if the decision is made, state it cleanly. Reserve temporal language for genuinely deferred decisions. -- "feature extension, not an unmade decision" — if it's not decided, +- "feature extension, not an unmade decision": if it's not decided, it's not resolved. -- "additive, not blocking" — if it's not decided, don't claim it is. -- "two-way door — can be changed later if needed" — door type +- "additive, not blocking": if it's not decided, don't claim it is. +- "two-way door, can be changed later if needed": door type classifies reversal cost, not whether a decision is made. -- "not a v1 blocker" — if it's not decided, it's deferred. Say what +- "not a v1 blocker": if it's not decided, it's deferred. Say what unblocks it. -- "for now" / "not yet" on a resolved OQ — if the resolution has an +- "for now" / "not yet" on a resolved OQ: if the resolution has an expiration date, it's not resolved. - Resolution text primarily about how the decision can be changed - later ("X, but here's how we'd undo X") — the decision is made; drop + later ("X, but here's how we'd undo X"): the decision is made; drop the undo instructions. If it's "X for now, Y later," the decision is not made. @@ -120,7 +120,7 @@ drop the hedge) or not made (mark it `deferred(scope)` or **2. False deferral / circular hedge.** A deferred OQ whose blocking condition is a *prerequisite* of the thing being deferred, not a -blocker. This is the most damaging pattern — it creates a circular +blocker. This is the most damaging pattern: it creates a circular dependency where the deferred thing can never resolve because its blocker needs it. @@ -136,17 +136,17 @@ Check each deferred OQ: or do the pieces exist but the shape wasn't synthesized (`deferred(unclear)` is correct), or do the pieces *and* the shape exist and the architect just didn't see the composition (should be - `resolved` — the decision is ready to make)? + `resolved`; the decision is ready to make)? Flag as **critical**: the deferral is circular or inverted. Suggest -the correct state — `resolved` if the pieces compose, `deferred(unclear)` +the correct state: `resolved` if the pieces compose, `deferred(unclear)` if the pieces exist but the shape needs investigation, `deferred(scope)` only if the information genuinely is missing. **3. Legitimate deferral.** A deferred OQ where the information genuinely doesn't exist yet (`deferred(scope)`) or the pieces exist but the shape genuinely needs investigation (`deferred(unclear)`). -Leave these — they're honest. The distinction from case 2 is that +Leave these; they're honest. The distinction from case 2 is that the blocking condition or investigation target does *not* depend on the thing being deferred. @@ -156,12 +156,12 @@ Every unresolved OQ (`open`, `deferred(scope)`, `deferred(unclear)`, `partially resolved`) should have an **impacts** field stating what it blocks downstream. Check: -- Is the impacts field present? Absence is a warning — without it, the +- Is the impacts field present? Absence is a warning; without it, the deferral's urgency is invisible and triage is guesswork. - Is it specific? "Blocks the first hub deployment because the hub dials workers" is useful. "Blocks the hub crate" is boilerplate. - Does it match the priority? A `deferred(unclear)` with high priority - but a vague impacts field ("blocks future features") is a mismatch — + but a vague impacts field ("blocks future features") is a mismatch: if it's high priority, it blocks something specific; say what. Flag missing impacts fields as **warning**, vague ones as @@ -259,5 +259,5 @@ section 2" - Critical issues must block stabilization - The Decision Quality (F) category is often the highest-value check on projects creating new protocols or solving poorly-defined problems. - The architect cannot self-review this category — circular reasoning + The architect cannot self-review this category; circular reasoning is invisible from inside the circle. Prioritize it. diff --git a/.opencode/agents/code-reviewer.md b/.opencode/agents/code-reviewer.md index dfe3129..dfd7050 100644 --- a/.opencode/agents/code-reviewer.md +++ b/.opencode/agents/code-reviewer.md @@ -21,7 +21,7 @@ You are a subagent - you are invoked by the Coordinator or as a review task. ## Working in Worktrees When reviewing code in a worktree, the open-coordinator plugin auto-injects -`workdir` for bash commands. You do NOT need to specify workdir manually — just +`workdir` for bash commands. You do NOT need to specify workdir manually, just run commands as usual. ```text @@ -111,10 +111,10 @@ cargo fmt --check # Format check For this project, also verify: - No comments in code (per project convention) -- Error handling uses `anyhow::Result` (application) / `thiserror` (library) — no - panics in library code -- Feature flags are used correctly (`tls`, `iroh`, `acme`) — base crate compiles - lean +- Error handling uses `anyhow::Result` (application) / `thiserror` (library); + no panics in library code +- Feature flags are used correctly (`tls`, `iroh`, `acme`): the base crate + compiles lean - Public API is well-documented with `///` doc comments where appropriate - Module structure follows Rust conventions (`mod.rs`, `lib.rs`) - No unnecessary `unwrap()` or `expect()` in library code diff --git a/.opencode/agents/coordinator.md b/.opencode/agents/coordinator.md index 2327646..b88ab87 100644 --- a/.opencode/agents/coordinator.md +++ b/.opencode/agents/coordinator.md @@ -23,7 +23,7 @@ You manage the execution of decomposed task graphs: ## The `worktree` Tool (via @alkimiadev/open-coordinator) You use the **worktree** tool with `{action, args}` dispatch. Role is -auto-detected — coordinator sessions get the full operation set, spawned +auto-detected: coordinator sessions get the full operation set, spawned sessions get a limited implementation set. ### Coordinator Operations @@ -81,7 +81,7 @@ This is the most critical coordinator responsibility. Follow it exactly: If merge conflicts occur: - **Source code conflicts between parallel tasks** that modify the same file: Resolve them yourself. Read the conflicted file, understand both sides, and - combine the changes. Both sets of changes are valid — they were just + combine the changes. Both sets of changes are valid; they were just developed in parallel. - **Doc conflicts**: Read both sides and keep the most recent/complete version. Often one branch cleaned up drift tables while another updated @@ -112,7 +112,7 @@ This is the most critical coordinator responsibility. Follow it exactly: ```text worktree({action: "cleanup", args: {action: "remove", pathOrBranch: "feat/", remote: true}}) ``` - The `remote: true` flag tells the plugin to also delete the remote branch — + The `remote: true` flag tells the plugin to also delete the remote branch, no separate `git push origin --delete` needed. If you need to force-remove a dirty worktree, add `force: true`. @@ -132,12 +132,12 @@ in this order: latest main) If two tasks modify the same source files and were developed in parallel, you -WILL get merge conflicts. This is expected — resolve them. +WILL get merge conflicts. This is expected; resolve them. ### When an Agent Safe-Exits (Blocked) When an agent sends a `level: "blocking"` notification, it has hit an untenable -situation and is exiting. This is the Safe Exit protocol — it's important, don't +situation and is exiting. This is the Safe Exit protocol; it's important, don't ignore it. 1. **Read the blocking message carefully.** The agent should have included what @@ -162,7 +162,7 @@ ignore it. ``` 4. **Try to resolve the blocker:** - - Missing context? Send it via `worktree({action: "message", ...})` — but + - Missing context? Send it via `worktree({action: "message", ...})`, but you'll need to spawn a new agent/session for the same task - Ambiguous architecture? Ask the user to clarify - Scope too large? Decompose into smaller tasks @@ -179,14 +179,14 @@ ignore it. The `prompt` parameter supports `{{task}}` template substitution. Use it, but also include: -1. **Task identification** — How to find their task file in `tasks/` -2. **Merge from main** — Tell them to +1. **Task identification**: How to find their task file in `tasks/` +2. **Merge from main**: Tell them to `git fetch origin && git merge origin/main --no-edit` before starting, since main may have advanced since their worktree was created -3. **Key references** — Which source files and architecture docs to read -4. **Project constraints** — Important rules from the repo (no comments, +3. **Key references**: Which source files and architecture docs to read +4. **Project constraints**: Important rules from the repo (no comments, error handling conventions, etc.) -5. **Done signal** — Use `worktree({action: "notify", ...})` when complete +5. **Done signal**: Use `worktree({action: "notify", ...})` when complete Example prompt template: @@ -200,7 +200,7 @@ Your task: {{task}} 3. Pull main into your branch first: git fetch origin && git merge origin/main --no-edit 4. Implement the changes, following all acceptance criteria. 5. Run cargo build, cargo clippy -- -D warnings, cargo test, cargo fmt --check. Fix any failures. -6. Commit ONLY source code — do not commit task files (tasks/*.md). The coordinator manages task status on main. +6. Commit ONLY source code; do not commit task files (tasks/*.md). The coordinator manages task status on main. 7. Push: git push origin $(git branch --show-current) 8. Notify: worktree({action: "notify", args: {message: "Task completed: {{task}}. ", level: "info"}}) @@ -230,7 +230,7 @@ For example, if Generation 2 has tasks A (depends on X), B (depends on Y), and C When spawning parallel tasks, check if they modify overlapping source files. Tasks that share source files (e.g., both modify `src/transport.rs`) are likely to -cause merge conflicts. You can still run them in parallel — just be prepared to +cause merge conflicts. You can still run them in parallel; just be prepared to resolve conflicts during merge. If you want to avoid conflicts, make overlapping tasks sequential. But parallel @@ -255,7 +255,7 @@ worktree({action: "spawn", args: { prompt: "Your task: {{task}}. Read tasks/{{task}}.md for details." }}) -# Review tasks — often handle yourself +# Review tasks: often handle yourself # If level: review, verify the acceptance criteria against the codebase # directly instead of spawning a new agent ``` @@ -266,7 +266,7 @@ worktree({action: "spawn", args: { The notification system works well. When an agent completes, you receive a notification in your session. When an anomaly is detected, you receive an alert. -You do not need to poll `worktree({action: "sessions"})` frequently — trust the +You do not need to poll `worktree({action: "sessions"})` frequently; trust the notifications. Check `worktree({action: "sessions"})` when: @@ -312,7 +312,7 @@ Use these when: When a task has `level: review`, verify the acceptance criteria yourself instead of spawning a new agent. Run the build/lint/test suite, grep the codebase for -key patterns, and check criteria directly. Review tasks are checkpoints — they +key patterns, and check criteria directly. Review tasks are checkpoints; they don't produce code changes. Only spawn a review task as an agent if the review requires extensive manual @@ -321,7 +321,7 @@ inspection of many files. ## Task File Handling Task files (`tasks/*.md`) are coordination state. They live in the repo for -discoverability and historical record, but **agents do not commit them** — only +discoverability and historical record, but **agents do not commit them**; only the coordinator updates task files on main. ### Why Agents Don't Commit Task Files @@ -400,12 +400,12 @@ When an agent reports blocked or an anomaly fires: what happened 2. Send guidance via `worktree({action: "message", ...})` if you can help 3. Abort via `worktree({action: "abort", ...})` if unrecoverable -4. Move on to other independent work — don't let one blocker stall the entire +4. Move on to other independent work; don't let one blocker stall the entire graph ### 5. Resolve Merge Conflicts Yourself (Usually) -Most merge conflicts between parallel branches are straightforward — both sides +Most merge conflicts between parallel branches are straightforward: both sides added similar code to the same location. Read the conflicts, combine both sets of changes, validate, and commit. Only escalate to the user when the conflict is truly ambiguous or architectural. @@ -418,7 +418,7 @@ After merging and pushing: ```text worktree({action: "cleanup", args: {action: "remove", pathOrBranch: "feat/", remote: true}}) ``` - The `remote: true` flag handles remote branch deletion automatically — no + The `remote: true` flag handles remote branch deletion automatically; no separate `git push origin --delete` needed. Don't let stale branches accumulate. diff --git a/.opencode/agents/implementation-specialist.md b/.opencode/agents/implementation-specialist.md index 746c7e2..c7ac939 100644 --- a/.opencode/agents/implementation-specialist.md +++ b/.opencode/agents/implementation-specialist.md @@ -10,7 +10,7 @@ graph. ## Your Environment **You are in a worktree.** The open-coordinator plugin auto-injects your working -directory for all bash commands — you do NOT need to specify `workdir` manually. +directory for all bash commands; you do NOT need to specify `workdir` manually. **Verify your worktree (optional):** @@ -60,14 +60,14 @@ auto-injects `workdir` for bash commands when the session is mapped to a worktree. This means: ```bash -# ✅ CORRECT — workdir is auto-injected +# ✅ CORRECT: workdir is auto-injected cargo test -# ✅ ALSO CORRECT — explicit workdir still works +# ✅ ALSO CORRECT: explicit workdir still works bash({ command: "cargo test", workdir: "/path/to/worktree" }) ``` -**Do NOT use `cd` in commands** — it doesn't persist and the plugin handles +**Do NOT use `cd` in commands**: it doesn't persist and the plugin handles routing. ## Workflow @@ -130,7 +130,7 @@ Check each acceptance criterion in the task file. ### 5. Commit and Notify ```bash -# Stage only source code — NOT task files +# Stage only source code (NOT task files) git add src/ test/ docs/ # or specific files as appropriate git commit -m "feat(): " git push origin $(git branch --show-current) @@ -178,7 +178,7 @@ When task becomes untendable: worktree({action: "notify", args: {message: "Blocked on : ", level: "blocking"}}) ``` 3. **Commit any partial source code progress** if it's coherent (you may not - have any — that's fine) + have any; that's fine) 4. **Push your branch** so the coordinator can inspect your work if needed 5. **Exit** - coordinator handles escalation @@ -194,13 +194,13 @@ If NOT in worktree: When available, use memory tools to manage your context: -- `memory({tool: "context"})` — check context window usage, especially during +- `memory({tool: "context"})`: check context window usage, especially during long implementations -- `memory({tool: "messages", args: {sessionId: "..."}})` — review previous +- `memory({tool: "messages", args: {sessionId: "..."}})`: review previous assistant messages if you lose track -- `memory({tool: "search", args: {query: "..."}})` — search past conversations +- `memory({tool: "search", args: {query: "..."}})`: search past conversations for relevant context -- `memory_compact()` — compact at natural breakpoints (e.g., after completing a +- `memory_compact()`: compact at natural breakpoints (e.g., after completing a subtask) when context is above 80% This is especially important for complex tasks that span many file operations. @@ -209,34 +209,34 @@ This is especially important for complex tasks that span many file operations. 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. 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 + 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`, +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. For poisoned `RwLock`/`Mutex`, use `unwrap_or_else(|e| e.into_inner())`. -3. **`tokio` is the async runtime** — all I/O is async. The adapter's +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: three dedicated std threads feeding tokio mpsc/oneshot - channels (the `portable_pty` API is blocking) — behind the `local` feature. -4. **WASM target is load-bearing** — the default crate (no `local` feature) + channels (the `portable_pty` API is blocking). 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. `Cargo.toml` uses `tokio = { default-features = false, features = ["rt", "sync", "io-util", "macros"] }` (the wasm-clean subset alkcall uses); `local` adds `tokio/process` + - `tokio/rt-multi-thread`. **Do NOT use `features = ["full"]`** — it pulls in + `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` is fine on wasm; the `local` module's std threads, `tokio::process::Command`, and `libc::kill` are the non-wasm parts, and they're feature-gated. -5. **Wire formats are stable** — two one-way doors: - - **5-byte chunk header** (`[stream_type: u8][length: u32 BE][payload]`) — +5. **Wire formats are stable**: two 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 @@ -244,14 +244,14 @@ Read `AGENTS.md` at project root for full details. Key rules: control chunks are never zero-length. Changing the header, the stream-type values, or the sentinel semantics breaks all peers. - **Negotiation frame** (4-byte BE length prefix + UTF-8 JSON - `NegotiateRequest` body) — self-contained, not reused from alkcall's + `NegotiateRequest` body): self-contained, not reused from alkcall's `EventEnvelope` framing. The `NegotiateRequest` JSON shape is wire-stable once consumers exist. Per ADR-093: TTY always uses its 5-byte format, even inside channels. The - channels layer strips its 8-byte header transparently — the same `wire.rs` + channels layer strips its 8-byte header 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 in +6. **`TtyBackend` trait shape is a one-way door**: the trait in `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 @@ -259,10 +259,10 @@ Read `AGENTS.md` at project root for full details. Key rules: keyed by the negotiation frame's `backend` string; backends produce `TtyHandle`s, they do not write to the wire. `TtyError` is `#[non_exhaustive]` so new variants are additive. -7. **Producer/consumer, not server/client** — both sides of a `alk/tty` or +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 +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`, @@ -272,36 +272,36 @@ Read `AGENTS.md` at project root for full details. Key rules: `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 +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` + 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 + optional dep; do not roll your own offset map or validator for complex formats. -11. **Feature flags** — the only feature is `local` (default = `[]`). `local` +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 +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 + `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/ +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 +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. diff --git a/.opencode/agents/poc-specialist.md b/.opencode/agents/poc-specialist.md index f5fae5f..2a6531d 100644 --- a/.opencode/agents/poc-specialist.md +++ b/.opencode/agents/poc-specialist.md @@ -10,10 +10,10 @@ approaches. ## Your Environment **You are in a research worktree.** The open-coordinator plugin auto-injects -your working directory for all bash commands — you do NOT need to specify +your working directory for all bash commands; you do NOT need to specify `workdir` manually. -- The current directory IS the worktree — do NOT navigate elsewhere +- The current directory IS the worktree; do NOT navigate elsewhere - You are on branch `research/` - Use relative paths for all file operations @@ -56,11 +56,11 @@ session is mapped to a worktree. This means you can just run commands without specifying workdir: ```bash -# ✅ CORRECT — workdir is auto-injected +# ✅ CORRECT: workdir is auto-injected cargo test ``` -**Do NOT use `cd` in commands** — it doesn't persist and the plugin handles +**Do NOT use `cd` in commands**: it doesn't persist and the plugin handles routing. ## When You Are Spawned diff --git a/AGENTS.md b/AGENTS.md index 9f7dd16..3a17534 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -17,7 +17,7 @@ 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 +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 @@ -25,13 +25,13 @@ The workflow: 5. `git push origin main` 6. Report the commit hash and the verification summary -Exceptions — **do not** commit or push without asking: +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 + (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) @@ -39,7 +39,7 @@ Exceptions — **do not** commit or push without asking: 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 +reject it, fix the issue and create a new commit; do not amend the failed one. Git identity is preconfigured (`glm-5.2 `). Do not @@ -47,7 +47,7 @@ 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 +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 @@ -61,46 +61,46 @@ implementation agents. 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"). + missed (e.g., "zero-length stdin is the EOF sentinel: the codec does + not special-case it, and the adapter interprets `length == 0` chunks"). -2. **Error handling** — `thiserror` for library error types +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 + 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 +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. + is blocking), and it's behind the `local` feature. -4. **WASM target is load-bearing** — the default crate (no `local` +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 + `features = ["full"]`**, because 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 + `signal_from_name` and the pipe-mode `kill` path, neither of which runs on wasm because the only callers are in `local`). -5. **Wire formats are stable** — two wire formats live in this crate, +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: + [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 @@ -108,7 +108,7 @@ implementation agents. 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 + `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`). @@ -117,11 +117,11 @@ implementation agents. 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` + 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 +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 @@ -134,90 +134,90 @@ implementation agents. (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. +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 + 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 +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` + - **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 + - **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` + - **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 + modules; it is only re-exported from `lib.rs` under `#[cfg(feature = "local")]`. -9. **`local` feature isolation** — the `local` module is the only +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>` — the local backend is + `HashMap>`; 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` +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** + `https://alk.dev/bast/v1/schema`. BAST is plain JSON; no + dependency is 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 + complex formats; use alktype (as an optional dep) when that surfaces. -11. **Access control** — the producer's direct-ALPN path keeps the +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 + "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 + (which wires `AccessControl` into the operation spec, so the registry runs the ACL before the wrapper) and runs the ownership - check in the establisher (ADR-010 — it is part of the + 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 + ADR-010 §2A the establisher also allocates, and 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 = `[]`). +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/ +13. **Naming** follows the 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 +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)` @@ -228,6 +228,17 @@ implementation agents. the pattern. Do not introduce `unsafe` for performance, and do not add `unsafe` outside the `local` module's `libc::kill` calls. +15. **No em dashes in prose.** Do not use the em dash in source + doc comments, markdown docs, 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. 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`, `tty-wire`) are untouched. + ## Verification Commands Run these before committing. All must pass. @@ -238,27 +249,27 @@ 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 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. +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 — +seed through the same invariant functions the cargo-fuzz targets run, on stable, no nightly needed. Run detached campaigns -(`fuzz/run-detached.sh ` — never foreground fuzzing) before a +(`fuzz/run-detached.sh `, 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 +- `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 + @@ -268,46 +279,46 @@ release and after touching `src/wire.rs`, `src/negotiation.rs`, 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 + `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 + 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 + - ADR-052 → 001, the 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 + - ADR-053 → 002, the `TtyBackend` trait (the inversion point between wire-format adapter and backend crates) - - ADR-054 → 003 — local backend placement. Ported with the + - 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 + - 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 + - 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 + - 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 + - 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 + - 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 + - (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. + 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 + 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 @@ -320,11 +331,11 @@ release and after touching `src/wire.rs`, `src/negotiation.rs`, `BiStream`, `BidiStreamSource`, `AuthContext`, `Identity`, `IdentityProvider`, `AccessControl`, `OwnershipProvider`, `OwnershipStore`, `InMemoryOwnershipStore`, `HandlerError`, - `StreamError`) come from `alkcall::core`. alkcall is v0.10.x — + `StreamError`) come from `alkcall::core`. alkcall is v0.10.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.10.1"` (0.10.1 is the adoption-window ordering fix: the `adopt_channel` install→drain window is atomic, so a routed chunk can no longer jump ahead of - parked early arrivals or get parked forever past the drain — and bump + parked early arrivals or get parked forever past the drain), and bump deliberately. \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md index 3b28e16..e202f51 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,7 +6,7 @@ this crate adheres to [Semantic Versioning](https://semver.org/). ## [0.8.1] - 2026-10-22 -The alkcall 0.10.1 adoption: a dependency bump only — no source change +The alkcall 0.10.1 adoption: a dependency bump only; no source change in alktty. alkcall 0.10.1 is the adoption-window ordering fix (review 013, routed from alkgit's wave-2 gate): a chunk routed by the demux inside the `adopt_channel` install→drain window could be delivered @@ -15,7 +15,7 @@ byte-stream order) and a related chunk could sit parked forever past the drain. alktty adopts channels only as a consumer-side client (`TtySession::open_via_channels`, which opens and then adopts the session channel), so the fix applies to every channels-path session -under multi-process load — the reassembled chunk stream now preserves +under multi-process load: the reassembled chunk stream now preserves wire-arrival order through the adoption window. The surface of the fix is private to alkcall (`ChannelState` and the `install_locked` path): no API, wire-format, or documented-cap change; the behavior only @@ -26,14 +26,14 @@ at all. - **`alkcall` requirement updated to `0.10.1`; lockfile bumped from 0.10.0 to 0.10.1.** Version-requirement-only (0.10.1 is inside the - `0.10` caret family; alkcall 0.10.1 changed no type alktty touches - — the fix surface is private). alktty's public signatures reference + `0.10` caret family; alkcall 0.10.1 changed no type alktty touches, + and the fix surface is private). alktty's public signatures reference alkcall types, so downstream crates composing alktty with the fixed alkcall pick this patch bump up on their next `cargo update`. ## [0.8.0] - 2026-10-19 -The alkcall 0.10.0 adoption: a dependency bump only — no source change +The alkcall 0.10.0 adoption: a dependency bump only; no source change in alktty. alkcall 0.10.0 is the gateway ownership seam release (`GatewayDispatch::with_ownership_provider`, the declared static-fallback opt-in, and the loud unwired-spine default). All of it @@ -46,26 +46,26 @@ trait, and alktty's public API surface are unchanged. ### Changed -- **BREAKING (ecosystem-coordination) — `alkcall` bumped to 0.10.0.** +- **BREAKING (ecosystem-coordination): `alkcall` bumped to 0.10.0.** alkcall 0.10.0 changed no type that alktty touches: the seam alktty exercises (`ChannelCore`/`register_openable_with_establisher`/ `OpenEstablisher`/`OpenHandler`/`Establishment`, `ChannelClient`, `OwnershipProvider`/`OwnershipStore`/`InMemoryOwnershipStore`) is - shape-identical to 0.9.0 — the loud unwired-spine default applies + shape-identical to 0.9.0; the loud unwired-spine default applies only to `resource_type`-gated ops served through `GatewayDispatch`, which alktty never registers against. alktty's public signatures reference alkcall types, so downstream crates must now unify on alkcall 0.10.0. Minor bump per the 0.x ecosystem-coordination rule - (raising the minimum requirement is breaking — hence 0.8.0, not a + (raising the minimum requirement is breaking, hence 0.8.0, not a patch release). - **`alkcall` requirement updated to `0.10.0`; lockfile bumped from 0.9.0 to 0.10.0.** Verification counts are unchanged from the 0.7.0 - baseline (113 default / 137 all-features) — the adoption required no + baseline (113 default / 137 all-features); the adoption required no source change in alktty. ## [0.7.0] - 2026-10-14 -The alkcall 0.9.0 adoption: a dependency bump only — no source change +The alkcall 0.9.0 adoption: a dependency bump only, with no source change in alktty. alkcall 0.9.0 is the stub-sweep release (the marked-pub open contract, `channel/control`, `channel/resources/subscribe`, and `op/register` forward-through are real implementations). All of @@ -73,19 +73,19 @@ alkcall's semver-relevant changes (`ManagerError::InvalidAdopt`, the `RelayError::PubTypedOpen` removal, the `register_imported` `Result` return, `Establishment` losing `Debug`, the new `register_openable*` refusal of `Pub`-typed specs) touch surfacing -alktty never uses — alktty's open op is `Sub`-typed and it only +alktty never uses: alktty's open op is `Sub`-typed and it only constructs `Establishment` (whose `new` is unchanged). The wire format, the direct-ALPN path, the `TtyBackend` trait, and alktty's public API surface are unchanged. ### Changed -- **BREAKING (ecosystem-coordination) — `alkcall` bumped to 0.9.0.** +- **BREAKING (ecosystem-coordination): `alkcall` bumped to 0.9.0.** alkcall 0.9.0 changed no type that alktty touches: the open-op machinery alktty registers through (`ChannelCore`/ `register_openable_with_establisher`/`OpenEstablisher`/`OpenHandler` /`Establishment`/`ChannelPlan`, `ChannelClient::open_channel`, - `ChannelOpenError::CallFailed`) is shape-identical to 0.8.1 — the + `ChannelOpenError::CallFailed`) is shape-identical to 0.8.1: the four stub implementations are net-new surfaces on ops alktty does not exercise, the established-wrapper refusal of `Pub`-typed specs does not apply to alktty's `Sub`-typed open op, and the removed @@ -93,18 +93,18 @@ surface are unchanged. signatures reference alkcall types, so downstream crates must now unify on alkcall 0.9.0. Minor bump per the 0.x ecosystem-coordination rule (raising the minimum requirement is - breaking — hence 0.7.0, not a patch release). + breaking, hence 0.7.0, not a patch release). - **`alkcall` requirement updated to `0.9.0`; lockfile bumped from 0.8.1 to 0.9.0.** ## [0.6.0] - 2026-09-28 The fuzzing adoption: a cargo-fuzz workspace over the four wire-facing -parse surfaces, with zero findings in the crate — every crash the +parse surfaces, with zero findings in the crate; every crash the campaigns produced was a harness-model gap, and every fix landed in the harness, not the crate. The `alkcall` requirement also updates to 0.8.1. No API, wire-format, or behavior change; this release ships no -source change in `src/` at all — the deltas are the fuzz workspace, +source change in `src/` at all: the deltas are the fuzz workspace, the dependency bump, and this changelog. Minor bump per the 0.x ecosystem-coordination rule (alktty's public signatures reference alkcall types, so downstream crates must unify on alkcall 0.8.1). @@ -115,20 +115,20 @@ alkcall types, so downstream crates must unify on alkcall 0.8.1). `docs/plans/fuzzing.md`, adopted from the alkcall fuzzing playbook): four targets over the wire-facing surfaces, with the invariant logic in a stable-toolchain shared crate (`fuzz/shared/`) so the corpus - replays on stable — the nightly pin is scoped to the subtree via + replays on stable; the nightly pin is scoped to the subtree via `fuzz/rust-toolchain.toml`: - - `chunk_frame` — the 5-byte chunk codec + - `chunk_frame`: the 5-byte chunk codec (`ChunkReader`/`ChunkWriter`), including the peek/disambiguation seam (`peek_stream_type` vs `read_chunk_after_peek` in both call orders must yield exactly one chunk). - - `negotiation_frame` — the 4-byte BE length-prefixed negotiation + - `negotiation_frame`: the 4-byte BE length-prefixed negotiation framing + `NegotiateRequest` JSON parse + `error_response_bytes`, and the cross-codec invariant that an error-response frame's length-prefix high byte is `0x00` so a `ChunkReader` peek distinguishes it from a chunk (ADR-001 §5). - - `control_json` — `ControlMessage` JSON parse + `signal_from_name`. - - `session_opseq` — the stateful target: a `#[derive(Arbitrary)]` + - `control_json`: `ControlMessage` JSON parse + `signal_from_name`. + - `session_opseq`, the stateful target: a `#[derive(Arbitrary)]` 12-op sequence drives the real producer session pump (`drive_session_pre_negotiated`) over a duplex pair against a fuzz-local mock backend, asserting the wire-contract invariants @@ -146,12 +146,12 @@ alkcall types, so downstream crates must unify on alkcall 0.8.1). same invariant functions the targets run, on stable. Added to the verification checklist. - **Detached-runner rule**: campaigns run via `fuzz/run-detached.sh` - (setsid/nohup, fork mode, bounded RSS) — never in the foreground of + (setsid/nohup, fork mode, bounded RSS), never in the foreground of an agent session. ### Changed -- **BREAKING (ecosystem-coordination) — `alkcall` bumped to 0.8.1.** +- **BREAKING (ecosystem-coordination): `alkcall` bumped to 0.8.1.** alkcall 0.8.1 is a patch release fixing the duplicate adopt/open channel-state destruction found by alkcall's own fuzz campaign; it changed no type alktty touches. Downstream crates must @@ -170,20 +170,20 @@ alkcall types, so downstream crates must unify on alkcall 0.8.1). - 0 findings across all four targets: detached campaigns (10 min each on the three wire targets; 10 min on `session_opseq` at 42.3k execs, cov 3934 → 4075 and still growing at budget end) plus a 60s smoke - run on target 4 — no crash/OOM/timeout/leak. + run on target 4: no crash/OOM/timeout/leak. - Corpus replay 9 tests green on stable (340 seeds); crate tests 113 default / 156 all-features; clippy stable + wasm; fmt; doc; wasm - check — all pass. + check: all pass. - The three crashes the harness-model smoke runs surfaced were harness gaps, each fixed in the fuzz harness (the alkcall §7.7 pattern): a failed client write can mean the session completed and - dropped the server duplex half mid-write — the harness drains and + dropped the server duplex half mid-write; the harness drains and requires the exit chunk on any write/shutdown error (a premature close is a finding). No adapter code changed. ## [0.5.0] - 2026-09-18 -The alkcall 0.8.0 adoption: a dependency bump only — no source change +The alkcall 0.8.0 adoption: a dependency bump only; no source change in alktty. alkcall 0.8.0 landed review 008's remediation (the establisher reply projection, flavor-form open-op ids in discovery, the in-tree `ChannelRelay` + `HubLegImports`/`HubLegTemplate`) plus a @@ -194,12 +194,12 @@ trait, and alktty's public API surface are unchanged. ### Changed -- **BREAKING (ecosystem-coordination) — `alkcall` bumped to 0.8.0.** +- **BREAKING (ecosystem-coordination): `alkcall` bumped to 0.8.0.** alkcall 0.8.0 changed no type that alktty touches: the open-op machinery alktty registers through (`ChannelCore`/ `register_openable_with_establisher`/`OpenEstablisher`/`OpenHandler` /`Establishment`/`ChannelPlan`, `ChannelClient::open_channel`, - `ChannelOpenError::CallFailed`) is shape-identical to 0.7.1 — the + `ChannelOpenError::CallFailed`) is shape-identical to 0.7.1: the new `open_channel_with_reply` / reply-field / relay / hub-leg surfaces are additive and alktty exercises none of them. alktty's own open op is the standard-shape name `channels/tty/sub`, so the @@ -211,13 +211,13 @@ trait, and alktty's public API surface are unchanged. the 0.x ecosystem-coordination rule. - **`alkcall` requirement updated to `0.8.0`; lockfile bumped from 0.7.1 to 0.8.0.** Verification counts are unchanged from the 0.7.1 - baseline (113 default / 156 all-features) — the adoption required no + baseline (113 default / 156 all-features); the adoption required no source change in alktty. ## [0.4.1] - 2026-09-10 The MSRV floor becomes honest: `rust-version` raises from 1.85 to -1.88. The 1.85 claim was already false at the dependency level — the +1.88. The 1.85 claim was already false at the dependency level: the lockfile pins `alkcall 0.7.x`, and alkcall 0.7.1 raised its MSRV floor to 1.88 (the ecosystem MSRV audit: noq's 1.88 matching the QUIC path; iroh sits above at 1.91), so no 1.85 toolchain could build 0.4.0 @@ -229,9 +229,9 @@ update`. ### Changed - **`rust-version` 1.85 → 1.88.** Verified on a real 1.88 toolchain - (full test suite, clippy). alktty's own code is 1.85-clean — - `cargo check` under a 1.85 toolchain passes with the pre-bump - lockfile — so the raise is dependency-driven only and needed no + (full test suite, clippy). alktty's own code is 1.85-clean + (`cargo check` under a 1.85 toolchain passes with the pre-bump + lockfile), so the raise is dependency-driven only and needed no clippy fixes. Behavior-identical. - **`alkcall` requirement updated to `0.7.1`; lockfile bumped from 0.7.0 to 0.7.1.** Version-requirement-only (0.7.x is already the @@ -246,7 +246,7 @@ on the channels path. The wire format, the direct-ALPN path, the ### Changed -- **BREAKING (ecosystem-coordination) — `alkcall` bumped to 0.7.0.** +- **BREAKING (ecosystem-coordination): `alkcall` bumped to 0.7.0.** alkcall 0.7.0 changed no type that alktty touches (`OpenEstablisher`/`OpenHandler`/`Establishment`/`ChannelPlan` are identical to 0.6.0; alktty never constructs `ServingConfig`), but @@ -256,7 +256,7 @@ on the channels path. The wire format, the direct-ALPN path, the - **The channels establisher and pump handler take their identity from the per-call `auth` (CF-006 alignment).** `register_openable` no longer captures the install-time identity and overrides the - establisher/handler `auth.identity` with it — post-alkcall-0.7 the + establisher/handler `auth.identity` with it: post-alkcall-0.7, the per-call context already carries the dispatch-resolved opener (the same identity view the registry's ACL gate checked; the end client, not the hub, on hub-forwarded opens). Behavior-identical in the @@ -268,7 +268,7 @@ on the channels path. The wire format, the direct-ALPN path, the ## [0.3.0] - 2026-09-07 -The alkcall 0.6.0 adoption (review 007 R-01 — the `Establishment` +The alkcall 0.6.0 adoption (review 007 R-01, the `Establishment` plan payload): backend allocation moves into the channels establisher. Breaking on the channels path's allocation-failure shape; the wire format, the direct-ALPN path, and the `TtyBackend` trait are @@ -276,11 +276,11 @@ unchanged. ### Changed -- **BREAKING — `alkcall` bumped to 0.6.0; channels-path allocation +- **BREAKING: `alkcall` bumped to 0.6.0; channels-path allocation failure changes shape (ADR-010 §2A).** alkcall 0.6.0 filled the reserved `Establishment` field (`plan: Option`, review 007 R-01), so `backend.allocate` moved from the pump handler - into the establisher — the allocated `TtyHandle` crosses to the + into the establisher; the allocated `TtyHandle` crosses to the handler via the plan payload (a private per-open one-shot slot; the handle is not `Sync`). Allocation failure now fails the open op itself: `TtySessionError::ChannelsOpen(CallFailed{ @@ -296,7 +296,7 @@ unchanged. - Handler closures in `make_tty_open_handler` gain the `Option` parameter (alkcall 0.6 `OpenHandler` shape); a `None` plan (no-establisher registration) falls back to the - pre-0.3.0 inline validate-and-allocate — defense-in-depth, keeping + pre-0.3.0 inline validate-and-allocate, defense-in-depth, keeping the in-band error-frame vocabulary for that registration shape. - New tests: establisher unit gate for allocate-in-establisher (plan slot carries the live handle; failure maps to `dial_failed`), the @@ -305,7 +305,7 @@ unchanged. ## [0.2.0] - 2026-09-06 -The alkcall 0.5.0 adoption (ADR-049 — review 006 E-01/N-1): the +The alkcall 0.5.0 adoption (ADR-049, review 006 E-01/N-1): the channels-path establishment phase. Breaking on the channels path's error surface; the wire format, the direct-ALPN path, and the `TtyBackend` trait are unchanged. @@ -316,15 +316,15 @@ error surface; the wire format, the direct-ALPN path, and the review 006 E-01).** `register_openable` now registers `channels/tty/sub` with an establisher (`ChannelCore::register_openable_with_establisher`): the semantic - validation that used to be post-open in-band error frames — the + validation that used to be post-open in-band error frames (the full `NegotiateRequest` parse of schema-valid `input`, `carriage == "raw"`, non-empty `cmd`, backend lookup, and the ADR-050 ownership - check — runs before the open reply. Rejections resolve the open op + check) runs before the open reply. Rejections resolve the open op with `channel:open_failed` carrying `details: { reason, message }` (`unknown_resource` for an unknown backend, `handler_error` for malformed-negotiation/ownership-denial, `timeout` on the - establishment deadline) and no channel ever exists opener-side — - the SSH contract. The phantom-channel workaround + establishment deadline) and no channel ever exists opener-side (the + SSH contract). The phantom-channel workaround (allocate → succeed → in-band-fail) is retired. - **`channel:open_failed` ErrorDefinition on `tty_open_spec()` (ADR-016).** The establishment-failure contract (the reachable @@ -338,13 +338,13 @@ error surface; the wire format, the direct-ALPN path, and the ### Changed -- **BREAKING — `TtySessionError::ChannelsOpen` carries alkcall's +- **BREAKING: `TtySessionError::ChannelsOpen` carries alkcall's typed `ChannelOpenError` (`#[from]`) instead of a flattened `String`** (alkcall 0.5.0 ADR-049 §4 / review 006 N-1, applied at alktty's layer). Consumers branch on `ChannelOpenError::establishment_reason()` instead of parsing a debug string. -- **BREAKING — channels-path semantic failures change shape.** An +- **BREAKING: channels-path semantic failures change shape.** An unknown backend (and schema-valid-but-unparseable params, ownership denial) previously surfaced as `TtySessionError::NegotiationRejected` (an in-band `0x00`-prefixed @@ -355,8 +355,8 @@ error surface; the wire format, the direct-ALPN path, and the `allocate_failed` (still `NegotiationRejected`): the establisher cannot carry the allocated `TtyHandle` across to the pump handler (`Establishment` is payloadless), and re-allocating would violate - ADR-005's kill-on-`Drop` contract — revisit when alkcall gives - `Establishment` a payload. The direct-ALPN path's failure surface + ADR-005's kill-on-`Drop` contract (revisit when alkcall gives + `Establishment` a payload). The direct-ALPN path's failure surface is unchanged (two transports, two contracts). - `alkcall = "0.5.0"`. @@ -369,7 +369,7 @@ error surface; the wire format, the direct-ALPN path, and the ## [0.1.0] - 2026-09-05 -Initial crates.io release: the `alk/tty` terminal-session protocol — +Initial crates.io release: the `alk/tty` terminal-session protocol, a producer/consumer protocol crate on top of alkcall channels, ported and consolidated from the `alknet-tty` + `alknet-tty-local` crates of the alknet mono-repo. @@ -378,21 +378,21 @@ alknet mono-repo. - **Wire format (ADR-001).** Two-carriage protocol: a 4-byte big-endian length-prefixed JSON negotiation frame (`NegotiateRequest` - — `carriage`/`backend`/`tty`/`cmd`/`cwd`/`env` plus opaque + with `carriage`/`backend`/`tty`/`cmd`/`cwd`/`env` plus opaque `serde(flatten)` backend params), then raw chunks (`[stream_type: u8][length: u32 be][payload]`) with 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 = client EOF; zero-length stdout = server drained); control chunks are never zero-length. Payloads are - capped at 16 MiB (`MAX_CHUNK_LEN`) — this keeps the length prefix's + capped at 16 MiB (`MAX_CHUNK_LEN`); this keeps the length prefix's high byte `0x00`, which is the disambiguation invariant between an error frame and a raw chunk (ADR-001 §5). The framing is self-contained (ADR-006): not reused from alkcall's `EventEnvelope` framing. The control channel is split into `STREAM_CTRL_IN`/`STREAM_CTRL_OUT` so it is genuinely bidirectional on the wire (the Phase 7 amendment inside ADR-001). TTY always uses its own 5-byte chunk format inside - channels — the channels layer carries it transparently in the channel + channels; the channels layer carries it transparently in the channel payload (ADR-008, reversing ADR-007). The machine-readable contract is the BAST document (`docs/architecture/tty-bast.md`); a unit test guards the BAST's `StreamType` enum against drift from the wire @@ -406,9 +406,9 @@ alknet mono-repo. is a one-way door once backends exist. - **Local backend behind the `local` feature (ADR-003).** The `alknet-tty-local` crate folded in as a feature-gated module: - PTY mode via `portable_pty` (real terminal semantics — resize, + PTY mode via `portable_pty` (real terminal semantics: resize, process-group signal forwarding, merged stdout/stderr) and pipe mode - via `tokio::process::Command` (the runner case — separate + via `tokio::process::Command` (the runner case: separate stdout/stderr, no-op resize, pid-only signal), selected by `TtyParams::terminal`. The blocking→async bridge uses three dedicated std threads feeding tokio mpsc/oneshot channels. Non-wasm by design; @@ -422,14 +422,14 @@ alknet mono-repo. via `tty:open`, optional `OwnershipProvider` resource check, `backend.allocate()`, then three concurrent pumps (stdin→backend, stdout/stderr→client, exit→exit chunk) with the **exit-chunk-is-last** - invariant — `{"type":"exit","code":N}` (`-1` on wait failure, ADR-004; + invariant: `{"type":"exit","code":N}` (`-1` on wait failure, ADR-004; negative = signal-terminated) is enqueued only after both stream pumps complete and `exit_code` resolves. A concurrent drainer task owns the client write half, so a burst of >64 chunks from the backend cannot deadlock the session (the drainer starts before the pumps join; the single FIFO preserves exit-chunk-last). On session cancel the `TtyHandle` drops without driving `exit_code` to completion, - which triggers the backend's kill-on-`Drop` guard — the adapter has + which triggers the backend's kill-on-`Drop` guard; the adapter has no separate kill path (the session-cancel contract, ADR-005: dropping the future kills the session target). - **`channels` integration (ADR-008, ADR-009).** `register_openable` + @@ -439,12 +439,12 @@ alknet mono-repo. scope gate, the op's input schema validates the shared `NegotiateRequest` fields (`carriage`/`backend`/`cmd` required; alkcall 0.4 enforces at dispatch), and the `OpenHandler` spawns the - session driver pre-negotiated — **the open op's params ARE the - negotiation** (ADR-009), no second frame on the channel stream. + session driver pre-negotiated; **the open op's params ARE the + negotiation** (ADR-009), with no second frame on the channel stream. Access control is enforced by the registry before the handler runs; the handler validates the backend exists and drives the session. - **`TtySession` consumer.** The typed client handle with two - constructors — `connect_direct` (direct `alk/tty` connection: + constructors, `connect_direct` (direct `alk/tty` connection: writes the negotiation frame, then typed methods) and `open_via_channels` (opens `channels/tty/sub` on a `ChannelClient`, adopts the channel, starts in raw-chunk mode). Typed methods: @@ -460,7 +460,7 @@ alknet mono-repo. `TtySessionError` is `#[non_exhaustive]`. Dropping the session aborts the read pump and closes the write half. - **Control messages.** `ControlMessage` (`Resize`/`Signal`/`Eof` on - `ctrl_in`; `Exit` on `ctrl_out`) — JSON, tagged by `"type"`, unknown + `ctrl_in`; `Exit` on `ctrl_out`): JSON, tagged by `"type"`, with unknown types ignored by policy. `signal_from_name` maps the common signal set (`HUP`/`INT`/`QUIT`/`TERM`/`KILL`/`USR1`/`USR2`/`TSTP`/`CONT`) to libc numbers (unix). @@ -470,7 +470,7 @@ alknet mono-repo. `LocalTtyBackend` under `local`) are re-exported at the crate root; the module paths remain the full surface. - **Wire write-path validation.** All `ChunkWriter` methods validate - before touching the transport — a `stream_type` > 4 or a payload over + before touching the transport; a `stream_type` > 4 or a payload over `MAX_CHUNK_LEN` fails locally instead of writing a header the peer cannot frame (the length is validated before the `u32` cast, so an oversized payload cannot truncate past the check and corrupt the @@ -488,13 +488,13 @@ alknet mono-repo. stream-types discard tests. - **PTY bridge robustness (`local` feature).** The stdin sink's EOF (`poll_shutdown`) parks an in-flight send on a full channel so the - waker is registered — a stdin blast followed by EOF always delivers + waker is registered, and a stdin blast followed by EOF always delivers the EOF to the child. Lock-poisoning no longer cascades (the five lock sites adopt `into_inner()`), and thread-spawn failure maps to `TtyError::AllocFailed` instead of panicking. - **Producer hardening.** The `tty:open` scope gate runs before the backend lookup (an unscoped identity gets `forbidden` regardless of - the request body — no backend-name enumeration differential), and + the request body, with no backend-name enumeration differential), and the client→backend pump is aborted at session end instead of lingering until the client disconnects. diff --git a/README.md b/README.md index deb64a5..2aa55dd 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ be][payload]`), a backend-agnostic `TtyBackend` trait, a backend-agnostic producer adapter, and a typed consumer client. A producer/consumer protocol crate on top of -[alkcall](https://crates.io/crates/alkcall) channels — no networking, no +[alkcall](https://crates.io/crates/alkcall) channels: no networking, no transport dependencies. Downstream crates (a `alknet-docker` or `alknet-ssh` backend, a sandboxed TS/Python adapter) compose on top of it: implement [`TtyBackend`](https://docs.rs/alktty/latest/alktty/trait.TtyBackend.html) @@ -14,7 +14,7 @@ for your execution environment, and the adapter handles the wire. ## Quick start -### Producer — register backends and run the adapter +### Producer: register backends and run the adapter ```rust use std::collections::HashMap; @@ -27,11 +27,11 @@ backends.insert("local".into(), Arc::new(alktty::LocalTtyBackend::new())); let tty_adapter = alktty::TtyAdapter::new(backends); // tty_adapter implements alkcall::core::ProtocolHandler on the `alk/tty` -// ALPN — hand it to your transport accept loop: +// ALPN; hand it to your transport accept loop: // tty_adapter.handle(connection, &auth).await ``` -### Producer — via `alk/channels` instead of a dedicated ALPN +### Producer via `alk/channels` instead of a dedicated ALPN ```rust use std::collections::HashMap; @@ -44,10 +44,10 @@ let backends: HashMap> = // In your channels acceptor's `install_channel_zero` hook (per-connection): // alktty::register_openable(&channel_core, Arc::new(backends), None, &mut registry, auth) // Consumers open `channels/tty/sub`; the open op's params ARE the -// negotiation (ADR-009) — no second frame on the channel stream. +// negotiation (ADR-009); no second frame on the channel stream. ``` -### Consumer — open a session over the direct ALPN +### Consumer: open a session over the direct ALPN ```rust use alkcall::core::Connection; @@ -73,14 +73,14 @@ session.close_stdin().await?; let mut stdout = session.recv_stdout().await; while let Some(bytes) = futures::StreamExt::next(&mut stdout).await { - // The stream ends on the zero-length stdout sentinel ("drained") — + // The stream ends on the zero-length stdout sentinel ("drained"); // it is never yielded as an item. // ... render bytes ... } let code = session.wait().await?; ``` -### Consumer — open a session via `alk/channels` +### Consumer: open a session via `alk/channels` ```rust use alkcall::channels::client::ChannelClient; @@ -106,14 +106,14 @@ both sides of a connection can be either): | Role | Direct `alk/tty` | Via `alk/channels` | |------|------------------|--------------------| -| **Producer** (runs backends, emits terminal bytes) | [`TtyAdapter`](https://docs.rs/alktty/latest/alktty/adapter/struct.TtyAdapter.html) as a `ProtocolHandler` — negotiation frame → access control → backend allocation → three-pump session driver | [`register_openable`](https://docs.rs/alktty/latest/alktty/channels/fn.register_openable.html) — the registry's ACL gates the open op; the handler spawns the same session driver pre-negotiated | -| **Consumer** (types input, renders output) | [`TtySession::connect_direct`](https://docs.rs/alktty/latest/alktty/struct.TtySession.html#method.connect_direct) — writes the negotiation frame, exposes typed stdin/stdout/stderr/control/exit methods | [`TtySession::open_via_channels`](https://docs.rs/alktty/latest/alktty/struct.TtySession.html#method.open_via_channels) — the open op's params are the negotiation | +| **Producer** (runs backends, emits terminal bytes) | [`TtyAdapter`](https://docs.rs/alktty/latest/alktty/adapter/struct.TtyAdapter.html) as a `ProtocolHandler`: negotiation frame → access control → backend allocation → three-pump session driver | [`register_openable`](https://docs.rs/alktty/latest/alktty/channels/fn.register_openable.html): the registry's ACL gates the open op; the handler spawns the same session driver pre-negotiated | +| **Consumer** (types input, renders output) | [`TtySession::connect_direct`](https://docs.rs/alktty/latest/alktty/struct.TtySession.html#method.connect_direct), which writes the negotiation frame and exposes typed stdin/stdout/stderr/control/exit methods | [`TtySession::open_via_channels`](https://docs.rs/alktty/latest/alktty/struct.TtySession.html#method.open_via_channels), where the open op's params are the negotiation | One connection hosts many sessions (one per bidi stream on the direct path, one per channel on the channels path); sessions are independent. The wire format (ADR-001) is two carriages: a 4-byte -length-prefixed JSON negotiation frame, then raw chunks — +length-prefixed JSON negotiation frame, then raw chunks of the form `[stream_type: u8][length: u32 be][payload]` with five stream types (`STREAM_STDIN`=0 … `STREAM_CTRL_OUT`=4; see [`alktty::wire`](https://docs.rs/alktty/latest/alktty/wire/index.html)). @@ -126,7 +126,7 @@ document at `docs/architecture/tty-bast.md`. ### WASM target -The default crate (no features) compiles to `wasm32-unknown-unknown` — +The default crate (no features) compiles 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. The `local` feature (the [`LocalTtyBackend`](https://docs.rs/alktty/latest/alktty/struct.LocalTtyBackend.html) @@ -135,10 +135,10 @@ real OS and is non-wasm by design. ## Documentation -- [Architecture docs](docs/architecture/README.md) — the authoritative +- [Architecture docs](docs/architecture/README.md), the authoritative spec: ADRs (001–009), the wire format, the backend trait contract, and the local backend's cancel-cleanup semantics. -- [API docs](https://docs.rs/alktty) — full crate documentation on +- [API docs](https://docs.rs/alktty): full crate documentation on docs.rs. ## License diff --git a/docs/architecture/README.md b/docs/architecture/README.md index 4222dbf..87bf367 100644 --- a/docs/architecture/README.md +++ b/docs/architecture/README.md @@ -1,4 +1,4 @@ -# alktty — Architecture +# alktty: Architecture Terminal session protocol for the `alk/tty` ALPN: a producer/consumer protocol crate on top of alkcall channels. This @@ -12,7 +12,7 @@ Syntax Tree) document for the wire format, and the ADRs. | [overview.md](overview.md) | draft | Crate purpose, the two-carriage model in brief, dependencies, ALPN, backend location map, feature gates | | [tty-wire.md](tty-wire.md) | draft | The wire format: negotiation frame (JSON carriage), raw chunk codec (`[stream_type: u8][length: u32 be][payload]`), control channel split into `STREAM_CTRL_IN` / `STREAM_CTRL_OUT` halves, sentinels | | [tty-bast.md](tty-bast.md) | draft | The BAST (Binary Abstract Syntax Tree) document for the binary framing layer of the `alk/tty` wire format (5-byte chunk header + negotiation length prefix); a normative JSON spec conforming to the BAST meta-schema at `https://alk.dev/bast/v1/schema`. JSON payloads are specified in `tty-wire.md` and the Rust source, not in the BAST | -| [tty-backend.md](tty-backend.md) | draft | `TtyBackend` trait, `TtyParams`, `TtyHandle`, `TtyControl` — the inversion point between the wire-format adapter and the backends. Carries REQ-TTY-01 (backends need not be natively async) | +| [tty-backend.md](tty-backend.md) | draft | `TtyBackend` trait, `TtyParams`, `TtyHandle`, `TtyControl`: the inversion point between the wire-format adapter and the backends. Carries REQ-TTY-01 (backends need not be natively async) | | [tty-adapter.md](tty-adapter.md) | draft | `TtyAdapter` (`ProtocolHandler` on `alk/tty`): session lifecycle, three-pump bidirectional driver, negotiation errors, exit-chunk ordering (ADR-004), access control, session-cancel cleanup (ADR-005) | | [tty-local.md](tty-local.md) | draft | `LocalTtyBackend` (the `local` feature module): `portable_pty` (PTY) and `tokio::process::Command` (pipe/runner). Carries REQ-TTY-02 (signal forwarding to the process group) | @@ -28,16 +28,16 @@ tty-specific and therefore not ported into alktty's ADR range). | ADR | Title | Origin | Status | |-----|-------|--------|--------| -| [001](decisions/001-wire-format-and-two-carriage.md) | alktty Wire Format and Two-Carriage Model | alknet ADR-052 | Accepted (amended 2026-07-18 — Phase 7 control-channel split) | -| [002](decisions/002-ttybackend-trait-and-ttyhandle.md) | TtyBackend Trait and TtyHandle — the Backend Inversion Point | alknet ADR-053 | Accepted | +| [001](decisions/001-wire-format-and-two-carriage.md) | alktty Wire Format and Two-Carriage Model | alknet ADR-052 | Accepted (amended 2026-07-18, Phase 7 control-channel split) | +| [002](decisions/002-ttybackend-trait-and-ttyhandle.md) | TtyBackend Trait and TtyHandle: the Backend Inversion Point | alknet ADR-053 | Accepted | | [003](decisions/003-local-backend-placement.md) | Local TTY Backend Placement (Single Crate with `local` Feature) | alknet ADR-054 | Accepted (records both the alknet sibling-crate decision and the alktty single-crate consolidation) | | [004](decisions/004-exit-code-on-control-chunk.md) | Exit Code on a Control Chunk (the Last Chunk Before Stream Close) | alknet ADR-055 | Accepted | | [005](decisions/005-backend-cleanup-on-session-cancel.md) | Backend Cleanup on Session Cancel (Drop of `exit_code` Kills) | alknet ADR-056 | Accepted | | [006](decisions/006-negotiation-framing-self-contained.md) | Self-Contained Negotiation Framing (No alkcall-Internal-Wire-Types Dependency) | alknet ADR-057 | Accepted | -| [007](decisions/007-tty-inside-channels.md) | TTY Inside Channels — Sub-Streams, Not Wire Format | alknet ADR-077 | Accepted (**reversed by ADR-008** — kept for historical context) | +| [007](decisions/007-tty-inside-channels.md) | TTY Inside Channels (Sub-Streams, Not Wire Format) | alknet ADR-077 | Accepted (**reversed by ADR-008**; kept for historical context) | | [008](decisions/008-channels-pure-channel-multiplexing.md) | Channels Pure Channel Multiplexing (8-Byte Header, No `stream_type`) | alknet ADR-093 | Accepted (amends alknet ADR-071/074; reverses ADR-007) | -| [009](decisions/009-channels-open-op-is-the-negotiation.md) | The Channels Open Op's `input` Is the Negotiation | alktty-native | Accepted (resolves review #001 L1; amended by review #002 R4 — parse failure is a client-visible error frame; amended by ADR-010 — semantic failures move into the establisher) | -| [010](decisions/010-channels-establisher-migration.md) | Channels-Path Establishment Failures Move into an Establisher | alktty-native | Accepted (adopts alkcall 0.5.0 ADR-049; amends ADR-009's R4 amendment; amended 2026-09-07 §2A for alkcall 0.6's plan payload — allocation moves into the establisher, no in-band frame from a registered producer) | +| [009](decisions/009-channels-open-op-is-the-negotiation.md) | The Channels Open Op's `input` Is the Negotiation | alktty-native | Accepted (resolves review #001 L1; amended by review #002 R4 (parse failure is a client-visible error frame); amended by ADR-010 (semantic failures move into the establisher)) | +| [010](decisions/010-channels-establisher-migration.md) | Channels-Path Establishment Failures Move into an Establisher | alktty-native | Accepted (adopts alkcall 0.5.0 ADR-049; amends ADR-009's R4 amendment; amended 2026-09-07 §2A for alkcall 0.6's plan payload, with allocation moves into the establisher and no in-band frame from a registered producer) | ## Key Design Principles @@ -75,7 +75,7 @@ tty-specific and therefore not ported into alktty's ADR range). depend on any backend. This preserves alknet ADR-003's no-handler-depends-on-another-handler rule. alktty does not depend on alkcall's internal wire types either (the negotiation framing is - self-contained — [ADR-006](decisions/006-negotiation-framing-self-contained.md)). + self-contained ([ADR-006](decisions/006-negotiation-framing-self-contained.md)). See [tty-backend.md](tty-backend.md) and [ADR-002](decisions/002-ttybackend-trait-and-ttyhandle.md). @@ -92,7 +92,7 @@ tty-specific and therefore not ported into alktty's ADR range). ([ADR-004](decisions/004-exit-code-on-control-chunk.md)).** `{"type":"exit","code":N}` rides on `STREAM_CTRL_OUT` (stream_type 4) and is the last chunk before the server closes the write half. This - gives coordinators deterministic completion notification — no + gives coordinators deterministic completion notification (no polling, no plugin state. The adapter owns the ordering; backends resolve `exit_code` and the adapter awaits, sends the chunk, closes. See [tty-adapter.md](tty-adapter.md). @@ -103,14 +103,14 @@ tty-specific and therefore not ported into alktty's ADR range). the `TtyHandle`, which drops the `exit_code` future without driving it to completion. The backend's `exit_code` future's `Drop`-on-cancel MUST kill the child/container/SSH process. This is a behavioral - contract on the `TtyBackend` trait — the adapter has no separate kill + contract on the `TtyBackend` trait: the adapter has no separate kill method; the cleanup is wired into the `exit_code` future's `Drop` by the backend. See [tty-adapter.md](tty-adapter.md) and [tty-local.md](tty-local.md). 8. **The runner pattern is preserved, not specialized.** The local backend in pipe mode (`terminal: None`) is a process-streaming - endpoint — the same shape as GitHub/Gitea Actions runners, just over + endpoint, the same shape as GitHub/Gitea Actions runners, just over alk's transport instead of HTTP polling. alktty provides the *mechanism* (framed byte stream + exit code); runner *policy* (job management, log persistence, task graph) is a downstream crate's @@ -130,7 +130,7 @@ tty-specific and therefore not ported into alktty's ADR range). | OQ | Title | Status | Relevance | |----|-------|--------|-----------| -| OQ-43 | `TtyControl` trait object `Clone` constraint | resolved | `control: Option` via a `#[derive(Clone)]` newtype wrapping `Arc`; the trait is NOT `Clone` (not object-safe) — the newtype carries `Clone`-ability | +| OQ-43 | `TtyControl` trait object `Clone` constraint | resolved | `control: Option` via a `#[derive(Clone)]` newtype wrapping `Arc`; the trait is NOT `Clone` (not object-safe); the newtype carries `Clone`-ability | | OQ-44 | Terminal modes (TTY modes) | deferred(scope) | `TerminalParams.modes` reserved; default terminal modes suffice for current scope; blocked on a concrete mode-control use case | | OQ-45 | Flow control for high-throughput stdout | resolved | QUIC per-stream flow control is the backpressure mechanism (chain complete by construction); no application-level windowing. Reversal is an additive `ControlMessage` variant, not a wire-format change | | OQ-46 | Runner API surface | deferred(scope) | The runner mechanism (pipe mode) is in alktty; runner policy (job management, log persistence, task graph) is a downstream crate, not in scope here | @@ -138,20 +138,20 @@ tty-specific and therefore not ported into alktty's ADR range). ## References -- `docs/plans/project-setup.md` — the current plan (phases 0–5). All +- `docs/plans/project-setup.md`: the current plan (phases 0–5). All five phases are landed: Phase 4 landed this directory (architecture docs + BAST schema + renumbered ADRs); Phase 5 landed the tests, including the integration tests in `tests/` at the crate root. - alknet originals of the ported ADRs (alknet ADR-052, 053, 054, 055, 056, 057, 077, 093) at - `/workspace/@alkdev/alknet/docs/architecture/decisions/` — the + `/workspace/@alkdev/alknet/docs/architecture/decisions/`: the authoritative source for any ADR not yet ported, and for the alknet ADRs referenced by alknet number in the docs above (which are not tty-specific and therefore not ported into alktty's ADR range). -- [alktype BAST format spec](https://alk.dev/bast/v1/schema) — the +- [alktype BAST format spec](https://alk.dev/bast/v1/schema): the normative format spec for BAST documents (the meta-schema `tty-bast.md` conforms to); see also `/workspace/@alkdev/alktype/docs/architecture/bast-format.md`. - alkcall architecture README at - `/workspace/@alkdev/alkcall/docs/architecture/README.md` — the + `/workspace/@alkdev/alkcall/docs/architecture/README.md`: the producer/consumer protocol-crate pattern alktty follows. \ No newline at end of file diff --git a/docs/architecture/decisions/001-wire-format-and-two-carriage.md b/docs/architecture/decisions/001-wire-format-and-two-carriage.md index 2512c29..543d255 100644 --- a/docs/architecture/decisions/001-wire-format-and-two-carriage.md +++ b/docs/architecture/decisions/001-wire-format-and-two-carriage.md @@ -2,11 +2,11 @@ ## Status -Accepted (amended 2026-07-18 — Phase 7: the control channel is split +Accepted (amended 2026-07-18, Phase 7: the control channel is split into `STREAM_CTRL_IN = 3` (client→server) and `STREAM_CTRL_OUT = 4` (server→client) halves; see §"Control channel split" below. Ported from alknet ADR-052 2026-08-17; cross-references renumbered to -alktty's ADR range — ADR-052→001, ADR-053→002, ADR-054→003, +alktty's ADR range: ADR-052→001, ADR-053→002, ADR-054→003, ADR-055→004, ADR-056→005, ADR-057→006, ADR-077→007, ADR-093→008. Alknet ADRs referenced by alknet number (001, 002, 003, 006, 007, 012, 044, 049, 050) are not ported into alktty's ADR range because they are @@ -16,15 +16,15 @@ authoritative.) ## Context -The alknet-docker POC validated that interactive attach — bidirectional +The alknet-docker POC validated that interactive attach (bidirectional byte pumping over a framed bidi stream with a 1-byte stream-type -multiplexer — is the same problem regardless of whether the backend is +multiplexer) is the same problem regardless of whether the backend is `bollard::attach_container()` or russh's `pty_request`. The POC's raw chunk format (`[stream_type: u8][length: u32 be][payload bytes]`, stream_type 0=stdin, 1=stdout, 2=stderr) is a deliberately impoverished version of SSH's channel multiplexer: fixed set of channel types, no negotiation, no open/close handshake, no windowing (QUIC provides flow -control on the bidi stream). That impoverishment is the feature — a +control on the bidi stream). That impoverishment is the feature: a terminal session needs exactly those channels and no more. The alknet-tty POC (built 2026-07-05) extended that format with a 4th @@ -44,8 +44,8 @@ Three load-bearing questions are decided here: rejected: JSON-encoding every byte chunk is wasteful (base64 for binary, per-chunk `EventEnvelope` overhead) and lossy (a TTY streams partial bytes with no message boundary that maps to a JSON object). - The two-carriage model — a single JSON negotiation frame, then raw - chunks — keeps the call protocol's JSON-RPC shape for the *request* + The two-carriage model (a single JSON negotiation frame, then raw + chunks) keeps the call protocol's JSON-RPC shape for the *request* and switches to bytes for the *body*, which is the part that is actually bytes. This is the pattern the docker POC validated and the SSH research independently arrived at for PTY. @@ -53,7 +53,7 @@ Three load-bearing questions are decided here: 2. **Fixed channel set, not extensible.** SSH multiplexes arbitrary services (forwarding, SFTP, agent, X11) over `ChannelId(u32)` with string-named types negotiated per channel. alktty multiplexes one - service — a terminal session — with a fixed `u8` set and no + service, a terminal session, with a fixed `u8` set and no negotiation. Adding a 6th channel type is a wire-format change (one-way door). The ALPN model handles extensibility at the protocol level: a genuinely new sideband (e.g., file transfer alongside the @@ -64,7 +64,7 @@ Three load-bearing questions are decided here: (`[control_type: u8][params...]`) would be faster but harder to extend and inconsistent with the negotiation layer. Control messages are rare (resize on window drag, signal on Ctrl-C, one eof, one exit - per session) — serialization cost is negligible against the data + per session), so serialization cost is negligible against the data chunks. If a hot control path appears, a binary `control_type` can be added without breaking the chunk format (additive within the control channel). @@ -79,7 +79,7 @@ implements `ProtocolHandler` (alknet ADR-002, revised by alknet ADR-007 to receive a `Connection`). The handler owns the entire connection lifecycle and accepts one bidi stream per terminal session. This is a separate ALPN, not a set of operations in the call protocol's -`OperationRegistry` — the raw-carriage byte pump is not a +`OperationRegistry`: the raw-carriage byte pump is not a `StreamingHandler` (alknet ADR-049); it is its own wire format after a single JSON negotiation frame. @@ -88,7 +88,7 @@ single JSON negotiation frame. The bidi stream has two phases: - **Negotiation (JSON carriage).** The client opens a bidi stream and - writes a single length-prefixed JSON frame — a 4-byte big-endian + writes a single length-prefixed JSON frame: a 4-byte big-endian length prefix + UTF-8 JSON body. The frame carries the terminal parameters and backend selector the server needs to allocate the session: @@ -113,13 +113,13 @@ The bidi stream has two phases: The `carriage` field is `"raw"` for terminal sessions (the only carriage in v1). The `tty` block is `null` for the pipe/runner case - (no PTY — see ADR-003). The `backend` field selects the `TtyBackend` + (no PTY; see ADR-003). The `backend` field selects the `TtyBackend` (ADR-002); backend-specific fields (e.g., `container` for docker) ride alongside. - **Raw carriage.** After the negotiation frame, the stream switches to the chunk format below for the life of the session. There is no - `call.responded`/`call.completed` — this is not the call protocol. + `call.responded`/`call.completed`; this is not the call protocol. ### 3. Chunk format @@ -127,24 +127,24 @@ The bidi stream has two phases: [stream_type: u8][length: u32 be][payload bytes] ``` -- `stream_type` — fixed set, no negotiation: +- `stream_type` (fixed set, no negotiation): | stream_type | channel | direction | payload | |---|---|---|---| | 0 | data-in (stdin) | client→server | raw bytes | | 1 | data-out (stdout) | server→client | raw bytes | | 2 | data-err (stderr) | server→client | raw bytes | - | 3 | ctrl-in | client→server | JSON control message (`Resize`, `Signal`, `Eof` — see §4a) | - | 4 | ctrl-out | server→client | JSON control message (`Exit` — see §4a) | + | 3 | ctrl-in | client→server | JSON control message (`Resize`, `Signal`, `Eof`; see §4a) | + | 4 | ctrl-out | server→client | JSON control message (`Exit`; see §4a) | `stream_type > 4` is a protocol error (`InvalidStreamType`). (Phase 7 amendment, §4a: the original `3 = control (bidirectional)` is split into `3 = ctrl-in` and `4 = ctrl-out`; the original bound was - `> 3`.) There is no extension escape hatch in the byte — a 6th + `> 3`.) There is no extension escape hatch in the byte: a 6th channel is a wire-format change requiring a new ALPN (`alk/tty/v2` per alknet ADR-006), not a negotiated addition to this format. -- `length` — payload length in bytes, u32 big-endian, max 16 MiB. A +- `length`: payload length in bytes, u32 big-endian, max 16 MiB. A chunk larger than 16 MiB is a protocol error (`ChunkTooLarge`). The 16 MiB bound accommodates large paste operations and large `env` blocks while bounding memory per chunk (a reader can allocate a @@ -166,20 +166,20 @@ Control chunks carry a small JSON payload, tagged by `type`: | client→server | eof | `{"type":"eof"}` | | server→client | exit | `{"type":"exit","code":0}` | -- **resize** — window-size change. Maps to SSH `window-change`, docker +- **resize**: window-size change. Maps to SSH `window-change`, docker exec resize, or `ioctl(TIOCSWINSZ)` on a local PTY. -- **signal** — signal forwarding. `name` is an uppercase string +- **signal**: signal forwarding. `name` is an uppercase string (`"INT"`, `"TERM"`, `"HUP"`, `"QUIT"`, `"TSTP"`, `"CONT"`, `"KILL"`, `"USR1"`, `"USR2"`). Unknown names fall back to the backend's default kill (see ADR-002 / `tty-local.md` REQ-TTY-02). -- **eof** — client signals no more stdin. Maps to SSH channel EOF, +- **eof**: client signals no more stdin. Maps to SSH channel EOF, docker stdin close, or `ChildStdin::drop` / PTY writer close. This is one of two canonical "stdin done" signals; the other is a zero-length stdin chunk (the docker POC's sentinel). Both are accepted as - equivalent — `eof` is recommended for explicitness (it's a control + equivalent: `eof` is recommended for explicitness (it's a control message, not a data-length hack); the zero-length stdin chunk is kept for compatibility with the docker POC's pattern. See OQ-47. -- **exit** — server signals process exit with code. This is the last +- **exit**: server signals process exit with code. This is the last control chunk before stream close (see ADR-004). The `type` tag is the extensibility seam: new control message types are @@ -187,7 +187,7 @@ added by extending the tagged enum. Unknown `type` values are ignored (not a protocol error) so that a newer client sending a control message an older server doesn't recognize degrades gracefully rather than tearing down the session. This is a two-way-door extension point within -the one-way-door wire format — adding a control message type is +the one-way-door wire format: adding a control message type is additive; changing the chunk header is not. ### 4a. Control channel split (Phase 7 amendment, 2026-07-18) @@ -207,7 +207,7 @@ The chunk header is otherwise unchanged (5 bytes: 1 type + 4 length). **Why the split.** The original §4 documented stream_type 3 as "bidirectional" and listed the four control messages with their directions. But the adapter had no way to distinguish the two -directions on the same stream_type — `Exit` from the client was always +directions on the same stream_type: `Exit` from the client was always ignored (the adapter's `pump_client_to_backend` matched `Exit` and logged "ignoring Exit control from client (server→client only)"). The spec said "bidirectional"; the code was half-duplex. The split makes @@ -220,7 +220,7 @@ exit vs. control without parsing the JSON `type` tag first. **Door type.** One-way, same as the original §3 / §4. The stream_type set is bytes clients and servers parse. A client written against the old single-`STREAM_CONTROL` shape will misread `STREAM_CTRL_OUT = 4` as -`InvalidStreamType (> 3)` and tear down the session — the split is a +`InvalidStreamType (> 3)` and tear down the session; the split is a wire-format change, not an additive extension. The reversal path is the same as the original: a new ALPN (`alk/tty/v2`), which coexists rather than replaces. The trade is one new stream_type byte now vs. @@ -231,7 +231,7 @@ the half-duplex-in-disguise flaw forever. direction table is unchanged in content (the four messages keep their directions and shapes) but the direction is now encoded in the stream_type, not just in the adapter's behavior. ADR-004's "exit chunk -is last" invariant is unchanged — the exit chunk still rides the +is last" invariant is unchanged: the exit chunk still rides the control channel, just on `STREAM_CTRL_OUT` (stream_type 4) instead of the old single `STREAM_CONTROL` (stream_type 3). @@ -243,7 +243,7 @@ response in the same 4-byte length-prefixed framing as the negotiation frame and closes the stream without entering raw mode. The error response shape is `{"error":"","message":"..."}` (see [tty-adapter.md](../tty-adapter.md) §"Negotiation errors" for the -codes). This is not `call.error` — this is not the call protocol; the +codes). This is not `call.error` (this is not the call protocol); the error is a JSON response in the negotiation framing, and the stream closes after it. @@ -252,11 +252,11 @@ allocation (raw chunks) and a failed allocation (JSON error frame) begin with bytes the client must read before knowing which framing applies. The disambiguation is by the first byte: a JSON error frame's 4-byte big-endian length prefix always starts with `0x00` (error frames -MUST be under 16 MiB — `MAX_CHUNK_LEN` — so the high byte is zero; this -is a wire-format invariant, not an assumption), while a raw chunk's +MUST be under 16 MiB, the `MAX_CHUNK_LEN` cap, so the high byte is +zero; this is a wire-format invariant, not an assumption), while a raw chunk's first byte is a `stream_type` in `{0, 1, 2, 3, 4}`. A stream_type of -`0` (stdin from server) is invalid — the server never sends stdin -chunks — so the client distinguishes: read the first byte; if it is +`0` (stdin from server) is invalid, because the server never sends +stdin chunks; the client distinguishes: read the first byte; if it is `0x00`, interpret the next 4 bytes as a big-endian length prefix and read that many bytes as a JSON error frame; otherwise interpret it as a `stream_type` byte and continue reading the raw chunk header. This is @@ -265,7 +265,7 @@ framing (length prefix) and MUST be under 16 MiB; success uses the raw chunk framing (stream_type byte first); the `0x00`-as-length-prefix vs `0x00`-as-invalid-stream_type disambiguation is what makes the two distinguishable on the wire. (With the Phase 7 split, the server-sent -set is `{1, 2, 4}` — stdout, stderr, `STREAM_CTRL_OUT`; `0x00` is +set is `{1, 2, 4}`: stdout, stderr, `STREAM_CTRL_OUT`; `0x00` is still unambiguous.) ### 6. Negotiation framing is self-contained in alktty (no alkcall-internal-wire-types dependency) @@ -274,7 +274,7 @@ The 4-byte length prefix + JSON body is implemented in alktty as a small, self-contained module (`src/negotiation.rs`, ~30 lines: read 4-byte BE length, bounds-check, read N bytes; write the inverse). The format coincides with alkcall's `EventEnvelope` framing by convention -(both are length-prefixed JSON) — not by code reuse. alktty does not +(both are length-prefixed JSON), not by code reuse. alktty does not depend on alkcall's internal wire types: the negotiation payload is a tty-specific struct (`NegotiateRequest`), not a `call.requested` `EventEnvelope`, and alkcall's `FrameFramedReader::read_frame()` is @@ -294,7 +294,7 @@ clarification. - The wire format is POC-validated twice (docker POC for stream_type 0/1/2 + bidirectional pump; tty POC for stream_type 3 + control messages + local PTY). No new wire-format invention in Phase 1. -- The fixed channel set is a `match`, not a hash lookup — fast on the +- The fixed channel set is a `match`, not a hash lookup, fast on the hot path where every chunk is data. - The two-carriage model keeps the call protocol's JSON-RPC shape for the structured request while letting the body be raw bytes, which is @@ -312,21 +312,21 @@ clarification. **Negative:** - A 6th channel type is a wire-format change (one-way door). The - justification is that the use cases are bounded — a terminal session + justification is that the use cases are bounded: a terminal session has stdin, stdout, stderr, and control (now split into ctrl-in / ctrl-out). New sideband needs are different ALPNs, not 6th channels. If this proves wrong, the reversal is a new ALPN string (`alk/tty/v2`), which coexists with the old one rather than replacing - it — but every client and server implementing the old format would + it, but every client and server implementing the old format would need updating to speak the new one. - Control as JSON means a `serde_json` deserialize per control chunk. Control chunks are rare (one per resize, one per signal, one eof, one exit), so this is negligible. A hot control path would warrant a - binary format — the `type`-tagged enum leaves that door open without + binary format; the `type`-tagged enum leaves that door open without a wire-format change. - The negotiation frame is a custom JSON shape, not a `call.requested` event, so a client library can't reuse its call-protocol client to - open a tty session — it speaks the tty wire format directly. This is + open a tty session; it speaks the tty wire format directly. This is intentional (the tty session is not a call-protocol operation) but means the tty client is a separate small client, not a `CallClient` method. @@ -337,8 +337,8 @@ clarification. construction (see §Assumptions 1). If a workload ever requires sub-QUIC-window backpressure signaling, the reversal is an additive `ControlMessage::WindowUpdate` (or similar) variant on the control - channel — a two-way-door extension to the control channel, not a - wire-format header change. The `type`-tagged enum's "unknown types + channel (a two-way-door extension to the control channel, not a + wire-format header change). The `type`-tagged enum's "unknown types ignored" rule means older peers degrade gracefully if such a variant is introduced. @@ -348,7 +348,7 @@ clarification. stream_type set (0-4), and the two-carriage sequence (JSON frame → raw chunks) are bytes clients and servers parse. Changing any of them breaks every client and server implementing the format. The reversal path is a -new ALPN (`alk/tty/v2`), which coexists rather than replaces — but the + new ALPN (`alk/tty/v2`), which coexists rather than replaces, but the cost of migrating every consumer is the one-way-door cost. The control message `type` enum is a two-way-door extension point within @@ -362,14 +362,14 @@ not. backpressure mechanism.** The chunk format carries no window field. This is decided (OQ-45 resolved): the backpressure chain from a slow client read all the way back to the child process's stdout write is - complete by construction — QUIC flow control → bounded drainer + complete by construction: QUIC flow control → bounded drainer channel → bounded stdout channel → OS pipe/PTY buffer → process `write()` blocks. Every link awaits its producer; no unbounded buffer breaks the chain. The reversal path, if a workload ever surfaces a problem QUIC's defaults cannot handle, is an additive - window-update `ControlMessage` variant (a two-way-door extension to - the control channel, not a wire-format header change) — noted in - §Consequences. See OQ-45. + window-update `ControlMessage` variant (a two-way-door extension to + the control channel, not a wire-format header change), noted in + §Consequences. See OQ-45. 2. **The negotiation frame fits in one chunk of the underlying stream's initial flow-control window.** The frame is small (terminal params + @@ -386,26 +386,26 @@ not. ## References -- alknet ADR-001 — ALPN-based dispatch -- alknet ADR-002 — ProtocolHandler trait -- alknet ADR-006 — `alk/` ALPN convention; one ALPN per +- alknet ADR-001: ALPN-based dispatch +- alknet ADR-002: ProtocolHandler trait +- alknet ADR-006: `alk/` ALPN convention; one ALPN per connection; new ALPN for incompatible versions -- alknet ADR-007 — handler receives a `Connection`, accepts bidi streams -- alknet ADR-003 Amendment 2 — alktty does not depend on alknet-call; +- alknet ADR-007: handler receives a `Connection`, accepts bidi streams +- alknet ADR-003 Amendment 2: alktty does not depend on alknet-call; self-contained negotiation framing -- [ADR-006](006-negotiation-framing-self-contained.md) — the +- [ADR-006](006-negotiation-framing-self-contained.md): the dependency-edge decision this ADR's §6 reflects -- alknet ADR-012 — the call protocol's stream model (which tty is *not* +- alknet ADR-012 (the call protocol's stream model, which tty is *not* using for the body, by design) -- alknet ADR-049 — the `StreamingHandler` path tty explicitly does not +- alknet ADR-049: the `StreamingHandler` path tty explicitly does not use for the byte body -- [ADR-002](002-ttybackend-trait-and-ttyhandle.md) — the backend trait +- [ADR-002](002-ttybackend-trait-and-ttyhandle.md): the backend trait the negotiation frame's `backend` field dispatches to -- [ADR-003](003-local-backend-placement.md) — the local backend's +- [ADR-003](003-local-backend-placement.md): the local backend's module placement (the `tty: null` pipe-mode case) -- [ADR-004](004-exit-code-on-control-chunk.md) — the exit-chunk +- [ADR-004](004-exit-code-on-control-chunk.md): the exit-chunk ordering this ADR's control channel carries -- [ADR-008](008-channels-pure-channel-multiplexing.md) — TTY always +- [ADR-008](008-channels-pure-channel-multiplexing.md): TTY always uses its 5-byte format, including inside channels (the channels layer carries this wire format transparently in its payload) - Spec: [tty-wire.md](../tty-wire.md), [tty-bast.md](../tty-bast.md) diff --git a/docs/architecture/decisions/002-ttybackend-trait-and-ttyhandle.md b/docs/architecture/decisions/002-ttybackend-trait-and-ttyhandle.md index 25ef866..1b9ba32 100644 --- a/docs/architecture/decisions/002-ttybackend-trait-and-ttyhandle.md +++ b/docs/architecture/decisions/002-ttybackend-trait-and-ttyhandle.md @@ -1,9 +1,9 @@ -# ADR-002: TtyBackend Trait and TtyHandle — the Backend Inversion Point +# ADR-002: TtyBackend Trait and TtyHandle: the Backend Inversion Point ## Status Accepted (ported from alknet ADR-053 2026-08-17; cross-references -renumbered to alktty's ADR range — ADR-052→001, ADR-053→002, +renumbered to alktty's ADR range: ADR-052→001, ADR-053→002, ADR-054→003, ADR-055→004, ADR-056→005, ADR-057→006, ADR-077→007, ADR-093→008. Alknet ADRs referenced by alknet number (003, 007, 017, 050) are not ported into alktty's ADR range because they are not @@ -13,7 +13,7 @@ authoritative.) ## Context -alktty's wire format (ADR-001) is backend-agnostic — the chunk codec +alktty's wire format (ADR-001) is backend-agnostic: the chunk codec pumps bytes and JSON control messages without knowing whether the backend is a docker container, an SSH session channel, or a local process. The question this ADR answers: **what is the seam between the @@ -22,7 +22,7 @@ wire-format adapter and the backends?** The alknet-tty research identified the `TtyBackend` trait as the inversion point. The guiding insight: -> A terminal session is not an SSH concern, or a Docker concern — it is +> A terminal session is not an SSH concern, or a Docker concern; it is > a terminal concern. SSH and Docker are just two backends that can > allocate a PTY. @@ -32,7 +32,7 @@ alknet-ssh, alktty's `local` feature module) implement the trait. This preserves alknet ADR-003's no-handler-depends-on-another-handler rule: alktty depends on alkcall; backend crates depend on alktty for the trait; alktty does not depend on any backend (and, per ADR-006, does -not depend on alkcall's internal wire types either — the negotiation +not depend on alkcall's internal wire types either; the negotiation framing is self-contained). ### What the local-PTY POC discovered about the trait shape @@ -46,7 +46,7 @@ in the alknet findings doc): `portable_pty` is a blocking `std::io::{Read, Write}` API with a blocking `Child::wait()`. The POC bridges it to async via three dedicated std threads (reader, writer, waiter) feeding tokio - mpsc/oneshot channels — the same pattern wezterm (portable_pty's + mpsc/oneshot channels, the same pattern wezterm (portable_pty's primary consumer) uses. The trait's adapter-facing types (`AsyncWrite`, `Stream`, `BoxFuture`) are the *adapter's* contract; a backend may expose blocking handles @@ -71,9 +71,9 @@ The POC used a separate cloneable `PtyControl` struct for resize/signal, not a trait object. The research noted this worked cleanly because the control-chunk dispatcher needs to be `Clone` to hand off to the spawned pump task. Phase 1 confirms the `control` field as a separate -`TtyControlHandle` newtype — a concrete `#[derive(Clone)]` struct +`TtyControlHandle` newtype: a concrete `#[derive(Clone)]` struct wrapping `Arc` (the trait is NOT `Clone`; -`Clone` is not object-safe — see OQ-43). The newtype carries the +`Clone` is not object-safe; see OQ-43). The newtype carries the `Clone`-ability; the trait stays object-safe. ## Decision @@ -90,7 +90,7 @@ pub trait TtyBackend: Send + Sync { /// The pre-existing resource this session targets, for ownership /// checks (alknet ADR-050). `None` = no pre-existing resource (the session - /// creates its own — local process, SSH channel). `Some((kind, id))` + /// creates its own: local process, SSH channel). `Some((kind, id))` /// = the session targets an existing resource the caller must own /// (e.g., DockerTtyBackend returns `Some(("container", id))`). The /// adapter calls this at negotiation to gate access; the backend @@ -106,11 +106,11 @@ The assembly layer registers backends at startup; the adapter dispatches by the `backend` field. A backend is the *thing that allocates a session*; the wire-format pump is backend-agnostic. -### 2. `TtyParams` — the allocation request +### 2. `TtyParams`: the allocation request ```rust pub struct TtyParams { - /// Terminal parameters. `None` = pipe mode (no PTY — the runner case, + /// Terminal parameters. `None` = pipe mode (no PTY; the runner case, /// ADR-003). `Some` = allocate a PTY with these dimensions. pub terminal: Option, /// Command vector (argv[0] + args). @@ -133,12 +133,12 @@ pub struct TerminalParams { pub rows: u16, pub pixel_width: u16, pub pixel_height: u16, - pub modes: serde_json::Value, // reserved — OQ-44; backends MUST ignore content in v1 + pub modes: serde_json::Value, // reserved (OQ-44); backends MUST ignore content in v1 } ``` -`terminal: None` is the pipe/runner case — no PTY, separate -stdout/stderr (ADR-003). `terminal: Some` is the PTY case — +`terminal: None` is the pipe/runner case: no PTY, separate +stdout/stderr (ADR-003). `terminal: Some` is the PTY case: stdout/stderr merged into the single stdout stream (`TtyHandle.stderr` is `None`), real terminal semantics (resize, signal delivery to process group). @@ -149,7 +149,7 @@ delivery to process group). a typed enum. The adapter passes the negotiation frame's backend-specific fields through verbatim; the backend deserializes its own strongly-typed params struct. alktty has zero knowledge of any -backend's params shape — not docker's `container`, not an SSH host +backend's params shape: not docker's `container`, not an SSH host selector, not anything. Each backend defines its own params struct: @@ -164,7 +164,7 @@ struct DockerBackendParams { container: String } struct SshBackendParams { /* host selector if multi-host; else empty */ } // in alktty's local feature module -// no backend-specific params — backend_params is empty +// no backend-specific params; backend_params is empty ``` And deserializes from `params.backend_params` inside `allocate()`: @@ -183,7 +183,7 @@ impl TtyBackend for DockerTtyBackend { This is a complete inversion: the *trait* is inverted (backends implement, alktty doesn't depend on them) and the *params* are inverted (backends define their own typed shape, alktty doesn't carry it). A new -backend crate requires zero changes to alktty — no enum variant to add, +backend crate requires zero changes to alktty: no enum variant to add, no forward-reference type to place, no dependency edge. **Why not a typed enum.** The earlier draft of this ADR defined @@ -203,7 +203,7 @@ problems: `ChannelId` and session reference." If it lives in alknet-ssh, alktty depends on alknet-ssh (violates the inversion). If it lives in alktty, alktty pulls in russh types (same violation). Opaque params - dissolve the contradiction — there is no `SshChannelRef` type in + dissolve the contradiction: there is no `SshChannelRef` type in alktty at all. The earlier draft rejected `serde_json::Value` because "it loses type @@ -212,40 +212,40 @@ shouldn't interpret." The first concern doesn't apply (each backend has its own strongly-typed struct via serde; type safety moves from alktty to the backend where it belongs). The second was already inconsistent with the adapter, which hardcoded extraction of docker's `container` -field for the ownership check — the adapter *was* parsing +field for the ownership check; the adapter *was* parsing backend-specific JSON. The opaque approach removes that: the adapter delegates the resource-id extraction to the backend via `resource_id()` below. -### 3. `TtyHandle` — what a backend produces +### 3. `TtyHandle`: what a backend produces ```rust pub struct TtyHandle { - /// Stdin writer — bytes the adapter pumps from client stdin chunks. + /// Stdin writer: bytes the adapter pumps from client stdin chunks. /// `tokio::io::AsyncWrite` (the tokio flavor, not the `futures::io` - /// one — they are incompatible traits; the tokio stack is the + /// one; they are incompatible traits; the tokio stack is the /// adapter's runtime). pub stdin: Box, - /// Stdout stream — bytes the adapter pumps to client stdout chunks. + /// Stdout stream: bytes the adapter pumps to client stdout chunks. /// Ends when the backend's stdout reaches EOF (process exited, /// container output stream ended, SSH channel closed). /// `futures_core::Stream` (re-exported by /// `tokio_stream::StreamExt` for extension methods). pub stdout: Pin + Send>>, - /// Stderr stream — `None` for PTY backends (stdout/stderr merged + /// Stderr stream: `None` for PTY backends (stdout/stderr merged /// into `stdout`). `Some` for pipe backends (separate streams). pub stderr: Option + Send>>>, - /// Exit code — a `Future` the adapter awaits. Resolves when the + /// Exit code: a `Future` the adapter awaits. Resolves when the /// process/container/SSH exec exits. The adapter sends the result /// as the `{"type":"exit","code":N}` control chunk (ADR-004) and /// closes the stream. This is `BoxFuture`, not a method on /// `TtyHandle`, so the adapter can `select` between exit and /// stream-close without coupling to the other fields. (REQ-TTY-01.) pub exit_code: BoxFuture<'static, Result>, - /// Control handle (resize, signal) — `Clone` so the adapter can + /// Control handle (resize, signal). `Clone` so the adapter can /// hand it to the spawned control-chunk dispatcher. `None` only /// when the backend genuinely has no control path (e.g., a pipe - /// backend with no PTY — signal still works via `kill(pid, sig)`, + /// backend with no PTY; signal still works via `kill(pid, sig)`, /// but resize is a no-op). See OQ-43 for the `TtyControlHandle` /// newtype rationale. pub control: Option, @@ -265,7 +265,7 @@ pub trait TtyControl: Send + Sync { } /// The `Clone`-able handle to a backend's control path. The `TtyControl` -/// trait is NOT `Clone` (`Clone` is not object-safe — `fn clone(&self) -> +/// trait is NOT `Clone` (`Clone` is not object-safe: `fn clone(&self) -> /// Self` returns `Self`, which forbids `dyn` dispatch); the `Clone`-ability /// lives on this concrete newtype, which holds the trait object behind an /// `Arc`. The adapter clones the `Arc` to hand a handle to the spawned @@ -279,9 +279,9 @@ pub struct TtyControlHandle(Arc); The trait's adapter-facing types (`AsyncWrite`, `Stream`, `BoxFuture`, the `TtyControl` trait object) are the **adapter's contract**. A backend may expose blocking handles internally and bridge -them to these async-facing types. The bridging pattern — blocking +them to these async-facing types. The bridging pattern (blocking `std::io` on dedicated std threads or `tokio::task::spawn_blocking`, -feeding tokio mpsc/oneshot channels — is a **documented, supported +feeding tokio mpsc/oneshot channels) is a **documented, supported implementation strategy**, not a workaround. The local backend (ADR-003) uses this pattern: `portable_pty` is a @@ -293,7 +293,7 @@ the threading. See `tty-local.md` for the bridge details. ### 5. Backend registration and the assembly layer -The `TtyAdapter` does not know the set of backends at compile time — it +The `TtyAdapter` does not know the set of backends at compile time; it holds a `HashMap>` populated at construction. The assembly layer (the CLI binary) constructs backends with their dependencies (a `DockerTtyBackend` wraps a `bollard::Docker` @@ -326,21 +326,21 @@ layer chooses what's available. `OperationAdapter` (alknet ADR-017): the trait lives where the types live; the implementations live where their transport dependencies live. -- Blocking-API backends (portable_pty) are first-class — the trait +- Blocking-API backends (portable_pty) are first-class: the trait accommodates them by making the adapter-facing types the contract and the bridging pattern a documented strategy. No re-spec required when a future backend is also blocking. - `exit_code` as a `Future` (not a method on the handle) lets the - adapter `select` between exit and stream-close — the load-bearing + adapter `select` between exit and stream-close, the load-bearing composition the session lifecycle needs (the exit chunk is sent after - the child is reaped, then the stream closes — ADR-004). + the child is reaped, then the stream closes; ADR-004). **Negative:** - **Backend params are opaque (`serde_json::Map`), not a typed enum.** Each backend deserializes its own params struct; the adapter passes the JSON through verbatim. The cost is one serde deserialize per - `allocate()` call (negligible — allocation is once per session, not on + `allocate()` call (negligible; allocation is once per session, not on the hot path). The benefit is a complete inversion: alktty has zero knowledge of any backend's params shape, and a new backend crate requires zero changes to alktty (no enum variant, no forward-reference @@ -372,7 +372,7 @@ layer chooses what's available. `TtyHandle` field set, and the `TtyControl` trait are the API surface every backend crate implements and the adapter consumes. Changing them after backends exist is a rewrite across crates. `backend_params` as an -opaque `serde_json::Map` is part of the one-way `TtyParams` shape — the +opaque `serde_json::Map` is part of the one-way `TtyParams` shape: the *carrier* is fixed (opaque JSON), but the *contents* are backend-defined and require no alktty change for new backends. The `resource_id()` default method is additive (a new method with a default @@ -384,7 +384,7 @@ impl doesn't break existing implementors); its return type 1. **The `TtyControl` trait is kept object-safe by NOT putting `Clone` on it; the `TtyControlHandle` newtype holds the trait object behind an `Arc` and implements `Clone` by cloning the `Arc`.** The POC used - a concrete `PtyControl` struct (inherently `Clone` — it held + a concrete `PtyControl` struct (inherently `Clone`: it held `Arc>` fields). The newtype generalizes the POC's shape so a backend produces its own control type via `TtyControlHandle::new(Arc::new(MyControl))` without the adapter @@ -393,43 +393,43 @@ impl doesn't break existing implementors); its return type 2. **Backends produce a single session per `allocate()` call.** The adapter calls `allocate()` once per accepted bidi stream (one session - per stream — ADR-001). A backend that multiplexed multiple sessions + per stream; ADR-001). A backend that multiplexed multiple sessions over one `allocate()` would not fit the trait; no such backend is contemplated. 3. **The adapter, not the backend, owns the exit-chunk ordering.** The backend resolves `exit_code`; the adapter awaits it, sends the exit control chunk, and closes the stream (ADR-004). The backend does not - write to the wire — it produces handles; the adapter pumps. This + write to the wire: it produces handles; the adapter pumps. This keeps the wire-format logic in one place (the adapter) and the backend focused on its allocation target (docker, ssh, local process). ## References -- alknet ADR-003 + Amendments 1 & 2 — no-handler-depends-on-another- +- alknet ADR-003 + Amendments 1 & 2: no-handler-depends-on-another- handler; alktty depends on alkcall (no alkcall-internal-wire-types per Am. 2 / ADR-006); backends depend on alktty for the trait -- [ADR-006](006-negotiation-framing-self-contained.md) — alktty does not +- [ADR-006](006-negotiation-framing-self-contained.md): alktty does not depend on alkcall's internal wire types (self-contained negotiation framing) -- alknet ADR-007 — `Connection`, `SendStream`, `RecvStream` (the adapter +- alknet ADR-007: `Connection`, `SendStream`, `RecvStream` (the adapter receives a `Connection`, accepts bidi streams, pumps per-session) -- [ADR-001](001-wire-format-and-two-carriage.md) — the wire format this +- [ADR-001](001-wire-format-and-two-carriage.md): the wire format this trait's backends feed -- [ADR-003](003-local-backend-placement.md) — the local backend's +- [ADR-003](003-local-backend-placement.md): the local backend's module placement (folded into alktty behind a `local` feature) -- [ADR-004](004-exit-code-on-control-chunk.md) — the exit chunk ordering +- [ADR-004](004-exit-code-on-control-chunk.md): the exit chunk ordering this trait's `exit_code` field feeds into -- [ADR-005](005-backend-cleanup-on-session-cancel.md) — the cancel- +- [ADR-005](005-backend-cleanup-on-session-cancel.md): the cancel- cleanup contract on this trait (`exit_code` future's `Drop`-on-cancel kills the session target) -- alknet ADR-017 — the adapter-location-map pattern (trait where types +- alknet ADR-017: the adapter-location-map pattern (trait where types live, implementation where deps live) this ADR follows -- alknet ADR-050 — the ownership model the `resource_id()` default +- alknet ADR-050: the ownership model the `resource_id()` default declares against -- OQ-43 — `TtyControl` as `Clone` trait object (resolved: confirmed) -- OQ-44 — terminal modes (deferred(scope): not needed for current scope) +- OQ-43: `TtyControl` as `Clone` trait object (resolved: confirmed) +- OQ-44: terminal modes (deferred(scope): not needed for current scope) - Spec: [tty-backend.md](../tty-backend.md) - Port origin: alknet ADR-053 at `/workspace/@alkdev/alknet/docs/architecture/decisions/053-ttybackend-trait-and-ttyhandle.md` \ No newline at end of file diff --git a/docs/architecture/decisions/003-local-backend-placement.md b/docs/architecture/decisions/003-local-backend-placement.md index b997b32..164b72c 100644 --- a/docs/architecture/decisions/003-local-backend-placement.md +++ b/docs/architecture/decisions/003-local-backend-placement.md @@ -4,7 +4,7 @@ Accepted (ported from alknet ADR-054 2026-08-17, with the single-crate consolidation recorded as the alktty resolution. Cross-references -renumbered to alktty's ADR range — ADR-052→001, ADR-053→002, +renumbered to alktty's ADR range: ADR-052→001, ADR-053→002, ADR-054→003, ADR-055→004, ADR-056→005, ADR-057→006, ADR-077→007, ADR-093→008. Alknet ADRs referenced by alknet number (003, 009, 017) are not ported into alktty's ADR range because they are not tty-specific; @@ -29,15 +29,15 @@ local-process backend (`std::process::Command` with piped stdio, or The local backend is the simplest backend and the one that enables the runner pattern (a process whose stdin/stdout/stderr/exit-code stream -over a framed bidi connection — the same shape as GitHub/Gitea Actions +over a framed bidi connection, the same shape as GitHub/Gitea Actions runners, just over alk's transport instead of HTTP polling). It has no -heavy dependencies in the pipe case — just `std` — but the PTY case +heavy dependencies in the pipe case (just `std`), but the PTY case pulls in `portable_pty` (a non-trivial native dependency that builds on Unix `openpty`/`ioctl` and Windows ConPTY). The relevant constraint from ADR-002: alktty itself depends only on alkcall and the wire-format codec (ADR-001). The `portable_pty` -dependency does not belong in the core tty crate — a docker-only +dependency does not belong in the core tty crate: a docker-only deployment or an SSH-PTY-only deployment should not pull in PTY allocation code. This is the same inversion as `OperationAdapter` (alknet ADR-017): the trait lives where the types live; the @@ -56,7 +56,7 @@ in `alknet-tty-local`; alknet-tty itself never depends on The sibling-crate placement was motivated by a cyclic-dependency workaround: `alknet-tty-local` depends on `alknet-tty` for the trait, and alknet ADR-054 wanted `alknet-tty` to re-export `LocalTtyBackend` -behind a `local` feature — which cargo rejects (a crate cannot re-export +behind a `local` feature, which cargo rejects (a crate cannot re-export from a sibling crate it depends on via an optional dep AND have that sibling depend back on it). The assembly-layer workaround (consumer depends on both crates directly) worked but was awkward. @@ -74,7 +74,7 @@ ssh-only deployment enables neither feature and never pulls in This ADR records both decisions: the original alknet sibling-crate decision (preserved as the historical context for why the local backend is a separable concern), and the alktty consolidation (the operative -decision — the local backend is folded into alktty behind a `local` +decision: the local backend is folded into alktty behind a `local` feature). ## Decision @@ -100,8 +100,8 @@ tokio-util = { version = "0.7", features = ["io"], optional = true } A consumer that wants the local backend enables `features = ["local"]` on alktty and gets `alktty::local::LocalTtyBackend`. A consumer that only wants docker/ssh backends uses the default features and depends on -`alknet-docker` / `alknet-ssh` (or their own backend crate) directly — -no `portable_pty` in the dependency tree. +`alknet-docker` / `alknet-ssh` (or their own backend crate) directly, +with no `portable_pty` in the dependency tree. This is the same feature-re-export pattern the Rust ecosystem uses for optional heavy dependencies (e.g., `tokio`'s `full` feature pulling in @@ -118,15 +118,15 @@ coupled to the trait. alktty (local feature enabled) ├── alktty (default) (TtyBackend trait, TtyHandle, TtyControl, wire types) ├── alkcall::core (via alktty's re-export; not direct) -├── portable_pty (PTY allocation — the heavy dep, here not in alktty default) -├── libc (signal forwarding — REQ-TTY-02, Unix only) +├── portable_pty (PTY allocation, the heavy dep, not in alktty default) +├── libc (signal forwarding, REQ-TTY-02, Unix only) └── tokio (process, rt-multi-thread, mpsc, oneshot, AsyncRead/AsyncWrite) ``` The `local` feature is inherently non-wasm (`portable-pty` + `tokio::process` need a real OS); enabling `local` on `wasm32-unknown-unknown` is a build error by design. The default crate -(no features) stays wasm-clean — this is what makes the downstream +(no features) stays wasm-clean; this is what makes the downstream TS/Python adapter story work (a wasm-compiled alktty is the protocol layer for a sandboxed adapter; the local-process backend runs on a real OS, never in a sandbox). @@ -135,19 +135,19 @@ OS, never in a sandbox). `TtyParams.terminal: Option` (ADR-002) selects the mode: -- **`terminal: Some(TerminalParams { ... })`** — allocate a real PTY +- **`terminal: Some(TerminalParams { ... })`**: allocate a real PTY via `portable_pty`. Terminal semantics: resize (via `ioctl(TIOCSWINSZ)`), signal delivery to the foreground process group (via `libc::kill(-pgid, sig)`, REQ-TTY-02), escape-sequence handling (the kernel PTY's line discipline). stdout and stderr are merged - (kernel PTY property — one output stream from the slave), so + (kernel PTY property: one output stream from the slave), so `TtyHandle.stderr` is `None`. -- **`terminal: None`** — pipe mode, no PTY. `tokio::process::Command` +- **`terminal: None`**: pipe mode, no PTY. `tokio::process::Command` with `Stdio::piped()` for stdin/stdout/stderr. No resize, no escape-sequence handling, but `kill(pid, sig)` still works for signal forwarding. stdout and stderr are separate streams, so - `TtyHandle.stderr` is `Some`. This is the **runner** case — a - command-streaming endpoint with no terminal semantics. +`TtyHandle.stderr` is `Some`. This is the **runner** case, a +command-streaming endpoint with no terminal semantics. The same `LocalTtyBackend` serves both; the `allocate()` call branches on `params.terminal`. A deployment that only does terminals always @@ -169,7 +169,7 @@ polling: chunks back, sends `{"type":"exit","code":N}` when it finishes (ADR-004). - The coordinator gets reliable completion notification (the exit - control chunk + stream close) — no polling. + control chunk + stream close), no polling. The runner-specific API surface (job management, log persistence, task graph integration) is **out of scope for alktty**. alktty provides the @@ -186,7 +186,7 @@ alktty. See OQ-46. wire-format codec). A docker-only or ssh-only deployment never pulls in `portable_pty`. - The local backend is a one-feature opt-in (`features = ["local"]`) - for the common case — a consumer that wants a terminal/runner + for the common case: a consumer that wants a terminal/runner endpoint with no docker or SSH gets it with one feature flag, not a separate dependency. - PTY vs pipe is per-session, so one `LocalTtyBackend` serves terminals @@ -198,8 +198,8 @@ alktty. See OQ-46. no-handler-depends-on-another-handler rule: the local backend is in the same crate as the trait, so there is no cross-crate dependency edge to worry about. The docker and SSH backends remain separate - crates (real external deps — `bollard`, `russh` — and their own - resource models). + crates with real external deps (`bollard`, `russh`) and their own + resource models. - The alktty consolidation resolves the alknet cyclic-dep workaround that motivated the original sibling-crate decision. No assembly-layer "depend on both crates directly" pattern; no cargo-rejected @@ -209,11 +209,11 @@ alktty. See OQ-46. - A consumer that wants the local backend must enable a feature flag (`features = ["local"]`). Forgetting the flag results in the - `alktty::local` module not existing — a compile error, not a silent - miss. This is the standard Rust feature-flag trade and is + `alktty::local` module not existing (a compile error, not a silent + miss). This is the standard Rust feature-flag trade and is self-documenting. - The `local` feature is non-wasm by design. A consumer that targets - `wasm32-unknown-unknown` must not enable `local` — the build error is + `wasm32-unknown-unknown` must not enable `local`; the build error is intentional (the local-process backend cannot run in a sandbox). The default crate stays wasm-clean so the downstream TS/Python adapter story works. @@ -230,7 +230,7 @@ reversible: if the local backend ever grows large enough to warrant splitting out (e.g., a future docker or SSH backend shares enough portable-pty bridge code to motivate a shared "blocking-backend bridge" crate), extracting `src/local/` into a sibling crate behind the same -`local` feature is mechanical — the trait is the seam, and the feature +`local` feature is mechanical: the trait is the seam, and the feature gate already exists. The cost of reversal is low (the module becomes a crate; the feature gate switches from `dep:portable-pty` to `dep:alktty-local`), and no downstream consumer breaks (the @@ -240,28 +240,28 @@ re-exports it). This is a two-way door that is **decided** (single crate + `local` feature), not deferred. The decision is made now; the reversal is cheap if a future split warrants it. See alknet ADR-009 §"What this framework -is NOT" — door type classifies reversal cost, not urgency. +is NOT"; door type classifies reversal cost, not urgency. ## References -- alknet ADR-003 + Amendments 1 & 2 — crate decomposition rule (the +- alknet ADR-003 + Amendments 1 & 2: crate decomposition rule (the single-crate placement preserves it; the local backend is in the same crate as the trait, not a separate crate that depends back) -- alknet ADR-009 — door-type-as-deferral anti-pattern (this ADR's +- alknet ADR-009: door-type-as-deferral anti-pattern (this ADR's two-way-door classification is reversal cost, not a deferral) -- alknet ADR-017 — the adapter-location-map pattern (trait where types +- alknet ADR-017: the adapter-location-map pattern (trait where types live, implementation where deps live) this ADR follows (the `local` feature is where the deps live) -- [ADR-002](002-ttybackend-trait-and-ttyhandle.md) — the `TtyBackend` +- [ADR-002](002-ttybackend-trait-and-ttyhandle.md), the `TtyBackend` trait this module implements -- [ADR-001](001-wire-format-and-two-carriage.md) — the wire format the +- [ADR-001](001-wire-format-and-two-carriage.md), the wire format the adapter pumps to/from this backend -- [ADR-004](004-exit-code-on-control-chunk.md) — the exit-chunk +- [ADR-004](004-exit-code-on-control-chunk.md): the exit-chunk ordering the local backend's waiter thread feeds -- [ADR-005](005-backend-cleanup-on-session-cancel.md) — the cancel- +- [ADR-005](005-backend-cleanup-on-session-cancel.md): the cancel- cleanup contract the local backend's `exit_code` future's `Drop` implements -- OQ-46 — runner API surface (deferred(scope): mechanism in alktty, +- OQ-46, runner API surface (deferred(scope): mechanism in alktty, policy is a downstream crate) - Spec: [tty-local.md](../tty-local.md) - Port origin: alknet ADR-054 at diff --git a/docs/architecture/decisions/005-backend-cleanup-on-session-cancel.md b/docs/architecture/decisions/005-backend-cleanup-on-session-cancel.md index 5805978..3d8d4db 100644 --- a/docs/architecture/decisions/005-backend-cleanup-on-session-cancel.md +++ b/docs/architecture/decisions/005-backend-cleanup-on-session-cancel.md @@ -3,10 +3,10 @@ ## Status Accepted (ported from alknet ADR-056 2026-08-17; cross-references -renumbered to alktty's ADR range — ADR-052→001, ADR-053→002, +renumbered to alktty's ADR range: ADR-052→001, ADR-053→002, ADR-054→003, ADR-055→004, ADR-056→005, ADR-057→006, ADR-077→007, ADR-093→008. The alknet ADR referenced by alknet number (052, 053, 054, -055) is not ported into alktty's ADR range as a separate ADR — it is +055) is not ported into alktty's ADR range as a separate ADR; it is ADR-001, 002, 003, 004 here. The alknet originals at `/workspace/@alkdev/alknet/docs/architecture/decisions/` remain authoritative for any ADR not yet ported.) @@ -17,31 +17,31 @@ A `alk/tty` session can be cancelled before the child process exits naturally. Three cancel paths exist (see `tty-adapter.md` §"Connection and Stream Lifecycle"): -1. **Connection drop** — the QUIC connection closes; all in-flight +1. **Connection drop**: the QUIC connection closes; all in-flight sessions on it are cancelled. -2. **Stream reset** — the client (or transport) resets the bidi stream +2. **Stream reset**: the client (or transport) resets the bidi stream mid-session. -3. **Task panic / adapter shutdown** — the session pump task exits +3. **Task panic / adapter shutdown**: the session pump task exits without completing the normal exit-chunk sequence. In all three, the adapter's per-session pump tasks are dropped (Rust -`Drop`). The pump tasks hold the `TtyHandle`, whose fields — `stdin`, -`stdout`, `stderr`, `exit_code`, `control` — are dropped too. +`Drop`). The pump tasks hold the `TtyHandle`, whose fields (`stdin`, +`stdout`, `stderr`, `exit_code`, `control`) are dropped too. The problem the local-PTY POC surfaced: **`Drop` alone is not sufficient cleanup for a backend whose child may outlive the session.** The local backend's three std threads illustrate the gap: -- **Reader thread** — blocking `MasterPty::try_clone_reader()` reads. +- **Reader thread** (blocking `MasterPty::try_clone_reader()` reads). On `mpsc::Sender::drop` (the stdout channel's sender side drops when the handle drops), the reader's next `blocking_send` returns a `SendError`; the thread exits. ✓ `Drop` works. -- **Writer thread** — drains an `mpsc::Receiver`. On +- **Writer thread** (drains an `mpsc::Receiver`). On `Receiver::drop`, the writer's `recv()` returns `None`; the thread drops the master writer (EOF to the slave's stdin) and exits. ✓ `Drop` works. -- **Waiter thread** — blocking wait (`Child::wait()`; on Unix now the - `libc::waitpid` reap — ADR-004). This is a syscall that +- **Waiter thread** (blocking wait; `Child::wait()`, on Unix now the + `libc::waitpid` reap, ADR-004). This is a syscall that returns **only when the child is reaped**. It does not observe channel close. If the child ignores stdin EOF (a daemon, a long-lived process with no stdin reader, a process in an @@ -58,7 +58,7 @@ in it.** The earlier spec text (`tty-adapter.md` §"Connection and Stream Lifecycle" pre-this-ADR) asserted: -> No explicit cleanup is needed — `Drop` is the cleanup. +> No explicit cleanup is needed: `Drop` is the cleanup. That is wrong for the waiter thread and any backend in the same shape. This ADR corrects the claim and commits a cleanup contract that closes @@ -71,7 +71,7 @@ contract (ADR-002), not a backend-internal detail, for two reasons: 1. **The adapter depends on the property.** The adapter's session driver holds the `TtyHandle` and, on cancel, drops it. The adapter - cannot itself call a backend-specific kill — it doesn't know the + cannot itself call a backend-specific kill; it doesn't know the child's pid (the local backend owns it; the adapter never sees it). The kill must be wired into the backend's handle shape, specifically into the `exit_code` future the adapter drops on cancel. The @@ -90,9 +90,9 @@ contract (ADR-002), not a backend-internal detail, for two reasons: ### 1. The `TtyBackend` cleanup contract: cancelling the `exit_code` future kills the session target When the adapter cancels a session (drops the pump tasks, which drops -the `TtyHandle`), the backend's `exit_code` future — the -`BoxFuture<'static, Result>` field of `TtyHandle` -(ADR-002) — is dropped *without being driven to completion*. The +the `TtyHandle`), the backend's `exit_code` future (the +`BoxFuture<'static, Result>` field of `TtyHandle`, +ADR-002) is dropped *without being driven to completion*. The cleanup contract: > **Dropping the `exit_code` future MUST kill the session target (the @@ -104,7 +104,7 @@ cleanup contract: > whose process ignores channel close). `exit_code`'s `Drop` is the cancel path. The adapter drives the future -to completion (the happy path — the child exits, the future resolves, +to completion (the happy path: the child exits, the future resolves, the adapter sends the exit chunk); on cancel, the adapter drops the future (their `Drop`), which runs the kill. @@ -118,8 +118,8 @@ contract is backend-agnostic. If the future resolved normally (the adapter awaited it, got the exit code, sent the exit chunk), the `Drop` runs on an already-resolved -future. The kill MUST be a no-op in that case — the child is already -reaped, the kill is delivered to a nonexistent pid, etc. This is the +future. The kill MUST be a no-op in that case (the child is already +reaped, the kill is delivered to a nonexistent pid, etc.). This is the "best-effort" qualifier: a kill on an already-exited child is not an error. Backends implement this with a guard that distinguishes "resolved" from "cancelled" (a flag, an `Option` taken on resolve, an @@ -128,7 +128,7 @@ error. Backends implement this with a guard that distinguishes ### 3. Local backend mechanism: `ChildKiller` held in the `exit_code` future's `Drop` guard The local backend (ADR-003) implements the contract using -`portable_pty::ChildKiller` — the kill handle `portable_pty` exposes +`portable_pty::ChildKiller`, the kill handle `portable_pty` exposes alongside `Child::wait()`. The pattern: - `allocate()` spawns the child via `portable_pty`, obtaining a `Child` @@ -139,7 +139,7 @@ alongside `Child::wait()`. The pattern: - The `exit_code` future's `poll` delegates to the inner `oneshot::Receiver::poll` (resolves when the waiter thread sends the exit code). -- The `exit_code` future's `Drop` (runs on cancel only — on resolve, +- The `exit_code` future's `Drop` (runs on cancel only; on resolve, the `Drop` is a no-op via the guard) calls `ChildKiller::kill(SIGHUP)` (or the backend's configured cancel signal). The kill causes the child to exit; the waiter thread's blocking wait returns; the waiter @@ -155,13 +155,13 @@ the waiter task). ### 4. Future backends (docker, SSH) follow the same contract -- **Docker (`DockerTtyBackend`)** — `bollard`'s exec stream is +- **Docker (`DockerTtyBackend`)**: `bollard`'s exec stream is cancelled by dropping the `AttachContainer` / `start_exec` stream and calling `bollard::container::kill_container` (or `exec::kill_exec` if available). The `exit_code` future's `Drop` holds the container/exec id and the `bollard::Docker` client; on cancel, it issues the kill. -- **SSH (`SshTtyBackend`)** — russh's `Channel::close()` and/or +- **SSH (`SshTtyBackend`)**: russh's `Channel::close()` and/or `Channel::signal(SIGHUP)` terminate the remote process. The `exit_code` future's `Drop` holds the russh channel handle; on cancel, it closes the channel. @@ -169,23 +169,23 @@ the waiter task). The docker and SSH backends are future work (out of scope for this spec set); the contract is what they implement. A future backend that does NOT fit the contract (e.g., a "recorded session replay" backend -with no live process) implements a no-op `Drop`-on-cancel — the -contract is "kill if there is a killable target; no-op if not." +with no live process) implements a no-op `Drop`-on-cancel (the +contract is "kill if there is a killable target; no-op if not."). ### 5. The adapter does not call a backend kill method The adapter has no `TtyBackend::cancel()` or `TtyHandle::kill()` method -to call — the cleanup is wired into the `exit_code` future's `Drop`, +to call; the cleanup is wired into the `exit_code` future's `Drop`, which the adapter triggers by dropping the future. This keeps the trait surface unchanged (no new method) and the cleanup in the backend (where the kill handle lives). The adapter's only responsibility is to drop the `TtyHandle` (and therefore the `exit_code` future) when the -session is cancelled — which it already does by virtue of dropping the +session is cancelled, which it already does by virtue of dropping the pump tasks. The `TtyControl::signal("HUP")` path (ADR-002) is the -*client-initiated* signal forwarding path — a client sends a -`{"type":"signal","name":"HUP"}` control chunk. It is NOT the +*client-initiated* signal forwarding path (a client sends a +`{"type":"signal","name":"HUP"}` control chunk). It is NOT the cancel-cleanup path. The cancel-cleanup path is server-internal (the adapter drops the handle) and does not involve the wire format. These are two different signal paths; both end in the child receiving SIGHUP @@ -199,18 +199,18 @@ different actors (client vs. server cancel). - A backend that conforms to the contract cannot orphan a process on cancel. The local-PTY POC's waiter-thread gap is closed at the contract level, not left to each backend to rediscover. -- The cleanup is idiomatic Rust — `Drop`-on-cancel of a `Future` is +- The cleanup is idiomatic Rust: `Drop`-on-cancel of a `Future` is the standard pattern for resource cleanup in async Rust (the same pattern `tokio::process::Child` uses; the same pattern `tokio::io` AsyncRead guards use). No new trait method; no adapter-side kill call. -- The contract is backend-agnostic — the mechanism (`ChildKiller` for +- The contract is backend-agnostic: the mechanism (`ChildKiller` for local, `kill_container` for docker, `channel::close` for SSH) lives in the backend; the contract ("drop the future, the target dies") lives at the seam. - The happy path (the child exits, the adapter drives `exit_code` to completion, sends the exit chunk, then drops the resolved future) is - unaffected — the `Drop`-on-resolve is a no-op via the guard. + unaffected (the `Drop`-on-resolve is a no-op via the guard). **Negative:** @@ -240,8 +240,8 @@ different actors (client vs. server cancel). **One-way.** The cleanup contract is part of the `TtyBackend` behavioral contract. Clients (the adapter) depend on "drop the handle, the session is cleaned up." Changing the contract after backends exist -— e.g., adding a separate `TtyBackend::cancel()` method and migrating -the cleanup out of `exit_code`'s `Drop` — would require every backend +(for example, adding a separate `TtyBackend::cancel()` method and migrating +the cleanup out of `exit_code`'s `Drop`) would require every backend to change. The `exit_code`-future-`Drop`-on-cancel mechanism is the seam. @@ -264,33 +264,33 @@ seam. exits; the waiter thread's blocking wait returns and reaps it (no zombie). The waiter thread then exits. The `oneshot::send` from the waiter thread fails silently (the receiver was dropped with the - future) — this is expected and not an error. + future), which is expected and not an error. ## References - [tty-adapter.md](../tty-adapter.md) §"Connection and Stream - Lifecycle" — the cancel paths (connection drop, stream reset) that + Lifecycle": the cancel paths (connection drop, stream reset) that trigger the contract -- [tty-local.md](../tty-local.md) §"Cancel-Cleanup (ADR-005)" — the +- [tty-local.md](../tty-local.md) §"Cancel-Cleanup (ADR-005)": the local backend's three-thread bridge and the waiter-thread gap this ADR closes -- [ADR-001](001-wire-format-and-two-carriage.md) — the wire format the +- [ADR-001](001-wire-format-and-two-carriage.md): the wire format the cancel does not involve (the cleanup is server-internal) -- [ADR-002](002-ttybackend-trait-and-ttyhandle.md) — the `TtyBackend` +- [ADR-002](002-ttybackend-trait-and-ttyhandle.md): the `TtyBackend` trait this contract is part of; the `exit_code` field the cleanup wires into; the `TtyControl::signal()` path the cancel-cleanup path is distinct from -- [ADR-003](003-local-backend-placement.md) — the local backend this +- [ADR-003](003-local-backend-placement.md): the local backend this ADR's reference mechanism (`ChildKiller`) is for -- [ADR-004](004-exit-code-on-control-chunk.md) — the happy-path +- [ADR-004](004-exit-code-on-control-chunk.md): the happy-path exit-chunk sequence (the cancel path bypasses it; no exit chunk is - sent on cancel — see `tty-adapter.md` §"Stream reset") -- `src/local/pty.rs` — the local backend's `LocalExitFuture` (the + sent on cancel, see `tty-adapter.md` §"Stream reset") +- `src/local/pty.rs`: the local backend's `LocalExitFuture` (the `Future` + `Drop` guard this ADR specifies, with the `ChildKiller` held in the guard) -- `src/local/pipe.rs` — the pipe-mode equivalent +- `src/local/pipe.rs`: the pipe-mode equivalent (`tokio::process::Child::start_kill()` on `Drop`-on-cancel) -- `portable-pty` 0.9 `ChildKiller` — the kill handle the local +- `portable-pty` 0.9 `ChildKiller`: the kill handle the local backend's cancel-cleanup uses - Spec: [tty-backend.md](../tty-backend.md), [tty-adapter.md](../tty-adapter.md), diff --git a/docs/architecture/decisions/008-channels-pure-channel-multiplexing.md b/docs/architecture/decisions/008-channels-pure-channel-multiplexing.md index a90745a..78f95c6 100644 --- a/docs/architecture/decisions/008-channels-pure-channel-multiplexing.md +++ b/docs/architecture/decisions/008-channels-pure-channel-multiplexing.md @@ -2,32 +2,33 @@ ## Status -Accepted (amends alknet ADR-071 — wire format is 8 bytes, not 9, and +Accepted (amends alknet ADR-071: wire format is 8 bytes, not 9, and the channels layer has no `stream_type` concept; amends alknet ADR-074 -— `into_sub_streams()` removed, `accept_bi` is the only accessor and -yields one `BiStream` per channel; reverses [ADR-007](007-tty-inside-channels.md) -— TTY always uses its 5-byte format, the channels layer carries it -transparently in the payload. Ported from alknet ADR-093 2026-08-17; -cross-references renumbered to alktty's ADR range — ADR-052→001, -ADR-053→002, ADR-054→003, ADR-055→004, ADR-056→005, ADR-057→006, -ADR-077→007, ADR-093→008. The alknet ADRs referenced by alknet number -(052, 070, 071, 072, 073, 074, 075, 076, 078, 079, 080, 081, 092) are -not ported into alktty's ADR range — they are alknet-core / -alknet-channels ADRs. The alknet originals at +(`into_sub_streams()` removed, `accept_bi` is the only accessor and +yields one `BiStream` per channel); reverses +[ADR-007](007-tty-inside-channels.md): TTY always uses its 5-byte +format, the channels layer carries it transparently in the payload. +Ported from alknet ADR-093 2026-08-17; cross-references renumbered to +alktty's ADR range: ADR-052→001, ADR-053→002, ADR-054→003, +ADR-055→004, ADR-056→005, ADR-057→006, ADR-077→007, ADR-093→008. The +alknet ADRs referenced by alknet number (052, 070, 071, 072, 073, 074, +075, 076, 078, 079, 080, 081, 092) are not ported into alktty's ADR +range; they are alknet-core / alknet-channels ADRs. The alknet +originals at `/workspace/@alkdev/alknet/docs/architecture/decisions/` remain authoritative.) ## Context alknet ADR-071 committed the channels wire format as a 9-byte chunk -header (`[channel_id:u32][stream_type:u8][length:u32]`) — a 4-byte +header (`[channel_id:u32][stream_type:u8][length:u32]`), a 4-byte extension of TTY's 5-byte format, with `stream_type` carried in the channels header and decomposed into unidirectional halves (0/1/2 = data write/read/err, 3/4/5 = control write/read/err, `% 3` formula). alknet ADR-074 added a second accessor (`into_sub_streams()`) alongside `accept_bi` for handlers that need typed sub-streams (TTY's stdin/stdout/stderr/ctrl-in/ctrl-out). [ADR-007](007-tty-inside-channels.md) -split TTY's wire format into two modes — direct (5-byte) and +split TTY's wire format into two modes: direct (5-byte) and inside-channels (the channels layer de-chunks and the adapter destructures via `into_sub_streams()`). @@ -39,18 +40,18 @@ because the channels layer reassembles per-`stream_type` and needs to expose the result. The two-mode TTY design exists because the channels layer's `stream_type` overlaps with TTY's own `stream_type`. The mod 2/mod 3/mod 4 numbering question (settled as mod 3 in alknet ADR-071 -revised) was a symptom of this overlap — a numbering convention for a +revised) was a symptom of this overlap: a numbering convention for a concept the channels layer shouldn't carry. ### The structural question The channels layer has two objectives in tension: -1. **"Pass a stream to/from any ALPN"** — every channel is a +1. **"Pass a stream to/from any ALPN"**: every channel is a `BiStream`; any handler gets `accept_bi()` and treats the channel as a duplex stream. Uniform, transport-agnostic, recursive-composition-friendly. -2. **"Channels carry N sub-streams"** — a TTY channel carries +2. **"Channels carry N sub-streams"**: a TTY channel carries stdin/stdout/stderr/control; the handler destructures via `into_sub_streams()`. Carries what the source produces. @@ -61,7 +62,7 @@ symmetry; you can't make the channel a single `BiStream` without losing the stdout/stderr distinction. alknet ADR-074's two-accessor design resolves this by making the "pass -a stream to/from any ALPN" objective *qualified* — it applies to +a stream to/from any ALPN" objective *qualified*: it applies to single-stream channels (tunnel, SSH, call), not multi-stream channels (TTY). The mod 2/mod 3/mod 4 numbering was a symptom of that qualified design. @@ -82,19 +83,19 @@ sub-multiplexing on the `BiStream` the channels layer gives them. SSH runs its own channel protocol. The channels layer carries the bytes transparently. - **The mod 2/mod 3/mod 4 question dissolves at the channels layer.** - The channels layer has no `stream_type` concept — not in its header, + The channels layer has no `stream_type` concept: not in its header, not in its code, not in its mental model. `stream_type` is the inner layer's framing byte, carried transparently. - **The control channel is handler-internal.** TTY sub-demuxes control from its io `BiStream` using its 5-byte format (`STREAM_CTRL_IN = 3`, - `STREAM_CTRL_OUT = 4` — ADR-001 amended by Phase 7). The channels + `STREAM_CTRL_OUT = 4`; ADR-001 amended by Phase 7). The channels layer doesn't carry control. The "control isn't actually bidirectional" flaw is fixed at the TTY layer, not the channels layer. - **Recursive composition is literal.** A channel with ALPN `alk/channels` runs another channels demux on its `BiStream`. The outer layer strips its 8-byte header; the inner layer parses its - own 8-byte header from the payload. Each level is the same shape — + own 8-byte header from the payload. Each level is the same shape: `BiStream → accept_bi → N BiStreams`. ### The wire format decision: 8 bytes @@ -109,7 +110,7 @@ was considered and rejected. The 9-byte format puts `stream_type` in the channels header, which means the channels layer carries a concept it doesn't own. For TTY this composes cleanly (the 9-byte header is TTY's 5-byte header with `channel_id` prepended), but for non-TTY handlers -(tunnel, call, SSH) the `stream_type` byte is dead weight — the +(tunnel, call, SSH) the `stream_type` byte is dead weight: the channels layer carries a byte it doesn't understand, and the handler ignores a byte in a header it doesn't control. @@ -133,11 +134,11 @@ payload). The handler layer (e.g. TTY) parses its own framing from the payload bytes per its existing `wire.rs`. The handler doesn't know or care that a `channel_id` was stripped before it saw the bytes. -The composition is uniform — the same shape at every level. This is +The composition is uniform: the same shape at every level. This is SSH's model (layered headers, each layer strips its own at its boundary), applied to channels. A `alk/channels`-inside-`alk/channels` recursive composition is the outer layer stripping its 8-byte header, -the inner layer parsing its own 8-byte header from the payload — same +the inner layer parsing its own 8-byte header from the payload; same code, same shape, each level. ### Why this can land now @@ -150,13 +151,13 @@ Three things changed since alknet ADR-071/074/077 were accepted: The join moves into core's quinn/iroh/stream impls (once per source, invisible to handlers). This ADR's "every channel is a `BiStream`" is the channels-layer consequence of alknet ADR-092's - handler-leaf decision — the research-then-sync pattern applied: + handler-leaf decision (the research-then-sync pattern applied: alknet ADR-092 settled the transport leaf, this ADR settles the - multiplexing layer above it. + multiplexing layer above it). 2. **Phase 7 fixed the TTY control channel at the TTY layer.** The `STREAM_CONTROL = 3` "bidirectional" flaw is fixed by splitting it - into `STREAM_CTRL_IN = 3` / `STREAM_CTRL_OUT = 4` — *inside TTY's - 5-byte format*, not at the channels layer. This removed the + into `STREAM_CTRL_IN = 3` / `STREAM_CTRL_OUT = 4`, *inside TTY's + 5-byte format*, not at the channels layer. This removed the load-bearing reason for the channels layer to carry `stream_type`: the control bidirectionality fix is a TTY-internal concern, not a channels-layer concern. ADR-007's two-mode TTY design was motivated @@ -175,19 +176,19 @@ Three things changed since alknet ADR-071/074/077 were accepted: - **The add/strip API shape** (built into read/write vs. a separate utility): the stream-unification research proposed `add_channel_id` / `strip_channel_id` as standalone functions. Ideally the header is - built into the read/write path so the utility isn't needed at the - handler boundary — but there may be a generalized reason to expose - it (recursive composition, test helpers, the hub relay's - `channel_id` rewrite). The exact API shape is an implementation - detail for the channels crate. The *contract* — the channels layer - strips its 8-byte header on read and the handler parses its own - framing from the payload — is decided here; the *function surface* - is not. + built into the read/write path so the utility isn't needed at the + handler boundary, but there may be a generalized reason to expose + it (recursive composition, test helpers, the hub relay's + `channel_id` rewrite). The exact API shape is an implementation + detail for the channels crate. The *contract* (the channels layer + strips its 8-byte header on read and the handler parses its own + framing from the payload) is decided here; the *function surface* + is not. - **TTY's `wire.rs` adaptation:** TTY's `ChunkReader` currently reads from an `AsyncRead`. Adapting it to read from a payload buffer - (`&[u8]` or `Cursor`) is a small, well-scoped change (the - framing logic — stream_type constants, length validation, control - message parsing — is unchanged). This is an implementation concern + (`&[u8]` or `Cursor`) is a small, well-scoped change; the + framing logic (stream_type constants, length validation, control + message parsing) is unchanged. This is an implementation concern for the channels + TTY integration, not an architecture decision. In alktty, `drive_session` already runs against a `BiStream` whose `AsyncRead` yields the post-channels-strip payload bytes; the same @@ -212,11 +213,11 @@ handler's framing, carried transparently. | field | offset | width | meaning | |-------|--------|-------|---------| | `channel_id` | 0 | 4 (BE) | The logical channel this chunk belongs to. Channel 0 is pre-negotiated as `alk/call` (alknet ADR-072). Channels 1..N are opened dynamically via `channel/open` (alknet ADR-073). | -| `length` | 4 | 4 (BE) | The payload length in bytes. 0 = EOF sentinel. Max `MAX_CHUNK_LEN` (16 MiB, matching TTY's cap — ADR-001 §5). | +| `length` | 4 | 4 (BE) | The payload length in bytes. 0 = EOF sentinel. Max `MAX_CHUNK_LEN` (16 MiB, matching TTY's cap; ADR-001 §5). | The `stream_type` byte is **removed** from the channels header. The -channels layer has no `stream_type` concept — not in its header, not in -its code, not in its mental model. What was the channels header's +channels layer has no `stream_type` concept (nothing in its header, +nothing in its code, nothing in its mental model). What was the channels header's `stream_type` byte is now the first byte of the payload, owned by the handler's framing (TTY's 5-byte format, call's length-prefixed JSON, tunnel's raw bytes, SSH's channel protocol). @@ -232,7 +233,7 @@ layer carries transparently. alknet ADR-074's `into_sub_streams()` / `ChannelSubStreams` / `SubStreamHandle` are removed. The channels layer exposes one accessor: `accept_bi()`, which yields one `BiStream` per channel (per alknet -ADR-092). Every handler — TTY, tunnel, SSH, call — receives a +ADR-092). Every handler (TTY, tunnel, SSH, call) receives a `Connection`, calls `accept_bi()` once, gets a `BiStream`, and sub-multiplexes it however it wants. @@ -240,7 +241,7 @@ This amends alknet ADR-074: the two-accessor design (`accept_bi` for generic handlers, `into_sub_streams` for typed handlers) collapses to one accessor. The "typed handler path" (alknet ADR-074's motivating case for TTY) is replaced by TTY sub-demuxing its `BiStream` via its -own 5-byte format — the same code TTY runs in direct mode. alknet +own 5-byte format, the same code TTY runs in direct mode. alknet ADR-074's yield-once `accept_bi` contract is preserved; the `into_sub_streams()` accessor is the amended part. @@ -257,16 +258,16 @@ same `wire.rs` code runs in both modes. When TTY is inside channels, the channels layer strips its 8-byte header and hands TTY the payload bytes. TTY parses its 5-byte header from the payload. The channels layer carries TTY's 5-byte chunks -transparently in its payload — no shared fields, no leaked +transparently in its payload: no shared fields, no leaked abstraction, no double-chunking concern (the 13-byte total header is 8 channels + 5 TTY, not 8 + 9; the channels `length` is always `tty_len + 5`). -This reverses ADR-007: the 5-byte format is NOT scoped to direct — +This reverses ADR-007: the 5-byte format is NOT scoped to direct; it's TTY's internal format, carried transparently in the channels payload. The `channels` feature on alktty (if added) becomes "run -TTY's sub-demux on a channels-backed `BiStream`" — the same code as -direct mode, different `BiStream` source. The control channel split +TTY's sub-demux on a channels-backed `BiStream`" (the same code as +direct mode, different `BiStream` source). The control channel split (`STREAM_CTRL_IN` / `STREAM_CTRL_OUT`, Phase 7) is TTY-internal; the channels layer doesn't know about it. @@ -291,65 +292,65 @@ TTY inside channels: (8 bytes) carried as channels payload ``` -The composition is uniform — the same shape at every level. A +The composition is uniform: the same shape at every level. A `alk/channels`-inside-`alk/channels` recursive composition is the outer layer stripping its 8-byte header, the inner layer parsing its -own 8-byte header from the payload — same code, same shape, each +own 8-byte header from the payload; same code, same shape, each level. ### 5. What does NOT change -- **alknet ADR-092's `BiStream` leaf** — unchanged. This ADR is the +- **alknet ADR-092's `BiStream` leaf** is unchanged. This ADR is the channels-layer consequence of alknet ADR-092: `accept_bi` yields a `BiStream`, handlers sub-multiplex it. The two ADRs compose (alknet ADR-092 settles the transport leaf; this ADR settles the multiplexing layer above it). -- **`ProtocolHandler` trait shape** (alknet ADR-002) — unchanged. +- **`ProtocolHandler` trait shape** (alknet ADR-002): unchanged. Handlers receive a `Connection` and call `accept_bi()`. -- **Channel 0 pre-negotiated as `alk/call`** (alknet ADR-072) — +- **Channel 0 pre-negotiated as `alk/call`** (alknet ADR-072): unchanged. Channel 0's chunks have `channel_id = 0` in the 8-byte header. The call protocol's `EventEnvelope` framing is the payload; the channels layer carries it transparently. -- **Channel lifecycle operations** (alknet ADR-073) — unchanged. The +- **Channel lifecycle operations** (alknet ADR-073): unchanged. The four operations (`channel/open`/`close`/`control`/`resources/subscribe`) and their `direction` semantics are call-protocol operations on channel 0, not channels-wire-format concerns. -- **`ChannelsAdapter` / `ChannelManager` split** (alknet ADR-075) — +- **`ChannelsAdapter` / `ChannelManager` split** (alknet ADR-075): structurally unchanged. The demux loop reads 8-byte headers (not 9-byte); the `ChannelManager` is ALPN-blind, auth-blind, transport-blind. The `stream_types` field on `channel/open` and `ChannelState` is removed (the channels layer doesn't track per-stream-type reassembly buffers; it tracks one reassembly buffer per `channel_id`, yielding a `BiStream`). -- **Backpressure, channel limits, ID reuse** (alknet ADR-076) — +- **Backpressure, channel limits, ID reuse** (alknet ADR-076): unchanged. The bounded-buffer backpressure is per-`channel_id` (was per-`(channel_id, stream_type)`; now per-`channel_id` since there's one reassembly buffer per channel). The 256-channel cap, 1 MiB default, and monotonic-ID-with-wrap strategy are unchanged. -- **Two-pump shutdown-on-completion** (alknet ADR-078) — unchanged. +- **Two-pump shutdown-on-completion** (alknet ADR-078): unchanged. Tunnel/SSH handlers call `tokio::io::split(bidi)` for their two pump halves; the shutdown-on-completion contract applies to the `ReadHalf` / `WriteHalf` unchanged. -- **Hub relay** (alknet ADR-079) — unchanged in contract. The hub +- **Hub relay** (alknet ADR-079): unchanged in contract. The hub translates `channel/open` on channel 0 and byte-forwards data channels with `channel_id` rewrite. The relay reads 8-byte headers (not 9-byte) and rewrites the `channel_id` field (a 4-byte rewrite within the 8-byte header, not a 9-byte header). The relay does not parse the payload. -- **`ChannelClient`** (alknet ADR-080) — unchanged in API. +- **`ChannelClient`** (alknet ADR-080): unchanged in API. `from_connection` primary, `open_channel` returns a `Channel`. The `stream_types` field on `open_channel` and `Channel` is removed (the channels layer doesn't negotiate per-stream-type sets; the handler owns its sub-stream multiplexing). The `channel:stream_type_unavailable` error code is removed (the channels layer can't refuse a `stream_type` it doesn't know about). -- **Sub-crate decomposition** (alknet ADR-081) — unchanged. +- **Sub-crate decomposition** (alknet ADR-081): unchanged. `channels-core` (pure multiplexer, depends on alknet-core only) / `channels-call` (channel 0 pre-negotiation + lifecycle op registrations, depends on `channels-core` + alknet-call). The 8-byte wire format, demux/mux, and `ChannelBidiStreamSource` are in `channels-core`; the call-protocol coupling is in `channels-call`. -- **`BidiStreamSource` trait** (alknet ADR-070) — unchanged in shape. +- **`BidiStreamSource` trait** (alknet ADR-070): unchanged in shape. `ChannelBidiStreamSource` implements it; `accept_bi` yields a `BiStream` (per alknet ADR-092, already landed). @@ -358,7 +359,7 @@ level. **Positive:** - **Clean separation of concerns.** The channels layer has no - `stream_type` concept — not in its header, not in its code, not in + `stream_type` concept: not in its header, not in its code, not in its mental model. The handler owns its framing entirely. This dissolves the mod 2/mod 3/mod 4 question at the channels layer (there's nothing to decompose) and fixes the "control isn't actually @@ -373,7 +374,7 @@ level. another channels demux on its `BiStream`. The outer layer strips its 8-byte header; the inner layer parses its own 8-byte header from the payload. Same code, same shape, each level. This is a property, - not a feature — the primary use case is one level of multiplexing, + not a feature: the primary use case is one level of multiplexing, but the add/strip composition makes the recursion cleaner than alknet ADR-071's group framing did. - **The `into_sub_streams()` accessor and its consuming handler code @@ -392,7 +393,7 @@ level. top-level one. (In alktty as built, `drive_session` already takes a generic `AsyncRead + AsyncWrite` pair, so both the direct `TtyAdapter::handle` path and the channels `TtyOpenHandler` path - call the same function — see `src/adapter.rs` and + call the same function (see `src/adapter.rs` and `src/channels.rs`.) - **The channels layer is WASM-compatible by construction.** The 8-byte header's core is pure byte manipulation (the sync core @@ -415,9 +416,9 @@ level. cause this ADR addresses. - **TTY's `wire.rs` needs a small adaptation.** `ChunkReader` currently reads from an `AsyncRead` (the transport stream). Inside channels, - it reads from a payload buffer (`&[u8]` or `Cursor`) — the - bytes the channels layer handed it after stripping its 8-byte - header. The framing logic (stream_type constants, length validation, + it reads from a payload buffer (`&[u8]` or `Cursor`), the + bytes the channels layer handed it after stripping its 8-byte + header. The framing logic (stream_type constants, length validation, control message parsing) is unchanged. This is a bounded, well-scoped implementation change, not an architecture change. The same adaptation applies to any handler that parses its own framing @@ -425,13 +426,13 @@ level. from a buffer; tunnel and SSH don't parse the payload, so no adaptation). In alktty as built, `drive_session` already reads from a `BiStream` whose `AsyncRead` yields the post-channels-strip - payload bytes — the same `wire.rs` code runs unchanged in both - modes. + payload bytes; the same `wire.rs` code runs unchanged in both + modes. - **`channel/open` loses the `stream_types` field.** alknet ADR-073's `channel/open` input included `stream_types: [u8]` (the active sub-stream set) and the response echoed the negotiated set. Under - this ADR, the channels layer doesn't negotiate sub-stream sets — - the handler owns its sub-stream multiplexing. The `stream_types` + this ADR, the channels layer doesn't negotiate sub-stream sets; the + handler owns its sub-stream multiplexing. The `stream_types` field is removed from `channel/open` (and from the `channel:stream_type_unavailable` error code). The `alpn` and `params` fields remain; the handler's sub-stream set is implicit in @@ -444,7 +445,7 @@ level. simplification (fewer reassembly buffers, simpler drain logic) but is an implementation change, not an architecture one. The bounded-buffer backpressure (alknet ADR-076) is per-`channel_id` - now, not per-`(channel_id, stream_type)` — the 1 MiB default and + now, not per-`(channel_id, stream_type)`: the 1 MiB default and the 256-channel cap are unchanged; the per-channel memory ceiling is 1 MiB (was up to 5 MiB for a TTY channel with 5 active stream_types). This is a net improvement (lower memory ceiling per @@ -459,7 +460,7 @@ removal of `stream_type` from the channels header, and the removal of them after the channels crate is implemented and handlers are written against them requires a version migration. Since the channels crate doesn't exist yet, the one-way door is being cast now, before -implementation — the right time to cast a one-way door. +implementation, the right time to cast a one-way door. The reversal of ADR-007 (TTY always uses its 5-byte format) is one-way in the same sense: once TTY's `wire.rs` runs in both modes (direct and @@ -467,53 +468,53 @@ inside-channels), re-introducing a separate inside-channels mode would be a rewrite of TTY's session driver. The trade is one unified session driver now vs. two-mode maintenance forever. -The add/strip API shape (alknet OQ-68) is a **two-way door** — whether +The add/strip API shape (alknet OQ-68) is a **two-way door**: whether the header add/strip is built into the read/write path or exposed as a standalone utility is an implementation detail that can change without breaking the wire format or the handler contract. ## References -- alknet ADR-071: channels wire format (amended — wire format is 8 +- alknet ADR-071: channels wire format (amended: wire format is 8 bytes, not 9; `stream_type` removed from the channels header; the stream_type decomposition is removed from the channels layer) -- alknet ADR-074: ChannelBidiStreamSource (amended — +- alknet ADR-074: ChannelBidiStreamSource (amended: `into_sub_streams()` removed; `accept_bi` is the only accessor, yields one `BiStream` per channel) -- [ADR-007](007-tty-inside-channels.md): TTY inside channels (reversed - — TTY always uses its 5-byte format; the channels layer carries it +- [ADR-007](007-tty-inside-channels.md): TTY inside channels (reversed: + TTY always uses its 5-byte format; the channels layer carries it transparently in the payload; the two-mode design is preserved but differs only in `BiStream` source, not in parsing) - alknet ADR-092: `BiStream` as the handler leaf (the transport-leaf - layer this ADR builds on — `accept_bi` returns `BiStream`; + layer this ADR builds on: `accept_bi` returns `BiStream`; `from_bidi` is the only public stream constructor) - alknet ADR-070: `BidiStreamSource` trait (the extension point `ChannelBidiStreamSource` implements; `accept_bi` yields `BiStream`) -- alknet ADR-072: channel 0 pre-negotiated `alk/call` (unchanged — +- alknet ADR-072: channel 0 pre-negotiated `alk/call` (unchanged: channel 0's chunks have `channel_id = 0` in the 8-byte header; the call protocol's framing is the payload) -- alknet ADR-073: channel lifecycle operations (amended — +- alknet ADR-073: channel lifecycle operations (amended: `stream_types` field removed from `channel/open`; `channel:stream_type_unavailable` error code removed) - alknet ADR-075: `ChannelsAdapter` and `ChannelManager` (structurally - unchanged — demux reads 8-byte headers; one reassembly buffer per + unchanged: demux reads 8-byte headers; one reassembly buffer per channel) -- alknet ADR-076: backpressure, channel limits, ID reuse (unchanged — +- alknet ADR-076: backpressure, channel limits, ID reuse (unchanged: bounded-buffer is per-`channel_id`; 256-channel cap, 1 MiB default, monotonic IDs) -- alknet ADR-078: two-pump shutdown-on-completion (unchanged — the +- alknet ADR-078: two-pump shutdown-on-completion (unchanged: the contract applies to `tokio::io::split(bidi)` halves) -- alknet ADR-079: hub relay (unchanged in contract — 8-byte header, +- alknet ADR-079: hub relay (unchanged in contract: 8-byte header, 4-byte `channel_id` rewrite, payload byte-forwarded) -- alknet ADR-080: `ChannelClient` (amended — `stream_types` field +- alknet ADR-080: `ChannelClient` (amended: `stream_types` field removed from `open_channel` and `Channel`) -- alknet ADR-081: sub-crate decomposition (unchanged — 8-byte wire +- alknet ADR-081: sub-crate decomposition (unchanged: 8-byte wire format in `channels-core`; call-protocol coupling in `channels-call`) - [ADR-001](001-wire-format-and-two-carriage.md): alktty wire format (the 5-byte format carried transparently in the channels payload; the control channel split from Phase 7 is TTY-internal) -- `src/channels.rs` — alktty's channels integration (the +- `src/channels.rs`: alktty's channels integration (the `register_openable` helper + `TtyOpenHandler` that runs `drive_session` on a channels-backed `BiStream`, the same code as the direct path) diff --git a/docs/architecture/decisions/009-channels-open-op-is-the-negotiation.md b/docs/architecture/decisions/009-channels-open-op-is-the-negotiation.md index ca5cef8..cdf3dbe 100644 --- a/docs/architecture/decisions/009-channels-open-op-is-the-negotiation.md +++ b/docs/architecture/decisions/009-channels-open-op-is-the-negotiation.md @@ -8,22 +8,22 @@ Accepted (2026-09-05). Resolves review #001 L1. Prerequisites: alkcall Amended 2026-09-05 (review #002 R4): a `NegotiateRequest` parse failure of the schema-validated `input` is now a client-visible -`malformed_negotiation` error frame, not a silent teardown — see +`malformed_negotiation` error frame, not a silent teardown; see §"Parse-failure error frame (R4 amendment, 2026-09-05)". -Amended 2026-09-06 (ADR-010): with alkcall 0.5.0 (ADR-049 — review 006 +Amended 2026-09-06 (ADR-010): with alkcall 0.5.0 (ADR-049, review 006 E-01), the semantic-failure classes the R4 amendment reported in-band (malformed negotiation, unknown backend, ownership denial) move into the producer's establisher and resolve as `channel:open_failed` call -errors — no phantom channel. The in-band error-frame path on this +errors; no phantom channel. The in-band error-frame path on this channel shrinks to one class: `allocate_failed` (the establisher -cannot carry the allocated handle across — see ADR-010 §2). The R4 +cannot carry the allocated handle across; see ADR-010 §2). The R4 tests that asserted the in-band frames for those classes now assert the call-error shape. -Amended 2026-09-07 (ADR-010 §2A, alkcall 0.6.0 — review 007 R-01): +Amended 2026-09-07 (ADR-010 §2A, alkcall 0.6.0, review 007 R-01): `Establishment` gained its plan payload, so `allocate` also moved -into the establisher — the in-band error-frame path on this channel +into the establisher; the in-band error-frame path on this channel shrinks to nothing from a registered producer (the frames remain the defense-in-depth fallback for no-establisher registrations and the direct path). See ADR-010 §2A. @@ -34,7 +34,7 @@ Before this ADR, the channels path carried the negotiation twice. The consumer opened a channel via the `channels/tty/sub` open op, passing the `NegotiateRequest` params as the op's `input`; the producer-side open handler then ignored that `input` (`let _ = input;`) and read a -second, full negotiation frame from the channel's data stream — the +second, full negotiation frame from the channel's data stream, the same frame the direct-ALPN path reads. The registry validated `input` against the op's schema, but the backend selection actually came from the unvalidated wire frame. Two independent gates (the registry's @@ -45,19 +45,19 @@ divergent frame. Review #001 (L1) flagged this as a latent inconsistency and asked for a publisher decision: either drop the `input` parameter or pass it through as the authoritative negotiation. Investigation of alkcall -found the review's premise wrong in an important way — alkcall (≤0.3.1) +found the review's premise wrong in an important way: alkcall (≤0.3.1) never enforced `input_schema` on any dispatch path at all: the schema was advertise-only (metadata in discovery). The registry gate this design leaned on did not exist. Two upstream changes landed to make the design sound: -1. **alkcall 0.4.0** — `OperationSpec.input_schema` is enforced at +1. **alkcall 0.4.0**: `OperationSpec.input_schema` is enforced at call time by all three registry dispatch entry points (`invoke`, `invoke_streaming`, `invoke_sink`). The schema is compiled once at registration (fail-closed, the same rule as `publish_schema` / CF-003). Violations return `INVALID_INPUT` before the handler runs. -2. **alkcall 0.4.1** — the demux parks early-arrival chunks for a +2. **alkcall 0.4.1**: the demux parks early-arrival chunks for a not-yet-adopted channel (bounded FIFO, 64 payloads per channel) and `adopt_channel` drains them into the new receiver. Before this, the open-op-response / producer's-first-write race silently dropped the @@ -70,30 +70,30 @@ Two upstream changes landed to make the design sound: The open op's registry-validated `input` **is** the negotiation (design 2 of the L1 decision). The channels path carries **no second -negotiation frame** on the channel's data stream — the stream starts +negotiation frame** on the channel's data stream; the stream starts directly in raw-chunk mode (TTY's 5-byte format, ADR-001/ADR-008). ### Producer side `make_tty_open_handler` parses the open op's `input` into a `NegotiateRequest` and drives the new `drive_session_pre_negotiated` -(`src/adapter.rs`) — the same three-pump session driver as the direct +(`src/adapter.rs`), the same three-pump session driver as the direct path, minus the wire-frame negotiation phase. Validation is factored into `validate_and_allocate`, shared by both paths: `carriage == "raw"`, non-empty `cmd`, backend lookup, the ADR-050 ownership check, and `backend.allocate`. On the channels path the `tty:open` scope gate -is NOT re-checked in the handler — the registry's `AccessControl` +is NOT re-checked in the handler; the registry's `AccessControl` enforced it before the handler ran (the identity the registry checked, resolved from the connection, is the authoritative one; the handler-side identity is only the ownership-check subject). Post-open failures (unknown backend, `allocate_failed`, ownership denial) still go to the client as a `0x00`-prefixed negotiation error -frame on the channel stream — the same framing the direct path uses — +frame on the channel stream (the same framing the direct path uses), so the consumer's ADR-001 §5 disambiguation read applies unchanged. A `NegotiateRequest` parse failure of the schema-validated `input` -(the schema is deliberately partial — `carriage`/`backend`/`cmd` +(the schema is deliberately partial: `carriage`/`backend`/`cmd` required, `tty`/`cwd`/`env`/backend-params free-form so the opaque ADR-053 params pass through) was originally a handler-side failure logged and returned without an error frame. The R4 amendment below @@ -106,7 +106,7 @@ failure. a `NegotiateRequest` locally first (fail-fast before a channel is allocated), opens the channel via `ChannelClient::open_channel`, builds the channel `Connection`, and starts raw-chunk mode directly -(`from_halves_raw` — no negotiation frame is written). The +(`from_halves_raw`; no negotiation frame is written). The `0x00`-error-frame peek is retained: the first byte of the producer's response disambiguates a post-open negotiation error frame (length prefix starting `0x00`) from a raw chunk (`stream_type` in `{1, 2, 4}`). @@ -132,9 +132,9 @@ client-visible through the same `0x00` error-frame peek (`from_halves_raw`): 1. **Semantic validation failures** (`carriage != "raw"`, empty `cmd`, - unknown backend, `allocate_failed`, ownership denial) — error - frames from `validate_and_allocate` (unchanged). -2. **`NegotiateRequest` parse failure of schema-valid `input`** — the +unknown backend, `allocate_failed`, ownership denial); error +frames from `validate_and_allocate` (unchanged). +2. **`NegotiateRequest` parse failure of schema-valid `input`**: the open handler accepts the channel's `BiStream` and writes a `malformed_negotiation` frame (`{"error":"malformed_negotiation","message":"..."}`) via the shared @@ -143,12 +143,12 @@ client-visible through the same `0x00` error-frame peek deliberately partial: e.g. `cwd` typed as a number passes the schema (unknown-key/type fields pass through for the opaque ADR-053 params) but fails the typed parse. -3. **Schema-invalid `input`** — rejected at dispatch by the registry +3. **Schema-invalid `input`**: rejected at dispatch by the registry (alkcall 0.4) before any handler runs: a `CallError` on the open op, no channel allocated, no error frame (unchanged). This replaces the original behavior (log + return, channel teardown, -consumer observes `NoExitChunk` — indistinguishable from a crashed +consumer observes `NoExitChunk`, indistinguishable from a crashed producer). The error-frame layout is unchanged (ADR-001's wire-stable contract); no new frame type, no new stream type. Tests: the `make_tty_open_handler` seam test (hand-built `input` bypassing the @@ -161,8 +161,8 @@ schema, `channels.rs::tests`) and the real-registry end-to-end test **Positive:** - **One negotiation per session.** The registry's schema check and the - semantic validation run on the same value — the two-gate - disagreement is structurally impossible. +semantic validation run on the same value; the two-gate +disagreement is structurally impossible. - **Fewer bytes and one less round trip** on the channels path: no 4-byte length prefix + JSON frame on the data stream, and the consumer needs no negotiation write before its first read. @@ -190,7 +190,7 @@ schema, `channels.rs::tests`) and the real-registry end-to-end test - **Consumer/producer version skew.** A consumer that writes the old second negotiation frame to a producer built on this ADR will have that frame parsed as a raw chunk (stream_type byte = first JSON - byte, garbage length) — the session fails confusingly. Acceptable + byte, garbage length), so the session fails confusingly. Acceptable pre-1.0 (no released channels-path consumers); the direct path is unaffected. @@ -198,7 +198,7 @@ schema, `channels.rs::tests`) and the real-registry end-to-end test **Two-way for now, one-way once a released consumer exists.** The channels-path negotiation location (open-op `input` vs. a wire frame) -is reversible pre-1.0 — the `NegotiateRequest` shape, the 5-byte chunk +is reversible pre-1.0: the `NegotiateRequest` shape, the 5-byte chunk format, and the error-frame layout (the wire-stable contracts) are untouched. Once a released consumer depends on "open op, then raw chunks," removing or re-adding the wire-frame negotiation on this path @@ -207,19 +207,19 @@ public API is additive; `drive_session` is unchanged. ## References -- [ADR-001](001-wire-format-and-two-carriage.md) — the two-carriage +- [ADR-001](001-wire-format-and-two-carriage.md), the two-carriage model (JSON negotiation + raw chunks) and the §5 framing disambiguation this ADR reuses for the post-open error frame -- [ADR-002](002-ttybackend-trait-and-ttyhandle.md) — the backend +- [ADR-002](002-ttybackend-trait-and-ttyhandle.md), the backend selection and allocation this ADR's validation runs -- [ADR-006](006-negotiation-framing-self-contained.md) — the +- [ADR-006](006-negotiation-framing-self-contained.md): the negotiation frame layout (unchanged; the direct path still uses it) -- [ADR-008](008-channels-pure-channel-multiplexing.md) — the channels +- [ADR-008](008-channels-pure-channel-multiplexing.md): the channels layer strips its 8-byte header transparently; TTY owns its framing (this ADR decides what TTY's framing on the channels path contains) - `docs/reviews/001-code-review.md` §"Resolution (2026-09-05, L1 + L3)" -- `src/adapter.rs` — `drive_session`, `drive_session_pre_negotiated`, +- `src/adapter.rs`: `drive_session`, `drive_session_pre_negotiated`, `validate_and_allocate` -- `src/channels.rs` — `make_tty_open_handler`, `tty_open_spec` -- `src/session.rs` — `open_via_channels`, `from_halves_raw` -- `src/testing.rs` — the shared producer/consumer channels harness \ No newline at end of file +- `src/channels.rs`: `make_tty_open_handler`, `tty_open_spec` +- `src/session.rs`: `open_via_channels`, `from_halves_raw` +- `src/testing.rs`: the shared producer/consumer channels harness \ No newline at end of file diff --git a/docs/architecture/decisions/010-channels-establisher-migration.md b/docs/architecture/decisions/010-channels-establisher-migration.md index 50cd605..b82dc0d 100644 --- a/docs/architecture/decisions/010-channels-establisher-migration.md +++ b/docs/architecture/decisions/010-channels-establisher-migration.md @@ -3,14 +3,14 @@ ## Status Accepted (2026-09-06). Migrates the channels-path semantic-failure -surface per alkcall 0.5.0 / ADR-049 (review 006 E-01 + N-1) — the -sequencing ADR-049 §5 names ("alktty migration — channels-path +surface per alkcall 0.5.0 / ADR-049 (review 006 E-01 + N-1): the +sequencing ADR-049 §5 names ("alktty migration: channels-path semantic failures move into an establisher; the direct-ALPN in-band -error frame is retained — two transports, two contracts"). +error frame is retained (two transports, two contracts)"). Prerequisites: alkcall 0.5.0 (`ChannelCore::register_openable_with_establisher`, `channel:open_failed`, typed `ChannelOpenError`). -**Amended (2026-09-07, alkcall 0.6.0 / ADR-049 amendment 2 — review +**Amended (2026-09-07, alkcall 0.6.0 / ADR-049 amendment 2, review 007 R-01):** `Establishment` gained its plan payload (`ChannelPlan = Arc`), removing §2's payloadless-`Establishment` blocker. Backend `allocate` moves into @@ -35,7 +35,7 @@ semantic-failure classes had nowhere to go but the channel stream: the handler wrote a `0x00`-prefixed negotiation error frame (`malformed_negotiation`, `unknown_backend`, `allocate_failed`, ownership-denial `forbidden`) on a channel the open op had already -reported as succeeding. The consumer observed success-then-frame — +reported as succeeding. The consumer observed success-then-frame, a per-crate workaround ADR-049 explicitly retires: > alktty's per-crate in-band error vocabulary is retired on the @@ -43,7 +43,8 @@ a per-crate workaround ADR-049 explicitly retires: > for free. alkcall 0.5.0 implements the resolution: the wrapper awaits a bounded -establisher (`OpenEstablisher` — `Fn(Value, AuthContext) -> BoxFuture< +establisher (`OpenEstablisher`: +`Fn(Value, AuthContext) -> BoxFuture< Result>`) between channel allocation and the open reply; on rejection it tears the channel down and replies `channel:open_failed` with `details: { reason, message }` @@ -60,19 +61,19 @@ branchable end-to-end. `register_openable` (alktty's helper, signature unchanged) now calls `ChannelCore::register_openable_with_establisher` with -`make_tty_establisher` — the establishment phase runs the semantic +`make_tty_establisher`; the establishment phase runs the semantic validation that used to be post-open error frames: - Full `NegotiateRequest` parse of the registry-schema-validated - `input` (the schema stays deliberately partial — the opaque ADR-053 + `input` (the schema stays deliberately partial: the opaque ADR-053 backend params pass through; the establisher is the typed gate). - `carriage == "raw"`, non-empty `cmd`. - Backend lookup against the adapter's backend map. - The ADR-050 ownership check (moved from the pump handler's - `validate_and_allocate`; the scope gate stays the registry's — - pre-allocation, unchanged). + `validate_and_allocate`; the scope gate stays the registry's, + pre-allocation and unchanged). -Rejections map into ADR-049's fixed reason vocabulary (no new codes — +Rejections map into ADR-049's fixed reason vocabulary (no new codes; the vocabulary is alkcall's one-way wire surface): | TTY failure class | `EstablishmentError` variant | `details.reason` | @@ -81,24 +82,24 @@ the vocabulary is alkcall's one-way wire surface): | `carriage != "raw"`, empty `cmd` | `HandlerError` | `handler_error` | | unknown backend | `UnknownResource` | `unknown_resource` | | ownership denial | `HandlerError` | `handler_error` | -| **allocation failure** (amended — §2A) | `DialFailed` | `dial_failed` | +| **allocation failure** (amended, §2A) | `DialFailed` | `dial_failed` | | establishment deadline | (wrapper) | `timeout` | `resource_shortage` has no TTY producer path (allocation-capacity -failures map to `dial_failed` — the target refused or the backend +failures map to `dial_failed`: the target refused or the backend lacked capacity; §2A). `HandlerError` covers both the malformed-negotiation semantics (the request shape is wrong) and the ACL-outcome ownership denial (an authorization failure, not a dial failure). -### 2. Backend allocation stays in the pump handler — `allocate_failed` stays in-band +### 2. Backend allocation stays in the pump handler: `allocate_failed` stays in-band -> **Amended away (2026-09-07) — see §2A.** This section is the +> **Amended away (2026-09-07; see §2A).** This section is the > historical record of the pre-0.3.0 shape; its blocker 1 > (payloadless `Establishment`) was resolved by alkcall 0.6.0 (ADR-049 > amendment 2, review 007 R-01). Blocker 2 (double-`allocate` > unsoundness) is avoided by construction in the amended shape: the -> establisher's handle *is* the handle the handler pumps — there is no +> establisher's handle *is* the handle the handler pumps; there is no > second allocate to be unsound about. The one failure class that cannot migrate: `backend.allocate`. Two @@ -113,12 +114,12 @@ reasons, both structural: 2. **Double-`allocate` is unsound under ADR-005's kill-on-`Drop`** contract: re-allocating in the handler after an establisher-side allocate would spawn the session target twice (the first handle's - kill-guard would fire on its drop — a session killed before it + kill-guard would fire on its drop: a session killed before it started), and leaking the establisher-side handle would leak the live target. -So `backend.allocate` runs where it always did — inside -`drive_session_pre_negotiated`'s `validate_and_allocate` — and +So `backend.allocate` runs where it always did (inside +`drive_session_pre_negotiated`'s `validate_and_allocate`), and `allocate_failed` remains a post-open `0x00`-prefixed negotiation error frame, surfaced consumer-side as [`TtySessionError::NegotiationRejected`] via the same peek the direct @@ -139,18 +140,18 @@ frame. alkcall 0.6.0 (ADR-049 amendment 2, review 007 R-01) filled the reserved field: `Establishment { plan: Option }` with -`ChannelPlan = Arc` — typed-opaque, wrapper -threaded to the `OpenHandler`'s new second parameter. §2's blocker 1 +`ChannelPlan = Arc` (typed-opaque, wrapper +threaded to the `OpenHandler`'s new second parameter). §2's blocker 1 is gone; the double-`allocate` hazard (blocker 2) is structurally -absent — one allocate per open, whose result the handler consumes. +absent: one allocate per open, whose result the handler consumes. The amended shape: 1. **The establisher allocates.** After the validation sequence (§1), it runs `backend.allocate(¶ms)`. Failure maps to - `EstablishmentError::DialFailed` (`details.reason == "dial_failed"` - — the target refused or the backend lacked capacity; one string - message, no nuance — the same granularity the direct path's + `EstablishmentError::DialFailed` (`details.reason == "dial_failed"`: + the target refused or the backend lacked capacity; one string + message, no nuance, the same granularity the direct path's `allocate_failed` frame always had). The `channel:open_failed` `ErrorDefinition` on `tty_open_spec()` therefore declares four reachable reasons (`dial_failed`, @@ -159,7 +160,7 @@ The amended shape: (boxed dyn streams), so it cannot be the `ChannelPlan` directly. The establisher wraps it in a private `AllocatedHandle` one-shot slot (`Mutex>`), created **per establisher - invocation** — per open. The wrapper moves this open's plan to this + invocation**, one per open. The wrapper moves this open's plan to this open's handler (alkcall's concurrent-same-resource test pins that plans are never shared), the handler `take()`s it exactly once, and establisher-before-handler ordering is alkcall's wrapper guarantee @@ -167,8 +168,8 @@ The amended shape: construction: there is no shared slot. 3. **The handler pumps the pre-allocated handle.** With a plan it calls `drive_session_pre_allocated` (new `pub(crate)` driver: the - pumps directly, no validation pass). With `plan: None` — a - no-establisher registration — it falls back to + pumps directly, no validation pass). With `plan: None` (a + no-establisher registration), it falls back to `drive_session_pre_negotiated`, whose inline validate-and-allocate keeps the full in-band error-frame vocabulary. That fallback is defense-in-depth for the assembly-layer freedom to register without @@ -176,7 +177,7 @@ The amended shape: 4. **Consumer surface.** `TtySession::open_via_channels` sees allocation failure as `ChannelsOpen(CallFailed{ channel:open_failed, reason: - dial_failed })` — a typed call error, branchable and retryable + dial_failed })`, a typed call error, branchable and retryable like every other establishment class. The `0x00` peek on the channel stream becomes a formality that no registered producer ever exercises; the pinned test flipped with the contract @@ -185,8 +186,8 @@ The amended shape: failure_to_dial_failed` in `channels.rs`). The R-02 lifetime contract (the handler's `JoinHandle` tracks the -data-plane lifetime) was already alktty's shape — the open handler -awaits the driver inline in its spawned task — so the amendment adds +data-plane lifetime) was already alktty's shape (the open handler +awaits the driver inline in its spawned task), so the amendment adds no lifetime changes. ### 3. The direct-ALPN path is unchanged @@ -194,21 +195,21 @@ no lifetime changes. `TtyAdapter::handle` / `drive_session` keep the wire-frame negotiation (ADR-001) and the full in-band error-frame vocabulary (`unknown_backend`, `malformed_negotiation`, `allocate_failed`, -`forbidden`). Two transports, two contracts — the direct path has no +`forbidden`). Two transports, two contracts: the direct path has no open op to fail, and its `0x00`-peek disambiguation is the ADR-001 wire-stable contract. The `enforce_scope` split from ADR-009 is -unchanged (`true` direct, `false` channels — the registry's +unchanged (`true` direct, `false` channels; the registry's `AccessControl` is the channels scope gate). ### 4. Spec enrichment (review 006 E-02 + ADR-016) `tty_open_spec()` gains: -- `description` — disclosed by `services/list` (the open op describes +- `description`, disclosed by `services/list` (the open op describes itself; the produced resource set stays OQ-40's deferred `channel/resources/subscribe` shape). - An `ErrorDefinition` for `channel:open_failed` with the - `details.reason` enum — so `services/schema` discloses the + `details.reason` enum, so `services/schema` discloses the establishment-failure contract per ADR-016 (ADR-049 §3: "ALPN crates' open-op specs gain matching `ErrorDefinition` entries so `services/schema` discloses the failure contract"). @@ -224,14 +225,14 @@ unchanged (`true` direct, `false` channels — the registry's | malformed negotiation (schema-valid) | establisher | `ChannelsOpen(CallFailed{ channel:open_failed, reason: handler_error })` | | unknown backend | establisher | `ChannelsOpen(CallFailed{ channel:open_failed, reason: unknown_resource })` | | ownership denial | establisher | `ChannelsOpen(CallFailed{ channel:open_failed, reason: handler_error })` | -| **allocation failure** (amended — §2A) | establisher | **`ChannelsOpen(CallFailed{ channel:open_failed, reason: dial_failed })`** | +| **allocation failure** (amended, §2A) | establisher | **`ChannelsOpen(CallFailed{ channel:open_failed, reason: dial_failed })`** | | establishment timeout | wrapper bound | `ChannelsOpen(CallFailed{ channel:open_failed, reason: timeout })` | | ACL denial (scope) | registry gate | `ChannelsOpen(CallFailed{ FORBIDDEN })` | | channel cap | wrapper `check_open` | `ChannelsOpen(CallFailed{ channel:too_many_channels })` | `TtySessionError::ChannelsOpen` now carries alkcall's typed `ChannelOpenError` verbatim (`#[from]`) instead of a flattened -`String` — the N-1 fix applied at alktty's layer, so consumers branch +`String`, the N-1 fix applied at alktty's layer, so consumers branch on `establishment_reason()` without unwrapping strings. ## Consequences @@ -241,11 +242,11 @@ on `establishment_reason()` without unwrapping strings. - The phantom-channel workaround is retired: a semantically invalid open no longer allocates → succeeds → in-band-fails. Ledger, policy count, and manager state balance on every rejection. -- Retry policy / UX can branch: `unknown_resource` (bad config — don't +- Retry policy / UX can branch: `unknown_resource` (bad config, don't retry), `timeout` (maybe retry), `dial_failed` (capacity/target - refused — maybe retry; amended §2A, was the in-band + refused, maybe retry; amended §2A, was the in-band `allocate_failed`). -- The consumer's `open_via_channels` failure is a typed call error — +- The consumer's `open_via_channels` failure is a typed call error; no peeking at the data stream for anything but the (now unreachable-from-registered-producers) error-frame formality. @@ -257,7 +258,7 @@ on `establishment_reason()` without unwrapping strings. direct path's failure surface is unchanged. - The `channel:open_failed` reason codes the establisher emits join the wire-stable error set (ADR-049's one-way door). -- **Amendment (0.3.0):** allocation failure changed shape again — +- **Amendment (0.3.0):** allocation failure changed shape again: `NegotiationRejected{ "allocate_failed" }` (in-band) → `ChannelsOpen(CallFailed{ channel:open_failed, reason: dial_failed })`. The direct path's `allocate_failed` frame is @@ -266,34 +267,34 @@ on `establishment_reason()` without unwrapping strings. ## Door type **One-way (wire-visible).** The establisher mapping fixes -`details.reason` values for TTY's channels path — consumers branch on +`details.reason` values for TTY's channels path: consumers branch on `unknown_resource` / `handler_error` / `timeout`, so re-mapping a failure class between reasons is a peer-breaking change. The establisher's internal validation order is a two-way door (implementation detail behind the fixed surface). ADR-009's -"two-way for now" negotiation-location decision is unaffected — this -ADR moves failure *reporting*, not the negotiation *location*. +"two-way for now" negotiation-location decision is unaffected (this +ADR moves failure *reporting*, not the negotiation *location*). ## References - alkcall ADR-049 (the establisher, `channel:open_failed`, the typed - client error — the upstream capability this ADR adopts) + client error; the upstream capability this ADR adopts) - alkcall review 006 (E-01 the establishment gap; N-1 the typed client error; E-02 the discovery enrichment this ADR's spec enrichment adopts) - ADR-009 (the open op's `input` is the negotiation; the R4 - amendment's in-band error frames — shrunk to `allocate_failed` by + amendment's in-band error frames, shrunk to `allocate_failed` by this ADR) -- ADR-005 (kill-on-`Drop` — why a *second* establisher-side allocate - would be unsound; avoided by construction in the §2A amendment — +- ADR-005 (kill-on-`Drop`: why a *second* establisher-side allocate + would be unsound; avoided by construction in the §2A amendment, one allocate per open, consumed by the handler) -- ADR-001 (the §5 `0x00` disambiguation — retained for the direct +- ADR-001 (the §5 `0x00` disambiguation, retained for the direct path; the channels-path peek is now a formality) - ADR-002 (backend selection/allocation ownership) - alkcall ADR-049 amendment 2 / review 007 R-01 (the `Establishment` - plan payload — the upstream capability this ADR's §2A adopts) -- `src/channels.rs` — `make_tty_establisher`, `AllocatedHandle`, + plan payload; the upstream capability this ADR's §2A adopts) +- `src/channels.rs`: `make_tty_establisher`, `AllocatedHandle`, `make_tty_open_handler`, `tty_open_spec` -- `src/session.rs` — `TtySessionError::ChannelsOpen`, `open_via_channels` -- `src/testing.rs` — the pinned `allocate_failure_fails_open_as_dial_failed` +- `src/session.rs`: `TtySessionError::ChannelsOpen`, `open_via_channels` +- `src/testing.rs`: the pinned `allocate_failure_fails_open_as_dial_failed` test (superseded `allocate_failure_still_arrives_in_band_on_channels_path`) \ No newline at end of file diff --git a/docs/architecture/overview.md b/docs/architecture/overview.md index c135afa..4257c25 100644 --- a/docs/architecture/overview.md +++ b/docs/architecture/overview.md @@ -6,7 +6,7 @@ status: draft (ported from alknet 2026-08-17; alknet-tty → alktty, last_updated: 2026-09-05 --- -# alktty — Overview +# alktty: Overview The terminal session protocol crate: a `ProtocolHandler` on `alk/tty` that pumps a bidirectional byte stream (stdin/stdout/stderr) with a JSON @@ -24,18 +24,18 @@ ALPN on the shared endpoint and implements the `ProtocolHandler` trait (alknet ADR-002, alknet ADR-007). The `TtyAdapter` receives a `Connection`, accepts one bidi stream per terminal session, reads a single JSON negotiation frame, switches to a raw chunk format, and -pumps bytes bidirectionally for the life of the session — +pumps bytes bidirectionally for the life of the session, backend-agnostic. The guiding insight that shapes the crate: -> A terminal session is not an SSH concern, or a Docker concern — it is +> A terminal session is not an SSH concern, or a Docker concern; it is > a terminal concern. SSH and Docker are just two backends that can > allocate a PTY. -The alknet-docker POC proved that the hard part of interactive attach — -bidirectional byte pumping over a framed stream with a 1-byte -stream-type multiplexer — is the same problem regardless of whether the +The alknet-docker POC proved that the hard part of interactive attach +(bidirectional byte pumping over a framed stream with a 1-byte +stream-type multiplexer) is the same problem regardless of whether the backend is `bollard::attach_container()` or russh's `pty_request`. The POC's raw chunk format is the seed of alktty's wire format. alktty extracts that pattern into its own crate and ALPN; the backends (Docker, @@ -53,7 +53,7 @@ consumers. A hub that runs agent workspaces in containers wires sessions over `alk/tty`. A coordinator that runs `cargo test` remotely wires `LocalTtyBackend` (pipe mode) and gets the runner pattern (a process whose stdin/stdout/stderr/exit-code stream over a framed bidi -connection) — the same shape as GitHub/Gitea Actions runners, just over +connection), the same shape as GitHub/Gitea Actions runners, just over alk's transport instead of HTTP polling. A browser terminal (xterm.js over WebTransport, when WebTransport revives) connects to `alk/tty` directly and gets raw bytes without implementing SSH or the call @@ -77,9 +77,9 @@ A `alk/tty` bidi stream has two phases (full detail in length-prefixed JSON frame carrying the terminal parameters, backend selector, command, and environment. The framing is a 4-byte big-endian length prefix + UTF-8 JSON body, self-contained in - alktty (the format coincides with alkcall's framing by convention; - alktty does not depend on alkcall's internal wire types — see - Dependencies below). + alktty (the format coincides with alkcall's framing by convention; + alktty does not depend on alkcall's internal wire types; see + Dependencies below). 2. **Raw carriage.** After the negotiation frame, the stream switches to the chunk format (`[stream_type: u8][length: u32 be][payload]`) for @@ -87,7 +87,7 @@ A `alk/tty` bidi stream has two phases (full detail in 1=stdout (server→client), 2=stderr (server→client), 3=ctrl-in (client→server, JSON control messages: resize, signal, eof), 4=ctrl-out (server→client, JSON control messages: exit). There is no - `call.responded`/`call.completed` — this is not the call protocol; + `call.responded`/`call.completed`; this is not the call protocol; the raw-carriage byte pump is its own wire format after the single JSON negotiation frame. @@ -101,12 +101,12 @@ control-channel split) is in ## Dependencies ``` -alktty (default — wasm-clean) +alktty (default; wasm-clean) ├── alkcall::core (ProtocolHandler, Connection, AuthContext, Identity, AccessControl, -│ OwnershipProvider — alknet ADR-050 for terminal sessions as resources) -└── (no backend deps — portable_pty, bollard, russh are in the backend crates / `local` feature) +│ OwnershipProvider; alknet ADR-050 for terminal sessions as resources) +└── (no backend deps; portable_pty, bollard, russh are in the backend crates / `local` feature) -alktty (local feature) — non-wasm by design +alktty (local feature) is non-wasm by design └── adds: portable-pty, tokio-util, tokio/process, tokio/rt-multi-thread ``` @@ -115,7 +115,7 @@ only. The negotiation framing is a self-contained ~30-line module in alktty (4-byte BE length prefix + UTF-8 JSON body on tokio's `AsyncRead`/`AsyncWrite`). The heavy backend dependencies (`portable_pty`, `bollard`, `russh`) live in the backend crates, not -here. alktty does **not** depend on alkcall's internal wire types — see +here. alktty does **not** depend on alkcall's internal wire types; see [ADR-006](decisions/006-negotiation-framing-self-contained.md). ### Why no alkcall-internal-wire-types dependency @@ -123,9 +123,9 @@ here. alktty does **not** depend on alkcall's internal wire types — see An earlier draft had alktty depending on alkcall for the `FrameFramedReader`/`FrameFramedWriter` "framing utility." A pre-implementation check found this was unsound: `FrameFramedReader`'s -`read_frame()` is hardcoded to deserialize `EventEnvelope` — the +`read_frame()` is hardcoded to deserialize `EventEnvelope` (the length-prefix read and the type-specific deserialize are one entangled -call, not a separable utility. alktty's negotiation payload is a +call, not a separable utility). alktty's negotiation payload is a `NegotiateRequest`, not an `EventEnvelope`, so the claimed reuse did not exist in a usable form. alktty implements its own framing (the format coincides with alkcall's by convention; the implementations are @@ -135,7 +135,7 @@ domain abstraction worth a cross-crate dependency. See full decision and alknet ADR-003 Amendment 2 for the dependency-edge clarification. -alktty stays lean — it has no `portable_pty` (default), no `bollard`, no +alktty stays lean: it has no `portable_pty` (default), no `bollard`, no `russh`, no alkcall-internal-wire-types. The `TtyBackend` implementations are opaque `Arc` from the adapter's perspective: constructed by the assembly layer at startup, stored in the adapter's @@ -145,18 +145,18 @@ backend map, dispatched by the `backend` field of the negotiation frame. | ALPN | Handler | Transport | Browser? | |------|---------|-----------|----------| -| `alk/tty` | `TtyAdapter` | QUIC bidi stream (direct) or `alk/channels` (multiplexed) | Yes (when WebTransport revives — alknet ADR-040 parked) | +| `alk/tty` | `TtyAdapter` | QUIC bidi stream (direct) or `alk/channels` (multiplexed) | Yes (when WebTransport revives; alknet ADR-040 parked) | `alk/tty` is a custom ALPN per the alknet ADR-006 `alk/` convention (renamed from `alknet/` in alkcall 0.1.1). The `TtyAdapter` registers for it; the endpoint's `HandlerRegistry` maps `alk/tty` to the adapter instance. One ALPN per connection (alknet ADR-006); within a connection, multiple bidi streams carry independent sessions (one -session per stream — see [tty-adapter.md](tty-adapter.md)). +session per stream; see [tty-adapter.md](tty-adapter.md)). The browser terminal case: a browser (xterm.js) connects via WebTransport to `alk/tty` and gets raw bytes. The browser doesn't need to implement -SSH or the call protocol for the terminal use case — only if it wants +SSH or the call protocol for the terminal use case, only if it wants SSH-specific features (port forwarding, SFTP). This is a cleaner browser story than "run a WASM SSH client." WebTransport is deferred per alknet ADR-044; when it revives, the `alk/tty` ALPN is reachable over @@ -178,26 +178,26 @@ The decomposition principle: the trait lives where the types live live. ``` -alktty (default — lean, no portable_pty, no bollard, no russh) -├── TtyBackend trait (the contract — ADR-002) +alktty (default; lean, no portable_pty, no bollard, no russh) +├── TtyBackend trait (the contract, ADR-002) ├── TtyHandle, TtyControl (the handle shape backends produce) ├── TtyParams, TerminalParams (the allocation request) -├── TtyAdapter (ProtocolHandler on alk/tty — session lifecycle) -├── wire format (ChunkReader/ChunkWriter, ControlMessage — ADR-001) +├── TtyAdapter (ProtocolHandler on alk/tty; session lifecycle) +├── wire format (ChunkReader/ChunkWriter, ControlMessage; ADR-001) └── negotiation framing (self-contained ~30-line module; format coincides - with alkcall's by convention — ADR-006) + with alkcall's by convention, ADR-006) -alktty (local feature module — ADR-003; folded in from the old - alknet-tty-local sibling crate) -├── LocalTtyBackend (impl TtyBackend — portable_pty for PTY, std::process for pipe) -├── portable_pty dependency (PTY allocation — the heavy dep, here not in alktty default) -└── libc (signal forwarding — REQ-TTY-02, Unix only) +alktty (local feature module, ADR-003; folded in from the old + alknet-tty-local sibling crate) +├── LocalTtyBackend (impl TtyBackend: portable_pty for PTY, std::process for pipe) +├── portable_pty dependency (PTY allocation, the heavy dep, here not in alktty default) +└── libc (signal forwarding, REQ-TTY-02, Unix only) -alknet-docker (or alktty-docker adapter — future crate, out of scope here) -└── DockerTtyBackend (impl TtyBackend — wraps bollard::attach_container / exec with tty:true) +alknet-docker (or alktty-docker adapter, future crate, out of scope here) +└── DockerTtyBackend (impl TtyBackend; wraps bollard::attach_container / exec with tty:true) -alknet-ssh (future crate — out of scope here) -└── SshTtyBackend (impl TtyBackend — wraps russh pty_request + shell_request/exec_request) +alknet-ssh (future crate, out of scope here) +└── SshTtyBackend (impl TtyBackend; wraps russh pty_request + shell_request/exec_request) ``` alktty never sees `portable_pty` (default), `bollard`, or `russh`. The @@ -207,7 +207,7 @@ transport dependencies. The local backend's module placement (folded into alktty behind a `local` feature, resolving the alknet cyclic-dep workaround) is decided in [ADR-003](decisions/003-local-backend-placement.md); the docker and SSH backends are future crates (out of scope for this -spec set — see [tty-backend.md](tty-backend.md) §"Backend implementations" +spec set; see [tty-backend.md](tty-backend.md) §"Backend implementations" for where they live). ## Feature Gates @@ -219,54 +219,54 @@ default = [] local = ["dep:portable-pty", "dep:tokio-util", "tokio/process", "tokio/rt-multi-thread"] ``` -- `default` — the wire format, `TtyAdapter`, and the `TtyBackend` trait. +- `default`: the wire format, `TtyAdapter`, and the `TtyBackend` trait. No backend implementations; the assembly layer registers backends from their own crates. A docker-only or ssh-only deployment uses the default features and depends on `alknet-docker` / `alknet-ssh` (or their own backend crate) directly. **The default crate compiles to - `wasm32-unknown-unknown`** — this is what makes the downstream + `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). -- `local` — enables `alktty::local::LocalTtyBackend`, pulling in +- `local` enables `alktty::local::LocalTtyBackend`, pulling in `portable_pty` (PTY mode) and `tokio::process` (pipe mode). A consumer that wants the local backend (terminal or runner) enables this - feature. Inherently non-wasm — `portable-pty` + `tokio::process` need - a real OS. + feature. Inherently non-wasm (`portable-pty` + `tokio::process` need + a real OS). The local backend's `portable_pty` dependency is the heavy dep that -motivates the feature gate — a docker-only deployment should not pull in +motivates the feature gate: a docker-only deployment should not pull in PTY allocation code. See [ADR-003](decisions/003-local-backend-placement.md). ## Architecture (component pointers) -- **[tty-wire.md](tty-wire.md)** — the wire format: the negotiation +- **[tty-wire.md](tty-wire.md)**: the wire format; the negotiation frame (JSON carriage, self-contained length-prefixed framing), the raw chunk codec (`[stream_type: u8][length: u32 be][payload]`), the five stream types, the control channel (split into `STREAM_CTRL_IN` / `STREAM_CTRL_OUT` halves, JSON control messages), sentinels, and the fixed-channel-set rationale. -- **[tty-bast.md](tty-bast.md)** — the BAST (Binary Abstract Syntax +- **[tty-bast.md](tty-bast.md)**: the BAST (Binary Abstract Syntax Tree) document for the binary framing layer of the `alk/tty` wire format (the 5-byte chunk header and the negotiation frame's 4-byte length prefix); a normative JSON spec conforming to the BAST meta-schema at `https://alk.dev/bast/v1/schema`, validatable by any JSON Schema Draft 2020-12 validator. The JSON payloads (`NegotiateRequest`, `ControlMessage`, `TerminalParams`) are - specified in `tty-wire.md` and the Rust source, not in the BAST — + specified in `tty-wire.md` and the Rust source, not in the BAST; BAST describes binary layouts, not JSON shapes. -- **[tty-backend.md](tty-backend.md)** — the `TtyBackend` trait, +- **[tty-backend.md](tty-backend.md)**: the `TtyBackend` trait, `TtyParams`, `TtyHandle`, `TtyControl`. The inversion point between the wire-format adapter and the backends. Carries REQ-TTY-01 (backends need not be natively async; the bridging pattern is a documented strategy). Notes where the docker/SSH backend crates live (future, out of scope here). -- **[tty-adapter.md](tty-adapter.md)** — the `TtyAdapter` +- **[tty-adapter.md](tty-adapter.md)**: the `TtyAdapter` (`ProtocolHandler` on `alk/tty`): the session lifecycle, the three-pump bidirectional driver (stdout→client, client→backend, exit→exit-chunk), negotiation errors, the exit-chunk ordering (ADR-004), access control (terminal sessions as runtime-spawned resources per alknet ADR-050), session-cancel cleanup (ADR-005). -- **[tty-local.md](tty-local.md)** — the `local` feature module: +- **[tty-local.md](tty-local.md)**: the `local` feature module: `LocalTtyBackend` via `portable_pty` (PTY mode) and `tokio::process::Command` (pipe/runner mode). Carries REQ-TTY-02 (signal forwarding to the foreground process group). The @@ -291,27 +291,27 @@ PTY allocation code. See [ADR-003](decisions/003-local-backend-placement.md). - **OQ-43** (resolved): `TtyControl` as a `Clone` trait object. - **OQ-44** (deferred(scope)): Terminal modes (TTY modes). -- **OQ-45** (resolved): Flow control for high-throughput stdout — no +- **OQ-45** (resolved): Flow control for high-throughput stdout (no application-level windowing; QUIC per-stream flow control is the - backpressure mechanism. + backpressure mechanism). - **OQ-46** (deferred(scope)): Runner API surface. - **OQ-47** (resolved): Stdin closure canonical signal. ## References -- alknet ADR-001 — ALPN-based dispatch -- alknet ADR-002 — ProtocolHandler trait -- alknet ADR-003 + Amendments 1 & 2 — crate decomposition +- alknet ADR-001: ALPN-based dispatch +- alknet ADR-002: ProtocolHandler trait +- alknet ADR-003 + Amendments 1 & 2: crate decomposition (no-handler-depends-on-another-handler; alktty depends on alkcall only; backends depend on alktty for the trait) -- alknet ADR-006 — `alk/` ALPN convention; one ALPN per +- alknet ADR-006: `alk/` ALPN convention; one ALPN per connection; new ALPN for incompatible versions -- alknet ADR-007 — `Connection`, `accept_bi`, the handler-receives- +- alknet ADR-007: `Connection`, `accept_bi`, the handler-receives- Connection pattern -- alknet ADR-050 — dynamic resource ownership (terminal sessions as +- alknet ADR-050: dynamic resource ownership (terminal sessions as runtime-spawned resources; the adapter's access-control shape declares against this model) -- `/workspace/@alkdev/alknet/docs/architecture/decisions/` — the alknet +- `/workspace/@alkdev/alknet/docs/architecture/decisions/`: the alknet originals of the ADRs ported here as 001..008, plus the alknet ADRs referenced by alknet number above (which are not ported into alktty's ADR range because they are not tty-specific) \ No newline at end of file diff --git a/docs/architecture/tty-adapter.md b/docs/architecture/tty-adapter.md index 4f5a07b..747aece 100644 --- a/docs/architecture/tty-adapter.md +++ b/docs/architecture/tty-adapter.md @@ -5,7 +5,7 @@ status: draft (ported from alknet 2026-08-17; alknet/tty → alk/tty, last_updated: 2026-09-05 --- -# alktty — TtyAdapter and Session Lifecycle +# alktty: TtyAdapter and Session Lifecycle The `TtyAdapter` is the `ProtocolHandler` for `alk/tty`: it receives a `Connection`, accepts bidi streams, reads the negotiation frame, selects @@ -22,7 +22,7 @@ alknet ADR-007 to receive a `Connection`) on ALPN `alk/tty` (alknet ADR-006). It holds a `HashMap>` populated at construction (ADR-002 §5). Its `handle()` method accepts the connection and loops `connection.accept_bi()`, dispatching each bidi stream to a -session. One `alk/tty` connection hosts multiple terminal sessions — one +session. One `alk/tty` connection hosts multiple terminal sessions, one session per bidi stream (DP-6, decided in the alknet research; matches the call protocol's one-operation-per-stream model). @@ -58,10 +58,10 @@ impl ProtocolHandler for TtyAdapter { } ``` -The `drive_session` function is the per-stream session driver — the +The `drive_session` function is the per-stream session driver: the counterpart to the POC's `session::drive_session`, generalized from the local PTY backend to the `TtyBackend` trait. It is also the function the -channels path reuses — per [ADR-008](decisions/008-channels-pure-channel-multiplexing.md), +channels path reuses; per [ADR-008](decisions/008-channels-pure-channel-multiplexing.md), TTY always uses its 5-byte format, so the same driver runs in both direct `alk/tty` and channels `alk/channels` modes; only the `BiStream` source differs. The two paths differ in where the @@ -69,7 +69,7 @@ both direct `alk/tty` and channels `alk/channels` modes; only the direct path reads the wire-frame negotiation (ADR-001 §"Negotiation Frame") and answers failures with in-band error frames; the channels path parses the open op's registry-validated `input` and runs -`drive_session_pre_negotiated` (ADR-009 — no second negotiation +`drive_session_pre_negotiated` (ADR-009; no second negotiation frame), with the establisher rejecting the semantic-failure classes as `channel:open_failed` before the handler spawns (ADR-010). @@ -77,7 +77,7 @@ as `channel:open_failed` before the handler spawns (ADR-010). The adapter is the place where the wire format (ADR-001), the backend trait (ADR-002), and the exit-chunk ordering (ADR-004) come together. -Keeping these in one place — the adapter — is what makes the invariants +Keeping these in one place (the adapter) is what makes the invariants enforceable: the wire format's "exit chunk is last" invariant is enforced here, not in the backends (which produce handles, not wire bytes); the backend's `exit_code` future is awaited here, not in the backend; the @@ -104,7 +104,7 @@ A `alk/tty` session on one bidi stream proceeds in three phases: allocated, docker exec failed, SSH channel request rejected), the adapter sends a JSON error response and closes the stream. -3. **Raw carriage — the bidirectional pump.** The adapter switches to +3. **Raw carriage: the bidirectional pump.** The adapter switches to the raw chunk format and pumps three concurrent tasks: - **A. stdout → client**: backend stdout (`TtyHandle.stdout`) → stdout @@ -127,7 +127,7 @@ A `alk/tty` session on one bidi stream proceeds in three phases: A drainer task writes chunks to the client in arrival order. After the exit chunk is written (task C resolves and the exit chunk drains), - the adapter closes the write half — the session ends. + the adapter closes the write half; the session ends. This is the POC's `session::drive_session` pattern, generalized: the POC hardcoded the local PTY backend; the adapter dispatches to any @@ -138,24 +138,24 @@ hardcoded the local PTY backend; the adapter dispatches to any The control channel is split into two halves so it is genuinely bidirectional on the wire: -- **`STREAM_CTRL_IN = 3`** — client→server control (`Resize`, `Signal`, +- **`STREAM_CTRL_IN = 3`**: client→server control (`Resize`, `Signal`, `Eof`). -- **`STREAM_CTRL_OUT = 4`** — server→client control (`Exit`). +- **`STREAM_CTRL_OUT = 4`**: server→client control (`Exit`). The adapter enforces the direction: - An `Exit` arriving on `STREAM_CTRL_IN` is a protocol violation - (server→client message on the client→server half) — the adapter + (server→client message on the client→server half); the adapter ignores it (the previous single `STREAM_CONTROL = 3` could not distinguish the two directions, so `Exit` from the client was always ignored; the split makes the rejection explicit). - A `Resize`/`Signal`/`Eof` arriving on `STREAM_CTRL_OUT` is a protocol - violation (client→server message on the server→client half) — the + violation (client→server message on the server→client half), and the adapter ignores it (the server never dispatches control messages it receives on the server→client half). - `STREAM_CTRL_OUT` (stream_type 4) chunks written by the client are a protocol violation (the client should not write on the server→client - half) — the adapter ignores them. + half); the adapter ignores them. The exit chunk (`Exit`) is emitted on `STREAM_CTRL_OUT` (stream_type 4), not on the previous `STREAM_CONTROL = 3`. A client distinguishing @@ -180,29 +180,29 @@ the semantic classes (unknown backend, malformed negotiation, ownership denial) **and allocation** are rejected by the establisher before the open reply and surface as `channel:open_failed` call errors (alkcall ADR-049 / alktty ADR-010, as amended for alkcall 0.6's -`Establishment` plan payload — ADR-010 §2A) — the in-band frames on +`Establishment` plan payload; ADR-010 §2A), so the in-band frames on that path are defense-in-depth arms only (reachable from no-establisher registrations). | Error | When | Shape | |-------|------|------| -| `unknown_backend` | the `backend` string is not in the adapter's backend map — direct path, or the channels path's defense-in-depth arm (the establisher rejects it as `channel:open_failed` / `unknown_resource` first) | `{"error":"unknown_backend","backend":"..."}` | -| `malformed_negotiation` | the negotiation frame failed to parse as JSON or failed `NegotiateRequest` validation — on the direct path the wire frame; on the channels path the open op's `input` (schema-valid values can still fail the typed parse, e.g. `cwd` typed as a number, because the schema is deliberately partial — the establisher rejects it as `channel:open_failed` / `handler_error` first; the handler-side frame is defense-in-depth, ADR-010 §2) | `{"error":"malformed_negotiation","message":"..."}` | -| `allocate_failed` | `backend.allocate()` returned a `TtyError` — the failure surface of the **direct path**. On the channels path the establisher runs `allocate` (the handle crosses to the pump handler via alkcall 0.6's `Establishment` plan payload — ADR-010 §2A) and its failure is a `channel:open_failed` call error (`details.reason == "dial_failed"`) | `{"error":"allocate_failed","message":"..."}` | +| `unknown_backend` | the `backend` string is not in the adapter's backend map; direct path, or the channels path's defense-in-depth arm (the establisher rejects it as `channel:open_failed` / `unknown_resource` first) | `{"error":"unknown_backend","backend":"..."}` | +| `malformed_negotiation` | the negotiation frame failed to parse as JSON or failed `NegotiateRequest` validation. On the direct path the wire frame; on the channels path the open op's `input` (schema-valid values can still fail the typed parse, e.g. `cwd` typed as a number, because the schema is deliberately partial; the establisher rejects it as `channel:open_failed` / `handler_error` first, and the handler-side frame is defense-in-depth per ADR-010 §2) | `{"error":"malformed_negotiation","message":"..."}` | +| `allocate_failed` | `backend.allocate()` returned a `TtyError` (the failure surface of the **direct path**). On the channels path the establisher runs `allocate` (the handle crosses to the pump handler via alkcall 0.6's `Establishment` plan payload; ADR-010 §2A) and its failure is a `channel:open_failed` call error (`details.reason == "dial_failed"`) | `{"error":"allocate_failed","message":"..."}` | After sending the error response, the adapter closes the write half of the bidi stream. The client reads the error frame and treats stream close -as the failure signal. There is no `call.error` — this is not the call +as the failure signal. There is no `call.error`, as this is not the call protocol; the error is a JSON response in the negotiation framing. **Framing disambiguation (success vs error).** Both a successful allocation (raw chunks) and a failed allocation (JSON error frame) begin with bytes the client must read before knowing which framing applies. The disambiguation is by the first byte: a JSON error frame's 4-byte big-endian length prefix always starts with `0x00` (error frames MUST -be under 16 MiB — `MAX_CHUNK_LEN` — so the high byte is zero; this is +be under 16 MiB, the `MAX_CHUNK_LEN` limit, so the high byte is zero; this is a wire-format invariant, not an assumption), while a raw chunk's first -byte is a `stream_type`. The server never sends `0` (stdin — -client→server only) or `3` (`STREAM_CTRL_IN` — client→server only), so +byte is a `stream_type`. The server never sends `0` (stdin, +client→server only) or `3` (`STREAM_CTRL_IN`, client→server only), so the server-sent set is `{1, 2, 4}` (stdout, stderr, `STREAM_CTRL_OUT`); `0x00` is unambiguous. The client distinguishes: read the first byte; if it is `0x00`, interpret the next 4 bytes as a big-endian length prefix @@ -223,7 +223,7 @@ adapter's session driver, not in the backend. The ordering: backend's stdout ends when the process exits and the PTY/pipe buffer drains (the waiter thread's blocking wait returns when the child is reaped, which - happens after the child exits and its stdout drains — ADR-004 + happens after the child exits and its stdout drains; ADR-004 assumption 1). 2. The exit task (task C) awaits `TtyHandle.exit_code`. The exit resolves after the child is reaped (the local backend's waiter thread blocks on @@ -239,12 +239,12 @@ adapter's session driver, not in the backend. The ordering: 4. After both resolve, the exit chunk (`{"type":"exit","code":N}`) is enqueued on the writer channel. 5. The drainer writes the exit chunk to the client. -6. The adapter closes the write half — the session ends. +6. The adapter closes the write half, and the session ends. A client reads stdout/stderr/control chunks until it sees the exit chunk, records the exit code, and treats subsequent stream close as the -session end. The exit chunk is the deterministic completion signal — -the same stopgap property the docker POC validated for logs subscriptions, +session end. The exit chunk is the deterministic completion signal, the +same stopgap property the docker POC validated for logs subscriptions, now for any backend. If `exit_code` resolves with a `TtyError` (the backend couldn't determine @@ -269,11 +269,11 @@ revokes. The adapter's access control declares against the ADR-050 model: process, an SSH backend's channel). The adapter delegates the resource-id extraction to the backend via `TtyBackend::resource_id(¶ms)` (ADR-002), which returns - `None` (no pre-existing resource — the session creates its own) or + `None` (no pre-existing resource; the session creates its own) or `Some((kind, id))` (the caller must own this resource). The adapter checks `OwnershipProvider::owns(identity, kind, id, "tty")` if an ownership provider is wired and the backend returns `Some`. The - adapter does not parse backend-specific JSON itself — the extraction + adapter does not parse backend-specific JSON itself; the extraction is backend-driven, so adding a backend with a new resource shape (e.g., a Kubernetes backend targeting a pod) requires no adapter change. - **`forwarded_for` for proxied sessions.** A hub that proxies a @@ -283,7 +283,7 @@ revokes. The adapter's access control declares against the ADR-050 model: layer. The tty adapter is a `ProtocolHandler`, not an `OperationSpec`-registered -operation — it doesn't go through the call protocol's +operation; it doesn't go through the call protocol's `OperationRegistry::invoke()`. The access-control shape is the adapter's own (scope-gate + backend-driven ownership check at negotiation), declaring against the ADR-050 model but not consuming @@ -293,12 +293,12 @@ extraction is delegated to the backend via `resource_id()` rather than a path expression). See alknet ADR-050 §"Specifics" for the model this declares against. -The concrete choice — the scope name (`tty:open`) and the -check-at-negotiation timing — is a **two-way-door** choice within the +The concrete choice, the scope name (`tty:open`) and the +check-at-negotiation timing, is a **two-way-door** choice within the one-way `TtyAdapter` shape. The scope name can be renamed (a deployment-configured scope, not a wire-format constant). The resource-id extraction is backend-driven via `resource_id()`, so new -backends with new resource shapes require no adapter change — the +backends with new resource shapes require no adapter change; the generalization is already in place (ADR-002). No ADR is warranted for the scope name; it is a reversible implementation choice, not an architectural commitment. @@ -309,7 +309,7 @@ architectural commitment. sessions on that connection are cancelled. Each session's pump tasks are dropped (Rust `Drop`); the `TtyHandle` is dropped; the `exit_code` future is dropped without being driven to completion, - which triggers the backend's cancel-cleanup — the session target is + which triggers the backend's cancel-cleanup: the session target is killed (ADR-005). For the local backend, the `exit_code` future's `Drop` calls `ChildKiller::kill(SIGHUP)`, the child exits, the waiter thread's blocking wait reaps it and exits, and the @@ -320,13 +320,13 @@ architectural commitment. - **Stream reset**: when a bidi stream is reset mid-session, the `ChunkReader` returns a `RawError` (ConnectionClosed or Io). The pump tasks exit; the `TtyHandle` is dropped; the cancel-cleanup runs - (ADR-005). No exit chunk is sent — the stream is gone, the client + (ADR-005). No exit chunk is sent: the stream is gone, the client that reset it already knows. - **Client cancel**: when the client closes the write half (or sends a zero-length stdin chunk / `eof` control chunk), the adapter signals EOF to the backend's stdin and keeps pumping stdout until the backend's - stdout ends and the exit resolves. The session completes normally — - the exit chunk is sent — the client just stopped sending input. This + stdout ends and the exit resolves. The session completes normally + (the exit chunk is sent); the client just stopped sending input. This is NOT a cancel from the adapter's perspective (the session runs to completion); the cancel-cleanup (ADR-005) is not triggered. The cancel-cleanup is triggered only when the *adapter* drops the handle @@ -342,14 +342,14 @@ architectural commitment. - **One session per bidi stream, multiple streams per connection.** A connection hosts multiple sessions (one stream each); the adapter spawns a `drive_session` task per accepted stream. Sessions are - independent — one session's exit doesn't affect another. + independent; one session's exit doesn't affect another. - **Negotiation errors are JSON, not raw chunks.** The error response uses the negotiation framing (length-prefixed JSON), not the raw chunk format. The stream enters raw mode only after a successful allocation. - **The exit chunk is the deterministic completion signal.** A client reading to completion sees the exit chunk and knows the process exited with code N. A client that cancels mid-stream (closes the write half) - won't see the exit chunk — that's correct; a cancelled stream doesn't + won't see the exit chunk. That's correct; a cancelled stream doesn't have a deterministic exit. - **The adapter triggers backend cleanup by dropping the `TtyHandle` (ADR-005).** On connection drop, stream reset, or pump-task panic, the @@ -377,33 +377,33 @@ architectural commitment. ## Open Questions -- **OQ-45** (resolved): Flow control for high-throughput stdout — no application-level windowing; QUIC per-stream flow control is the backpressure mechanism. +- **OQ-45** (resolved): Flow control for high-throughput stdout (no application-level windowing; QUIC per-stream flow control is the backpressure mechanism). ## References -- [ADR-001](decisions/001-wire-format-and-two-carriage.md) — the wire +- [ADR-001](decisions/001-wire-format-and-two-carriage.md): the wire format the adapter pumps -- [ADR-002](decisions/002-ttybackend-trait-and-ttyhandle.md) — the +- [ADR-002](decisions/002-ttybackend-trait-and-ttyhandle.md): the backend trait the adapter dispatches to -- [ADR-004](decisions/004-exit-code-on-control-chunk.md) — the +- [ADR-004](decisions/004-exit-code-on-control-chunk.md): the exit-chunk ordering the adapter enforces -- [ADR-005](decisions/005-backend-cleanup-on-session-cancel.md) — the +- [ADR-005](decisions/005-backend-cleanup-on-session-cancel.md): the cancel-cleanup contract the adapter triggers by dropping the `TtyHandle` on session cancel -- [ADR-008](decisions/008-channels-pure-channel-multiplexing.md) — why +- [ADR-008](decisions/008-channels-pure-channel-multiplexing.md): why the same session driver runs in channels mode -- [ADR-009](decisions/009-channels-open-op-is-the-negotiation.md) — the +- [ADR-009](decisions/009-channels-open-op-is-the-negotiation.md): the channels path carries no second negotiation frame (the open op's registry-validated `input` is the negotiation) -- [ADR-010](decisions/010-channels-establisher-migration.md) — the +- [ADR-010](decisions/010-channels-establisher-migration.md): the channels path's semantic failures are establisher call errors (`channel:open_failed`); since alkcall 0.6 the establisher also allocates (the `TtyHandle` crosses via the plan payload), so no failure class arrives in-band from a registered producer -- alknet ADR-050 — the ownership model the adapter's access control +- alknet ADR-050: the ownership model the adapter's access control declares against -- alknet ADR-007 — `Connection`, `accept_bi`, the handler-receives- +- alknet ADR-007: `Connection`, `accept_bi`, the handler-receives- Connection pattern -- `src/adapter.rs` — the Rust source this spec documents -- [tty-wire.md](tty-wire.md) — the wire format details -- [tty-backend.md](tty-backend.md) — the backend trait details \ No newline at end of file +- `src/adapter.rs`: the Rust source this spec documents +- [tty-wire.md](tty-wire.md): the wire format details +- [tty-backend.md](tty-backend.md): the backend trait details \ No newline at end of file diff --git a/docs/architecture/tty-backend.md b/docs/architecture/tty-backend.md index 9a30b00..5128889 100644 --- a/docs/architecture/tty-backend.md +++ b/docs/architecture/tty-backend.md @@ -5,7 +5,7 @@ status: draft (ported from alknet 2026-08-17; alknet-tty → alktty, last_updated: 2026-08-17 --- -# alktty — TtyBackend Trait and TtyHandle +# alktty: TtyBackend Trait and TtyHandle The `TtyBackend` trait is the inversion point that keeps alktty decoupled from its backends. alktty defines the trait, the `TtyParams` @@ -24,30 +24,30 @@ terminal/process session. The adapter holds a adapter reads the negotiation frame, selects the backend by the `backend` field, calls `allocate()`, and pumps the resulting `TtyHandle`'s fields bidirectionally using the chunk format -(ADR-001). The backend does not write to the wire — it produces handles; -the adapter pumps. +(ADR-001). The backend does not write to the wire; it produces handles, +and the adapter pumps. Three implementations are contemplated, each in its own crate (the no-handler-depends-on-another-handler rule from alknet ADR-003 is -preserved — backends depend on alktty for the trait, alktty doesn't +preserved: backends depend on alktty for the trait, alktty doesn't depend on them): - **`LocalTtyBackend`** (in alktty's `local` feature module, - [ADR-003](decisions/003-local-backend-placement.md)) — wraps + [ADR-003](decisions/003-local-backend-placement.md)), which wraps `portable_pty` for the PTY case and `std::process::Command` with `Stdio::piped()` for the pipe/runner case. See [tty-local.md](tty-local.md). -- **`DockerTtyBackend`** (in `alknet-docker` or a sibling adapter crate - — future, out of scope here) — wraps `bollard::attach_container()` for +- **`DockerTtyBackend`** (in `alknet-docker` or a sibling adapter crate, + future and out of scope here) wraps `bollard::attach_container()` for interactive attach or `bollard::exec::start_exec` with `tty: true` for exec-with-PTY. `control.resize()` calls `bollard::exec::resize_exec` or `bollard::container::resize_container`. stdout/stderr are merged when `tty: true` (bollard's `LogOutput` on a TTY exec returns `StdOut` only), so `TtyHandle.stderr` is `None` for the PTY case. -- **`SshTtyBackend`** (in `alknet-ssh` — future, out of scope here) — +- **`SshTtyBackend`** (in `alknet-ssh`, future and out of scope here) wraps russh's `pty_request` + `shell_request` (or `exec_request` with a PTY) on a session channel. `channel.into_stream()` gives - `(AsyncRead, AsyncWrite)` — the stream *is* the PTY; russh handles + `(AsyncRead, AsyncWrite)`; the stream *is* the PTY, and russh handles kernel PTY allocation on the server side. `control.resize()` sends a `window_change` channel request; `control.signal()` sends a `signal` channel request. stdout and stderr are merged (PTY property), so @@ -60,9 +60,9 @@ without re-spec'ing the seam. ## Why The guiding insight: **a terminal session is not an SSH concern, or a -Docker concern — it is a terminal concern. SSH and Docker are just two +Docker concern; it is a terminal concern. SSH and Docker are just two backends that can allocate a PTY.** The `TtyBackend` trait is what makes -that insight load-bearing — alktty owns the wire format and session +that insight load-bearing: alktty owns the wire format and session lifecycle; the backends own PTY allocation. The full rationale (the inversion point, why the trait is the seam) is in [ADR-002](decisions/002-ttybackend-trait-and-ttyhandle.md) §Context. @@ -88,7 +88,7 @@ pub trait TtyBackend: Send + Sync { /// The pre-existing resource this session targets, for ownership /// checks (alknet ADR-050). `None` = no pre-existing resource (the session - /// creates its own — local process, SSH channel). `Some((kind, id))` + /// creates its own; local process, SSH channel). `Some((kind, id))` /// = the session targets an existing resource the caller must own /// (e.g., DockerTtyBackend returns `Some(("container", id))`). The /// adapter calls this at negotiation to gate access; the backend @@ -106,7 +106,7 @@ that allocates a session*; the wire-format pump is backend-agnostic. The error type for `allocate()` and `exit_code`. `#[non_exhaustive]` so new variants are additive (two-way-door extension within the one-way -trait shape — ADR-002). +trait shape; see ADR-002). ```rust #[non_exhaustive] @@ -123,24 +123,24 @@ pub enum TtyError { } ``` -- `AllocFailed` — the PTY couldn't be allocated, the docker exec failed +- `AllocFailed`: the PTY couldn't be allocated, the docker exec failed to start, the SSH channel request was rejected. Returned by `allocate()`; on the direct path the adapter sends `{"error":"allocate_failed",...}` and closes (tty-adapter.md §"Negotiation errors"); on the channels path the establisher maps it to `channel:open_failed` / `dial_failed` (ADR-010 §2A). -- `WaitFailed` — the backend couldn't reap the child / determine the +- `WaitFailed`: the backend couldn't reap the child / determine the exit code. Returned by the `exit_code` future; the adapter sends `{"type":"exit","code":-1}` (ADR-004 §4). -- `Io` — an I/O error from a backend's stream/handle. -- `Backend` — backend-specific error not covered by the above (e.g., a +- `Io`: an I/O error from a backend's stream/handle. +- `Backend`: backend-specific error not covered by the above (e.g., a bollard API error, a russh protocol error). -### `TtyParams` — the allocation request +### `TtyParams`: the allocation request ```rust pub struct TtyParams { - /// Terminal parameters. `None` = pipe mode (no PTY — the runner case, + /// Terminal parameters. `None` = pipe mode (no PTY, the runner case, /// ADR-003). `Some` = allocate a PTY with these dimensions. pub terminal: Option, /// Command vector (argv[0] + args). Non-empty. @@ -163,16 +163,16 @@ pub struct TerminalParams { pub rows: u16, pub pixel_width: u16, pub pixel_height: u16, - pub modes: serde_json::Value, // reserved — OQ-44; backends MUST ignore content in v1 + pub modes: serde_json::Value, // reserved (OQ-44); backends MUST ignore content in v1 } ``` -`terminal: None` is the pipe/runner case — no PTY, separate -stdout/stderr. `terminal: Some` is the PTY case — stdout/stderr merged +`terminal: None` is the pipe/runner case (no PTY, separate +stdout/stderr). `terminal: Some` is the PTY case (stdout/stderr merged into the single stdout stream (`TtyHandle.stderr` is `None`), real terminal semantics (resize, signal delivery to process group). The per-session choice is the backend's branch in `allocate()`, not a -per-deployment choice — see ADR-003. +per-deployment choice (see ADR-003). ### Backend params are opaque @@ -180,7 +180,7 @@ per-deployment choice — see ADR-003. a typed enum. The adapter passes the negotiation frame's backend-specific fields through verbatim; the backend deserializes its own strongly-typed params struct. alktty has zero knowledge of any -backend's params shape — not docker's `container`, not an SSH host +backend's params shape: not docker's `container`, not an SSH host selector, not anything. Each backend defines its own params struct and deserializes from `params.backend_params` inside `allocate()`: @@ -197,7 +197,7 @@ impl TtyBackend for DockerTtyBackend { // use p.container ... } fn resource_id(&self, params: &TtyParams) -> Option<(&'static str, String)> { - // extract for the ownership check — backend-driven, not adapter-hardcoded + // extract for the ownership check: backend-driven, not adapter-hardcoded let p: DockerBackendParams = serde_json::from_value( serde_json::Value::Object(params.backend_params.clone()) ).ok()?; @@ -209,36 +209,36 @@ impl TtyBackend for DockerTtyBackend { This is a complete inversion: the *trait* is inverted (backends implement, alktty doesn't depend on them) and the *params* are inverted (backends define their own typed shape, alktty doesn't carry -it). A new backend crate requires zero changes to alktty — no +it). A new backend crate requires zero changes to alktty: no enum variant to add, no forward-reference type to place, no dependency edge. See ADR-002 §"Backend params are opaque" for the full rationale and why the typed-enum alternative (with `SshChannelRef`) was rejected. -### `TtyHandle` — what a backend produces +### `TtyHandle`: what a backend produces ```rust pub struct TtyHandle { - /// Stdin writer — bytes the adapter pumps from client stdin chunks. + /// Stdin writer: bytes the adapter pumps from client stdin chunks. /// `tokio::io::AsyncWrite` (the tokio flavor, not the `futures::io` - /// one — they are incompatible traits; the tokio stack is the + /// one; they are incompatible traits, and the tokio stack is the /// adapter's runtime). pub stdin: Box, - /// Stdout stream — bytes the adapter pumps to client stdout chunks. + /// Stdout stream: bytes the adapter pumps to client stdout chunks. /// Ends when the backend's stdout reaches EOF. /// `futures_core::Stream` (re-exported by /// `tokio_stream::StreamExt` for extension methods). pub stdout: Pin + Send>>, - /// Stderr stream — `None` for PTY backends (stdout/stderr merged + /// Stderr stream: `None` for PTY backends (stdout/stderr merged /// into `stdout`). `Some` for pipe backends (separate streams). pub stderr: Option + Send>>>, - /// Exit code — a `Future` the adapter awaits. Resolves when the + /// Exit code: a `Future` the adapter awaits. Resolves when the /// process/container/SSH exec exits. The adapter sends the result /// as the `{"type":"exit","code":N}` control chunk (ADR-004) and /// closes the stream. This is `BoxFuture`, not a method on /// `TtyHandle`, so the adapter can `select` between exit and /// stream-close without coupling to the other fields. (REQ-TTY-01.) pub exit_code: BoxFuture<'static, Result>, - /// Control handle (resize, signal) — `Clone` so the adapter can + /// Control handle (resize, signal), `Clone` so the adapter can /// hand it to the spawned control-chunk dispatcher. `None` only /// when the backend genuinely has no control path. See OQ-43. pub control: Option, @@ -261,7 +261,7 @@ pub trait TtyControl: Send + Sync { } /// The `Clone`-able handle to a backend's control path. The `TtyControl` -/// trait is NOT `Clone` (`Clone` is not object-safe — `fn clone(&self) -> +/// trait is NOT `Clone` (`Clone` is not object-safe: `fn clone(&self) -> /// Self` returns `Self`, which forbids `dyn` dispatch); the `Clone`-ability /// lives on this concrete newtype, which holds the trait object behind an /// `Arc`. The adapter clones the `Arc` to hand a handle to the spawned @@ -278,7 +278,7 @@ impl TtyControlHandle { The trait is kept object-safe by NOT putting `Clone` on it; the `Clone` newtype (`TtyControlHandle`) holds the trait object behind an `Arc`. The -POC used a concrete `PtyControl` struct (inherently `Clone` — it held +POC used a concrete `PtyControl` struct (inherently `Clone`, since it held `Arc>` fields); this newtype generalizes the POC's shape so a backend produces its own control type via `TtyControlHandle::new(Arc::new(MyControl))` without the adapter knowing the concrete shape. See OQ-43 for the @@ -288,7 +288,7 @@ itself. ### REQ-TTY-01: backends are not required to be natively async `portable_pty`'s API is blocking `std::io::{Read, Write}` and a blocking -`Child::wait()` — there is no async variant. The local-PTY POC bridges +`Child::wait()`; there is no async variant. The local-PTY POC bridges this with three dedicated std threads (reader, writer, waiter) feeding tokio mpsc/oneshot channels; the async-facing `LocalPty` then exposes `mpsc::Receiver` for stdout, `mpsc::Sender` for stdin, @@ -298,8 +298,8 @@ and `oneshot::Receiver` for exit. This is the same pattern wezterm The trait's adapter-facing types (`AsyncWrite`, `Stream`, `BoxFuture`, `TtyControl`) are the **adapter's contract**. A backend may expose blocking handles internally and bridge them to these async-facing -types. The bridging pattern — blocking `std::io` on dedicated std threads -or `tokio::task::spawn_blocking`, feeding tokio mpsc/oneshot channels — +types. The bridging pattern (blocking `std::io` on dedicated std threads +or `tokio::task::spawn_blocking`, feeding tokio mpsc/oneshot channels) is a **documented, supported implementation strategy**, not a workaround. This resolves the first half of OQ-TTY-01 (the research's open question @@ -339,8 +339,8 @@ layer chooses what's available. The SSH backend crate is future work; this spec commits the trait shape it implements so it can be built against it without re-spec'ing the seam. The `DockerTtyBackend` is future work in -`alknet-docker` — the natural extension of the alknet-docker POC's -`drive_attach_raw` — with the trait, it becomes `impl TtyBackend for +`alknet-docker`, the natural extension of the alknet-docker POC's +`drive_attach_raw`; with the trait, it becomes `impl TtyBackend for DockerTtyBackend`. The `SshTtyBackend` dissolves the alknet-ssh research's PTY hedge (DP-5): alknet-ssh's session channel still does `exec` (structured, JSON carriage, exit code on completion) but @@ -367,10 +367,10 @@ inside alknet-ssh. produce handles; the adapter pumps. A backend that wrote to the wire directly would break the wire-format invariants (the exit-chunk ordering, ADR-004). The backend's `exit_code` future resolves and the - adapter sends the exit chunk — the backend does not serialize + adapter sends the exit chunk; the backend does not serialize `ControlMessage::Exit`. - **PTY backends merge stdout/stderr.** `TtyHandle.stderr` is `None` for - the PTY case (kernel PTY property — one output stream from the slave). + the PTY case (kernel PTY property: one output stream from the slave). The adapter pumps only stdout chunks (stream_type 1). Pipe backends set `stderr: Some` and the adapter pumps both stdout (stream_type 1) and stderr (stream_type 2) chunks. @@ -383,7 +383,7 @@ inside alknet-ssh. `exit_code` field is a `BoxFuture<'static, Result>` whose `Drop`-on-cancel (i.e., dropped without being driven to completion) MUST kill the child/container/SSH process. This is a - behavioral contract on the `TtyBackend` trait — the adapter triggers + behavioral contract on the `TtyBackend` trait: the adapter triggers it by dropping the `TtyHandle` on session cancel (connection drop, stream reset); the backend wires the kill into the `exit_code` future's `Drop`. A backend that returns a bare `oneshot::Receiver` @@ -410,19 +410,19 @@ inside alknet-ssh. ## References -- [ADR-002](decisions/002-ttybackend-trait-and-ttyhandle.md) — the +- [ADR-002](decisions/002-ttybackend-trait-and-ttyhandle.md): the trait shape decision (this spec is its elaboration) -- [ADR-001](decisions/001-wire-format-and-two-carriage.md) — the wire +- [ADR-001](decisions/001-wire-format-and-two-carriage.md): the wire format the adapter pumps to/from these handles -- [ADR-004](decisions/004-exit-code-on-control-chunk.md) — the +- [ADR-004](decisions/004-exit-code-on-control-chunk.md): the exit-chunk ordering the `exit_code` field feeds into -- [ADR-005](decisions/005-backend-cleanup-on-session-cancel.md) — the +- [ADR-005](decisions/005-backend-cleanup-on-session-cancel.md): the cancel-cleanup contract on this trait (`exit_code` future's `Drop`-on-cancel kills the session target) -- [ADR-003](decisions/003-local-backend-placement.md) — the local +- [ADR-003](decisions/003-local-backend-placement.md): the local backend's placement (folded into alktty behind a `local` feature) -- `src/backend.rs` — the Rust source this spec documents -- [tty-local.md](tty-local.md) — the `LocalTtyBackend` spec (carries +- `src/backend.rs`: the Rust source this spec documents +- [tty-local.md](tty-local.md): the `LocalTtyBackend` spec (carries REQ-TTY-02: signal forwarding to the process group) -- [tty-adapter.md](tty-adapter.md) — the session driver that consumes +- [tty-adapter.md](tty-adapter.md): the session driver that consumes these handles \ No newline at end of file diff --git a/docs/architecture/tty-bast.md b/docs/architecture/tty-bast.md index 6bec1c7..6db6db7 100644 --- a/docs/architecture/tty-bast.md +++ b/docs/architecture/tty-bast.md @@ -3,7 +3,7 @@ status: draft last_updated: 2026-08-17 --- -# alktty — BAST Document for the `alk/tty` Wire Format +# alktty: BAST Document for the `alk/tty` Wire Format This document is the **BAST (Binary Abstract Syntax Tree)** specification for the binary portions of the `alk/tty` wire format. It is a normative @@ -24,7 +24,7 @@ and [ADR-006](decisions/006-negotiation-framing-self-contained.md). The `alk/tty` wire format mixes a small binary framing layer with UTF-8 JSON payloads for the structured parts. Per the BAST format -spec, a BAST document describes **binary data layouts** — it is not a +spec, a BAST document describes **binary data layouts**; it is not a format for validating JSON payloads ([`validate_bytes` vs `validate_json`](https://alk.dev/bast/v1/spec)). This document therefore specifies only the binary framing; the JSON shapes are @@ -36,25 +36,25 @@ The binary portions this BAST covers (both one-way doors per [ADR-001](decisions/001-wire-format-and-two-carriage.md)): 1. **The 5-byte chunk header** (`[stream_type: u8][length: u32 BE] - [payload]`) — the raw chunk codec in `src/wire.rs`. Five stream + [payload]`), the raw chunk codec in `src/wire.rs`. Five stream types: `STREAM_STDIN=0`, `STREAM_STDOUT=1`, `STREAM_STDERR=2`, `STREAM_CTRL_IN=3`, `STREAM_CTRL_OUT=4`. 2. **The negotiation frame length prefix** (4-byte BE `u32` followed - by `length` bytes of UTF-8 JSON `NegotiateRequest` body) — + by `length` bytes of UTF-8 JSON `NegotiateRequest` body), self-contained per [ADR-006](decisions/006-negotiation-framing-self-contained.md). What this BAST deliberately does **not** cover: -- **`NegotiateRequest` JSON shape** — a UTF-8 JSON object, not a +- **`NegotiateRequest` JSON shape**: a UTF-8 JSON object, not a binary layout. Specified in [tty-wire.md](tty-wire.md) §"Phase 1" and implemented by `NegotiateRequest` in `src/negotiation.rs`. -- **`ControlMessage` JSON shape** — the UTF-8 JSON payloads of +- **`ControlMessage` JSON shape**: the UTF-8 JSON payloads of `STREAM_CTRL_IN`/`STREAM_CTRL_OUT` chunks (the `resize`/`signal`/ `eof`/`exit` variants). Specified in [tty-wire.md](tty-wire.md) §"Control Channel" and implemented by `ControlMessage` in `src/control.rs`. -- **`TerminalParams` JSON shape** — the `tty` field of +- **`TerminalParams` JSON shape**: the `tty` field of `NegotiateRequest`. Specified in [tty-wire.md](tty-wire.md) §"Phase 1" and implemented by `TerminalParamsWire` in `src/negotiation.rs`. @@ -67,8 +67,8 @@ struct-encoded binary. The JSON field types (`u16`, `i32`, `String`) are JSON value types produced and consumed by `serde_json`, not binary field widths. Describing them as BAST structs implied a binary encoding that does not exist on the wire and would have misled any -generated validator. The simpler model — BAST for the binary framing, -prose + Rust source for the JSON payloads — matches what the wire +generated validator. The simpler model, BAST for the binary framing +and prose + Rust source for the JSON payloads, matches what the wire format actually is. ## The BAST document @@ -114,7 +114,7 @@ format actually is. ## Annotations -### `ChunkHeader` — binary, exact +### `ChunkHeader`: binary, exact Maps 1:1 to the on-wire bytes. 5 bytes: `stream_type` as `uint8` (1 byte), `length` as `uint32` big-endian (4 bytes). This is the 5-byte @@ -123,12 +123,12 @@ The Rust types are `STREAM_STDIN`/`STREAM_STDOUT`/`STREAM_STDERR`/ `STREAM_CTRL_IN`/`STREAM_CTRL_OUT` and `CHUNK_HEADER_LEN = 5` in `src/wire.rs`. -### `StreamType` — enum, documented deviation: on-wire encoding is `uint8`, not BAST's standard `u32` enum index +### `StreamType`: enum, documented deviation: on-wire encoding is `uint8`, not BAST's standard `u32` enum index The BAST meta-schema specifies that an `enum`'s binary representation is a `u32` index into `values` (0-based). The `alk/tty` wire format encodes `stream_type` as a **`uint8`** (1 byte), not a `u32` (4 -bytes) — the chunk header is 5 bytes, not 8. This is a deliberate +bytes); the chunk header is 5 bytes, not 8. This is a deliberate deviation: the chunk header needs a 1-byte discriminator, and 4 bytes of padding would double the per-chunk overhead. @@ -149,9 +149,10 @@ integer→name table for validation and diagnostics. `stream_type > 4` is a protocol error (`InvalidStreamType`); there is no extension escape hatch in the byte (a 6th channel is a wire-format -change requiring a new ALPN — [ADR-001](decisions/001-wire-format-and-two-carriage.md)). +change requiring a new ALPN; see +[ADR-001](decisions/001-wire-format-and-two-carriage.md)). -### `Chunk` — binary, exact +### `Chunk`: binary, exact A chunk is the 5-byte `ChunkHeader` followed by `length` bytes of payload. The `payload` field is `bytes` with `encoding: @@ -166,16 +167,16 @@ in `src/wire.rs`). The `payload`'s interpretation depends on - `CtrlIn`/`CtrlOut` (3/4): UTF-8 JSON text (a serialized `ControlMessage`). Control chunks are never zero-length (the JSON payload is at least `{}`). The JSON shape is **not** specified by - this BAST — see [tty-wire.md](tty-wire.md) §"Control Channel" and + this BAST; see [tty-wire.md](tty-wire.md) §"Control Channel" and `src/control.rs`. -### `NegotiationFrame` — binary, exact (out-of-band for the chunk codec) +### `NegotiationFrame`: binary, exact (out-of-band for the chunk codec) The negotiation frame is a 4-byte big-endian length prefix + a UTF-8 JSON body. This maps 1:1 to the on-wire bytes: `length` as `uint32` big-endian (4 bytes), `body` as a length-prefixed byte string (`length` bytes of UTF-8 JSON). The `maxLength: 16777216` (16 MiB) -constraint is the same as `MAX_CHUNK_LEN` — error frames MUST be under +constraint is the same as `MAX_CHUNK_LEN`: error frames MUST be under 16 MiB so the 4-byte length prefix's high byte is `0x00`, which is what makes the framing-disambiguation trick sound (first byte `0x00` = error/negotiation frame; first byte `1`/`2`/`4` = raw chunk; see @@ -200,22 +201,22 @@ See [ADR-006](decisions/006-negotiation-framing-self-contained.md). The BAST document above is validatable in two ways: -1. **Structural validation** — run any JSON Schema Draft 2020-12 +1. **Structural validation**: run any JSON Schema Draft 2020-12 validator against the BAST meta-schema at `https://alk.dev/bast/v1/schema`. This checks whether the BAST is well-formed (correct `kind` strings, required fields present, `$ref` targets exist). It does NOT check whether a binary payload - conforms to the layout — that is the BAST-native validator's job + conforms to the layout; that is the BAST-native validator's job (see the alktype BAST format spec, `/workspace/@alkdev/alktype/docs/architecture/bast-format.md`). -2. **Drift detection** — a cheap test that parses the BAST document +2. **Drift detection**: a cheap test that parses the BAST document with `serde_json` and asserts the `StreamType` enum values match `wire.rs`'s `STREAM_STDIN`/`STREAM_STDOUT`/`STREAM_STDERR`/ `STREAM_CTRL_IN`/`STREAM_CTRL_OUT` constants (the index of each name in `values` is the integer constant). This catches the common drift case (a new stream_type added to `wire.rs` but not the BAST, - or vice versa) without requiring alktype as a dep — just - `serde_json`, which alktty already has. See the project setup plan's + or vice versa) without requiring alktype as a dep (just + `serde_json`, which alktty already has). See the project setup plan's "Risk: BAST schema drift" mitigation. ## Design Decisions @@ -228,22 +229,22 @@ The BAST document above is validatable in two ways: ## References -- [ADR-001](decisions/001-wire-format-and-two-carriage.md) — the wire +- [ADR-001](decisions/001-wire-format-and-two-carriage.md), the wire format decision this BAST specifies -- [ADR-006](decisions/006-negotiation-framing-self-contained.md) — the +- [ADR-006](decisions/006-negotiation-framing-self-contained.md): the negotiation framing is self-contained (the `NegotiationFrame` is not reused from alkcall) -- [tty-wire.md](tty-wire.md) — the prose wire format spec this BAST +- [tty-wire.md](tty-wire.md), the prose wire format spec this BAST formalizes (covers both the binary framing and the JSON payloads) -- `src/wire.rs` — the runtime chunk codec (`ChunkReader`/`ChunkWriter`, +- `src/wire.rs`: the runtime chunk codec (`ChunkReader`/`ChunkWriter`, `STREAM_*` constants, `MAX_CHUNK_LEN`) this BAST describes -- `src/negotiation.rs` — the `NegotiationReader`/`NegotiationWriter` +- `src/negotiation.rs`: the `NegotiationReader`/`NegotiationWriter` for the `NegotiationFrame`, and `NegotiateRequest` (JSON shape, out of BAST scope) -- `src/control.rs` — `ControlMessage` (JSON shape, out of BAST scope) -- [alktype BAST format spec](https://alk.dev/bast/v1/schema) — the +- `src/control.rs`: `ControlMessage` (JSON shape, out of BAST scope) +- [alktype BAST format spec](https://alk.dev/bast/v1/schema), the normative format spec for BAST documents (the meta-schema this document conforms to); see also `/workspace/@alkdev/alktype/docs/architecture/bast-format.md` -- Project setup plan, "Risk: BAST schema drift" — the drift-detection +- Project setup plan, "Risk: BAST schema drift": the drift-detection test mitigation (`docs/plans/project-setup.md`) \ No newline at end of file diff --git a/docs/architecture/tty-wire.md b/docs/architecture/tty-wire.md index 0be8b77..a909a56 100644 --- a/docs/architecture/tty-wire.md +++ b/docs/architecture/tty-wire.md @@ -5,11 +5,11 @@ status: draft (ported from alknet 2026-08-17; alknet-tty → alktty, last_updated: 2026-08-17 --- -# alktty — Wire Format +# alktty: Wire Format The wire protocol for `alk/tty`: the negotiation frame (JSON carriage), the raw chunk codec, the control channel (split into `STREAM_CTRL_IN` / -`STREAM_CTRL_OUT` halves — Phase 7), and the sentinels. The +`STREAM_CTRL_OUT` halves in Phase 7), and the sentinels. The two-carriage model is decided in [ADR-001](decisions/001-wire-format-and-two-carriage.md); this document specifies what an implementer builds. @@ -39,8 +39,8 @@ channel set (five stream types, no negotiation) is an impoverishment of SSH's channel multiplexer that is the feature: alktty multiplexes *one* service (a terminal session) with a fixed channel structure, not *arbitrary* services, so the demux is a `match`, not a hash lookup. The -full rationale — why not JSON for everything, why fixed channel set -rather than extensible — is in +full rationale (why not JSON for everything, why a fixed channel set +rather than an extensible one) is in [ADR-001](decisions/001-wire-format-and-two-carriage.md) §Context. ## Architecture @@ -49,11 +49,11 @@ rather than extensible — is in The client opens a bidi stream (or the server accepts one) and writes a single length-prefixed JSON frame. The framing is a 4-byte big-endian -length prefix + UTF-8 JSON body — a self-contained ~30-line module in +length prefix + UTF-8 JSON body, a self-contained ~30-line module in alktty (read 4-byte length, bounds-check, read N bytes; write the inverse) on tokio's `AsyncRead`/`AsyncWrite`. The format coincides with alkcall's `EventEnvelope` framing by convention (both are -length-prefixed JSON), not by code reuse — alktty does not depend on +length-prefixed JSON), not by code reuse; alktty does not depend on alkcall's internal wire types. The negotiation payload is a tty-specific struct (`NegotiateRequest`), not a `call.requested` event. See ADR-001 §6 and [ADR-006](decisions/006-negotiation-framing-self-contained.md). @@ -80,7 +80,7 @@ The payload shape: Fields: -- `carriage` — `"raw"` for terminal sessions (the only carriage in v1). +- `carriage`: `"raw"` for terminal sessions (the only carriage in v1). Selects the post-negotiation byte format. MUST be `"raw"` in v1; any other value (e.g., `"json"`, an unknown carriage, or the field absent) is a `malformed_negotiation` error and the adapter closes the @@ -88,19 +88,19 @@ Fields: structured JSON-only mode for a non-terminal use case) is a v2 addition; in v1 the field is required and must be the literal `"raw"`. -- `backend` — the backend selector string (`"local"`, `"docker"`, +- `backend`: the backend selector string (`"local"`, `"docker"`, `"ssh"`). The adapter dispatches to the registered `TtyBackend` by this key (ADR-002 §5). -- `tty` — terminal parameters. `null` for the pipe/runner case (no PTY — +- `tty`: terminal parameters. `null` for the pipe/runner case (no PTY; [ADR-003](decisions/003-local-backend-placement.md)). `Some` for the PTY case. The `tty` block maps directly to SSH's `pty_request` parameters (term, cols, rows, pixel_width, pixel_height, modes) and to docker's `CreateExecOptions { tty: true }`; a local backend passes it to `portable_pty::PtySystem::openpty`. The - `modes` field is reserved (OQ-44 — default terminal modes suffice for + `modes` field is reserved (OQ-44: default terminal modes suffice for the current scope). -- `cmd` — command vector (argv[0] + args). Non-empty. -- `cwd` — working directory (`null` = inherit/default). -- `env` — environment variables (empty = inherit). +- `cmd`: command vector (argv[0] + args). Non-empty. +- `cwd`: working directory (`null` = inherit/default). +- `env`: environment variables (empty = inherit). The Rust struct the adapter parses the frame into: @@ -130,7 +130,7 @@ pub struct TerminalParamsWire { #[serde(default)] pub pixel_height: u16, #[serde(default)] - pub modes: serde_json::Value, // reserved — OQ-44; backends MUST ignore content in v1 + pub modes: serde_json::Value, // reserved (OQ-44); backends MUST ignore content in v1 } ``` @@ -149,11 +149,11 @@ Backend-specific selector fields ride alongside (e.g., `"container": "abc123"` for docker). The adapter parses the negotiation frame, extracts the `backend` string, and passes the remaining backend-specific fields to the selected backend's `allocate()` as an opaque -`serde_json::Map` (ADR-002) — the adapter does not interpret them; the +`serde_json::Map` (ADR-002). The adapter does not interpret them; the backend deserializes its own strongly-typed params struct. After the negotiation frame, the stream switches to raw chunks. There is -no `call.responded`/`call.completed` — this is not the call protocol. +no `call.responded`/`call.completed`: this is not the call protocol. ### Phase 2: Raw Chunk Format @@ -161,7 +161,7 @@ no `call.responded`/`call.completed` — this is not the call protocol. [stream_type: u8][length: u32 be][payload bytes] ``` -- **`stream_type`** (1 byte) — the channel: +- **`stream_type`** (1 byte): the channel: | stream_type | channel | direction | payload | |-------------|-------------|----------------|---------------------| @@ -172,7 +172,7 @@ no `call.responded`/`call.completed` — this is not the call protocol. | 4 | ctrl-out | server→client | JSON control message (`Exit`) | `stream_type > 4` is a protocol error (`InvalidStreamType`). There is - no extension escape hatch in the byte — a 6th channel is a wire-format + no extension escape hatch in the byte: a 6th channel is a wire-format change requiring a new ALPN (`alk/tty/v2` per alknet ADR-006), not a negotiated addition to this format. See ADR-001 §"Fixed channel set, not extensible." @@ -190,11 +190,11 @@ no `call.responded`/`call.completed` — this is not the call protocol. violation and is ignored; a `Resize` arriving on `STREAM_CTRL_OUT` is likewise a protocol violation and is ignored). -- **`length`** (4 bytes, big-endian) — payload length in bytes. Max +- **`length`** (4 bytes, big-endian): payload length in bytes. Max 16 MiB (`MAX_CHUNK_LEN = 16 * 1024 * 1024`). A chunk larger than 16 MiB is a protocol error (`ChunkTooLarge`). -- **`payload`** (`length` bytes) — the raw bytes (for data channels) or +- **`payload`** (`length` bytes): the raw bytes (for data channels) or UTF-8 JSON (for the control channel). The codec is `ChunkReader`/`ChunkWriter` in `src/wire.rs`: @@ -206,15 +206,15 @@ writes the header and payload. See ADR-001. Zero-length data chunks are sentinels: -- **Zero-length stdin chunk (stream_type 0, length 0)** — EOF from the +- **Zero-length stdin chunk (stream_type 0, length 0)**: EOF from the client. The server closes the backend's stdin (`ChildStdin::drop` / PTY writer close). This is one of two canonical "stdin done" signals; - the other is a `{"type":"eof"}` control chunk — see OQ-47. -- **Zero-length stdout chunk (stream_type 1, length 0)** — "drained" + the other is a `{"type":"eof"}` control chunk (see OQ-47). +- **Zero-length stdout chunk (stream_type 1, length 0)**: "drained" from the server. The backend's stdout stream ended (process exited, container output stream ended, SSH channel closed). This is an implementation sentinel; the deterministic completion signal is the - exit control chunk ([ADR-004](decisions/004-exit-code-on-control-chunk.md)), not this sentinel — but the drained + exit control chunk ([ADR-004](decisions/004-exit-code-on-control-chunk.md)), not this sentinel, but the drained sentinel is emitted for symmetry with the docker POC's pattern. Control chunks are never zero-length (the JSON payload is at least @@ -224,8 +224,8 @@ Control chunks are never zero-length (the JSON payload is at least The control channel is split into two halves (Phase 7): -- **`STREAM_CTRL_IN` (stream_type 3)** — client→server control. -- **`STREAM_CTRL_OUT` (stream_type 4)** — server→client control. +- **`STREAM_CTRL_IN` (stream_type 3)**: client→server control. +- **`STREAM_CTRL_OUT` (stream_type 4)**: server→client control. Each half carries JSON payloads tagged by `type`. The schema is the `ControlMessage` enum (`src/control.rs`): @@ -259,7 +259,7 @@ The adapter enforces the direction: an `Exit` arriving on `STREAM_CTRL_IN` is a protocol violation (the adapter ignores it); a `Resize`/`Signal`/`Eof` arriving on `STREAM_CTRL_OUT` is likewise a protocol violation (the adapter ignores it). The split makes the -control channel genuinely bidirectional on the wire — the previous +control channel genuinely bidirectional on the wire: the previous single `STREAM_CONTROL = 3` was documented as "bidirectional" but the adapter had to ignore `Exit` from the client because the two directions were indistinguishable on the same stream_type. @@ -267,14 +267,14 @@ were indistinguishable on the same stream_type. **Signal names.** `name` is an uppercase string. The supported set (per `signal_from_name` in `src/control.rs`): `HUP`, `INT`, `QUIT`, `TERM`, `KILL`, `USR1`, `USR2`, `TSTP`, `CONT`. Unknown names fall back to the -backend's default kill (see [tty-local.md](tty-local.md) REQ-TTY-02 — +backend's default kill (see [tty-local.md](tty-local.md) REQ-TTY-02: `portable_pty`'s `ChildKiller::kill` sends SIGHUP). **Exit code.** `code` is `i32`: `ExitStatus::code()` on normal exit; on -Unix, the negative signal number on signal-terminated exit — the crate's -own convention, not `ExitStatus::code()`'s, which returns `None` for -signal termination. `-1` is the adapter's "could not determine" sentinel -and (via the `-N` encoding) the SIGHUP value — the SIGHUP/sentinel +Unix, the negative signal number on signal-terminated exit; this is the +crate's own convention, not `ExitStatus::code()`'s, which returns `None` +for signal termination. `-1` is the adapter's "could not determine" sentinel +and (via the `-N` encoding) the SIGHUP value; the SIGHUP/sentinel collision is a documented edge case (ADR-004). The exit chunk is the last control chunk before stream close (ADR-004). @@ -283,7 +283,7 @@ message types are added by extending the tagged enum. Unknown `type` values are **ignored** (not a protocol error) so that a newer client sending a control message an older server doesn't recognize degrades gracefully rather than tearing down the session. This is a two-way-door -extension point within the one-way-door wire format (ADR-001) — adding a +extension point within the one-way-door wire format (ADR-001): adding a control message type is additive; changing the meaning of an existing type is not. @@ -292,12 +292,12 @@ type is not. Two signals both close the client's stdin: 1. **`{"type":"eof"}` control chunk** (stream_type 3, `STREAM_CTRL_IN`) - — explicit, recommended. Tells the server to close the backend's + is explicit and recommended. Tells the server to close the backend's stdin (`ChildStdin::drop` / PTY writer close). The client may still want to receive remaining stdout + the exit code, so the server does - not tear down the session on eof — it just closes stdin and keeps + not tear down the session on eof; it just closes stdin and keeps pumping output. -2. **Zero-length stdin chunk** (stream_type 0, length 0) — the docker +2. **Zero-length stdin chunk** (stream_type 0, length 0), the docker POC's sentinel. Accepted for compatibility with that pattern. The spec recommends `eof` for explicitness (it's a control message, not @@ -306,14 +306,14 @@ a data-length hack), but both are accepted. See OQ-47. ### Connection vs Stream A `Connection` (alknet ADR-007) can open/accept multiple bidi streams. One -`alk/tty` connection hosts multiple terminal sessions — one session per +`alk/tty` connection hosts multiple terminal sessions, one session per bidi stream (DP-6, decided in the alknet research). This matches the call protocol's model (one operation per stream, multiple operations per connection) and is the natural fit for QUIC's stream multiplexing. A coordinator opens one connection to an endpoint and launches multiple sessions (one stream each) for parallel tasks. The `TtyAdapter::handle` accepts the connection and loops `accept_bi`, dispatching each stream to -a session — see [tty-adapter.md](tty-adapter.md). +a session (see [tty-adapter.md](tty-adapter.md)). ## Constraints @@ -326,13 +326,13 @@ a session — see [tty-adapter.md](tty-adapter.md). `STREAM_CTRL_OUT = 4` is server→client (`Exit`). The adapter enforces the direction: an `Exit` on `STREAM_CTRL_IN` is ignored; a `Resize` on `STREAM_CTRL_OUT` is ignored. The split is what makes the control - channel genuinely bidirectional on the wire — the previous single + channel genuinely bidirectional on the wire: the previous single `STREAM_CONTROL = 3` was documented as "bidirectional" but the adapter had to ignore `Exit` from the client because the two directions were indistinguishable on the same stream_type. - **No windowing.** The chunk format has no flow-control window; QUIC's per-stream flow control is the backpressure mechanism (OQ-45 resolved: - the backpressure chain is complete by construction — QUIC flow control + the backpressure chain is complete by construction; QUIC flow control → bounded drainer channel → bounded stdout channel → OS pipe/PTY buffer → process `write()` blocks; no unbounded buffer breaks the chain). The reversal path, if ever needed, is an additive @@ -341,16 +341,16 @@ a session — see [tty-adapter.md](tty-adapter.md). - **No negotiation round-trip.** The client writes the negotiation frame and starts sending chunks; the server reads the frame and starts pumping. There is no "the server acknowledges the negotiation before - the client sends data" step — QUIC's stream reliability handles + the client sends data" step, as QUIC's stream reliability handles in-order delivery, and the negotiation frame is small (fits in the - initial flow-control window — ADR-001 assumption 2). + initial flow-control window; ADR-001 assumption 2). - **Negotiation errors are JSON, not chunks.** If the server cannot allocate the session (unknown backend, PTY allocation failed, the command is invalid), it sends a JSON error response in the same length-prefixed framing as the negotiation frame and closes the stream without entering raw mode. The error response MUST be under 16 MiB (`MAX_CHUNK_LEN`) so the 4-byte big-endian length prefix's high byte - is `0x00` — this is what makes the framing-disambiguation trick + is `0x00`; this is what makes the framing-disambiguation trick (first byte `0x00` = error frame, first byte `1`/`2`/`4` = raw chunk; the server never sends `0` (stdin, client→server) or `3` (`STREAM_CTRL_IN`, client→server), so `0x00` is unambiguous) sound; it @@ -370,34 +370,34 @@ a session — see [tty-adapter.md](tty-adapter.md). ## Open Questions - **OQ-44** (deferred(scope)): Terminal modes. -- **OQ-45** (resolved): Flow control for high-throughput stdout — no +- **OQ-45** (resolved): Flow control for high-throughput stdout (no application-level windowing; QUIC per-stream flow control is the - backpressure mechanism. + backpressure mechanism). - **OQ-47** (resolved): Stdin closure canonical signal. ## References -- [ADR-001](decisions/001-wire-format-and-two-carriage.md) — the wire +- [ADR-001](decisions/001-wire-format-and-two-carriage.md): the wire format decision -- [ADR-004](decisions/004-exit-code-on-control-chunk.md) — the +- [ADR-004](decisions/004-exit-code-on-control-chunk.md): the exit-chunk ordering the control channel carries -- [ADR-006](decisions/006-negotiation-framing-self-contained.md) — the +- [ADR-006](decisions/006-negotiation-framing-self-contained.md): the dependency-edge decision (negotiation framing is self-contained in alktty) -- alknet ADR-003 Amendment 2 — alktty does not depend on alknet-call +- alknet ADR-003 Amendment 2: alktty does not depend on alknet-call (self-contained framing); see the alknet originals at `/workspace/@alkdev/alknet/docs/architecture/decisions/` -- `src/wire.rs` — the chunk codec (`ChunkReader`/`ChunkWriter`, +- `src/wire.rs`: the chunk codec (`ChunkReader`/`ChunkWriter`, stream_type 0-4) this spec documents -- `src/control.rs` — the JSON control schema (`ControlMessage` tagged +- `src/control.rs`: the JSON control schema (`ControlMessage` tagged enum) this spec documents -- [tty-bast.md](tty-bast.md) — the BAST (Binary Abstract Syntax Tree) +- [tty-bast.md](tty-bast.md): the BAST (Binary Abstract Syntax Tree) document for the binary framing layer of this wire format (the 5-byte chunk header and the negotiation frame's 4-byte length prefix); a normative JSON spec downstream consumers can validate against. The JSON payloads (`NegotiateRequest`, `ControlMessage`, `TerminalParams`) are specified in this document and the Rust - source, not in the BAST — BAST describes binary layouts, not JSON + source, not in the BAST; BAST describes binary layouts, not JSON shapes -- [tty-adapter.md](tty-adapter.md) — the session lifecycle that consumes +- [tty-adapter.md](tty-adapter.md): the session lifecycle that consumes this wire format \ No newline at end of file diff --git a/docs/plans/fuzzing.md b/docs/plans/fuzzing.md index 50f8132..82db665 100644 --- a/docs/plans/fuzzing.md +++ b/docs/plans/fuzzing.md @@ -1,6 +1,6 @@ # Fuzzing alktty: Plan -**Status:** adopted — implementation in progress (see §8 progress log) +**Status:** adopted; implementation in progress (see §8 progress log) **Date:** 2026-09-28 **Research input:** `/workspace/@alkdev/alkcall/docs/research/fuzzing.md` (the alkcall fuzzing evaluation; the toolchain decision, corpus policy, detached @@ -13,7 +13,7 @@ re-derived). **Yes.** Alktty is the *named follow-up* in the alkcall research: §5 "Non-surfaces" explicitly defers the downstream parsers to "downstream -crates' fuzz targets" — the channels layer carries opaque `Bytes` and the +crates' fuzz targets": the channels layer carries opaque `Bytes`, and the handler owns sub-stream framing (ADR-035). Alktty is that handler, and it owns four parse surfaces fed by arbitrary peers on both transports (direct ALPN and channels): @@ -21,7 +21,7 @@ ALPN and channels): | # | Surface | Location | Shape | |---|---|---|---| | 1 | 5-byte chunk codec | `src/wire.rs` (`ChunkReader`/`ChunkWriter`, `validate_header`) | async, `Cursor`-drivable; validate-then-alloc at `wire.rs:244` | -| 2 | Negotiation framing | `src/negotiation.rs:185` (`NegotiationReader::read_frame`) | 4-byte BE prefix, bounds-check then `vec![0u8; length]` at `:199` — the same bounded-but-allocation-rate pattern alkcall flagged (§6.2-3) | +| 2 | Negotiation framing | `src/negotiation.rs:185` (`NegotiationReader::read_frame`) | 4-byte BE prefix, bounds-check then `vec![0u8; length]` at `:199`; the same bounded-but-allocation-rate pattern alkcall flagged (§6.2-3) | | 3 | `NegotiateRequest` JSON | `src/negotiation.rs` (serde parse of up to 16 MiB bodies) | attacker-shaped JSON, typed struct | | 4 | `ControlMessage` JSON | `src/control.rs:105` (`from_slice`), `signal_from_name` | sync pure | @@ -29,7 +29,7 @@ Reasons this pays off here, beyond the generic alkcall argument: 1. **Semantic invariants no example test explores adversarially.** The peek/disambiguation machinery (`peek_stream_type` vs - `read_chunk_after_peek` — both call orders must yield exactly one + `read_chunk_after_peek`; both call orders must yield exactly one chunk) and the cross-codec invariant that makes it sound: an error-response frame's length-prefix high byte must be `0x00` so a `ChunkReader` peek distinguishes it from a chunk whose first byte is a @@ -40,14 +40,14 @@ Reasons this pays off here, beyond the generic alkcall argument: 2. **The alkcall bug class has a plausible analog here.** The duplicate adopt/open finding (alkcall §7.8) was *stateful*: an interleaving no unit test replays destroyed the live channel. Alktty's adapter has the - same shape — take-once `Mutex>` slots (`adapter.rs:777-792`), + same shape: take-once `Mutex>` slots (`adapter.rs:777-792`), session re-registration, handle replacement. A stateful op-sequence target against the session pump + mock backend is where a real finding would live. 3. **Cost is low and the playbook is proven.** Mirroring alkcall's as-built layout is a port, not a design exercise (§5 below). -Zero parser-bug yield is a successful negative result — it converts "we +Zero parser-bug yield is a successful negative result: it converts "we think the codecs are robust" into a demonstrated property (the quinn lesson cuts both ways). @@ -64,8 +64,8 @@ compiled for `wasm32-unknown-unknown`. - `cargo-fuzz` + `arbitrary`, standard `fuzz/` workspace with its own `[workspace]` table (nightly-only bins). -- **The invariant logic lives in `fuzz/shared/` — a stable-toolchain - library crate** — not inline in the target binaries. The `fuzz_target!` +- **The invariant logic lives in `fuzz/shared/`, a stable-toolchain + library crate**, not inline in the target binaries. The `fuzz_target!` binaries are thin wrappers. This makes corpus replay a plain `cargo test --manifest-path fuzz/shared/Cargo.toml` on stable: it replays every committed seed through the identical invariant functions @@ -74,8 +74,8 @@ compiled for `wasm32-unknown-unknown`. crate stays stable at MSRV 1.88. - Committed hand-made seeds, gitignored grown corpora (quinn/h2 pattern); deterministic seed generator script committed next to them. -- **No hosted CI** — this repo has the same no-CI policy as alkcall - (§7.9 there): the gitea host serves git only; verification and +- **No hosted CI**: this repo has the same no-CI policy as alkcall + (§7.9 there), where the gitea host serves git only; verification and campaigns are manual. Corpus replay is the standing fuzz gate; campaigns run via the detached runner before releases and after touching `src/wire.rs`, `src/negotiation.rs`, `src/control.rs`, the @@ -91,7 +91,7 @@ session's own process tree and cgroup. A fuzz campaign is exactly the workload shape that can kill its own host: - **OOM-killer shared fate.** The target's worst case *is* unbounded - allocation. libFuzzer's RSS guard is a *soft* poll — a fast single huge + allocation. libFuzzer's RSS guard is a *soft* poll; a fast single huge allocation can beat it, and then the kernel OOM killer fires. It picks the largest-RSS process in the cgroup, which can be the opencode server hosting the session. Losing that server is recoverable (sessions are @@ -108,7 +108,7 @@ Three layers, all standard: `-malloc_limit_mb=2048` make libFuzzer exit cleanly (OOM-class artifact) before the kernel gets involved. Do **not** use `ulimit -v` with ASAN (the sanitizer reserves terabytes of virtual address space; - the classic failure is instant `MmapAlloc` death) — the ASAN-safe + the classic failure is instant `MmapAlloc` death). The ASAN-safe equivalent is `malloc_limit_mb`. 2. **Fork mode as blast-radius containment (default):** `-fork=1` runs each input in a short-lived child; a crash/timeout/OOM/leak kills only @@ -144,7 +144,7 @@ alkcall §7.4). | 1 | `chunk_frame` | raw `&[u8]` | `ChunkReader`/`ChunkWriter` over `Cursor` + `block_on` | no-panic; error-shape partition (`ConnectionClosed` only on truncation; `InvalidStreamType` iff `stream_type > 4`; `ChunkTooLarge` iff `length > MAX_CHUNK_LEN`; `Io` impossible on a Cursor); Ok iff header complete + payload complete; round-trip (`write_chunk` then `read_chunk` reproduces stream_type + bytes); 5-byte consumption accounting; peek interleave (`peek_stream_type` → `read_chunk_after_peek` == `read_chunk`, idempotent peek, peek state resets per chunk); never allocates for out-of-range headers | | 2 | `negotiation_frame` | raw `&[u8]` | `NegotiationReader`/`NegotiationWriter` + `NegotiateRequest` serde parse + `error_response_bytes` | no-panic; error-shape partition (`ConnectionClosed` only on truncation; `FrameTooLarge` iff `length > MAX_CHUNK_LEN`; `Json` only when the full body was present; `Io` impossible); consumption accounting (4 + length); negotiation JSON round-trip (`NegotiateRequest` → `to_json` → frame → `from_slice` → equal struct); **cross-codec disambiguation** (see §4.1) | | 3 | `control_json` | raw `&[u8]` | `ControlMessage::from_slice` / `to_json`, `signal_from_name` | no-panic on any bytes; clean serde errors (never silent); `to_json` → `from_slice` structural round-trip; `signal_from_name` is total (returns `Option`, never panics) | -| 4 | `session_opseq` | `#[derive(Arbitrary)]` op sequence | the producer session pump (`drive_session_pre_negotiated`) + a fuzz-local mock backend (`fuzz/shared/src/session_opseq.rs`; the in-crate `MockBackend` is `#[cfg(test)]`-only) | no-panic (driver, pump tasks via the session `JoinError`, harness model); **kill-on-Drop (ADR-005)** — after teardown `kill_fired == !exit_resolved`, and with no exit op issued the kill MUST have fired; **exit chunk is last (ADR-004)** — at most one ctrl_out chunk, `type == "exit"`, `code` matches the first-issued exit op (`-1` on wait-failure or dropped exit sender), nothing observed after it, server never writes stream types 0/3; lossless per-stream FIFO of produced `(len, byte)` patterns with exactly one stdout sentinel (only after the stdout-EOF op) and no stderr sentinel; stdin prefix-losslessness (post-shutdown chunks never delivered); control dispatch never over-counts and dispatches promptly pre-exit; take-once allocation; teardown terminates within the bound | +| 4 | `session_opseq` | `#[derive(Arbitrary)]` op sequence | the producer session pump (`drive_session_pre_negotiated`) + a fuzz-local mock backend (`fuzz/shared/src/session_opseq.rs`; the in-crate `MockBackend` is `#[cfg(test)]`-only) | no-panic (driver, pump tasks via the session `JoinError`, harness model); **kill-on-Drop (ADR-005)**: after teardown `kill_fired == !exit_resolved`, and with no exit op issued the kill MUST have fired; **exit chunk is last (ADR-004)**: at most one ctrl_out chunk, `type == "exit"`, `code` matches the first-issued exit op (`-1` on wait-failure or dropped exit sender), nothing observed after it, server never writes stream types 0/3; lossless per-stream FIFO of produced `(len, byte)` patterns with exactly one stdout sentinel (only after the stdout-EOF op) and no stderr sentinel; stdin prefix-losslessness (post-shutdown chunks never delivered); control dispatch never over-counts and dispatches promptly pre-exit; take-once allocation; teardown terminates within the bound | ### 4.1 Cross-codec disambiguation invariant (target 2, the subtle one) @@ -154,14 +154,14 @@ a 4-byte BE length prefix ≤ 16 MiB − 1 starts with `0x00`, which is neither. Encoded as fuzz assertions: - For every `error_response_bytes` output and every negotiation frame of - length < `MAX_CHUNK_LEN`, the first byte is `0x00` — a `ChunkReader` + length < `MAX_CHUNK_LEN`, the first byte is `0x00`, so a `ChunkReader` peek on `[frame bytes…]` never mistakes it for a chunk. - **Known boundary collision, encoded deliberately:** a frame of length - exactly `MAX_CHUNK_LEN` (= `0x01000000`) has prefix `[0x01, …]` — + exactly `MAX_CHUNK_LEN` (= `0x01000000`) has prefix `[0x01, …]`: first byte `0x01`, colliding with `STREAM_STDOUT`. The read paths admit it (`length > MAX_CHUNK_LEN` rejects, `==` passes). This is an inherent edge of the one-byte-peek design; producers only ever send - tiny error frames, so it is accepted — but the assertion pins the + tiny error frames, so it is accepted, but the assertion pins the boundary so any future `MAX_CHUNK_LEN` change that widens the collision gets caught. (`validate_header`'s doc comment at `wire.rs:262-270` claims the ≤ cap high byte is `0x00`; strictly the @@ -184,7 +184,7 @@ fuzz/ │ ├── negotiation_frame.rs thin wrapper │ ├── control_json.rs thin wrapper │ └── session_opseq.rs thin wrapper (typed SessionSequence input) -├── shared/ alktty-fuzz-shared — STABLE-toolchain library +├── shared/ alktty-fuzz-shared, STABLE-toolchain library │ └── src/{chunk_frame,negotiation_frame,control_json,session_opseq}.rs ├── corpus// committed seeds ├── gen_fuzz_seeds.py deterministic seed generator @@ -194,12 +194,12 @@ fuzz/ Root `Cargo.toml` changes (the alkcall §7.7 footguns, both required): -- `[workspace]` table: `members = ["."]`, `exclude = ["fuzz"]` — without - it cargo auto-discovers `fuzz/shared/` into the main workspace and the +- `[workspace]` table: `members = ["."]`, `exclude = ["fuzz"]`, since + without it cargo auto-discovers `fuzz/shared/` into the main workspace and the MSRV toolchain tries to build nightly-consumed dev-deps. - `"fuzz/"` added to the publish `exclude` list. -No `#[cfg(fuzzing)]` exposure expected — `ChunkReader`/`ChunkWriter`, +No `#[cfg(fuzzing)]` exposure expected: `ChunkReader`/`ChunkWriter`, `NegotiationReader`/`NegotiationWriter`, `NegotiateRequest`, `ControlMessage`, `error_response_bytes`, `drive_session_pre_negotiated`, and the `TtyBackend`/`TtyHandle`/`TtyParams` shapes are all already `pub` @@ -226,8 +226,8 @@ deterministic (no randomness) so seeds are reproducible. ## 7. Sequencing 1. **Bump alkcall 0.8.0 → 0.8.1** (the duplicate adopt/open fix lands on - alktty's own path — `TtySession::open_via_channels` and the channels - establisher run on the fixed `ChannelManager`). Full verification set + alktty's own path, where `TtySession::open_via_channels` and the + channels establisher run on the fixed `ChannelManager`). Full verification set including wasm checks. Also fix AGENTS.md's stale "pin alkcall 0.6.0" line while touching it. **Status: see §8.** 2. Scaffold `fuzz/` + root workspace table + publish exclude. @@ -239,13 +239,13 @@ deterministic (no randomness) so seeds are reproducible. 5. Detached campaigns (10 min per target via `run-detached.sh`), triage anything found, record results in §8. 6. Decide on target 4 (`session_opseq`) after 1–3 are clean. - **Status: landed — see §8. All four targets are in scope and live.** + **Status: landed; see §8. All four targets are in scope and live.** ## 8. Progress log (append-only across sessions) -- **2026-09-28** — Proposal discussed and adopted; this plan written. +- **2026-09-28**: Proposal discussed and adopted; this plan written. Alkcall dependency bump + fuzz scaffold started. -- **2026-09-28 (later)** — **All of §7 steps 1–5 landed.** +- **2026-09-28 (later)**: **All of §7 steps 1–5 landed.** - alkcall 0.8.0 → 0.8.1 verified across the full checklist (113 + all-features 156 tests, clippy stable/wasm, fmt, doc). - AGENTS.md: stale "pin alkcall 0.6.0" corrected to 0.8.1; corpus @@ -260,38 +260,38 @@ deterministic (no randomness) so seeds are reproducible. (`cargo test --manifest-path fuzz/shared/Cargo.toml`). - Harness-model corrections the corpus replay caught *before* any campaign (the alkcall §7.7 pattern repeating): (1) a valid first - chunk with trailing stream bytes is correct reader behavior — the - payload slice is `5..5+len`, not `5..`; (2) the negotiation framing - layer is length-prefix-only — `read_frame` returns raw bytes and the + chunk with trailing stream bytes is correct reader behavior (the + payload slice is `5..5+len`, not `5..`); (2) the negotiation framing + layer is length-prefix-only: `read_frame` returns raw bytes and the adapter does the serde parse (`adapter.rs:411`), so `NegotiationError::Json` is unreachable from this reader (unlike alkcall's serde-backed envelope reader; the `Json` arm now panics as "unreachable" and the trailing-byte probe asserts exact-body consumption instead of a Json error); (3) zero-length negotiation frames are admitted by this reader (the adapter's parse rejects - them) — the shape assertion was relaxed accordingly. + them), so the shape assertion was relaxed accordingly. - **10-min detached campaigns, all three targets: every run exited 0 - with an empty artifact directory — no crashes, hangs, OOMs, or - leaks** (per-target `oom/timeout/crash: 0/0/0` on all ~28 fork + with an empty artifact directory (no crashes, hangs, OOMs, or + leaks)** (per-target `oom/timeout/crash: 0/0/0` on all ~28 fork jobs): `chunk_frame` 380k execs (cov saturated at 710 edges within - seconds — the 5-byte-parser ceiling is fully enumerated); + seconds; the 5-byte-parser ceiling is fully enumerated); `negotiation_frame` 3.69M execs (cov 3157, still finding coverage at - budget end — JSON structure exploration); `control_json` 8.6M execs + budget end, JSON structure exploration); `control_json` 8.6M execs (cov 2035, still growing). Grown corpus entries were deleted per the §6 policy; committed seeds unchanged. - §7 step 6 (target 4 `session_opseq`) remains open for a follow-up session. -- **2026-09-28 (later still)** — **§7 step 6 landed: target 4 +- **2026-09-28 (later still)**: **§7 step 6 landed: target 4 `session_opseq`. All four targets are now live.** - `fuzz/shared/src/session_opseq.rs`: `SessionOp`/`SessionSequence` - (`#[derive(Arbitrary)]`, 12-op vocabulary — client writes incl. + (`#[derive(Arbitrary)]`, 12-op vocabulary: client writes incl. barrier probes, backend production/EOF, exit resolve/fail/drop, control messages, bounded reads, yields) + a fuzz-local `FuzzBackend` (the in-crate `MockBackend` is `#[cfg(test)]`-only) + the harness driving `drive_session_pre_negotiated` over a `tokio::io::duplex` pair, with a delivery model mirroring §4's invariants. 12 committed seeds via the deterministic generator - (hand-encoded against the `arbitrary` 1.4.x derive layout — variant + (hand-encoded against the `arbitrary` 1.4.x derive layout: variant tag `(u32_le * 12) >> 32`, fields LE zero-filled, keep-going byte gating each `Vec` element; the encoding is pinned by a decode test asserting the exact ops each seed decodes to). @@ -303,22 +303,22 @@ deterministic (no randomness) so seeds are reproducible. unbounded backend stdin (control dispatch stays prompt). - **Harness-model correction before the campaign** (the alkcall §7.7 pattern repeating, third time): a 60s smoke run crashed on - `ClientWrite` after the session had completed — a failed client + `ClientWrite` after the session had completed; a failed client write can mean the *server half of the duplex dropped* because the session finished (exit resolved → pumps join → exit chunk → drainer exits → duplex halves drop) while the write was in flight. The harness now treats any write/shutdown error as "the session closed": it drains the read side and requires the exit chunk (a - close without it is a premature close — a real bug). No adapter + close without it is a premature close, a real bug). No adapter code changed; the finding was a harness-model gap. - Corpus replay green on stable (9 tests, incl. the seed-decode pin). Full checklist green: 113 + 156 all-features tests, clippy stable/wasm + fuzz-shared clippy, fmt, doc, publish dry-run, wasm check. - Campaign: **10 min detached via `run-detached.sh`, exited 0 with an - empty artifact directory — no crashes, hangs, OOMs, or leaks** (all + empty artifact directory and no crashes, hangs, OOMs, or leaks** (all 33 fork jobs `oom/timeout/crash: 0/0/0`). 42.3k execs at ~74/s per - job (the op-seq target is stateful — each input runs a full tokio + job (the op-seq target is stateful, with each input running a full tokio session with bounded reads/yields, so exec/s is inherently tens, not millions; the value is interleaving exploration). Cov grew 3934 → 4075 edges over the run, still finding coverage at budget @@ -340,7 +340,7 @@ example-based tests structurally cannot enumerate. Verification additions once landed: ```bash -cargo test --manifest-path fuzz/shared/Cargo.toml # corpus replay — the standing fuzz gate +cargo test --manifest-path fuzz/shared/Cargo.toml # corpus replay: the standing fuzz gate ``` …added to the AGENTS.md checklist alongside the existing commands. The diff --git a/docs/plans/project-setup.md b/docs/plans/project-setup.md index 5684281..a0ecd47 100644 --- a/docs/plans/project-setup.md +++ b/docs/plans/project-setup.md @@ -1,14 +1,14 @@ # Project Setup Plan Status: draft (revised 2026-08-17 to reflect landed upstream changes; -Phase 4 landed 2026-08-17 — architecture docs + BAST schema + -renumbered ADRs; Phase 5 landed 2026-08-17 — tests) +Phase 4 landed 2026-08-17 (architecture docs + BAST schema + +renumbered ADRs); Phase 5 landed 2026-08-17 (tests) Last updated: 2026-08-17 ## Overview Extract `alknet-tty` + `alknet-tty-local` from the alknet mono-repo into a -standalone `alktty` crate — a producer/consumer protocol crate on top of +standalone `alktty` crate: a producer/consumer protocol crate on top of alkcall channels. This is the first protocol crate built on alkcall and establishes patterns that `alktunnels` and others will follow. @@ -29,7 +29,7 @@ These were "open questions" or "upstream changes needed" in the 2026-08-14 draft and are now resolved. The plan reflects the as-built shape, not the original intent. -### alkcall 0.1.1 — ALPN rename done +### alkcall 0.1.1: ALPN rename done - `CHANNELS_ALPN`: `b"alknet/channels"` → `b"alk/channels"` (in `alkcall/src/channels/adapter.rs`). @@ -39,40 +39,40 @@ shape, not the original intent. - `alkcall` `Cargo.toml` dependency needs updating from `0.1.0` to `0.1.1` (the scaffold still pins `0.1.0`). -### alkcall — `Identity.resources` shape +### alkcall: `Identity.resources` shape The alknet-core `Identity` had `resources: HashMap` (a flat resource-type → resource-id map). alkcall's `Identity` (in -`alkcall::core::auth`) has `resources: HashMap>` — +`alkcall::core::auth`) has `resources: HashMap>`; one resource type can carry multiple resource ids. The ported adapter tests that build an `Identity` by hand need this shape; the production code only reads `identity.scopes` (the scope-gate at negotiation), so the change is test-side. -### alkcall — `OwnershipProvider` / `OwnershipStore` signature +### alkcall: `OwnershipProvider` / `OwnershipStore` signature - `OwnershipProvider::owns(&self, identity, resource_type, - resource_id, action: &str)` — added an `action` parameter (was 3 + resource_id, action: &str)`, which added an `action` parameter (was 3 args, now 4). The adapter's call site (`provider.owns(id_ref, kind, &id, "tty")`) already passes an `"tty"` action string, which is the new arity. No change to the production adapter; the mock and test fixtures need the 4-arg shape. - `OwnershipStore::record(&self, identity, resource_type, - resource_id)` — **dropped** the old `action` parameter that + resource_id)` **dropped** the old `action` parameter that alknet-core's variant took. The adapter test's `store.record(&owner, "container", "c1").await` matches the new arity. (The scaffold's plan previously listed an `OwnershipStore` import that no longer needs an action arg.) -### alkcall — `AuthContext::anonymous(alpn)` +### alkcall: `AuthContext::anonymous(alpn)` alkcall added a convenience constructor `AuthContext::anonymous(alpn)` that builds an `AuthContext` with no identity, no fingerprint, no -remote address — only the ALPN. Useful in adapter tests; not used by +remote address, just the ALPN. Useful in adapter tests; not used by the production adapter (which receives the `AuthContext` from the dispatch path). -### alktype 0.2.0 — BAST format pivot +### alktype 0.2.0: BAST format pivot alktype was reworked: the v0.1.0 `AlkType:*` custom-keyword JSON Schema backend was dropped in favor of a straightforward `kind`-based BAST @@ -81,7 +81,7 @@ document is a plain JSON file (conforming to a JSON Schema meta-schema) that describes binary layouts using `kind` strings (`"uint8"`, `"uint32"`, `"struct"`, etc.) and `$defs`/`$ref` for composition. -`alktty` will **not** depend on alktype — the hand-rolled +`alktty` will **not** depend on alktype. The hand-rolled `ChunkReader`/`ChunkWriter` in `wire.rs` already works and is straightforward. The BAST format matters here only as a normative **schema document** for the `alk/tty` wire format: a JSON file under @@ -93,7 +93,7 @@ dependency. ## Crate Structure Single crate (`alktty`) with the local backend gated behind a `local` -feature. This is a "tty-specific monorepo" — one crate, one concern, +feature. This is a "tty-specific monorepo": one crate, one concern, both halves of the original alknet split (`alknet-tty` + `alknet-tty-local`) folded in. @@ -106,9 +106,9 @@ alktty/ │ ├── control.rs # ControlMessage enum, signal_from_name │ ├── backend.rs # TtyBackend trait, TtyHandle, TtyParams, TtyError │ ├── adapter.rs # TtyAdapter (ProtocolHandler), drive_session -│ ├── session.rs # TtySession — typed consumer client +│ ├── session.rs # TtySession (typed consumer client) │ ├── channels.rs # OpenHandler factory, register_openable helper -│ └── local/ # (behind `local` feature — the folded alknet-tty-local) +│ └── local/ # (behind `local` feature, the folded alknet-tty-local) │ ├── mod.rs # LocalTtyBackend re-export │ ├── backend.rs # LocalTtyBackend (dispatches terminal: Some→pty, None→pipe) │ ├── pty.rs # PTY mode (portable_pty, 3 std threads → tokio channels, #[cfg(unix)]) @@ -136,7 +136,7 @@ alktty/ The old split (`alknet-tty` + `alknet-tty-local`) had a cyclic dependency problem: `alknet-tty-local` depends on `alknet-tty` for the trait, and ADR-054 wanted `alknet-tty` to re-export `LocalTtyBackend` behind a -`local` feature — which cargo rejects. The assembly-layer workaround +`local` feature, which cargo rejects. The assembly-layer workaround (consumer depends on both crates directly) works but is awkward. Single crate with `local` feature solves this cleanly. The @@ -149,31 +149,31 @@ original alknet split: the wire/adapter/backend-trait half (from local backend is small (~1.4k lines), tightly coupled to the trait, and the cyclic-dep workaround it required in the alknet mono-repo doesn't exist in a single crate. A docker or SSH backend would still -be a separate crate — those have real external deps (`bollard`, +be a separate crate; those have real external deps (`bollard`, `russh`) and their own resource models. **WASM target.** The default crate (no `local` feature) MUST compile to `wasm32-unknown-unknown`. alkcall and alktype both target wasm; keeping alktty wasm-clean is what makes the downstream TS/Python -adapter story work — a wasm-compiled alktty is the protocol layer for +adapter story work. A wasm-compiled alktty is the protocol layer for a sandboxed adapter (browser terminal over WebTransport, a Python wheel that shells out to a wasm module, etc.). The `local` feature is inherently non-wasm (`portable-pty` needs a real OS, `tokio::process` needs `spawn`), so enabling `local` on wasm is a build error by -design — the local-process backend runs on a real OS (Linux, macOS, +design: the local-process backend runs on a real OS (Linux, macOS, Windows), never in a sandbox. Concretely, `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 +`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 and `tokio::process::Command` 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`). +there for `signal_from_name` and the pipe-mode `kill` path, neither +of which runs on wasm because the only callers are in `local`). ### 2. Dependency: `alknet-core` → `alkcall::core` @@ -191,7 +191,8 @@ comes from. The trait shape is identical. The `Identity` struct's reads `identity.scopes`, so the change is test-side. `Cargo.toml` pins `alkcall = "0.1.1"` (the scaffold's `0.1.0` pin is -stale — 0.1.1 is the ALPN-rename release; same public API per semver). +stale; 0.1.1 is the ALPN-rename release with the same public API per +semver). ### 3. Channels integration (the new part) @@ -199,11 +200,11 @@ This is where alktty becomes a proper producer/consumer on alkcall. **ALPN strings (settled).** `alk/tty` (direct) and `alk/channels` (multiplexed). The `alknet/` → `alk/` rename landed in -alkcall 0.1.1 — `CHANNELS_ALPN` is `b"alk/channels"`, the channels +alkcall 0.1.1. `CHANNELS_ALPN` is `b"alk/channels"` and the channels client test uses `"alk/tty"`. No upstream work remains; we just use the new strings. -**Producer half** — two paths to expose TTY: +**Producer half** (two paths to expose TTY): - **Direct ALPN** (`alk/tty`): `TtyAdapter` implements `ProtocolHandler`. Existing code, just import migration. Used for direct-connect scenarios @@ -216,38 +217,38 @@ the new strings. Per ADR-093 (reversal of ADR-077): TTY always uses its 5-byte format. The channels layer strips its 8-byte header and hands TTY the payload - transparently. The same `wire.rs` code runs in both modes — only the + transparently. The same `wire.rs` code runs in both modes; only the `BiStream` source differs. The channels integration code is small: a `register_openable` helper function that takes a `ChannelCore`, `OperationRegistry`, and backend - map, and registers the per-ALPN open op (`channels/tty/sub` — + map, and registers the per-ALPN open op (`channels/tty/sub`, consumer-opens). This lives in `src/channels.rs`. The op spec carries the `channel_open` marker (`ChannelOpenSpec::new("alk/tty")`) and the `AccessControl` for the scope-gate + ownership check; the channels wrapper does `check_open` → `open_channel` → spawn the `OpenHandler` → respond with `{ channel_id }`. -**Consumer half** — `TtySession`: +**Consumer half** (the `TtySession`): A typed client that wraps the wire protocol. Two constructors: -- `TtySession::connect_direct(connection)` — for direct `alk/tty` -- `TtySession::open_via_channels(client, params)` — opens a channel via +- `TtySession::connect_direct(connection)`: for direct `alk/tty` +- `TtySession::open_via_channels(client, params)`: opens a channel via `ChannelClient`, negotiates, returns a session handle The session handle exposes: - `send_stdin(bytes)` / `close_stdin()` -- `recv_stdout()` / `recv_stderr()` — streams +- `recv_stdout()` / `recv_stderr()`: streams - `resize(cols, rows)`, `signal(name)` -- `wait()` — awaits exit code +- `wait()`: awaits exit code -### 4. Negotiation framing — keep self-contained +### 4. Negotiation framing: keep it self-contained ADR-057 says the negotiation framing is self-contained (~30 lines, format coincides with alkcall's by convention). Even though alkcall is now a dependency, we keep the self-contained framing because: -- The payload is `NegotiateRequest`, not `EventEnvelope` — alkcall's +- The payload is `NegotiateRequest`, not `EventEnvelope`; alkcall's `FrameFramedReader` is hardcoded to deserialize `EventEnvelope` - The framing is trivial (4-byte BE length prefix + body) - Avoids coupling to alkcall's internal wire types for a TTY-specific payload @@ -257,11 +258,11 @@ dependency, we keep the self-contained framing because: The architecture README in alkcall describes protocol crates as having two halves: -1. **Producer half** — `register_*()` functions that take an +1. **Producer half**: `register_*()` functions that take an `&mut OperationRegistry` and register ops with handlers. For channels, an `OpenHandler` factory. -2. **Consumer half** — a typed client wrapper around `CallConnection` or +2. **Consumer half**: a typed client wrapper around `CallConnection` or `ChannelClient` that exposes the crate's ops as async methods. We make this explicit in the module structure: @@ -273,13 +274,14 @@ We make this explicit in the module structure: The old code had minimal access control (a scope-gate at negotiation). With alkcall, we get: -- **Operation-level ACL**: `AccessControl` on `OperationSpec` — scopes, - resource ownership checks (`required_scopes`, `required_scopes_any`, - `resource_type` + `resource_id_path`, `resource_action`) -- **Channel lifecycle policy**: `ChannelLifecyclePolicy` — per-identity - channel caps, open/close hooks (consulted by the `ChannelCore` +- **Operation-level ACL**: `AccessControl` on `OperationSpec`, covering + scopes and resource ownership checks (`required_scopes`, + `required_scopes_any`, `resource_type` + `resource_id_path`, + `resource_action`) +- **Channel lifecycle policy**: `ChannelLifecyclePolicy` for per-identity + channel caps and open/close hooks (consulted by the `ChannelCore` wrapper, not by alktty directly) -- **Ownership**: `OwnershipProvider` — runtime-spawned resource tracking +- **Ownership**: `OwnershipProvider` for runtime-spawned resource tracking (terminal sessions are resources per ADR-050). alkcall's `OwnershipProvider::owns` takes an `action: &str` arg (the adapter passes `"tty"`); `OwnershipStore::record` takes no action arg. @@ -301,7 +303,7 @@ types round-trip. Keeping them as separate artifacts avoids pulling in alktype as a runtime dep for a codec that's already ~200 lines and works. If we later want runtime validation against the BAST (e.g., to reject malformed chunks at the framing boundary with a generated -validator), alktype becomes an optional dep — the BAST document is +validator), alktype becomes an optional dep; the BAST document is already there to feed it. The `TtyAdapter`'s direct-ALPN path keeps the existing ad-hoc scope @@ -309,7 +311,7 @@ check (`has_scope(identity, TTY_OPEN_SCOPE)`) for the scope gate and optionally consults `OwnershipProvider` for the resource-ownership check. 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 +operation spec; the registry runs the ACL before the wrapper, so the `OpenHandler` only needs to validate params and spawn the protocol. ## Migration Strategy @@ -333,26 +335,26 @@ operation spec — the registry runs the ACL before the wrapper, so the 4. Confirm the `local` feature already wires `portable-pty` + `tokio-util` as optional deps (it does in the scaffold). The `local` module's `pty.rs` additionally needs `libc` under - `cfg(unix)` — already in `[target.'cfg(unix)'.dependencies]`. + `cfg(unix)`, which is already in `[target.'cfg(unix)'.dependencies]`. ### Phase 1: Port core types from `alknet-tty` (no new logic) -1. Port `wire.rs` — no `alknet-core` imports; just `std::io`, `tokio::io`, +1. Port `wire.rs`: no `alknet-core` imports; just `std::io`, `tokio::io`, `bytes`, `thiserror`. Rename crate references in doc comments (`alknet/tty` → `alk/tty`, `alknet-tty` → `alktty`). -2. Port `control.rs` — no `alknet-core` imports; `serde`, `bytes`, +2. Port `control.rs`: no `alknet-core` imports; `serde`, `bytes`, `libc` (under `cfg(unix)`). Rename `alknet/tty` → `alk/tty` in doc comments. -3. Port `negotiation.rs` — no `alknet-core` imports; `std::io`, +3. Port `negotiation.rs`: no `alknet-core` imports; `std::io`, `bytes`, `tokio::io`, `thiserror`, `serde_json`. Rename doc references. -4. Port `backend.rs` — no `alknet-core` imports; `std`, `async_trait`, +4. Port `backend.rs`: no `alknet-core` imports; `std`, `async_trait`, `bytes`, `futures_core`, `tokio::sync`, `tokio_stream`. Change the `BoxFuture` type alias from `Pin>` to `futures::future::BoxFuture<'static, T>` (functionally identical; the alias matches the alkcall convention and lets us drop the `Pin`/`Future` imports). -5. Port `adapter.rs` — change imports: +5. Port `adapter.rs`; change the imports: - `alknet_core::auth::{AuthContext, Identity}` → `alkcall::core::{AuthContext, Identity}` - `alknet_core::ownership::OwnershipProvider` → @@ -368,48 +370,48 @@ operation spec — the registry runs the ACL before the wrapper, so the - `Identity.resources: HashMap>` (was `HashMap` in alknet-core). - `OwnershipStore::record(&self, identity, resource_type, - resource_id)` — 3 args, no `action` (alkcall dropped it). - - `OwnershipProvider::owns(identity, rt, rid, action)` — 4 args + resource_id)`: 3 args, no `action` (alkcall dropped it). + - `OwnershipProvider::owns(identity, rt, rid, action)`: 4 args (alkcall added `action`). ### Phase 2: Channels integration (new code) 1. Create `src/channels.rs`: - - `register_openable` helper — takes `ChannelCore`, backend map, + - `register_openable` helper: takes `ChannelCore`, backend map, `OperationRegistry`, `AuthContext`; builds the `OperationSpec` for `channels/tty/sub` with `ChannelOpenSpec::new("alk/tty")`, `AccessControl` carrying `TTY_OPEN_SCOPE` + resource-type/ownership if a backend declares a `resource_id`; calls `ChannelCore::register_openable(spec, open_handler, registry, auth)`. - - `TtyOpenHandler` — the `OpenHandler` (an + - `TtyOpenHandler`: the `OpenHandler` (an `Arc JoinHandle<()>`) that receives a channel `Connection`, calls `accept_bi()`, runs `drive_session` on the resulting `BiStream`. 2. Create `src/session.rs`: - - `TtySession` struct — typed consumer client - - `TtySession::connect_direct(connection)` — direct `alk/tty` - - `TtySession::open_via_channels(client, params)` — opens a channel + - `TtySession` struct: typed consumer client + - `TtySession::connect_direct(connection)`: direct `alk/tty` + - `TtySession::open_via_channels(client, params)`: opens a channel via `ChannelClient::open_channel("channels/tty/sub", params, "alk/tty")`, negotiates, returns a session handle - Methods: `send_stdin`, `recv_stdout`, `recv_stderr`, `resize`, `signal`, `wait` -### Phase 3: Local backend (behind `local` feature) — folded `alknet-tty-local` +### Phase 3: Local backend (behind `local` feature), the folded `alknet-tty-local` 1. Create `src/local/mod.rs` (re-exports `LocalTtyBackend`), `src/local/backend.rs`, `src/local/pty.rs`, `src/local/pipe.rs`. 2. Port from `alknet-tty-local/src/`: - - `backend.rs` — change `alknet_tty::backend::{...}` → + - `backend.rs`: change `alknet_tty::backend::{...}` → `crate::backend::{...}`. `LocalTtyBackend::allocate` dispatches on `params.terminal`: `Some` → `pty::allocate_pty`, `None` → `pipe::allocate_pipe`. - - `pty.rs` — `portable_pty` + 3 std threads (reader/writer/waiter) + - `pty.rs`: `portable_pty` + 3 std threads (reader/writer/waiter) feeding tokio mpsc/oneshot. `#[cfg(unix)]`-only: PTY mode uses `libc::kill(-pgid, sig)` for process-group signal forwarding. Update `alknet_tty::backend::{BoxFuture, TtyControl, ...}` → `crate::backend::{...}` and `alknet_tty::control::signal_from_name` → `crate::control::signal_from_name`. - - `pipe.rs` — `tokio::process::Command` + `tokio_util::io::ReaderStream` + - `pipe.rs`: `tokio::process::Command` + `tokio_util::io::ReaderStream` for stdout/stderr. Cross-platform: signal forwarding uses `libc::kill(pid, sig)` under `cfg(unix)`, falls back to `Child::kill()` on non-Unix. Update `alknet_tty::...` → `crate::...`. @@ -417,11 +419,11 @@ operation spec — the registry runs the ACL before the wrapper, so the `local` feature in `src/lib.rs`: `#[cfg(feature = "local")] pub mod local;` -### Phase 4: Architecture docs + BAST schema — landed 2026-08-17 +### Phase 4: Architecture docs + BAST schema (landed 2026-08-17) 1. Ported the 5 spec docs from `alknet/docs/architecture/crates/tty/` into `docs/architecture/` (flat layout, not the `crates/tty/` - subpath — this is a single-crate repo now): + subpath; this is a single-crate repo now): `overview.md` (the crate overview; the alknet `README.md` index is ported as `docs/architecture/README.md`), `tty-wire.md`, `tty-backend.md`, `tty-adapter.md`, `tty-local.md`. Each is renamed @@ -432,30 +434,30 @@ operation spec — the registry runs the ACL before the wrapper, so the 2. Ported the 8 alknet ADRs (052, 053, 054, 055, 056, 057, 077, 093) into `docs/architecture/decisions/` renumbered 001..008 in order: - [001](../architecture/decisions/001-wire-format-and-two-carriage.md) - ← alknet ADR-052 — wire format + two-carriage model (incl. the + ← alknet ADR-052: wire format + two-carriage model (incl. the Phase 7 control-channel split amendment) - [002](../architecture/decisions/002-ttybackend-trait-and-ttyhandle.md) - ← alknet ADR-053 — `TtyBackend` trait + `TtyHandle` + ← alknet ADR-053: `TtyBackend` trait + `TtyHandle` - [003](../architecture/decisions/003-local-backend-placement.md) - ← alknet ADR-054 — local backend placement (records both the + ← alknet ADR-054: local backend placement (records both the alknet sibling-crate decision and the alktty single-crate consolidation behind a `local` feature) - [004](../architecture/decisions/004-exit-code-on-control-chunk.md) - ← alknet ADR-055 — exit code on a control chunk + ← alknet ADR-055: exit code on a control chunk - [005](../architecture/decisions/005-backend-cleanup-on-session-cancel.md) - ← alknet ADR-056 — backend cleanup on session cancel + ← alknet ADR-056: backend cleanup on session cancel - [006](../architecture/decisions/006-negotiation-framing-self-contained.md) - ← alknet ADR-057 — self-contained negotiation framing + ← alknet ADR-057: self-contained negotiation framing - [007](../architecture/decisions/007-tty-inside-channels.md) - ← alknet ADR-077 — TTY inside channels (reversed by 008; kept for + ← alknet ADR-077: TTY inside channels (reversed by 008; kept for historical context with its reversal notice pointing to 008) - [008](../architecture/decisions/008-channels-pure-channel-multiplexing.md) - ← alknet ADR-093 — channels pure channel multiplexing (reverses + ← alknet ADR-093: channels pure channel multiplexing (reverses 007; TTY always uses its 5-byte format) -3. Wrote - [`docs/architecture/tty-bast.md`](../architecture/tty-bast.md) — - the BAST (Binary Abstract Syntax Tree) document for the `alk/tty` - wire format. Conforms to the BAST meta-schema at +3. Wrote the BAST (Binary Abstract Syntax Tree) document for the `alk/tty` + wire format at + [`docs/architecture/tty-bast.md`](../architecture/tty-bast.md). Conforms + to the BAST meta-schema at `https://alk.dev/bast/v1/schema`; validatable by any JSON Schema Draft 2020-12 validator. Covers: - The 5-byte chunk header (`struct` with `endian: "big"`: @@ -467,12 +469,12 @@ operation spec — the registry runs the ACL before the wrapper, so the that on-wire control payloads are UTF-8 JSON, not BAST's binary union encoding. - The negotiation frame as a separate `struct` (4-byte BE length - prefix + UTF-8 JSON `NegotiateRequest` body) — annotated as + prefix + UTF-8 JSON `NegotiateRequest` body), annotated as out-of-band for the chunk codec but documented for completeness. - The `StreamType` enum's documented deviation: on-wire encoding is - `uint8` (1 byte), not BAST's standard `u32` enum index (4 bytes) - — the chunk header is 5 bytes, not 8. -4. Wrote [`docs/architecture/README.md`](../architecture/README.md) — + `uint8` (1 byte), not BAST's standard `u32` enum index (4 bytes); + the chunk header is 5 bytes, not 8. +4. Wrote [`docs/architecture/README.md`](../architecture/README.md), the architecture index: documents table, ADR table (with alknet origin numbers and port status), key design principles, open questions, and references. @@ -488,28 +490,28 @@ split (`STREAM_CTRL_IN` = 3, `STREAM_CTRL_OUT` = 4) is an amendment inside ADR-001, mirroring alknet (it was not a standalone ADR there either). `AGENTS.md` was updated to match this mapping. -### Phase 5: Tests — landed 2026-08-17 +### Phase 5: Tests (landed 2026-08-17) 1. Ported existing unit tests from `alknet-tty` (wire, negotiation, control, adapter) inline in each `src/*.rs` module's - `#[cfg(test)] mod tests` — done in Phases 1–2 alongside the + `#[cfg(test)] mod tests`, done in Phases 1–2 alongside the production code (80 lib tests, all passing). 2. Ported integration tests from `alknet-tty-local/tests/` into `tests/` at the crate root: - - [`tests/common/mod.rs`](../tests/common/mod.rs) — the + - [`tests/common/mod.rs`](../tests/common/mod.rs): the `ClientSide` wire-protocol harness + `spawn_session` helper + `negotiate_pty_json` / `negotiate_pipe_json` builders. Imports renamed `alknet_core::auth::Identity` → `alkcall::core::auth::Identity`, `alknet_tty::...` → `alktty::...`. - - [`tests/negotiation.rs`](../tests/negotiation.rs) — 4 + - [`tests/negotiation.rs`](../tests/negotiation.rs): 4 negotiation-error scenarios (unknown_backend, malformed_negotiation ×3, allocate_failed). - - [`tests/pipe.rs`](../tests/pipe.rs) — 6 pipe-mode scenarios + - [`tests/pipe.rs`](../tests/pipe.rs): 6 pipe-mode scenarios (echo happy path, separate stderr, SIGTERM, cancel cleanup, resize no-op, stdout sentinel). The 2 cancel-cleanup / SIGTERM tests are `#[cfg(unix)]`. - - [`tests/pty.rs`](../tests/pty.rs) — 8 PTY-mode scenarios + - [`tests/pty.rs`](../tests/pty.rs): 8 PTY-mode scenarios (echo, interactive cat, resize, SIGINT, process-group signal, stdin-EOF sentinel, cancel cleanup, exit-chunk-is-last). The 4 signal / cancel-cleanup tests @@ -534,7 +536,7 @@ the 80/19 the plan originally recorded). ## Open Questions -### OQ-1: ALPN strings (RESOLVED — landed in alkcall 0.1.1) +### OQ-1: ALPN strings (RESOLVED, landed in alkcall 0.1.1) **Decision**: `alk/tty` (direct) and `alk/channels` (multiplexed). The `alknet/` → `alk/` rename landed in alkcall 0.1.1: @@ -552,12 +554,12 @@ correct. **Decision**: `channels/tty/sub` for consumer-opens, `channels/tty/pub` reserved for future producer-opens use case. -### OQ-3: `TtySession` API — stream vs callback (RESOLVED) +### OQ-3: `TtySession` API, stream vs callback (RESOLVED) **Decision**: Stream-based (`Stream`). Consistent with the backend trait's `TtyHandle` shape. -### OQ-4: `drive_session` — keep the three-pump pattern? (RESOLVED) +### OQ-4: `drive_session`, keep the three-pump pattern? (RESOLVED) **Decision**: Keep it. It works, it's tested, and ADR-093 means the same code runs in both direct and channels modes. @@ -571,7 +573,7 @@ transitive dep. ### OQ-6: BAST schema scope (RESOLVED) -**Decision**: Documentation artifact only — `docs/architecture/tty-bast.md`. +**Decision**: Documentation artifact only, at `docs/architecture/tty-bast.md`. alktty does not depend on alktype. The hand-rolled `wire.rs` codec is the runtime; the BAST document is the normative contract for downstream consumers. If runtime validation against the BAST becomes desirable @@ -598,7 +600,7 @@ of what was pushed. ## Risks and Concerns -### Risk: alkcall is v0.1.x — API stability +### Risk: alkcall is v0.1.x (API stability) alkcall is published at 0.1.1. Breaking changes are expected at this major-zero stage. The integration surface we use @@ -656,17 +658,17 @@ from the BAST will be wrong. `serde_json` and asserts the stream-type enum values match `wire.rs`'s `STREAM_STDIN`/`STREAM_STDOUT`/`STREAM_STDERR`/ `STREAM_CTRL_IN`/`STREAM_CTRL_OUT` constants. Cheap; catches the -common drift case. (Doesn't require alktype as a dep — just +common drift case. (Doesn't require alktype as a dep, just `serde_json`, which we already have.) ## References - [alkcall architecture README](/workspace/@alkdev/alkcall/docs/architecture/README.md) -- [alkcall channels operations](/workspace/@alkdev/alkcall/src/channels/operations.rs) — `ChannelCore::register_openable`, `OpenHandler` -- [alkcall channels client](/workspace/@alkdev/alkcall/src/channels/client.rs) — `ChannelClient::open_channel`, reference integration pattern -- [alktype BAST format](/workspace/@alkdev/alktype/docs/architecture/bast-format.md) — normative format spec for the `tty-bast.md` document -- [alknet-tty source](/workspace/@alkdev/alknet/crates/alknet-tty/src/) — port origin (wire, control, negotiation, backend, adapter) -- [alknet-tty-local source](/workspace/@alkdev/alknet/crates/alknet-tty-local/src/) — port origin (local backend, pty, pipe) -- [alknet-tty architecture docs](/workspace/@alkdev/alknet/docs/architecture/crates/tty/) — port origin for the spec docs -- [ADR-093](/workspace/@alkdev/alknet/docs/architecture/decisions/093-channels-pure-channel-multiplexing.md) — TTY always uses 5-byte format -- [ADR-077](/workspace/@alkdev/alknet/docs/architecture/decisions/077-tty-inside-channels.md) — reversed, historical context +- [alkcall channels operations](/workspace/@alkdev/alkcall/src/channels/operations.rs): `ChannelCore::register_openable`, `OpenHandler` +- [alkcall channels client](/workspace/@alkdev/alkcall/src/channels/client.rs): `ChannelClient::open_channel`, reference integration pattern +- [alktype BAST format](/workspace/@alkdev/alktype/docs/architecture/bast-format.md): normative format spec for the `tty-bast.md` document +- [alknet-tty source](/workspace/@alkdev/alknet/crates/alknet-tty/src/): port origin (wire, control, negotiation, backend, adapter) +- [alknet-tty-local source](/workspace/@alkdev/alknet/crates/alknet-tty-local/src/): port origin (local backend, pty, pipe) +- [alknet-tty architecture docs](/workspace/@alkdev/alknet/docs/architecture/crates/tty/): port origin for the spec docs +- [ADR-093](/workspace/@alkdev/alknet/docs/architecture/decisions/093-channels-pure-channel-multiplexing.md): TTY always uses 5-byte format +- [ADR-077](/workspace/@alkdev/alknet/docs/architecture/decisions/077-tty-inside-channels.md): reversed, historical context diff --git a/docs/sdd_process.md b/docs/sdd_process.md index 0ee8a31..88726fc 100644 --- a/docs/sdd_process.md +++ b/docs/sdd_process.md @@ -18,7 +18,7 @@ leverages: 4. **Task-Driven**: Structured task graphs with dependency analysis 5. **Safe Exit**: Always have a way to unblock progress when stuck 6. **Categorical Estimates**: Use risk/scope/impact categories, not time - estimates. These are structurally important — upstream failures multiply + estimates. These are structurally important: upstream failures multiply downstream damage regardless of developer type (human or LLM). See the cost-benefit framework in taskgraph's framework docs. @@ -47,11 +47,11 @@ approaches **Process**: 1. Architect creates the architecture documentation structure: - - `docs/architecture/README.md` — index with doc table, ADR table, lifecycle - - `docs/architecture/overview.md` — package purpose, exports, dependencies - - `docs/architecture/.md` — one focused doc per component/area - - `docs/architecture/decisions/` — numbered ADR files (ADR-001, ADR-002, ...) - - `docs/architecture/open-questions.md` — centralized tracker with OQ-IDs + - `docs/architecture/README.md`: index with doc table, ADR table, lifecycle + - `docs/architecture/overview.md`: package purpose, exports, dependencies + - `docs/architecture/.md`: one focused doc per component/area + - `docs/architecture/decisions/`: numbered ADR files (ADR-001, ADR-002, ...) + - `docs/architecture/open-questions.md`: centralized tracker with OQ-IDs 2. All docs start in `Draft` status. Spec docs reference ADRs by number (not inline rationale) and OQs by number (not inline questions). 3. Architecture Review validates for ambiguities, risks, and structural issues @@ -134,16 +134,16 @@ WHAT, ADRs explain WHY, open questions track what's unresolved. - Extract decisions into numbered ADRs in `decisions/` directory - Centralize open questions in `open-questions.md` with OQ-IDs - Maintain `README.md` as the architecture index -- Keep spec documents focused (~500 lines) — reference ADRs and OQs, don't inline them +- Keep spec documents focused (~500 lines): reference ADRs and OQs, don't inline them - Redirect exploration work to Research Specialist - Iterate based on review feedback **Deliverables**: -- `docs/architecture/README.md` — index with doc table, ADR table, lifecycle -- `docs/architecture/.md` — focused spec documents -- `docs/architecture/decisions/` — numbered ADR files -- `docs/architecture/open-questions.md` — centralized OQ tracker +- `docs/architecture/README.md`: index with doc table, ADR table, lifecycle +- `docs/architecture/.md`: focused spec documents +- `docs/architecture/decisions/`: numbered ADR files +- `docs/architecture/open-questions.md`: centralized OQ tracker --- @@ -179,17 +179,17 @@ sessions. **Mode**: Primary (manages worktrees and agent sessions) **Uses**: The `worktree` tool from the **open-coordinator** opencode plugin. -Single tool with `{action, args}` dispatch. Role is auto-detected — coordinator +Single tool with `{action, args}` dispatch. Role is auto-detected: coordinator sessions get the full operation set, spawned implementation sessions get a limited set (current, notify, status). No mode toggle required. **Tools**: -- `worktree({action, args})` — spawn, sessions, dashboard, message, abort, +- `worktree({action, args})`: spawn, sessions, dashboard, message, abort, cleanup - Bash (opencode CLI for session interaction) - Read (monitor task files) -- `memory` / `memory_compact` — context management and session history (via +- `memory` / `memory_compact`: context management and session history (via @alkdev/open-memory, when available) **Key Behaviors**: @@ -218,10 +218,10 @@ limited set (current, notify, status). No mode toggle required. **Tools**: - Read, Write, Edit, Glob, Grep, Bash -- `worktree({action: "notify", ...})` — report progress/blockers to coordinator -- `worktree({action: "current"})` — verify worktree assignment +- `worktree({action: "notify", ...})`: report progress/blockers to coordinator +- `worktree({action: "current"})`: verify worktree assignment - webSearch (documentation lookup) -- `memory` / `memory_compact` — context management (via @alkdev/open-memory, +- `memory` / `memory_compact`: context management (via @alkdev/open-memory, when available) **Key Behaviors**: @@ -388,7 +388,7 @@ Implement OAuth2 authentication with provider abstraction. ### Categorical Estimates These fields are structurally important, not optional metadata. They power -`taskgraph decompose`, `risk-path`, `critical`, and `bottleneck` — commands that +`taskgraph decompose`, `risk-path`, `critical`, and `bottleneck`, commands that reveal structural problems in the task graph. A task missing `scope`, `risk`, `impact`, or `level` is a red flag indicating incomplete decomposition. See the cost-benefit framework in taskgraph's framework docs for the reasoning. @@ -422,15 +422,15 @@ cost-benefit framework in taskgraph's framework docs for the reasoning. ### Architecture work and the `level` field -Architecture work uses the same taskgraph format as implementation work — +Architecture work uses the same taskgraph format as implementation work; there is no separate `architecture` level. The `level` enum (`planning, decomposition, implementation, review, research`) already covers architecture activities: | Architecture activity | `level` | Rationale | |---|---|---| -| Writing an ADR (the decision) | `implementation` | The ADR *is* the deliverable — the implementation of an architecture decision. Calling it `planning` would mislabel the artifact. | -| Decomposing a spec into ADRs + OQs | `decomposition` | Direct fit — breaking a large architecture area into decideable units. | +| Writing an ADR (the decision) | `implementation` | The ADR *is* the deliverable: the implementation of an architecture decision. Calling it `planning` would mislabel the artifact. | +| Decomposing a spec into ADRs + OQs | `decomposition` | Direct fit: breaking a large architecture area into decideable units. | | Architecture review pass | `review` | Direct fit. | | Research / POC before a decision | `research` | Direct fit. | | Spec writing (overview, component spec) | `implementation` | The spec is the deliverable. | @@ -480,7 +480,7 @@ the deferral has two halves that must stay in sync: 2. **The machine-readable half** (in `tasks/architecture/`): an external-trigger tracker task represents the unblocking condition. It is tagged `[external-trigger, deferred-oq]`, has `risk: trivial` and - `level: research` (it is not actionable work — it tracks whether the + `level: research` (it is not actionable work; it tracks whether the external condition has arrived), and its `id` is referenced from the OQ's `Blocked on:` text. This is the surface for the task graph: `taskgraph` tools can reason about it, and downstream work that depends on the @@ -490,7 +490,7 @@ When the unblocking condition arrives (a use case materializes, a crate is specced), the tracker task is marked `completed` and the OQ transitions from `deferred(scope)` to `open` (or directly to `resolved` if no architecture decision remains). The two halves serve different audiences and use one edge -type (`depends_on`) — the reverse lookup uses `taskgraph dependents`. Do not +type (`depends_on`); the reverse lookup uses `taskgraph dependents`. Do not add a `blocks:` field; it duplicates the edge and creates a sync hazard. ## Review Injection @@ -509,7 +509,7 @@ Use graph analysis to determine where reviews should happen: ### Current (open-coordinator plugin) The Coordinator uses the `worktree` tool from the open-coordinator opencode -plugin. It's a single tool with `{action, args}` dispatch — no separate +plugin. It's a single tool with `{action, args}` dispatch; no separate enable/toggle steps. Role is auto-detected from session state. ``` @@ -563,13 +563,13 @@ a worktree. When the open-memory plugin is available alongside open-coordinator, the coordinator gains: -- `memory({tool: "children", args: {sessionId: "..."}})` — view sub-agent +- `memory({tool: "children", args: {sessionId: "..."}})`: view sub-agent sessions spawned from the coordinator -- `memory({tool: "messages", args: {sessionId: "..."}})` — read a spawned +- `memory({tool: "messages", args: {sessionId: "..."}})`: read a spawned session's conversation for debugging -- `memory({tool: "context"})` — check context window usage before long +- `memory({tool: "context"})`: check context window usage before long monitoring sessions -- `memory_compact()` — proactively compact at natural breakpoints +- `memory_compact()`: proactively compact at natural breakpoints Implementation agents can also use `memory({tool: "context"})` and `memory_compact()` to manage their context during long tasks. @@ -593,7 +593,7 @@ Once the hub is operational, coordination uses native operations: ``` State moves from in-process tracking to Postgres `mappings` table. The -open-coordinator plugin becomes unnecessary — the hub provides the same +open-coordinator plugin becomes unnecessary; the hub provides the same capabilities as server-side operations accessible from any environment. ## Document Structure @@ -660,7 +660,7 @@ Accepted | Proposed | Deprecated | Superseded (Links to related specs and ADRs) ``` -Numbering starts at 001 within each project. ADRs are stable — once Accepted, +Numbering starts at 001 within each project. ADRs are stable: once Accepted, they don't revert. If superseded, mark the old one and create a new one. ### Open Questions Format @@ -676,7 +676,7 @@ they don't revert. If superseded, mark the old one and create a new one. - **Status**: open | resolved | deferred(scope) | partially resolved - **Priority**: high | medium | low - **Resolution**: (when resolved) -- **Blocked on**: (when deferred — concrete dependency that would unblock) +- **Blocked on**: (when deferred: concrete dependency that would unblock) - **Cross-references**: OQ-NN, ADR-NNN ``` diff --git a/fuzz/README.md b/fuzz/README.md index 1f6fad6..bea3ca3 100644 --- a/fuzz/README.md +++ b/fuzz/README.md @@ -6,12 +6,12 @@ operating rules live in `docs/plans/fuzzing.md` (adopted from alkcall's ## Layout -- `fuzz_targets/` — nightly-only `fuzz_target!` binaries (thin wrappers). -- `shared/` — stable-toolchain library holding the invariant logic; the +- `fuzz_targets/`: nightly-only `fuzz_target!` binaries (thin wrappers). +- `shared/` is the stable-toolchain library holding the invariant logic; the corpus replay tests run here on plain `cargo test`. -- `corpus//` — committed seeds (regenerate with +- `corpus//` holds the committed seeds (regenerate with `python3 fuzz/gen_fuzz_seeds.py`). -- `artifacts/` — gitignored crash/oom/timeout artifacts + campaign logs. +- `artifacts/`: gitignored crash/oom/timeout artifacts + campaign logs. ## Targets @@ -20,9 +20,9 @@ operating rules live in `docs/plans/fuzzing.md` (adopted from alkcall's | `chunk_frame` | `ChunkReader`/`ChunkWriter` (5-byte chunk codec, ADR-001) | | `negotiation_frame` | `NegotiationReader`/`NegotiationWriter` + `NegotiateRequest` + `error_response_bytes` + the cross-codec peek-disambiguation seam | | `control_json` | `ControlMessage::from_slice`/`to_json` + `signal_from_name` | -| `session_opseq` | the producer session pump (`drive_session_pre_negotiated`) + a fuzz-local mock backend — the stateful op-sequence target (ADR-004/ADR-005 invariants; the alkcall-bug-class hunt) | +| `session_opseq` | the producer session pump (`drive_session_pre_negotiated`) + a fuzz-local mock backend: the stateful op-sequence target (ADR-004/ADR-005 invariants; the alkcall-bug-class hunt) | -## Running a campaign — always detached +## Running a campaign (always detached) Agent sessions must never run fuzzing in the foreground (an OOM in a target can take down the session host; see docs/plans/fuzzing.md §3). @@ -63,11 +63,11 @@ Run campaigns before releases and after touching `src/wire.rs`, ## session_opseq notes The op-sequence target is inherently slower than the byte-format targets -(each input drives a full tokio session with bounded reads and yields — -tens of exec/s per worker, not millions). That is expected: the value is +(each input drives a full tokio session with bounded reads and yields, +which means tens of exec/s per worker, not millions). That is expected: the value is state-space exploration, not throughput. It uses `tokio::time::timeout` internally, so the nightly target needs the `--features time` tokio -surface already present via the shared crate's deps — no extra flags. +surface already present via the shared crate's deps; no extra flags. Its invariants (kill-on-Drop, exit-chunk-is-last, sentinel/FIFO semantics) are the ones unit tests assert individually; the fuzzer explores interleavings no example test can enumerate. \ No newline at end of file diff --git a/src/adapter.rs b/src/adapter.rs index 73141d0..0e9c064 100644 --- a/src/adapter.rs +++ b/src/adapter.rs @@ -10,13 +10,13 @@ //! //! A `alk/tty` session on one bidi stream proceeds in three phases: //! -//! 1. **Negotiation** — read a length-prefixed JSON frame, parse +//! 1. **Negotiation**: read a length-prefixed JSON frame, parse //! `NegotiateRequest`, validate (`carriage == "raw"`, `cmd` non-empty), //! look up the `TtyBackend`, run access control, construct `TtyParams`. //! Errors → JSON error response in negotiation framing, stream close. -//! 2. **Allocation** — `backend.allocate(¶ms)`. Errors → +//! 2. **Allocation**: `backend.allocate(¶ms)`. Errors → //! `allocate_failed` JSON error response, stream close. -//! 3. **Raw carriage** — three concurrent pumps: +//! 3. **Raw carriage**: three concurrent pumps: //! - **A. stdout → client**: `TtyHandle.stdout` → stdout chunks //! (stream_type 1); a concurrent stderr pump emits stderr chunks //! (stream_type 2) when `TtyHandle.stderr` is `Some`. On backend stdout @@ -48,17 +48,17 @@ //! 3` was documented as "bidirectional" but the adapter ignored `Exit` //! from the client because the two directions were indistinguishable on //! the same stream_type. The split makes the bidirectionality explicit -//! — see `docs/architecture/tty-wire.md` §"Control Channel". +//! (see `docs/architecture/tty-wire.md` §"Control Channel"). //! //! # Cancel cleanup (ADR-056) //! //! On connection drop or stream reset, the pump tasks are dropped, which //! drops the `TtyHandle`, which drops the `exit_code` future without -//! driving it to completion — the backend's kill-on-`Drop` guard fires +//! driving it to completion; the backend's kill-on-`Drop` guard fires //! and kills the session target. The adapter has no separate kill //! method; the cleanup is wired into the `exit_code` future's `Drop` by //! the backend. A client closing the write half (stdin EOF) does NOT -//! trigger cancel-cleanup — the session runs to completion. +//! trigger cancel-cleanup, and the session runs to completion. use std::collections::HashMap; use std::sync::Arc; @@ -91,7 +91,7 @@ pub const TTY_OPEN_SCOPE: &str = "tty:open"; /// resource-ownership check. `handle()` accepts the connection and loops /// `accept_bi`, dispatching each bidi stream to a [`drive_session`] task. /// -/// One `alk/tty` connection hosts multiple terminal sessions — one session +/// One `alk/tty` connection hosts multiple terminal sessions: one session /// per bidi stream (DP-6). Sessions are independent: one session's exit /// doesn't affect another. pub struct TtyAdapter { @@ -101,7 +101,7 @@ pub struct TtyAdapter { impl TtyAdapter { /// Construct with the given backend map and no ownership provider - /// (scope-gate only — no resource-level ACL). + /// (scope-gate only; no resource-level ACL). pub fn new(backends: HashMap>) -> Self { Self { backends: Arc::new(backends), @@ -170,7 +170,7 @@ fn has_scope(identity: &Option, scope: &str) -> bool { /// /// Shared by the direct path's failure arms and the channels open /// handler (`make_tty_open_handler` writes the same `malformed_negotiation` -/// frame on a `NegotiateRequest` parse failure of the open op's `input` — +/// frame on a `NegotiateRequest` parse failure of the open op's `input`; /// the pre-negotiated driver has no writer to consume, so the handler /// builds its own over the channel's write half). `pub(crate)` so the /// channels handler reuses it; not public API. @@ -195,11 +195,11 @@ pub(crate) async fn send_negotiation_error( /// `client_send` / `client_recv` are the two halves of the bidi stream /// (split from the `BiStream` yielded by `accept_bi` via `tokio::io::split`). /// Returns when the session is complete (exit chunk sent, stream closed) or -/// when the stream is reset (cancel-cleanup path — no exit chunk sent). +/// when the stream is reset (cancel-cleanup path: no exit chunk sent). /// -/// This is the per-stream session driver — generalized from the POC's +/// This is the per-stream session driver, generalized from the POC's /// `session::drive_session` to the [`TtyBackend`] trait. The negotiation -/// frame is read from the wire (the direct-ALPN path — ADR-052); the +/// frame is read from the wire (the direct-ALPN path, ADR-052); the /// channels path (open-op `input` is the negotiation) uses /// [`drive_session_pre_negotiated`] instead. pub async fn drive_session( @@ -217,18 +217,18 @@ pub async fn drive_session( } /// Drive a `alk/tty` session whose negotiation already happened out of -/// band — the channels path (ADR-009). The open op's registry-validated +/// band: the channels path (ADR-009). The open op's registry-validated /// `input` was parsed into `req` by the channels wrapper; the raw-chunk /// data plane starts immediately. No negotiation frame is read from the /// wire, and none is written by the client. /// /// Validation still runs here (`carriage`/`cmd`/backend lookup) plus the -/// ADR-050 ownership check — failures go to the client as a negotiation +/// ADR-050 ownership check; failures go to the client as a negotiation /// error frame on the channel stream (the same framing the direct path /// uses, so the consumer's M1 disambiguation read applies unchanged). /// The `tty:open` scope gate is NOT re-checked: on the channels path the /// registry's `AccessControl` enforced it at open time (the identity view -/// the registry checked — resolved from the connection — is the +/// the registry checked (resolved from the connection) is the /// authoritative one; the handler-side identity is only the /// ownership-check subject). pub async fn drive_session_pre_negotiated( @@ -254,7 +254,7 @@ pub async fn drive_session_pre_negotiated( } /// Drive a `alk/tty` session whose negotiation AND allocation already -/// happened out of band — the channels path with an establisher +/// happened out of band, namely the channels path with an establisher /// (alkcall 0.6, ADR-010 as amended). The establisher validated the /// request and ran `backend.allocate`; its `TtyHandle` arrived via the /// establishment plan (`AllocatedHandle`, taken by the open handler). @@ -289,11 +289,11 @@ pub(crate) async fn drive_session_pre_allocated( /// /// `enforce_scope` gates the `tty:open` scope check: `true` on the direct /// path (the adapter is the only gate), `false` on the channels path (the -/// registry's `AccessControl` enforced the scope at open time — see +/// registry's `AccessControl` enforced the scope at open time; see /// [`drive_session_pre_negotiated`]). The scope gate runs *first* (review /// #003 P5): checking it after the backend lookup would let an /// authenticated-but-unscoped identity distinguish registered backend -/// names (`unknown_backend`) from unregistered ones (`forbidden`) — a +/// names (`unknown_backend`) from unregistered ones (`forbidden`): a /// name-enumeration differential. Gated first, every unscoped request /// gets the same `forbidden` regardless of the request body. #[allow(clippy::type_complexity)] @@ -458,8 +458,8 @@ where /// Enforces the exit-chunk-is-last invariant (ADR-055): the exit chunk /// is enqueued only after the stdout/stderr pumps have finished sending /// AND `exit_code` has resolved. The drainer runs concurrently with the -/// pumps so the shared writer channel is always being consumed — see -/// `drain_chunks`. +/// pumps so the shared writer channel is always being consumed (see +/// `drain_chunks`). async fn pump_session( client_write: W, client_read: R, @@ -519,7 +519,7 @@ where drop(writer_tx); // The client→backend pump outlives the session data plane only until - // the client disconnects; aborting it at session end is tighter — no + // the client disconnects; aborting it at session end is tighter: no // lingering task holding the backend stdin half after the exit chunk. input_pump.abort(); @@ -614,7 +614,7 @@ async fn pump_stderr( /// (`Exit` is server→client only, belongs on `STREAM_CTRL_OUT`); the /// adapter ignores it. (The previous single `STREAM_CONTROL = 3` /// couldn't distinguish the two directions, so `Exit` from the client -/// was always ignored — the split makes the rejection explicit.) +/// was always ignored; the split makes the rejection explicit.) /// - `STREAM_CTRL_OUT` (stream_type 4) from the client is a protocol /// violation (it's the server→client half); the adapter ignores it. async fn pump_client_to_backend( @@ -828,7 +828,7 @@ mod tests { } } - /// `AsyncWrite` adapter over `mpsc::Sender` — the test backend's + /// `AsyncWrite` adapter over `mpsc::Sender`, the test backend's /// stdin sink. On shutdown, drops the sender so the test's /// `stdin_rx` observes EOF (channel close). struct TestStdinSink { @@ -877,7 +877,7 @@ mod tests { /// A kill-guard future wrapping `oneshot::Receiver>`. /// On `Drop`-without-resolve, sets `cancel_dropped` to true (the - /// cancel-cleanup signal for tests — ADR-056). + /// cancel-cleanup signal for tests, ADR-056). struct ExitFuture { rx: Option>>, cancel_dropped: Arc>, @@ -1386,7 +1386,7 @@ mod tests { #[tokio::test] async fn exit_chunk_arrives_on_ctrl_out_not_ctrl_in() { // Verifies the adapter emits `Exit` on `STREAM_CTRL_OUT` (4), not - // `STREAM_CTRL_IN` (3) — the Phase 7 bidirectionality fix. A client + // `STREAM_CTRL_IN` (3), the Phase 7 bidirectionality fix. A client // distinguishing the two halves can route exit vs. control // without parsing the JSON tag first. let (backend, _control, _cancel) = TestBackend::builder().build(); diff --git a/src/backend.rs b/src/backend.rs index 2407c58..0b5e698 100644 --- a/src/backend.rs +++ b/src/backend.rs @@ -5,7 +5,7 @@ //! (`crate::adapter`) and the backend crates (`alktty`'s own `local` //! feature module, future `alknet-docker`, `alknet-ssh`). alktty //! defines the trait; the -//! backends implement it. The trait shape is a one-way door — changing it +//! backends implement it. The trait shape is a one-way door: changing it //! after backends exist is a rewrite across crates. The adapter holds a //! `HashMap>` keyed by the negotiation frame's //! `backend` string and pumps the `TtyHandle` fields bidirectionally; @@ -32,7 +32,7 @@ use crate::negotiation::{NegotiateRequest, TerminalParamsWire}; /// `futures::future::BoxFuture<'static, T>` to match the alkcall /// convention (alkcall pulls in `futures`, so no new transitive dep). /// Functionally identical to the hand-rolled `Pin>` — `futures::future::BoxFuture<'static, T>` is +/// Send + 'static>>`: `futures::future::BoxFuture<'static, T>` is /// exactly that alias. pub type BoxFuture = futures::future::BoxFuture<'static, T>; @@ -40,7 +40,7 @@ pub type BoxFuture = futures::future::BoxFuture<'static, T>; /// [`TtyHandle::exit_code`] future. /// /// `#[non_exhaustive]` so new variants are additive (two-way-door extension -/// within the one-way trait shape — ADR-053). +/// within the one-way trait shape; see ADR-053). #[non_exhaustive] #[derive(Debug, thiserror::Error)] pub enum TtyError { @@ -79,13 +79,13 @@ pub struct TerminalParams { pub rows: u16, pub pixel_width: u16, pub pixel_height: u16, - /// Reserved — OQ-44; backends MUST ignore the content in v1. + /// Reserved (OQ-44); backends MUST ignore the content in v1. pub modes: serde_json::Value, } /// The allocation request the adapter passes to [`TtyBackend::allocate`]. /// -/// `terminal: None` is pipe/runner mode (no PTY, separate stdout/stderr — +/// `terminal: None` is pipe/runner mode (no PTY, separate stdout/stderr; /// ADR-054). `terminal: Some` is PTY mode (stdout/stderr merged into /// `stdout` by the kernel PTY, real terminal semantics). /// @@ -95,7 +95,7 @@ pub struct TerminalParams { /// §"Backend params are opaque." #[derive(Debug, Clone)] pub struct TtyParams { - /// Terminal parameters. `None` = pipe mode (no PTY — ADR-054). `Some` = + /// Terminal parameters. `None` = pipe mode (no PTY; ADR-054). `Some` = /// allocate a PTY with these dimensions. pub terminal: Option, /// Command vector (`argv[0]` + args). Non-empty. @@ -127,7 +127,7 @@ impl From for TerminalParams { /// Map a wire negotiation frame to the allocation request the backend /// consumes. The adapter calls this after parsing the negotiation /// carriage; `backend_params` is passed through verbatim (alktty has -/// zero knowledge of any backend's params shape — ADR-053). Lives here so +/// zero knowledge of any backend's params shape; ADR-053). Lives here so /// the adapter does not hand-roll the mapping. impl From for TtyParams { fn from(req: NegotiateRequest) -> Self { @@ -144,25 +144,25 @@ impl From for TtyParams { /// What a backend's `allocate()` produces. The adapter pumps these fields /// bidirectionally against the wire format (ADR-052). pub struct TtyHandle { - /// Stdin writer — bytes the adapter pumps from client stdin chunks. - /// `tokio::io::AsyncWrite` (the tokio flavor, not `futures::io` — they + /// Stdin writer: bytes the adapter pumps from client stdin chunks. + /// `tokio::io::AsyncWrite` (the tokio flavor, not `futures::io`, as they /// are incompatible traits; the tokio stack is the adapter's runtime). pub stdin: Box, - /// Stdout stream — bytes the adapter pumps to client stdout chunks. + /// Stdout stream: bytes the adapter pumps to client stdout chunks. /// Ends when the backend's stdout reaches EOF. pub stdout: Pin + Send>>, - /// Stderr stream — `None` for PTY backends (stdout/stderr merged into + /// Stderr stream: `None` for PTY backends (stdout/stderr merged into /// `stdout` by the kernel PTY). `Some` for pipe backends (separate /// streams). pub stderr: Option + Send>>>, - /// Exit code — a `Future` the adapter awaits. Resolves when the + /// Exit code: a `Future` the adapter awaits. Resolves when the /// process/container/SSH exec exits. The adapter sends the result as /// the `{"type":"exit","code":N}` control chunk (ADR-055) and closes /// the stream. This is a `BoxFuture`, not a method on `TtyHandle`, so /// the adapter can `select` between exit and stream-close without /// coupling to the other fields (REQ-TTY-01). /// - /// # ADR-056 — kill-on-Drop contract + /// # ADR-056: kill-on-Drop contract /// /// Dropping this future without driving it to completion MUST kill the /// session target (the child process, the docker exec, the SSH @@ -182,7 +182,7 @@ pub struct TtyHandle { /// adapter awaited it to completion). See ADR-056 and `tty-local.md` /// §"Cancel-Cleanup" for the local backend's mechanism. pub exit_code: BoxFuture>, - /// Control handle (resize, signal) — `Clone` so the adapter can hand + /// Control handle (resize, signal); `Clone` so the adapter can hand /// it to the spawned control-chunk dispatcher. `None` only when the /// backend genuinely has no control path. See OQ-43. pub control: Option, @@ -190,7 +190,7 @@ pub struct TtyHandle { /// Control path for a live terminal session: resize and signal forwarding. /// -/// Object-safe (`Send + Sync`, no `Clone` — `Clone` is not object-safe). +/// Object-safe (`Send + Sync`, no `Clone`, since `Clone` is not object-safe). /// The `Clone`-ability lives on the [`TtyControlHandle`] newtype, which /// holds the trait object behind an `Arc`. A backend produces its own /// control type via `TtyControlHandle::new(Arc::new(MyControl))` without @@ -209,7 +209,7 @@ pub trait TtyControl: Send + Sync { } /// The `Clone`-able handle to a backend's control path. The -/// [`TtyControl`] trait is NOT `Clone` (`Clone` is not object-safe — +/// [`TtyControl`] trait is NOT `Clone` (`Clone` is not object-safe; /// `fn clone(&self) -> Self` returns `Self`, which forbids `dyn` dispatch); /// the `Clone`-ability lives on this concrete newtype, which holds the /// trait object behind an `Arc`. The adapter clones the `Arc` to hand a @@ -244,7 +244,7 @@ impl TtyControlHandle { /// `HashMap>` keyed by the negotiation frame's /// `backend` string and dispatches by that key. /// -/// # REQ-TTY-01 — backends need not be natively async +/// # REQ-TTY-01: backends need not be natively async /// /// The adapter-facing types this trait returns (`AsyncWrite`, /// `Stream`, `BoxFuture`, `TtyControl`) are the **adapter's @@ -260,7 +260,7 @@ impl TtyControlHandle { /// wrapped in a kill-guard future (exit). The adapter consumes the bridged /// async-facing types and is unaware of the threading. /// -/// # ADR-056 — kill-on-Drop contract +/// # ADR-056: kill-on-Drop contract /// /// The [`TtyHandle::exit_code`] future returned by `allocate()` MUST kill /// the session target when dropped without being driven to completion. See @@ -274,7 +274,7 @@ pub trait TtyBackend: Send + Sync { /// The pre-existing resource this session targets, for ownership /// checks (ADR-050). `None` = no pre-existing resource (the session - /// creates its own — local process, SSH channel). `Some((kind, id))` + /// creates its own: a local process, an SSH channel). `Some((kind, id))` /// = the session targets an existing resource the caller must own /// (e.g., `DockerTtyBackend` returns `Some(("container", id))`). The /// adapter calls this at negotiation to gate access; the backend @@ -371,7 +371,7 @@ impl TtyBackend for MockBackend { } } -/// `AsyncWrite` adapter over an `mpsc::Sender` — the mock +/// `AsyncWrite` adapter over an `mpsc::Sender`, the mock /// backend's stdin sink. Copies the buffer into a `Bytes` and best-effort /// sends; on a full channel returns `Pending`, on a closed channel returns /// a broken-pipe error. diff --git a/src/channels.rs b/src/channels.rs index aaefbfa..13917a5 100644 --- a/src/channels.rs +++ b/src/channels.rs @@ -1,4 +1,4 @@ -//! Producer half of the channels integration — the `register_openable` +//! Producer half of the channels integration: the `register_openable` //! helper and the `TtyOpenHandler` factory (ADR-047 §3, amended by //! alkcall ADR-049 / alktty ADR-010 with an establishment phase). //! @@ -15,10 +15,10 @@ //! `{ channel_id }`. The establisher performs the semantic validation //! (full `NegotiateRequest` parse, `carriage == "raw"`, backend //! lookup, ADR-050 ownership check) and fails the open op with -//! `channel:open_failed` on rejection — no phantom channel. The +//! `channel:open_failed` on rejection; no phantom channel. The //! `TtyOpenHandler` receives the channel's `Connection` (data-plane //! ALPN `alk/tty`), calls `accept_bi()` to get the channel's -//! `BiStream`, and runs the session pumps on it — the same three-pump +//! `BiStream`, and runs the session pumps on it: the same three-pump //! session driver as the direct-ALPN path, minus the wire-frame //! negotiation phase (the open op's `input` is the negotiation, //! ADR-009). Backend allocation runs in the establisher (alkcall 0.6 @@ -28,7 +28,7 @@ //! //! The access control (scope-gate + ownership) is wired into the //! `OperationSpec`'s `AccessControl` and enforced by the registry's -//! `invoke`/`invoke_streaming` before the establisher runs — the +//! `invoke`/`invoke_streaming` before the establisher runs; the //! establisher itself only runs the ADR-050 ownership check (the //! scope gate is the registry's); this is the difference from the //! direct-ALPN `TtyAdapter`, which does its own ad-hoc scope check; @@ -65,7 +65,7 @@ pub const OP_TTY_OPEN: &str = "channels/tty/sub"; pub const TTY_ALPN: &str = "alk/tty"; /// Register the `channels/tty/sub` open op on a per-connection -/// `OperationRegistry` (ADR-047 §3, as amended 2026-08-13 — +/// `OperationRegistry` (ADR-047 §3, as amended 2026-08-13, /// per-connection registration). /// /// The assembly layer (an `alk/channels` acceptor's @@ -76,14 +76,14 @@ pub const TTY_ALPN: &str = "alk/tty"; /// `channels/tty/sub` with the `channel_open` marker set /// (`ChannelOpenSpec::new("alk/tty")`), an `AccessControl` carrying /// the `tty:open` scope gate, and the partial `NegotiateRequest` -/// input schema (`carriage`/`backend`/`cmd` required — alkcall 0.4 +/// input schema (`carriage`/`backend`/`cmd` required, since alkcall 0.4 /// enforces it at dispatch; the full parse and semantic validation /// runs in the handler). It then wraps the /// `TtyOpenHandler` factory and calls /// [`ChannelCore::register_openable`]. /// /// `backends` is the same backend map the direct-ALPN `TtyAdapter` -/// holds — `HashMap>` keyed by the +/// holds: `HashMap>` keyed by the /// negotiation frame's `backend` string. The assembly layer typically /// shares one backend map between the direct-ALPN adapter and the /// channels `register_openable` registration. @@ -92,7 +92,7 @@ pub const TTY_ALPN: &str = "alk/tty"; /// resource-ownership check. `None` = scope-gate only. The provider /// is consulted inside `drive_session_pre_negotiated` (after the /// backend is selected and `resource_id` is extracted from -/// `backend_params`), not by the channels wrapper — the wrapper's +/// `backend_params`), not by the channels wrapper; the wrapper's /// `AccessControl` carries only the scope gate. This mirrors the /// direct-ALPN `TtyAdapter` shape: the scope gate is enforced once at /// the entry point (here by the registry's `AccessControl`), the @@ -101,14 +101,14 @@ pub const TTY_ALPN: &str = "alk/tty"; /// `auth` is the peer's `AuthContext`, the install-time context passed /// through to `register_openable_with_establisher`. The establisher /// and pump handler read their identity from the per-call context -/// alkcall derives (alkcall 0.7, CF-006 corollary) — the +/// alkcall derives (alkcall 0.7, CF-006 corollary): the /// dispatch-resolved opener, the same view the registry's ACL gate /// checked. /// /// # Errors /// /// Returns an error if `ChannelCore::register_openable` rejects the -/// spec (e.g., the spec has no `channel_open` marker — won't happen +/// spec (e.g., the spec has no `channel_open` marker; won't happen /// with the spec this helper builds, but the underlying call is /// fallible). pub fn register_openable( @@ -133,7 +133,7 @@ pub fn register_openable( /// Build the [`OperationSpec`] for `channels/tty/sub`. /// -/// The op is `Sub`-typed (consumer-opens — the initiator opens a +/// The op is `Sub`-typed (consumer-opens: the initiator opens a /// channel and the data plane flows on the channel's `BiStream`, not /// in the call response stream). The `channel_open` marker is set to /// `ChannelOpenSpec::new("alk/tty")` so the channels wrapper allocates @@ -141,26 +141,26 @@ pub fn register_openable( /// gate (the same scope the direct-ALPN `TtyAdapter` checks). /// /// The input schema is the shared part of the `NegotiateRequest` shape -/// (`carriage`/`backend`/`cmd` required — alkcall 0.4 enforces it at -/// dispatch). The schema is deliberately partial: `tty`, `cwd`, `env`, +/// (`carriage`/`backend`/`cmd` required, enforced at dispatch by +/// alkcall 0.4). The schema is deliberately partial: `tty`, `cwd`, `env`, /// and backend-specific selector fields are free-form (`additionalProperties` /// defaults to `true` in raw JSON Schema), because the /// `NegotiateRequest` wire shape is owned by `negotiation.rs` and the -/// backend params are opaque (ADR-053) — duplicating their full schemas +/// backend params are opaque (ADR-053); duplicating their full schemas /// here would create a second definition to drift. The registry check /// catches structurally-broken opens (missing command, wrong types); /// the full parse and semantic validation (`carriage == "raw"`, /// backend lookup, ownership) and backend allocation run in the -/// establisher (ADR-010, as amended for alkcall 0.6 — failures are +/// establisher (ADR-010, as amended for alkcall 0.6: failures are /// `channel:open_failed`, including `allocate_failed` as /// `dial_failed`). /// /// The op declares an [`ErrorDefinition`] for `channel:open_failed` -/// (ADR-016 — establishment failures are disclosed so `services/schema` +/// (ADR-016: establishment failures are disclosed so `services/schema` /// shows the failure contract; reason ∈ `unknown_resource` / -/// `handler_error` / `timeout`, per the ADR-049 vocabulary — see -/// `make_tty_establisher` for the mapping) and carries a -/// `description` (review 006 E-02 — discovery enrichment). +/// `handler_error` / `timeout`, per the ADR-049 vocabulary, with the +/// mapping in `make_tty_establisher`) and carries a +/// `description` (review 006 E-02, discovery enrichment). pub fn tty_open_spec() -> OperationSpec { let open_failed_schema = json!({ "type": "object", @@ -222,7 +222,7 @@ pub fn tty_open_spec() -> OperationSpec { .with_channel_open(ChannelOpenSpec::new(TTY_ALPN)) } -/// Build the [`OpenEstablisher`] for `channels/tty/sub` — the awaited +/// Build the [`OpenEstablisher`] for `channels/tty/sub`: the awaited /// establishment phase (alkcall ADR-049 §1, alktty ADR-010). /// /// The establisher runs the semantic validation that used to be @@ -230,45 +230,45 @@ pub fn tty_open_spec() -> OperationSpec { /// full `NegotiateRequest` parse of the registry-schema-validated /// `input` (the schema is deliberately partial so the opaque ADR-053 /// backend params pass through), `carriage == "raw"`, non-empty -/// `cmd`, the backend lookup, the ADR-050 ownership check, and — +/// `cmd`, the backend lookup, the ADR-050 ownership check, and, /// since alkcall 0.6 (ADR-049 amendment 2, review 007 R-01) gave -/// `Establishment` its plan payload — the backend allocation +/// `Establishment` its plan payload, the backend allocation /// (ADR-010 as amended: the allocated `TtyHandle` crosses to the pump /// handler via the plan). Any rejection resolves /// `Err(EstablishmentError)` and the wrapper replies -/// `channel:open_failed` with `details.reason` — the SSH contract: +/// `channel:open_failed` with `details.reason` (the SSH contract: /// the channel never exists opener-side, no `channel_id` is ever -/// returned. +/// returned). /// -/// The `tty:open` scope gate is NOT checked here — the registry's +/// The `tty:open` scope gate is NOT checked here; the registry's /// `AccessControl` enforced it before the establisher ran. The /// establisher's `auth` is the per-call context (alkcall 0.7, CF-006 -/// corollary): its identity is the dispatch-resolved opener — the same -/// identity view the registry's ACL gate checked — and is the +/// corollary): its identity is the dispatch-resolved opener (the same +/// identity view the registry's ACL gate checked) and is the /// ownership-check subject (the same split as the old handler-side /// check). /// -/// Mapping into the ADR-049 reason vocabulary (no new codes — the +/// Mapping into the ADR-049 reason vocabulary (no new codes; the /// vocabulary is alkcall's one-way wire surface): /// /// - `NegotiateRequest` parse failure, `carriage != "raw"`, empty /// `cmd` → [`EstablishmentError::HandlerError`] (`malformed_ -/// negotiation` semantics — the request shape is wrong). +/// negotiation` semantics: the request shape is wrong). /// - Unknown backend → [`EstablishmentError::UnknownResource`]. /// - Ownership denial → [`EstablishmentError::HandlerError`] (an ACL /// outcome, not a dial outcome; the registry's scope gate stays the /// pre-allocation `FORBIDDEN` path). /// - `backend.allocate` failure (ADR-005's `AllocFailed`: PTY /// couldn't be allocated, docker exec failed to start, SSH channel -/// request rejected) → [`EstablishmentError::DialFailed`] — the +/// request rejected) → [`EstablishmentError::DialFailed`] (the /// target refused or the backend lacked capacity; the review-007 /// verification gate's "allocate in the establisher → /// `allocate_failed` is a call error, no in-band frame" shape. /// /// The allocation is handed to the pump handler through the /// establishment plan: a private `AllocatedHandle` slot (the -/// `TtyHandle` itself is not `Sync` — its fields are boxed dyn -/// streams — so it cannot be the `ChannelPlan` directly). The slot is +/// `TtyHandle` itself is not `Sync`: its fields are boxed dyn +/// streams, so it cannot be the `ChannelPlan` directly). The slot is /// created per establisher invocation (per open), so the /// shared-slot same-resource race alkcall's R-01 test pins is /// unreachable by construction; the wrapper moves the plan to this @@ -361,23 +361,23 @@ impl AllocatedHandle { /// Build the [`OpenHandler`] for `channels/tty/sub`. /// -/// The handler receives the open op's `input` — the `NegotiateRequest` +/// The handler receives the open op's `input`: the `NegotiateRequest` /// params, validated (and allocated against) by the establisher before /// the handler was spawned (alkcall ADR-049 §1: the establisher is -/// awaited bounded, before the reply and before the pump handler) — +/// awaited bounded, before the reply and before the pump handler), /// plus the establisher's `plan`: the [`AllocatedHandle`] slot /// carrying the backend-allocated `TtyHandle` (alkcall 0.6 ADR-049 /// amendment 2, review 007 R-01). It re-parses `input`, calls /// `accept_bi()` to get the channel's [`BiStream`], splits it into -/// read/write halves (the stdlib `tokio::io::split` idiom — the same +/// read/write halves (the stdlib `tokio::io::split` idiom, the same /// split the direct-ALPN `TtyAdapter::handle` does), and runs the -/// pre-allocated session driver on them — the same three-pump session +/// pre-allocated session driver on them: the same three-pump session /// driver as the direct-ALPN path. /// /// With a plan the handler never allocates: the establisher already /// ran `backend.allocate` (ADR-010 as amended), so no negotiation /// error frame is reachable on the registered path. With `plan: None` -/// (a no-establisher registration — defense-in-depth, the ADR-010 +/// (a no-establisher registration: defense-in-depth, the ADR-010 /// pre-0.3.0 shape) the handler re-runs the full inline /// validate-and-allocate, whose negotiation error frames /// (`malformed_negotiation`, `allocate_failed`, ...) remain the only @@ -385,11 +385,11 @@ impl AllocatedHandle { /// /// The returned task awaits the session driver inline (R-02 lifetime /// contract): the `JoinHandle`'s completion is the wrapper's teardown -/// trigger, so the pumps must finish before the task returns — never +/// trigger, so the pumps must finish before the task returns; never /// spawn-and-forget. /// /// The `tty:open` scope gate is enforced by the registry's `AccessControl` -/// before this handler runs — the handler does not re-check it. +/// before this handler runs; the handler does not re-check it. fn make_tty_open_handler(backends: Arc>>) -> OpenHandler { Arc::new( move |input: Value, @@ -474,7 +474,7 @@ mod tests { /// feed `make_tty_open_handler` a hand-built `input` the (partial) /// schema would accept but the full `NegotiateRequest` parse rejects /// (`cwd` typed as a number). Post-ADR-010 the establisher rejects - /// this class before the handler ever spawns — this test pins the + /// this class before the handler ever spawns; this test pins the /// handler's own parse arm, which must still write a /// `0x00`-prefixed `malformed_negotiation` error frame rather than /// silently EOFing if that input ever reaches it (e.g. a @@ -718,10 +718,10 @@ mod tests { } } - /// Allocation runs in the establisher (alkcall 0.6 — the - /// `TtyHandle` crosses to the pump handler via the `Establishment` + /// Allocation runs in the establisher (alkcall 0.6, with the + /// `TtyHandle` crossing to the pump handler via the `Establishment` /// plan payload, ADR-010 as amended). Its failure maps to - /// `DialFailed` (`details.reason == "dial_failed"` — the target + /// `DialFailed` (`details.reason == "dial_failed"`, the target /// refused or the backend lacked capacity), and a successful /// allocation lands in the plan slot the handler takes. #[tokio::test] @@ -776,7 +776,7 @@ mod tests { /// E-02: the spec carries a description (disclosed by /// `services/list`) and declares the `channel:open_failed` error - /// schema (ADR-016 — the establishment-failure contract is + /// schema (ADR-016: the establishment-failure contract is /// discoverable via `services/schema`). #[test] fn tty_open_spec_declares_description_and_open_failed_error() { @@ -795,7 +795,7 @@ mod tests { .expect("reason enum"); let reasons: Vec<&str> = enum_values.iter().filter_map(|v| v.as_str()).collect(); // The establisher produces these four (resource_shortage has - // no TTY producer path — ADR-010; allocation failure maps to + // no TTY producer path, ADR-010; allocation failure maps to // dial_failed, the amended mapping). assert_eq!( reasons, @@ -954,7 +954,7 @@ mod tests { /// Sanity that the `MockBackend` we use in the channels tests /// compiles as a `TtyBackend` and resolves to the configured exit - /// code. Belt-and-suspenders — the backend module already tests + /// code. Belt-and-suspenders: the backend module already tests /// this, but the channels tests depend on it. #[tokio::test] async fn mock_backend_resolves_configured_exit_code() { diff --git a/src/control.rs b/src/control.rs index b62211c..1f905e6 100644 --- a/src/control.rs +++ b/src/control.rs @@ -7,7 +7,7 @@ //! server→client control (`Exit`). The previous single //! `STREAM_CONTROL = 3` was documented as "bidirectional" but the adapter //! ignored `Exit` from the client because it had no way to distinguish -//! the two directions on the same stream_type — see the Phase 7 notes +//! the two directions on the same stream_type (see the Phase 7 notes //! in `docs/architecture/tty-wire.md` (the bidirectionality fix that //! split the single `STREAM_CONTROL = 3` into `STREAM_CTRL_IN` (3) //! and `STREAM_CTRL_OUT` (4)). @@ -30,7 +30,7 @@ //! //! Unknown `type` values: `from_slice` returns a `serde_json::Error`. The //! adapter (task `tty/adapter`) ignores that error per the wire spec's -//! "unknown types are ignored" policy — this keeps the enum exhaustive and +//! "unknown types are ignored" policy; this keeps the enum exhaustive and //! the policy an adapter-level concern, not a schema-level leak. use serde::{Deserialize, Serialize}; @@ -51,7 +51,7 @@ use serde::{Deserialize, Serialize}; /// arriving on `STREAM_CTRL_OUT` is a protocol violation (the adapter /// ignores it), and an `Exit` arriving on `STREAM_CTRL_IN` is likewise a /// protocol violation (the adapter ignores it). The split is what makes -/// the control channel genuinely bidirectional — the previous single +/// the control channel genuinely bidirectional: the previous single /// `STREAM_CONTROL = 3` was documented as "bidirectional" but the /// adapter had to ignore `Exit` from the client because the two /// directions were indistinguishable on the same stream_type. @@ -87,10 +87,10 @@ pub enum ControlMessage { /// chunk is the last control chunk before stream close (ADR-055). /// `code` is `i32`: `ExitStatus::code()` on normal exit; on Unix, /// the negative signal number on signal-terminated exit (e.g., - /// `-9` for SIGKILL — the crate's convention, not + /// `-9` for SIGKILL; the crate's convention, not /// `ExitStatus::code()`'s, which returns `None` there). `-1` is /// the adapter's best-effort "backend could not determine the - /// exit code" sentinel (ADR-055 §4) — colliding with the SIGHUP + /// exit code" sentinel (ADR-055 §4), colliding with the SIGHUP /// encoding on the Unix pipe path. Exit { code: i32 }, } @@ -117,7 +117,7 @@ impl ControlMessage { /// (Ctrl-C → `INT`, Ctrl-\ → `QUIT`, Ctrl-Z → `TSTP`). Unknown names /// return `None`; the caller (the local backend) decides whether to /// ignore or fall back to the backend's default kill -/// (`portable_pty`'s `ChildKiller::kill` sends SIGHUP — see +/// (`portable_pty`'s `ChildKiller::kill` sends SIGHUP; see /// tty-local.md REQ-TTY-02). /// /// Unix-only: the non-Unix path falls back to `ChildKiller::kill` diff --git a/src/lib.rs b/src/lib.rs index 56ab23d..9e15c80 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -3,18 +3,18 @@ //! Producer/consumer protocol crate on top of alkcall channels. Two //! halves (per alkcall's protocol-crate pattern): //! -//! - **Producer half** — [`adapter::TtyAdapter`] (direct `alk/tty` +//! - **Producer half**: [`adapter::TtyAdapter`] (direct `alk/tty` //! ALPN via `ProtocolHandler`) + [`channels`] (registers the //! `channels/tty/sub` open op via `ChannelCore::register_openable` //! for the `alk/channels` multiplexed path). -//! - **Consumer half** — [`session::TtySession`] (typed client wrapper +//! - **Consumer half**: [`session::TtySession`] (typed client wrapper //! around the wire protocol, with `connect_direct` and //! `open_via_channels` constructors). //! //! Two-carriage wire format (ADR-052): a JSON negotiation frame, then //! raw chunks (`[stream_type: u8][length: u32 be][payload]`). //! Backend-agnostic via the [`backend::TtyBackend`] trait (ADR-053). -//! Depends on alkcall (ADR-057 — the negotiation framing is +//! Depends on alkcall (ADR-057: the negotiation framing is //! self-contained; alkcall's `EventEnvelope` framing is not reused). //! //! # WASM target @@ -48,7 +48,7 @@ //! //! # Crate-root surface //! -//! The primary types are re-exported at the crate root — +//! The primary types are re-exported at the crate root: //! [`TtyAdapter`]/[`drive_session`] (producer), [`TtySession`] //! (consumer), [`TtyBackend`]/[`TtyHandle`]/[`TtyParams`] (backend //! authors), plus the wire codec ([`Chunk`], [`ChunkReader`], @@ -56,7 +56,7 @@ //! [`MAX_CHUNK_LEN`], ...), [`ControlMessage`], the negotiation types //! [`NegotiateRequest`], ...), the channels registration helpers //! ([`register_openable`]), and `local::LocalTtyBackend` under the -//! `local` feature. The module paths below remain the full public surface — +//! `local` feature. The module paths below remain the full public surface; //! the root re-exports are the ergonomic short paths for the common //! imports. diff --git a/src/local/backend.rs b/src/local/backend.rs index 1b3e285..66cb128 100644 --- a/src/local/backend.rs +++ b/src/local/backend.rs @@ -17,7 +17,7 @@ use async_trait::async_trait; /// (the runner case, this crate's `pipe` module). One backend serves both /// (ADR-054). /// -/// Takes no constructor dependencies — unlike `DockerTtyBackend` (wraps a +/// Takes no constructor dependencies: unlike `DockerTtyBackend` (wraps a /// `bollard::Docker` client) or `SshTtyBackend` (wraps an SSH session), the /// `portable_pty` system is process-global. The assembly layer constructs one /// `LocalTtyBackend` and registers it as `"local"`. @@ -43,9 +43,9 @@ impl TtyBackend for LocalTtyBackend { /// `params.terminal`. /// /// `Some(TerminalParams)` dispatches to [`crate::local::pty::allocate_pty`] - /// (PTY mode, real terminal semantics — resize, process-group signal + /// (PTY mode, real terminal semantics: resize, process-group signal /// forwarding, merged stdout/stderr). `None` dispatches to - /// [`crate::local::pipe::allocate_pipe`] (pipe mode, the runner case — + /// [`crate::local::pipe::allocate_pipe`] (pipe mode, the runner case: /// separate stdout/stderr, no-op resize, pid-only signal). /// /// `backend_params` is ignored: the local backend has no diff --git a/src/local/mod.rs b/src/local/mod.rs index 40670c5..5e00e9f 100644 --- a/src/local/mod.rs +++ b/src/local/mod.rs @@ -10,7 +10,7 @@ //! non-wasm by design (`portable_pty` + `tokio::process` need a real //! OS). The default crate (no features) compiles to //! `wasm32-unknown-unknown`; enabling `local` on a wasm target is a -//! build error by design — the local-process backend runs on a real +//! build error by design: the local-process backend runs on a real //! OS (Linux, macOS, Windows), never in a sandbox. pub mod backend; diff --git a/src/local/pipe.rs b/src/local/pipe.rs index 8b982bf..3ce6749 100644 --- a/src/local/pipe.rs +++ b/src/local/pipe.rs @@ -122,13 +122,13 @@ where } /// Control handle for pipe mode: no-op resize and `libc::kill(pid, sig)` -/// signal forwarding (the runner case — ADR-054). +/// signal forwarding (the runner case; ADR-054). /// -/// # Signal scope — no process group (documented limitation) +/// # Signal scope: no process group (documented limitation) /// /// Pipe mode has no session leader / controlling tty, so `signal()` targets /// the direct child pid only via `libc::kill(pid, sig)`. If the child has -/// spawned its own children, they will NOT receive the signal — this is a +/// spawned its own children, they will NOT receive the signal; this is a /// known limitation of the runner case. A runner that needs process-group /// signal delivery uses the PTY case, not the pipe case. See /// `tty-local.md` §"Pipe Mode". @@ -149,11 +149,11 @@ impl PipeControl { impl TtyControl for PipeControl { fn resize(&self, _cols: u16, _rows: u16, _pixel_width: u16, _pixel_height: u16) { - // No-op — no PTY in pipe mode. + // No-op: no PTY in pipe mode. } fn signal(&self, name: &str) { - // Targets the direct child pid only (no process group — see the + // Targets the direct child pid only (no process group; see the // type-level doc comment on `PipeControl`). #[cfg(unix)] { @@ -197,13 +197,13 @@ impl TtyControl for PipeControl { /// The `exit_code` future for pipe mode: wraps `tokio::process::Child::wait` /// plus the ADR-056 kill guard. /// -/// # ADR-056 — kill-on-Drop contract +/// # ADR-056: kill-on-Drop contract /// /// On cancel (the future is dropped without being driven to completion), /// `Drop` calls `Child::start_kill()` (SIGKILL); the child exits and the /// inner `wait` future reaps it. On the happy path, `poll` resolves the /// `wait` future, then **disarms** the guard by taking the `Child` out of -/// `self.child` — so the subsequent `Drop` is a no-op. The guard is the +/// `self.child`, so the subsequent `Drop` is a no-op. The guard is the /// spec-compliant mechanism; `kill_on_drop(true)` on the `Command` is a /// defense-in-depth backstop, not the primary path (the `Child` is moved /// into the future and the future's `Drop` is the cancel path, not the @@ -283,10 +283,10 @@ impl Drop for PipeExitFuture { /// Map an `ExitStatus` to the adapter's `i32` exit code: `code()` on normal /// exit; on Unix, the negative signal number on signal-terminated exit -/// (ADR-055 §4's convention — `ExitStatus::code()` returns `None` for +/// (ADR-055 §4's convention: `ExitStatus::code()` returns `None` for /// signal termination, so this crate's encoding is via /// `ExitStatusExt::signal()`). Falls back to `-1` (the adapter's -/// "could not determine" sentinel — ADR-055 §4). +/// "could not determine" sentinel; ADR-055 §4). fn exit_code_from(status: ExitStatus) -> i32 { if let Some(code) = status.code() { return code; @@ -385,7 +385,7 @@ mod tests { /// Readiness signal (review #001 N4): the child writes `ready` to /// the marker file right before `exec sleep`, so the test knows - /// the exec'd process exists — no fixed pre-signal sleep. + /// the exec'd process exists; no fixed pre-signal sleep. async fn wait_marker(marker: &std::path::Path) { let deadline = tokio::time::Instant::now() + std::time::Duration::from_secs(5); loop { @@ -453,7 +453,7 @@ mod tests { let pid_str = std::fs::read_to_string(&pid_file).expect("pid file written"); let pid: i32 = pid_str.trim().parse().expect("pid parses"); let _ = std::fs::remove_file(&pid_file); - // Drop the handle without awaiting exit_code — the ADR-056 guard + // Drop the handle without awaiting exit_code; the ADR-056 guard // must kill the child. drop(handle); // Poll for the child's death (pid_file written before exec; the diff --git a/src/local/pty.rs b/src/local/pty.rs index 5817926..c35e840 100644 --- a/src/local/pty.rs +++ b/src/local/pty.rs @@ -6,7 +6,7 @@ //! forwarding (`libc::kill(-pgid, sig)`), and the ADR-056 kill guard on the //! `exit_code` future. //! -//! # REQ-TTY-01 — blocking→async bridge +//! # REQ-TTY-01: blocking→async bridge //! //! `portable_pty` exposes a blocking `std::io` API //! (`MasterPty::try_clone_reader()`, `take_writer()`, `Child::wait()`). @@ -14,7 +14,7 @@ //! backend: dedicated std threads feed tokio mpsc/oneshot channels, and //! the async-facing `TtyHandle` fields are wrappers over those channels. //! -//! # REQ-TTY-02 — signal targets the process group +//! # REQ-TTY-02: signal targets the process group //! //! `portable_pty` spawns the child as a session leader //! (`CommandBuilder::set_controlling_tty(true)`, the default), so the @@ -24,12 +24,12 @@ //! child already exited). Unknown signal names fall back to //! `ChildKiller::kill` (SIGHUP). See `tty-local.md` §"REQ-TTY-02". //! -//! # ADR-056 — kill-on-Drop guard +//! # ADR-056: kill-on-Drop guard //! //! `LocalExitFuture` wraps the `oneshot::Receiver` from the waiter //! thread alongside a `portable_pty::ChildKiller`. On cancel (the future //! is dropped without being driven to completion), `Drop` calls -//! `ChildKiller::kill()` (SIGHUP) — best-effort, the child may already be +//! `ChildKiller::kill()` (SIGHUP); best-effort, the child may already be //! exiting. On the happy path (the future resolves), `poll` disarms the //! guard (`Option::take()`), so the subsequent `Drop` is a no-op. The //! waiter thread reaps the killed child via its blocking wait (Unix: @@ -43,7 +43,7 @@ //! number (ADR-004 decision 1): `signal("TERM")` resolves to `-15`, the //! ADR-056 cancel kill (SIGHUP) to `-1`. portable-pty 0.9.0's `ExitStatus` //! would flatten the same death to code `1` with the signal only as a -//! strsignal name string — indistinguishable from a real `exit 1`. The pipe +//! strsignal name string, indistinguishable from a real `exit 1`. The pipe //! path encodes via `std::os::unix::process::ExitStatusExt::signal()` //! (`pipe.rs::exit_code_from`); both backends now agree on the convention. //! See `wait_exit_code` and review #004. @@ -53,19 +53,19 @@ //! The three-thread bridge's error arms are documented-unreachable //! through the public path, per the review's disposition: //! -//! - **reader `try_clone_reader` failure** — the master fd is open and +//! - **reader `try_clone_reader` failure**: the master fd is open and //! owned by this allocation; `dup` failure requires an exhausted //! fd table, which no test can force deterministically. -//! - **reader read error** — the `PtyFd` `Read` impl maps `EIO` +//! - **reader read error**: the `PtyFd` `Read` impl maps `EIO` //! (slave closed) to EOF, the only realistic master read error; any //! other error kind has no trigger from the public path. -//! - **writer `take_writer` failure** — portable-pty 0.9 fails this +//! - **writer `take_writer` failure**: portable-pty 0.9 fails this //! only on the *second* take (`took_writer` flag); the bridge takes //! it exactly once. -//! - **writer write/flush failure** — the master fd is open and the +//! - **writer write/flush failure**: the master fd is open and the //! writer thread is the sole writer; a write error requires an //! externally-closed fd the bridge never observes. -//! - **waiter `wait()` failure** — reached only on the waitpid-failure +//! - **waiter `wait()` failure**, reached only on the waitpid-failure //! fallback (see `wait_exit_code`); `std::process::Child::wait` fails //! only if the child was already reaped (the bridge normally reaps via //! `libc::waitpid` and never calls `try_wait`). The ADR-055 §4 @@ -136,7 +136,7 @@ impl PtyControl { } impl TtyControl for PtyControl { - /// Resize the PTY. Safe to call from the async pump — + /// Resize the PTY. Safe to call from the async pump, since /// `MasterPty::resize` is non-blocking (it issues an `ioctl`). fn resize(&self, cols: u16, rows: u16, pixel_width: u16, pixel_height: u16) { let size = PtySize { @@ -155,7 +155,7 @@ impl TtyControl for PtyControl { /// /// On Unix: maps the name to a `libc` signal number via /// `crate::control::signal_from_name`, then calls `kill(-pgid, sig)` (the - /// negative pid reaches the whole process group — the child is a + /// negative pid reaches the whole process group; the child is a /// session leader because `set_controlling_tty(true)`, the default). /// Falls back to `kill(pid, sig)` if the group signal fails (e.g. the /// child already exited). Unknown names fall back to @@ -207,9 +207,9 @@ impl TtyControl for PtyControl { /// `portable_pty::ChildKiller`. /// /// `poll` delegates to the oneshot receiver (resolves on natural exit). On -/// `Ready`, the killer is taken (`Option::take()`) — disarmed — so the +/// `Ready`, the killer is taken (`Option::take()`, disarming it) so the /// subsequent `Drop` is a no-op. On cancel (the future is dropped before -/// resolving), `Drop` calls `ChildKiller::kill()` (SIGHUP) — best-effort; +/// resolving), `Drop` calls `ChildKiller::kill()` (SIGHUP), best-effort; /// the child may already be exiting. The waiter thread's blocking wait /// (Unix: `libc::waitpid`) reaps the killed child, so there is no zombie. /// The contract is "kill on cancel; no-op on resolve." @@ -234,7 +234,7 @@ impl Future for LocalExitFuture { match Pin::new(&mut self.rx).poll(cx) { Poll::Pending => Poll::Pending, Poll::Ready(Ok(code)) => { - // Disarm the kill guard — the child exited naturally. + // Disarm the kill guard: the child exited naturally. self.killer.take(); Poll::Ready(Ok(code)) } @@ -253,7 +253,7 @@ impl Future for LocalExitFuture { impl Drop for LocalExitFuture { fn drop(&mut self) { if let Some(killer) = self.killer.take() { - // ADR-056: kill on cancel. Best-effort — the child may already + // ADR-056: kill on cancel. Best-effort, since the child may already // be exiting. SIGHUP is what portable_pty sends on Unix. let mut killer = killer; if let Err(e) = killer.kill() { @@ -269,9 +269,9 @@ impl Drop for LocalExitFuture { /// /// When the channel is full, `poll_write` parks in an in-flight send /// future stored on the struct (so a re-poll resumes the same send rather -/// than starting a new one — `reserve()` is not `Unpin`). +/// than starting a new one; `reserve()` is not `Unpin`). /// `poll_shutdown` parks the same way (a separate slot, so the EOF send -/// cannot be confused with a byte send) — returning `Pending` without a +/// cannot be confused with a byte send). Returning `Pending` without a /// registered waker would strand the EOF forever on a full channel /// (review #003 P8). struct StdinSink { @@ -410,19 +410,19 @@ impl tokio::io::AsyncWrite for StdinSink { /// Wait for the child to terminate and resolve to the ADR-004 exit-status /// encoding: `ExitStatus::code()` on normal exit; on Unix, the negative -/// signal number on signal-terminated exit (e.g. `-15` for SIGTERM — +/// signal number on signal-terminated exit (e.g. `-15` for SIGTERM; /// Python's `Popen.returncode` parity). /// /// On Unix the reap goes through `libc::waitpid(pid, ..)` on the direct /// child pid, not portable-pty's `Child::wait()`: portable-pty 0.9.0 /// converts a signal-terminated child to `ExitStatus { code: 1, signal: -/// Some(name) }` — the signal identity survives only as a strsignal name +/// Some(name) }`; the signal identity survives only as a strsignal name /// string that cannot be mapped back numerically (it is locale-dependent), /// and `code = 1` collides with a real `exit 1`. The reaped child is a /// direct child of this process (portable-pty's unix backend spawns a /// `std::process::Child`, whose `Drop` neither kills nor reaps), and -/// `Child::wait()` is reachable only on the waitpid-failure path — where -/// the child was not reaped — so exactly one of the two paths reaps. +/// `Child::wait()` is reachable only on the waitpid-failure path (where +/// the child was not reaped), so exactly one of the two paths reaps. /// /// Non-Unix has no Unix signal semantics; `Child::wait()` → /// `exit_code() as i32` matches the convention for normal exits. `-1` is @@ -478,7 +478,7 @@ fn wait_exit_code(pid: Option, child: &mut Box) -> /// `TtyHandle`. /// /// Spawns the child as a session leader with a controlling tty -/// (`CommandBuilder::set_controlling_tty(true)` — the default; REQ-TTY-02 +/// (`CommandBuilder::set_controlling_tty(true)`, the default; REQ-TTY-02 /// depends on it). Wires the three-thread bridge (reader/writer/waiter), /// the `PtyControl`, and the ADR-056 `LocalExitFuture`. pub fn allocate_pty( @@ -516,7 +516,7 @@ pub fn allocate_pty( for (k, v) in env { builder.env(k, v); } - // Session leader + controlling tty — the default. REQ-TTY-02: + // Session leader + controlling tty (the default). REQ-TTY-02: // `kill(-pgid, sig)` reaches the whole group only when the child is a // session leader, which requires a controlling tty. builder.set_controlling_tty(true); @@ -684,7 +684,7 @@ mod tests { /// Readiness signal (review #001 N4): the child writes `ready` to /// the marker file right before `exec sleep`, so the test knows - /// the exec'd process exists — no fixed pre-signal sleep. + /// the exec'd process exists (no fixed pre-signal sleep). async fn wait_marker(marker: &std::path::Path) { let deadline = tokio::time::Instant::now() + std::time::Duration::from_secs(5); loop { @@ -802,7 +802,7 @@ mod tests { #[cfg(unix)] #[tokio::test(flavor = "multi_thread", worker_threads = 4)] async fn signal_reaches_process_group_child() { - // bash -c 'echo ready > marker; sleep 60' — sleep is a child of + // bash -c 'echo ready > marker; sleep 60', where sleep is a child of // bash. The group signal must reach sleep too (REQ-TTY-02). bash // exits when its child does. let marker = marker_path("pgroup_ready"); @@ -854,7 +854,7 @@ mod tests { // ADR-056: dropping the TtyHandle (and thus the exit_code future) // without awaiting it MUST kill the child. The waiter thread reaps // the killed child (no zombie). We assert the kill happened by - // spawning a second session that completes promptly — i.e., the + // spawning a second session that completes promptly, i.e., the // dropped session's child does not outlive a short grace period // (if it did, the SIGHUP from Drop would not have fired). let handle = allocate_pty( @@ -900,7 +900,7 @@ mod tests { wait_marker(&marker).await; // "NOSUCH" is not a known signal name → falls back to // ChildKiller::kill (SIGHUP). The child should die by SIGHUP, - // which resolves to `-1` — the documented ADR-004 sentinel + // which resolves to `-1`, the documented ADR-004 sentinel // collision (SIGHUP = signal 1). control.signal("NOSUCH"); let code = tokio::time::timeout(std::time::Duration::from_secs(5), handle.exit_code) @@ -919,7 +919,7 @@ mod tests { // The signal path's fallback chain (REQ-TTY-02): `kill(-pgid, sig)` // fails once the child (and its group) is gone, then the // `kill(pid, sig)` fallback fails too, and the call degrades to a - // warn + return — no panic, no cascade. A late signal (the client + // warn + return: no panic, no cascade. A late signal (the client // signals after the process already exited) must be safe. let handle = allocate_pty( term(), diff --git a/src/negotiation.rs b/src/negotiation.rs index fb64dd7..bb41004 100644 --- a/src/negotiation.rs +++ b/src/negotiation.rs @@ -8,8 +8,8 @@ //! //! The framing is self-contained in alktty (ADR-057): a 4-byte //! big-endian length prefix + UTF-8 JSON body. The format coincides with -//! alkcall's `EventEnvelope` framing by convention, not by code reuse -//! — alktty does not depend on alkcall's internal wire types. +//! alkcall's `EventEnvelope` framing by convention, not by code reuse: +//! alktty does not depend on alkcall's internal wire types. //! //! # Framing disambiguation //! @@ -22,7 +22,7 @@ //! because error frames MUST be under 16 MiB ([`MAX_CHUNK_LEN`]) so the //! high byte is zero (a wire-format invariant, not an assumption). //! - A raw chunk's first byte is a `stream_type`. The server never sends -//! `0` (stdin — client→server only) or `3` (`STREAM_CTRL_IN` — +//! `0` (stdin, client→server only) or `3` (`STREAM_CTRL_IN`, //! client→server only), so the server-sent set is `{1, 2, 4}` //! (stdout, stderr, `STREAM_CTRL_OUT`); `0x00` is unambiguous. //! @@ -68,7 +68,7 @@ pub struct NegotiateRequest { pub carriage: String, /// Backend selector key (`"local"`, `"docker"`, `"ssh"`). pub backend: String, - /// `None` = pipe mode (no PTY — ADR-054). `Some` = allocate a PTY with + /// `None` = pipe mode (no PTY; ADR-054). `Some` = allocate a PTY with /// these dimensions. #[serde(default)] pub tty: Option, @@ -95,7 +95,7 @@ pub struct NegotiateRequest { /// /// Maps to SSH's `pty_request` parameters, to docker's /// `CreateExecOptions { tty: true }`, and to `portable_pty::PtySystem::openpty` -/// for the local backend. The `modes` field is reserved (OQ-44 — default +/// for the local backend. The `modes` field is reserved (OQ-44: default /// terminal modes suffice for the current scope); backends MUST ignore its /// content in v1. #[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] @@ -114,7 +114,7 @@ pub struct TerminalParamsWire { /// Pixel height (most terminals don't report this; defaults to 0). #[serde(default)] pub pixel_height: u16, - /// Reserved — OQ-44; backends MUST ignore the content in v1. + /// Reserved (OQ-44); backends MUST ignore the content in v1. #[serde(default)] pub modes: serde_json::Value, } @@ -122,7 +122,7 @@ pub struct TerminalParamsWire { /// Errors from the negotiation framing reader/writer. /// /// `ConnectionClosed` is returned (rather than `Io`) when `read_frame` hits -/// a clean `UnexpectedEof` reading either the length prefix or the body — +/// a clean `UnexpectedEof` reading either the length prefix or the body: /// the peer closed the stream cleanly rather than failing the transport. #[derive(Debug, thiserror::Error)] pub enum NegotiationError { @@ -217,7 +217,7 @@ impl NegotiationReader { /// prefix followed by the body, then flushes. /// /// For server-side error responses, the body MUST be under 16 MiB -/// ([`MAX_CHUNK_LEN`]) so the high byte of the length prefix is `0x00` — +/// ([`MAX_CHUNK_LEN`]) so the high byte of the length prefix is `0x00`; /// this is the wire-format invariant that makes the framing-disambiguation /// trick sound (see ADR-052 §5). [`Self::write_frame`] does not enforce /// this; callers building error responses with [`error_response_bytes`] are @@ -251,13 +251,13 @@ impl NegotiationWriter { /// Serialize a negotiation error response to JSON bytes. /// -/// Produces `{"error":"","":"",...}` — the +/// Produces `{"error":"","":"",...}`, the /// length-prefixed error frame the server sends when it cannot allocate /// the session (unknown backend, malformed negotiation, allocate failed). /// The caller writes the result via [`NegotiationWriter::write_frame`]. /// /// Error frames MUST be under 16 MiB ([`MAX_CHUNK_LEN`]) so the high byte -/// of the 4-byte length prefix is `0x00` (framing disambiguation — ADR-052 +/// of the 4-byte length prefix is `0x00` (framing disambiguation; ADR-052 /// §5). Realistic error responses are tens of bytes; this invariant holds /// by construction. pub fn error_response_bytes(error: &str, fields: &[(&str, &str)]) -> serde_json::Result> { diff --git a/src/session.rs b/src/session.rs index 8babe04..618a419 100644 --- a/src/session.rs +++ b/src/session.rs @@ -1,9 +1,9 @@ -//! Consumer half — `TtySession`, the typed client wrapper around the +//! Consumer half: `TtySession`, the typed client wrapper around the //! `alk/tty` wire protocol (per alkcall's protocol-crate pattern). //! //! Two constructors: //! -//! - [`TtySession::connect_direct`] — for a direct `alk/tty` ALPN +//! - [`TtySession::connect_direct`]: for a direct `alk/tty` ALPN //! connection. The consumer dials the transport (TLS, QUIC, //! WebSocket), negotiates the `alk/tty` ALPN, and hands the //! `Connection` to `connect_direct`. The session takes ownership of @@ -11,35 +11,35 @@ //! ADR-065), writes the negotiation frame, and exposes the typed //! methods. //! -//! - [`TtySession::open_via_channels`] — for the `alk/channels` +//! - [`TtySession::open_via_channels`]: for the `alk/channels` //! multiplexed path. The consumer holds a //! [`alkcall::channels::client::ChannelClient`], calls //! `open_via_channels(client, params)`, which invokes //! `channels/tty/sub` on channel 0, adopts the resulting channel, //! builds a `Connection` from the reassembled read half + mux write //! half, and runs the typed-methods flow directly in raw chunk mode -//! (ADR-009: the open op's `params` — validated by the registry's -//! input schema — *are* the negotiation; no second negotiation +//! (ADR-009: the open op's `params`, validated by the registry's +//! input schema, *are* the negotiation; no second negotiation //! frame is written on the channel's data stream). Semantic -//! establishment failures — including allocation failure since -//! alkcall 0.6 — are rejected by the producer's establisher and +//! establishment failures (including allocation failure since +//! alkcall 0.6) are rejected by the producer's establisher and //! surface as `channel:open_failed` call errors (alkcall ADR-049 / //! alktty ADR-010, as amended for the 0.6 plan payload; see //! `open_via_channels`' docs). //! //! The session handle exposes: -//! - [`TtySession::send_stdin`] / [`TtySession::close_stdin`] — write +//! - [`TtySession::send_stdin`] / [`TtySession::close_stdin`]: write //! stdin chunks, close stdin (zero-length sentinel). -//! - [`TtySession::recv_stdout`] / [`TtySession::recv_stderr`] — +//! - [`TtySession::recv_stdout`] / [`TtySession::recv_stderr`]: //! streams of stdout/stderr chunks (`Stream`). -//! - [`TtySession::resize`] — send a `Resize` control message. -//! - [`TtySession::signal`] — send a `Signal` control message. -//! - [`TtySession::wait`] — await the `Exit` control chunk (the +//! - [`TtySession::resize`]: send a `Resize` control message. +//! - [`TtySession::signal`]: send a `Signal` control message. +//! - [`TtySession::wait`]: await the `Exit` control chunk (the //! process exit code). //! -//! The session does NOT re-serialize the negotiation request itself -//! — the caller passes a `NegotiateRequest` (direct path) or a -//! `serde_json::Value` (channels path — the open op's `input` is the +//! The session does NOT re-serialize the negotiation request itself: +//! the caller passes a `NegotiateRequest` (direct path) or a +//! `serde_json::Value` (channels path, where the open op's `input` is the //! negotiation). The direct path writes the frame and switches to //! raw-chunk mode; the channels path starts in raw-chunk mode. See //! ADR-052 for the two-carriage model. @@ -48,7 +48,7 @@ //! //! `TtySession` is wasm-clean (no `tokio::process`, no `std::thread`, //! no `libc`). The consumer half is exactly the part a browser-side -//! or Python-wasm adapter would use — it runs the wire protocol +//! or Python-wasm adapter would use: it runs the wire protocol //! against a `Connection` the consumer dials (a WebSocket binary //! stream, a WebTransport bidi stream, etc.). The producer half //! (`TtyAdapter` + `register_openable`) runs on a real OS with a @@ -100,7 +100,7 @@ pub enum TtySessionError { /// `ChannelOpenError` verbatim (alkcall 0.5.0, ADR-049 §4): a /// `CallFailed` variant wraps the wire `CallError`, so a /// semantic-establishment failure (unknown backend, malformed - /// negotiation, ownership denial — rejected by the producer's + /// negotiation, ownership denial, all rejected by the producer's /// establisher) surfaces as `channel:open_failed` with /// `details.reason` branchable via /// `ChannelOpenError::establishment_reason()`; ACL denial and @@ -110,7 +110,7 @@ pub enum TtySessionError { #[error("channels open: {0}")] ChannelsOpen(#[from] alkcall::channels::client::ChannelOpenError), /// The channels open op's `params` failed the consumer's local - /// `NegotiateRequest` parse — the fail-fast check in + /// `NegotiateRequest` parse: the fail-fast check in /// [`TtySession::open_via_channels`] before a channel is allocated. /// (R5: the parse previously surfaced as `NegotiationSerialize`, /// a variant whose name and doc describe serializing the @@ -120,9 +120,9 @@ pub enum TtySessionError { /// The server sent a negotiation error frame (the first frame on /// the stream is a length-prefixed JSON `{"error":"..."}` rather /// than a raw chunk). On the channels path this is - /// defense-in-depth only — a registered producer rejects every + /// defense-in-depth only, because a registered producer rejects every /// failure class (including allocation, since alkcall 0.6) in the - /// establisher as `channel:open_failed` before the reply — but + /// establisher as `channel:open_failed` before the reply, but /// the variant stays reachable (no-establisher registrations, the /// direct-ALPN path's full in-band vocabulary). #[error("negotiation rejected: {error}")] @@ -141,12 +141,12 @@ pub enum TtySessionError { /// The transport refused the session open (the `Connection`'s /// `accept_bi` failed on either constructor path). Carries the /// upstream error type rather than flattening it into an - /// `io::Error` (review #003 P15 — additive pre-consumers). + /// `io::Error` (review #003 P15, additive pre-consumers). #[error("session open failed: {0}")] Open(#[from] alkcall::core::StreamError), } -/// A live `alk/tty` session — the typed consumer-side handle. +/// A live `alk/tty` session, the typed consumer-side handle. /// /// Constructed via [`TtySession::connect_direct`] (direct `alk/tty` /// ALPN) or [`TtySession::open_via_channels`] (multiplexed over @@ -217,18 +217,18 @@ impl TtySession { Self::from_bidi_stream(stream, negotiate).await } - /// Open a TTY session via `alk/channels` — the multiplexed path. + /// Open a TTY session via `alk/channels`: the multiplexed path. /// /// The consumer holds a [`ChannelClient`], calls /// `open_via_channels(client, params)`, which invokes /// `channels/tty/sub` on channel 0, adopts the resulting channel, /// builds a `Connection` from the reassembled read half + mux /// write half, and runs the typed-methods flow directly in raw - /// chunk mode (ADR-009: the open op's `params` — validated by the - /// registry's input schema — *are* the negotiation; no second + /// chunk mode (ADR-009: the open op's `params`, validated by the + /// registry's input schema, *are* the negotiation; no second /// negotiation frame is written on the channel's data stream). /// - /// `params` is the `NegotiateRequest` as a `serde_json::Value` — + /// `params` is the `NegotiateRequest` as a `serde_json::Value`: /// the channels open op takes a `Value`, not a typed struct. The /// session parses it as a `NegotiateRequest` before opening (so a /// malformed request fails fast, before a channel is allocated) @@ -241,12 +241,12 @@ impl TtySession { /// establishment failures (a `NegotiateRequest` parse failure of a /// schema-valid-but-unparseable params value, `carriage != "raw"`, /// empty `cmd`, unknown backend, ownership denial) are rejected by - /// the producer's establisher before the reply — they surface as + /// the producer's establisher before the reply; they surface as /// [`TtySessionError::ChannelsOpen`] wrapping a `CallFailed` whose /// `CallError` is `channel:open_failed` with /// `details.reason` (alkcall 0.5.0 ADR-049; the SSH contract: no /// `channel_id` is ever returned). Since alkcall 0.6 (ADR-010 as - /// amended — allocation moved into the establisher, the allocated + /// amended: allocation moved into the establisher, the allocated /// handle crossing via the `Establishment` plan payload) no /// failure class arrives in-band on the channels path: allocation /// failure surfaces as `channel:open_failed` with @@ -259,7 +259,7 @@ impl TtySession { ) -> Result { // Borrowing deserialize (`&Value` implements `Deserializer`): // fails fast before a channel is allocated, and the owned - // `params` still goes to `open_channel` (review #003 P15 — no + // `params` still goes to `open_channel` (review #003 P15, no // clone). use serde::Deserialize as _; NegotiateRequest::deserialize(¶ms) @@ -296,7 +296,7 @@ impl TtySession { /// Like `from_bidi_stream` but takes the channel's `Connection` /// directly (the channels path already has the `Connection` from /// `Connection::from_source`). The negotiation already happened in - /// the open op (ADR-009) — the stream starts in raw-chunk mode. + /// the open op (ADR-009): the stream starts in raw-chunk mode. async fn from_bidi_stream_via(channel_conn: Connection) -> Result { let stream = channel_conn.accept_bi().await?; let (read, write) = tokio::io::split(stream); @@ -327,7 +327,7 @@ impl TtySession { // `{1, 2, 4}` (the server never sends `0` or `3`). If the server // rejected the negotiation, read the error frame and return // `NegotiationRejected`; otherwise hand the peeked reader to the - // read pump — `ChunkReader` tracks the peeked byte, so the pump's + // read pump; `ChunkReader` tracks the peeked byte, so the pump's // `read_chunk()` completes that first chunk. match reader.peek_stream_type().await { Ok(0x00) => { @@ -345,11 +345,11 @@ impl TtySession { } /// Core inner for the channels path (ADR-009): the negotiation - /// already happened in the open op — the stream is already in + /// already happened in the open op: the stream is already in /// raw-chunk mode. The peek still applies as defense-in-depth: a /// `0x00`-prefixed error frame means the producer rejected the /// session post-open (only reachable from a no-establisher - /// registration — the establisher rejects every semantic class + /// registration: the establisher rejects every semantic class /// before the reply, and allocation runs there too since alkcall /// 0.6 / ADR-010 as amended), while a raw chunk (`stream_type` in /// `{1, 2, 4}`) is the normal first data. @@ -379,7 +379,7 @@ impl TtySession { /// read pump. Shared by `from_halves` and `from_halves_raw`. The /// reader may arrive with a peeked first byte (the negotiation /// disambiguation peek); `ChunkReader` tracks that state, so the - /// pump reads every chunk — including the first — via `read_chunk`. + /// pump reads every chunk, including the first, via `read_chunk`. fn start_pump( writer: ChunkWriter>, reader: ChunkReader, @@ -404,7 +404,7 @@ impl TtySession { /// Send stdin bytes. Writes a stdin chunk (stream_type 0) with the /// given payload. An empty `bytes` writes a zero-length sentinel - /// (client stdin EOF — see `tty-wire.md` §"Sentinels"); callers + /// (client stdin EOF; see `tty-wire.md` §"Sentinels"); callers /// that want to signal EOF should use [`Self::close_stdin`] instead, /// which is explicit. pub async fn send_stdin(&self, bytes: Bytes) -> Result<(), TtySessionError> { @@ -414,7 +414,7 @@ impl TtySession { Ok(()) } - /// Close stdin — send a zero-length stdin chunk (the EOF sentinel) + /// Close stdin: send a zero-length stdin chunk (the EOF sentinel) /// and flush. The server closes the backend's stdin but keeps /// pumping stdout + the exit chunk (see `tty-wire.md` §"Stdin /// Closure"). @@ -451,7 +451,7 @@ impl TtySession { /// Send a `Signal` control message (client→server, /// `STREAM_CTRL_IN`). `name` is an uppercase string from the /// supported set (`HUP`, `INT`, `QUIT`, `TERM`, `KILL`, `USR1`, - /// `USR2`, `TSTP`, `CONT` — see [`crate::control::signal_from_name`]). + /// `USR2`, `TSTP`, `CONT`; see [`crate::control::signal_from_name`]). /// Unknown names are forwarded as-is; the backend decides whether /// to ignore or fall back to its default kill. pub async fn signal(&self, name: &str) -> Result<(), TtySessionError> { @@ -467,11 +467,11 @@ impl TtySession { /// Get the stdout stream. Returns a `Stream` that /// yields stdout chunks as they arrive. The stream ends when the - /// server's stdout reaches EOF — the zero-length stdout sentinel + /// server's stdout reaches EOF: the zero-length stdout sentinel /// chunk (`tty-wire.md` §"Sentinels") terminates the stream and is /// NOT yielded as an item. /// - /// This consumes the stdout receiver — calling it twice returns + /// This consumes the stdout receiver: calling it twice returns /// an empty stream the second time (the receiver is behind a /// `Mutex>` and is taken). pub async fn recv_stdout(&self) -> Pin + Send>> { @@ -488,7 +488,7 @@ impl TtySession { } })); } - // Already taken — return an empty stream (documented on + // Already taken; return an empty stream (documented on // `recv_stdout`). Box::pin(futures::stream::empty()) } @@ -497,7 +497,7 @@ impl TtySession { /// (stdout/stderr merged into stdout by the kernel PTY), or if /// already taken. The stream ends when the server's stderr reaches /// EOF. Stderr has no sentinel on the wire (the adapter's stderr - /// pump emits none — only stdout carries the drained sentinel), so + /// pump emits none, since only stdout carries the drained sentinel), so /// this stream ends when the read pump terminates. pub async fn recv_stderr(&self) -> Option + Send>>> { let mut guard = self.stderr_rx.lock().await; @@ -511,10 +511,10 @@ impl TtySession { /// code. The exit chunk is the last control chunk before stream /// close (ADR-055). `code` is `i32`: `ExitStatus::code()` on /// normal exit; on Unix, the negative signal number on - /// signal-terminated exit (e.g., `-15` for SIGTERM — the crate's + /// signal-terminated exit (e.g., `-15` for SIGTERM; the crate's /// convention, not `ExitStatus::code()`'s, which returns `None` /// there). `-1` is the adapter's best-effort "backend could not - /// determine the exit code" sentinel — colliding with the SIGHUP + /// determine the exit code" sentinel, colliding with the SIGHUP /// encoding on the Unix pipe path (ADR-055 §4). /// /// Returns [`TtySessionError::NoExitChunk`] if the session ends @@ -522,7 +522,7 @@ impl TtySession { pub async fn wait(&self) -> Result { let mut rx = self.exit_code.clone(); // If the pump already resolved before we started waiting, the - // watch's current value is `Some(_)` — return it. + // watch's current value is `Some(_)`; return it. { let borrow = rx.borrow(); if let Some(outcome) = borrow.as_ref() { @@ -615,7 +615,7 @@ where /// - `STREAM_STDERR` (2) → stderr channel /// - `STREAM_CTRL_OUT` (4) → control message; parses as /// `ControlMessage` and, if it's `Exit`, resolves the exit watch -/// - `STREAM_STDIN` (0) / `STREAM_CTRL_IN` (3) — client→server only; +/// - `STREAM_STDIN` (0) / `STREAM_CTRL_IN` (3), client→server only; /// the server shouldn't send these, the pump ignores them (with a /// debug log) async fn read_pump( @@ -701,7 +701,7 @@ mod tests { use std::sync::Arc; use tokio::io::duplex; - /// Build a `NegotiateRequest` for tests — minimal valid shape. + /// Build a `NegotiateRequest` for tests: minimal valid shape. fn test_negotiate(backend: &str) -> NegotiateRequest { NegotiateRequest { carriage: "raw".to_string(), @@ -764,7 +764,7 @@ mod tests { /// A backend whose exit is held back until the test releases it, so /// typed-method writes happen while the session data plane is /// definitively open (the exit-resolves-immediately `MockBackend` - /// races them against session teardown — review #003 P15's + /// races them against session teardown; review #003 P15's /// `input_pump.abort()` made that race fail loudly). struct GatedBackend { release: Arc>>>, @@ -904,7 +904,7 @@ mod tests { let len = u32::from_be_bytes(len_buf) as usize; let mut body = vec![0u8; len]; let _ = server.read_exact(&mut body).await; - // Drop `server` — the client's read pump hits EOF. + // Drop `server`: the client's read pump hits EOF. }); let client_conn = Connection::from_bidi(client, b"alk/tty".to_vec(), None); let session = TtySession::connect_direct(client_conn, test_negotiate("mock")) @@ -1079,14 +1079,13 @@ mod tests { } /// Client→server stream types (0 and 3) arriving from the server are - /// protocol violations the read pump ignores — they must be skipped + /// protocol violations the read pump ignores; they must be skipped /// without ending the pump, desynchronizing framing, or disturbing /// the stdout routing. The server interleaves them with real stdout /// chunks, then sends a well-formed exit; the session should deliver /// exactly the stdout chunks and the exit code. (The first chunk /// must be a legitimate server→client type: a leading `0x00` byte - /// is the negotiation-error disambiguation marker — ADR-052 §5 — - /// and would surface `NegotiationRejected` before the pump starts.) + /// is the negotiation-error disambiguation marker (ADR-052 §5) and would surface `NegotiationRejected` before the pump starts.) #[tokio::test] async fn read_pump_ignores_client_to_server_stream_types_from_server() { let (client, mut server) = duplex(64 * 1024); @@ -1237,13 +1236,13 @@ mod tests { } /// The ADR-049 §5 migration gate: an unknown backend is rejected by - /// the producer's establisher before the reply — the open op fails + /// the producer's establisher before the reply: the open op fails /// with `channel:open_failed` + `details.reason == "unknown_resource"` /// (the SSH "channel never exists opener-side" contract: no /// `channel_id`, no session), instead of the pre-0.2.0 post-open /// in-band `unknown_backend` negotiation error frame /// (`NegotiationRejected`). The direct-ALPN path keeps the in-band - /// frame (two transports, two contracts — see + /// frame (two transports, two contracts; see /// `connect_direct_returns_negotiation_rejected`). #[tokio::test] async fn open_via_channels_surfaces_negotiation_rejected() { @@ -1325,7 +1324,7 @@ mod tests { /// End-to-end with an emitting backend (L2's backend): stdout and /// stderr route through the channels data plane to the consumer's - /// typed streams — the full producer+consumer channels path with + /// typed streams: the full producer+consumer channels path with /// real data. #[tokio::test] async fn open_via_channels_routes_backend_stdout_and_stderr() { diff --git a/src/testing.rs b/src/testing.rs index aa2964e..25bd9ac 100644 --- a/src/testing.rs +++ b/src/testing.rs @@ -1,7 +1,7 @@ //! Shared test wiring for the channels-based tests (the producer //! harness in `channels.rs::tests` and the consumer end-to-end tests //! in `session.rs::tests` both need the same `ChannelClient` ↔ -//! server wiring). `#[cfg(test)]`-only — never part of the public +//! server wiring). `#[cfg(test)]`-only; never part of the public //! API. use std::collections::HashMap; use std::sync::Arc; @@ -67,7 +67,7 @@ pub(crate) async fn wire_client_and_server( // `resolve_identity` sees it. The // `ChannelsAdapter` builds `channel0_conn` fresh // from `channel_source` and does NOT inherit the - // outer connection's identity — we set it here + // outer connection's identity; we set it here // so the `AccessControl` scope-gate can check it. if let Some(id) = _identity { let _ = channel0_conn.set_identity(id); @@ -192,7 +192,7 @@ mod tests { // The producer writes a 5-byte TTY header (stdout sentinel) // almost immediately after the open (MockBackend resolves - // exit right away) — a push-first producer whose first write + // exit right away), a push-first producer whose first write // races the consumer's adopt. Read it: alkcall 0.4.1's // early-arrival park guarantees the first chunks are not lost. let mut first = [0u8; 5]; @@ -209,14 +209,14 @@ mod tests { /// End-to-end (R4, post-ADR-010): `input` that passes the registry's /// (partial) schema but fails the establisher's full - /// `NegotiateRequest` parse — `cwd` typed as a number. The + /// `NegotiateRequest` parse (`cwd` typed as a number). The /// establisher rejects it before the reply: the open op fails with /// `channel:open_failed` + `details.reason == "handler_error"` (the /// malformed-negotiation mapping), the channel is torn down, and no /// `channel_id` is ever returned. Uses `ChannelClient::open_channel` /// directly (the consumer's local fail-fast parse in /// `open_via_channels` would reject these params before the open op - /// runs — R5's fail-fast path). + /// runs; R5's fail-fast path). #[tokio::test] async fn schema_valid_but_unparseable_input_fails_open_with_open_failed() { use alkcall::channels::client::ChannelOpenError; @@ -268,8 +268,8 @@ mod tests { /// longer arrives in-band. `backend.allocate` runs in the /// establisher (the allocated `TtyHandle` crosses to the pump /// handler via the `Establishment` plan payload), so its failure - /// fails the open op itself — `channel:open_failed` with - /// `details.reason == "dial_failed"` — and no channel survives + /// fails the open op itself (`channel:open_failed` with + /// `details.reason == "dial_failed"`), and no channel survives /// (the SSH contract: no `channel_id`, no `0x00`-prefixed /// in-band frame on a channel that was reported as succeeding). #[tokio::test] diff --git a/src/wire.rs b/src/wire.rs index d05f500..90dd996 100644 --- a/src/wire.rs +++ b/src/wire.rs @@ -10,8 +10,8 @@ //! - 0 = stdin (client→server, raw bytes) //! - 1 = stdout (server→client, raw bytes) //! - 2 = stderr (server→client, raw bytes) -//! - 3 = ctrl_in (client→server, JSON control message — see [`crate::control`]) -//! - 4 = ctrl_out (server→client, JSON control message — see [`crate::control`]) +//! - 3 = ctrl_in (client→server, JSON control message; see [`crate::control`]) +//! - 4 = ctrl_out (server→client, JSON control message; see [`crate::control`]) //! //! The control channel is split into two halves so it is genuinely //! bidirectional on the wire: `STREAM_CTRL_IN = 3` carries client→server @@ -19,7 +19,7 @@ //! server→client control (exit). The previous single `STREAM_CONTROL = 3` //! was documented as "bidirectional" but the adapter ignored `Exit` from //! the client because it had no way to distinguish the two directions on -//! the same stream_type — see the Phase 7 notes in +//! the same stream_type. See the Phase 7 notes in //! `docs/architecture/tty-wire.md` (the bidirectionality fix that //! split the single `STREAM_CONTROL = 3` into `STREAM_CTRL_IN` (3) //! and `STREAM_CTRL_OUT` (4)). @@ -27,7 +27,7 @@ //! Zero-length data chunks are sentinels: a zero-length stdin chunk is EOF //! from the client; a zero-length stdout chunk is "drained" from the //! server. Control chunks are never zero-length (the JSON payload is at -//! least `{}`). The codec does not special-case sentinels — they are just +//! least `{}`). The codec does not special-case sentinels: they are just //! chunks with `length == 0`; the adapter interprets them. See //! `docs/architecture/tty-wire.md` §"Sentinels". @@ -41,17 +41,17 @@ pub const STREAM_STDIN: u8 = 0; pub const STREAM_STDOUT: u8 = 1; /// stderr channel (server→client, raw bytes). pub const STREAM_STDERR: u8 = 2; -/// Control channel, client→server half (JSON control message — +/// Control channel, client→server half (JSON control message: /// `Resize`, `Signal`, `Eof`). pub const STREAM_CTRL_IN: u8 = 3; -/// Control channel, server→client half (JSON control message — `Exit`). +/// Control channel, server→client half (JSON control message: `Exit`). pub const STREAM_CTRL_OUT: u8 = 4; /// Chunk header length in bytes: 1 byte `stream_type` + 4 bytes `length`. pub const CHUNK_HEADER_LEN: usize = 5; /// Maximum payload length. A larger chunk is a `ChunkTooLarge` protocol /// error. Shared with the negotiation module so error frames (which reuse -/// the 4-byte length-prefix framing) stay under 16 MiB — this keeps the +/// the 4-byte length-prefix framing) stay under 16 MiB; this keeps the /// high byte of the length prefix `0x00`, which is what makes the /// framing-disambiguation trick sound (see ADR-052 §5). pub const MAX_CHUNK_LEN: u32 = 16 * 1024 * 1024; @@ -59,7 +59,7 @@ pub const MAX_CHUNK_LEN: u32 = 16 * 1024 * 1024; /// Errors from the raw chunk codec. /// /// `ConnectionClosed` is returned (rather than `Io`) when `read_chunk` -/// hits a clean `UnexpectedEof` reading either the header or the payload — +/// hits a clean `UnexpectedEof` reading either the header or the payload: /// the peer closed the stream cleanly rather than failing the transport. #[derive(Debug, thiserror::Error)] pub enum RawError { @@ -117,7 +117,7 @@ impl Chunk { } } - /// A client→server control chunk (stream_type 3) — `Resize`, `Signal`, + /// A client→server control chunk (stream_type 3): `Resize`, `Signal`, /// or `Eof`. pub fn ctrl_in(bytes: bytes::Bytes) -> Self { Self { @@ -126,7 +126,7 @@ impl Chunk { } } - /// A server→client control chunk (stream_type 4) — `Exit`. + /// A server→client control chunk (stream_type 4): `Exit`. pub fn ctrl_out(bytes: bytes::Bytes) -> Self { Self { stream_type: STREAM_CTRL_OUT, @@ -141,7 +141,7 @@ impl Chunk { /// `stream_type` (≤ 4, else [`RawError::InvalidStreamType`]) and the /// payload length (≤ [`MAX_CHUNK_LEN`], else [`RawError::ChunkTooLarge`]), /// then reads the payload. On a clean `UnexpectedEof` reading either the -/// header or the payload, it returns [`RawError::ConnectionClosed`] — the +/// header or the payload, it returns [`RawError::ConnectionClosed`]; the /// stream ended cleanly, not with a transport error. /// /// The reader tracks peeked state: after @@ -190,7 +190,7 @@ impl ChunkReader { /// consuming the rest of the header. Used by the consumer to /// disambiguate a negotiation error frame (whose 4-byte length prefix /// starts with `0x00`) from a raw chunk (whose first byte is a - /// `stream_type` in `{1, 2, 4}` — the server never sends `0` or `3`). + /// `stream_type` in `{1, 2, 4}`; the server never sends `0` or `3`). /// After peeking, the caller either reads the error frame itself (via /// [`ChunkReader::into_inner`]) or completes the chunk with /// [`ChunkReader::read_chunk_after_peek`] or @@ -261,10 +261,10 @@ impl ChunkReader { /// Shared header invariant for both codec directions: a `stream_type` in /// `{0..=4}` and a payload length ≤ [`MAX_CHUNK_LEN`] (which keeps the -/// length prefix's high byte `0x00` — the negotiation-disambiguation +/// length prefix's high byte `0x00`, the negotiation-disambiguation /// invariant, ADR-052 §5). The read path validates peer input (length is /// already a `u32` from the header); the write paths validate caller -/// input *before* the length is cast to `u32` — validating the truncated +/// input *before* the length is cast to `u32`; validating the truncated /// value would let a ≥2³² payload wrap past the check and corrupt the /// peer's framing, which is exactly the bug this validation exists to /// catch. @@ -291,7 +291,7 @@ fn validate_header(stream_type: u8, length: u64) -> Result<(), RawError> { /// [`RawError::InvalidStreamType`] / [`RawError::ChunkTooLarge`] instead /// of writing a header the peer cannot frame (a truncated `u32` length /// would corrupt the stream). An empty payload writes the same header as -/// a zero-length payload — every helper takes `&[u8]`-shaped input and +/// a zero-length payload. Every helper takes `&[u8]`-shaped input and /// emits `length == 0` with no payload bytes. pub struct ChunkWriter { writer: W, @@ -309,7 +309,7 @@ impl ChunkWriter { } /// Write a chunk: header + payload (if non-empty) + flush. The - /// chunk's `stream_type` and payload length are validated first — + /// chunk's `stream_type` and payload length are validated first: /// an invalid chunk fails locally with [`RawError::InvalidStreamType`] /// or [`RawError::ChunkTooLarge`] instead of corrupting the peer's /// framing. @@ -483,7 +483,7 @@ mod tests { } /// Write-side validation: a payload over `MAX_CHUNK_LEN` fails with - /// `ChunkTooLarge` before any header is written — previously the + /// `ChunkTooLarge` before any header is written; previously the /// `as u32` cast would silently write a truncated length and /// desynchronize the peer. #[tokio::test] @@ -554,7 +554,7 @@ mod tests { } /// P7 regression: after `peek_stream_type`, plain `read_chunk` must - /// complete the already-peeked chunk — not consume a second header + /// complete the already-peeked chunk, not consume a second header /// byte and desynchronize the stream. The old shape silently read /// the peeked byte's `length` bytes from the *payload* as a header. #[tokio::test] @@ -680,7 +680,7 @@ mod tests { /// the BAST's index→name table matches the on-wire `uint8` values. /// Catches the common drift case (a new stream_type added to /// `wire.rs` but not the BAST, or vice versa) without requiring - /// alktype as a dep — just `serde_json`, which alktty already has. + /// alktype as a dep: just `serde_json`, which alktty already has. /// See the project setup plan's "Risk: BAST schema drift" /// mitigation. #[test] @@ -727,7 +727,7 @@ mod tests { assert_eq!( values.len(), expected.len(), - "StreamType enum has {} values, wire.rs has {} — drift", + "StreamType enum has {} values, wire.rs has {} (drift)", values.len(), expected.len() );