docs: remove em dashes from repo prose and forbid them going forward

- Rewrote ~1090 em dashes across docs/ (excluding docs/reviews/
  history), README, CHANGELOG, and .opencode/agents prompts with varied
  restructures (colons, semicolons, parentheses, commas, sentence
  splits); ADR decisions, status lines, wire-shape examples, and code
  fences preserved
- Rewrote 234 em dashes in src doc comments plus a handful of existing
  inline comments and one test assert message; no code, JSON literals,
  or wire shapes touched
- Added Project Convention 15: no em dashes in prose (doc comments,
  markdown, agent prompts, commit messages); restructure instead; no
  spaced hyphens as a substitute
Verification: cargo test; cargo clippy --all-targets -- -D warnings;
cargo fmt --check; cargo doc --no-deps; cargo test --all-features;
cargo test --manifest-path fuzz/shared/Cargo.toml; cargo check and
clippy for wasm32-unknown-unknown with -D warnings; em dash grep over
src + docs + agent prompts returns no matches outside docs/reviews/
This commit is contained in:
glm-5.3-flash committed 2026-11-01 13:11:32 +00:00
1 parent e9e01c04e1
commit 58da42a021
39 files changed
+1310 -1295

No files matched your search

+44 -44
View File
@@ -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**: <one-way | two-way>
- **Priority**: <high | medium | low>
- **Impacts**: <what this blocks downstream — be specific>
- **Impacts**: <what this blocks downstream: be specific>
- **Blocked on**: <concrete dependency> (for deferred(scope))
**Investigation**: <what work would make the shape clear> (for deferred(unclear))
- **Resolution**: Not yet decidable. <Why — either the information
- **Resolution**: Not yet decidable. <Why: either the information
doesn't exist yet (deferred(scope)) or the pieces exist but the
composition isn't clear (deferred(unclear), cite the pieces).>
- **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
+15 -15
View File
@@ -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.
+5 -5
View File
@@ -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
+20 -20
View File
@@ -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/<task-name>", 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}}. <brief summary>", 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/<task-name>", 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.
+33 -33
View File
@@ -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(<task-id>): <description>"
git push origin $(git branch --show-current)
@@ -178,7 +178,7 @@ When task becomes untendable:
worktree({action: "notify", args: {message: "Blocked on <task-id>: <detailed explanation including what was attempted, what failed, and suggested resolution>", 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.
+4 -4
View File
@@ -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/<task-id>`
- 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
+71 -60
View File
@@ -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 <glm-5.2@alk.dev>`). 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<String, Arc<dyn TtyBackend>>` — the local backend is
`HashMap<String, Arc<dyn TtyBackend>>`; the local backend is
injected at the assembly layer, not wired in by the adapter. A
regression where a non-`local` module pulls in `portable_pty`,
`tokio::process`, `std::thread`, or `libc` (outside the existing
`cfg(unix)` calls in `control.rs`/`pipe.rs`) silently breaks the
downstream TS/Python adapter story.
10. **BAST document for the wire format** — `docs/architecture/tty-bast.md`
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 <target>` — never foreground fuzzing) before a
(`fuzz/run-detached.sh <target>`, never foreground fuzzing) before a
release and after touching `src/wire.rs`, `src/negotiation.rs`,
`src/control.rs`, the adapter, or the session pump.
## Architecture Context
- `docs/plans/project-setup.md` — the current plan (phases 0–5). All
- `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.
+64 -64
View File
@@ -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<ChannelPlan>`,
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<ChannelPlan>` 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.
+14 -14
View File
@@ -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<String, Arc<dyn alktty::TtyBackend>> =
// 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
+16 -16
View File
@@ -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<TtyControlHandle>` via a `#[derive(Clone)]` newtype wrapping `Arc<dyn TtyControl + Send + Sync>`; the trait is NOT `Clone` (not object-safe) — the newtype carries `Clone`-ability |
| OQ-43 | `TtyControl` trait object `Clone` constraint | resolved | `control: Option<TtyControlHandle>` via a `#[derive(Clone)]` newtype wrapping `Arc<dyn TtyControl + Send + Sync>`; 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.
@@ -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":"<code>","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/<name>` ALPN convention; one ALPN per
- alknet ADR-001: ALPN-based dispatch
- alknet ADR-002: ProtocolHandler trait
- alknet ADR-006: `alk/<name>` 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)
@@ -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<Item = Bytes>`, `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<dyn TtyControl + Send + Sync>` (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<TerminalParams>,
/// 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<dyn tokio::io::AsyncWrite + Send + Unpin>,
/// 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<Item = bytes::Bytes>` (re-exported by
/// `tokio_stream::StreamExt` for extension methods).
pub stdout: Pin<Box<dyn futures_core::Stream<Item = bytes::Bytes> + 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<Pin<Box<dyn futures_core::Stream<Item = bytes::Bytes> + 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<i32, TtyError>>,
/// 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<TtyControlHandle>,
@@ -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<dyn TtyControl + Send + Sync>);
The trait's adapter-facing types (`AsyncWrite`, `Stream<Item = Bytes>`,
`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<String, Arc<dyn TtyBackend>>` 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<Mutex<...>>` 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`
@@ -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<TerminalParams>` (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
@@ -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<StdinCmd>`. On
- **Writer thread** (drains an `mpsc::Receiver<StdinCmd>`). 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<i32, TtyError>>` 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<i32, TtyError>>` 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),
@@ -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<Bytes>`) 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<Bytes>`) 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<Bytes>`) — 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<Bytes>`), 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)
@@ -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
- `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
@@ -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<dyn Any + Send + Sync>`), 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<Establishment, EstablishmentError>>`) 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<ChannelPlan> }` with
`ChannelPlan = Arc<dyn Any + Send + Sync>` — typed-opaque, wrapper
threaded to the `OpenHandler`'s new second parameter. §2's blocker 1
`ChannelPlan = Arc<dyn Any + Send + Sync>` (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(&params)`. 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<Option<TtyHandle>>`), 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`)
+58 -58
View File
@@ -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<dyn TtyBackend>` 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/<name>` convention
(renamed from `alknet/<name>` 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/<name>` ALPN convention; one ALPN per
- alknet ADR-006: `alk/<name>` 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)
+50 -50
View File
@@ -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<String, Arc<dyn TtyBackend>>` 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(&params)` (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
- `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
+51 -51
View File
@@ -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<TerminalParams>,
/// 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<dyn tokio::io::AsyncWrite + Send + Unpin>,
/// 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<Item = bytes::Bytes>` (re-exported by
/// `tokio_stream::StreamExt` for extension methods).
pub stdout: Pin<Box<dyn futures_core::Stream<Item = bytes::Bytes> + 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<Pin<Box<dyn futures_core::Stream<Item = bytes::Bytes> + 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<i32, TtyError>>,
/// 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<TtyControlHandle>,
@@ -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<Mutex<...>>` 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<Bytes>` for stdout, `mpsc::Sender<StdinCmd>` for stdin,
@@ -298,8 +298,8 @@ and `oneshot::Receiver<i32>` for exit. This is the same pattern wezterm
The trait's adapter-facing types (`AsyncWrite`, `Stream<Item = Bytes>`,
`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<i32, TtyError>>`
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<i32>`
@@ -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
+31 -30
View File
@@ -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`)
+54 -54
View File
@@ -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
+41 -41
View File
@@ -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<Option<tx>>` slots (`adapter.rs:777-792`),
same shape: take-once `Mutex<Option<tx>>` 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/<target>/ 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
+102 -100
View File
@@ -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<String, String>` (a
flat resource-type → resource-id map). alkcall's `Identity` (in
`alkcall::core::auth`) has `resources: HashMap<String, Vec<String>>` —
`alkcall::core::auth`) has `resources: HashMap<String, Vec<String>>`;
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/<name>` → `alk/<name>` 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<Box<dyn Future + Send>>` 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<String, Vec<String>>` (was
`HashMap<String, String>` 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<dyn Fn(Value, Connection, AuthContext) -> 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/<name>` → `alk/<name>` 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<Item = Bytes>`). 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
+31 -31
View File
@@ -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/<component>.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/<component>.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/<component>.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/<component>.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
```
+9 -9
View File
@@ -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/<target>/` — committed seeds (regenerate with
- `corpus/<target>/` 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.
+25 -25
View File
@@ -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(&params)`. Errors →
//! 2. **Allocation**: `backend.allocate(&params)`. 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<String, Arc<dyn TtyBackend>>) -> Self {
Self {
backends: Arc::new(backends),
@@ -170,7 +170,7 @@ fn has_scope(identity: &Option<Identity>, 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<W: AsyncWrite + Unpin>(
/// `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<W, R>(
///
/// `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<W, R>(
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<R>(
@@ -828,7 +828,7 @@ mod tests {
}
}
/// `AsyncWrite` adapter over `mpsc::Sender<Bytes>` — the test backend's
/// `AsyncWrite` adapter over `mpsc::Sender<Bytes>`, 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<Result<i32, TtyError>>`.
/// 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<oneshot::Receiver<Result<i32, TtyError>>>,
cancel_dropped: Arc<Mutex<bool>>,
@@ -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();
+20 -20
View File
@@ -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<String, Arc<dyn TtyBackend>>` 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<Box<dyn Future +
/// Send + 'static>>` — `futures::future::BoxFuture<'static, T>` is
/// Send + 'static>>`: `futures::future::BoxFuture<'static, T>` is
/// exactly that alias.
pub type BoxFuture<T> = futures::future::BoxFuture<'static, T>;
@@ -40,7 +40,7 @@ pub type BoxFuture<T> = 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<TerminalParams>,
/// Command vector (`argv[0]` + args). Non-empty.
@@ -127,7 +127,7 @@ impl From<TerminalParamsWire> 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<NegotiateRequest> for TtyParams {
fn from(req: NegotiateRequest) -> Self {
@@ -144,25 +144,25 @@ impl From<NegotiateRequest> 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<dyn tokio::io::AsyncWrite + Send + Unpin>,
/// 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<Box<dyn Stream<Item = Bytes> + 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<Pin<Box<dyn Stream<Item = Bytes> + 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<Result<i32, TtyError>>,
/// 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<TtyControlHandle>,
@@ -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<String, Arc<dyn TtyBackend>>` 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<Item = Bytes>`, `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<Bytes>` — the mock
/// `AsyncWrite` adapter over an `mpsc::Sender<Bytes>`, 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.
+46 -46
View File
@@ -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<String, Arc<dyn TtyBackend>>` keyed by the
/// holds: `HashMap<String, Arc<dyn TtyBackend>>` 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<HashMap<String, Arc<dyn TtyBackend>>>) -> 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() {
+6 -6
View File
@@ -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`
+5 -5
View File
@@ -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.
+3 -3
View File
@@ -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
+1 -1
View File
@@ -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;
+11 -11
View File
@@ -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
+29 -29
View File
@@ -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<i32>` 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<i32>, child: &mut Box<dyn Child + Send + Sync>) ->
/// `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(),
+10 -10
View File
@@ -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<TerminalParamsWire>,
@@ -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<R: AsyncRead + Unpin> NegotiationReader<R> {
/// 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<W: AsyncWrite + Unpin> NegotiationWriter<W> {
/// Serialize a negotiation error response to JSON bytes.
///
/// Produces `{"error":"<error>","<field>":"<value>",...}` — the
/// Produces `{"error":"<error>","<field>":"<value>",...}`, 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<Vec<u8>> {
+53 -54
View File
@@ -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<Item = Bytes>`).
//! - [`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<Self, TtySessionError> {
// 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(&params)
@@ -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<Self, TtySessionError> {
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<R>(
writer: ChunkWriter<Box<dyn AsyncWrite + Send + Unpin>>,
reader: ChunkReader<R>,
@@ -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<Item = Bytes>` 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<Option<...>>` and is taken).
pub async fn recv_stdout(&self) -> Pin<Box<dyn Stream<Item = Bytes> + 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<Pin<Box<dyn Stream<Item = Bytes> + 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<i32, TtySessionError> {
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<R>(
@@ -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<tokio::sync::Mutex<Option<tokio::sync::oneshot::Receiver<()>>>>,
@@ -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() {
+7 -7
View File
@@ -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]
+20 -20
View File
@@ -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<R: AsyncRead + Unpin> ChunkReader<R> {
/// 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<R: AsyncRead + Unpin> ChunkReader<R> {
/// 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<W: AsyncWrite + Unpin> {
writer: W,
@@ -309,7 +309,7 @@ impl<W: AsyncWrite + Unpin> ChunkWriter<W> {
}
/// 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()
);