Plan M4b: approvals over Mattermost in 15 tasks
Skeletons for new files only; existing files change by exact edits, so no task overwrites an earlier one's work. Each task's tests were run against a reference built in order from master, with the gate at each step and mutations checked; each skeleton compiles and fails its tests, and every todo!() comment was read beside its signature. The reference is kept off this machine. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -15,7 +15,9 @@ merged: tool containers run from the image `deploy/tools-image.nix` builds, chec
|
|||||||
M4 is split: M4a (`gatewayd`, conversations over Mattermost, spec
|
M4 is split: M4a (`gatewayd`, conversations over Mattermost, spec
|
||||||
`docs/specs/2026-09-23-m4a-gateway.md`) is done and merged, reviewed in `docs/implementer-log.md`
|
`docs/specs/2026-09-23-m4a-gateway.md`) is done and merged, reviewed in `docs/implementer-log.md`
|
||||||
and checked end to end against the owner's Mattermost; no finding is open. M4b (approvals over
|
and checked end to end against the owner's Mattermost; no finding is open. M4b (approvals over
|
||||||
Mattermost) is specified in `docs/specs/2026-09-24-m4b-approvals.md` (approved) and not yet planned. The owner's Mattermost settings and the
|
Mattermost) is specified in `docs/specs/2026-09-24-m4b-approvals.md` (approved) and planned in
|
||||||
|
`docs/plans/M4b/` (15 tasks, branch `m4b`); its reference is a bundle on straylight,
|
||||||
|
`~/boxmaker-refs/m4b-ref.bundle`. The owner's Mattermost settings and the
|
||||||
bot's token are in `mattermost.env` at the repository root: ignored by git, never committed, and
|
bot's token are in `mattermost.env` at the repository root: ignored by git, never committed, and
|
||||||
the token is never printed.
|
the token is never printed.
|
||||||
Straylight now serves Ornith as four slots over one 262,144-token pool; see
|
Straylight now serves Ornith as four slots over one 262,144-token pool; see
|
||||||
|
|||||||
@@ -62,7 +62,7 @@ How it is used:
|
|||||||
| T25 | Size a task by the largest function the model must hold in one turn, not by the task. Ornith writes one function with a few branches well; a function with half a dozen branches and threads (M3b's `run`) it plans in its head until the turn runs out, with nothing written. Give such a task a compiling skeleton with the big function already written as glue over small `todo!()` helpers, and say to fill one at a time with `cargo check` between. | M3b task 11: four sessions. Two wrote nothing; a whole-file skeleton got five of six functions; the finer skeleton finished it in ten minutes, and tasks 12 and 13 followed without a stop. M4a task 08: again, a skeleton whose `header` was one `todo!()` with a dozen branches; nothing written. Size every `todo!()`, not only the task: the largest comment above a `todo!()` is a fair proxy, and one over about six lines needs splitting. M4a task 14: a task with a dozen small `todo!()`s still stalled, because the model read the whole crate to learn the APIs first; give a table of every call the task makes, with its signature, and make copying and the failing test the first two actions. |
|
| T25 | Size a task by the largest function the model must hold in one turn, not by the task. Ornith writes one function with a few branches well; a function with half a dozen branches and threads (M3b's `run`) it plans in its head until the turn runs out, with nothing written. Give such a task a compiling skeleton with the big function already written as glue over small `todo!()` helpers, and say to fill one at a time with `cargo check` between. | M3b task 11: four sessions. Two wrote nothing; a whole-file skeleton got five of six functions; the finer skeleton finished it in ten minutes, and tasks 12 and 13 followed without a stop. M4a task 08: again, a skeleton whose `header` was one `todo!()` with a dozen branches; nothing written. Size every `todo!()`, not only the task: the largest comment above a `todo!()` is a fair proxy, and one over about six lines needs splitting. M4a task 14: a task with a dozen small `todo!()`s still stalled, because the model read the whole crate to learn the APIs first; give a table of every call the task makes, with its signature, and make copying and the failing test the first two actions. |
|
||||||
| T26 | Replay each task's end state on its own, **and** read the task file against the reference for anything the reference has that the task does not ask for. The replay proves the tests can pass; only the reading finds a field the reference reads through a getter the task never mentions. | M3b task 11: the task stored `egress_dir` without the reference's getter, so the field was never read and clippy failed; the replay passed because the reference had the getter. |
|
| T26 | Replay each task's end state on its own, **and** read the task file against the reference for anything the reference has that the task does not ask for. The replay proves the tests can pass; only the reading finds a field the reference reads through a getter the task never mentions. | M3b task 11: the task stored `egress_dir` without the reference's getter, so the field was never read and clippy failed; the replay passed because the reference had the getter. |
|
||||||
|
|
||||||
| T27 | Every wait in a given test has a limit (`recv_timeout`, a deadline loop, `is_finished` before `join`). A test that waits forever on a broken implementation hangs the driver instead of failing, and the implementer cannot tell a hang from slowness. | M4a planning: breaking the reference's ping made `ws_conn` hang on `rx.recv()`; retrying a refused token made the serve test hang on `join()`. Both now fail within 5 s. |
|
| T27 | Every wait in a given test has a limit (`recv_timeout`, a deadline loop, `is_finished` before `join`). A test that waits forever on a broken implementation hangs the driver instead of failing, and the implementer cannot tell a hang from slowness. | M4a planning: breaking the reference's ping made `ws_conn` hang on `rx.recv()`; retrying a refused token made the serve test hang on `join()`. Both now fail within 5 s. M4b planning: a failed assertion inside `std::thread::scope` hangs instead of failing, because the scope joins its threads first; give such a test a guard that stops the thread on drop, and wait on `is_finished()` with a deadline instead of `join()`. |
|
||||||
| T28 | Before hand-over, break the reference on purpose, one line at a time (drop a check, move a bound by one, skip a save), and run the given tests against each change. A change the tests still pass is a missing test, unless it cannot change behaviour. Rustfmt reflows lines: match on text that survives formatting. | M4a planning: 69 changes over six modules; the tests missed 8. Five were real gaps and got tests (a reconnect that re-sent "interrupted" for a running turn was one), two could not change behaviour, and one was left (an event frame with another id, which `loopd` never sends). |
|
| T28 | Before hand-over, break the reference on purpose, one line at a time (drop a check, move a bound by one, skip a save), and run the given tests against each change. A change the tests still pass is a missing test, unless it cannot change behaviour. Rustfmt reflows lines: match on text that survives formatting. | M4a planning: 69 changes over six modules; the tests missed 8. Five were real gaps and got tests (a reconnect that re-sent "interrupted" for a running turn was one), two could not change behaviour, and one was left (an event frame with another id, which `loopd` never sends). |
|
||||||
|
|
||||||
| T29 | Check a skeleton's comments, not only its code. Print every `todo!()` beside its signature and read each pair before hand-over. Filling the bodies from your own code proves the split can pass; it cannot show that a comment sits on the wrong function. Edit comments by function, never by "the next comment after this name". And tell the implementer to stop and quote a comment that does not fit. | M4a task 14, second session: `post`'s comment was `now_ms`'s text, put there by a script; Ornith saw the contradiction and deliberated over it until the turn ran out. M4a task 16: a comment naming the function it replaced made the task's own `grep` fail; and a given test copied into the plan before `cargo fmt` came back reformatted. Run the task's checks, and `cargo fmt`, on the end state before hand-over. |
|
| T29 | Check a skeleton's comments, not only its code. Print every `todo!()` beside its signature and read each pair before hand-over. Filling the bodies from your own code proves the split can pass; it cannot show that a comment sits on the wrong function. Edit comments by function, never by "the next comment after this name". And tell the implementer to stop and quote a comment that does not fit. | M4a task 14, second session: `post`'s comment was `now_ms`'s text, put there by a script; Ornith saw the contradiction and deliberated over it until the turn ran out. M4a task 16: a comment naming the function it replaced made the task's own `grep` fail; and a given test copied into the plan before `cargo fmt` came back reformatted. Run the task's checks, and `cargo fmt`, on the end state before hand-over. |
|
||||||
|
|||||||
@@ -0,0 +1,42 @@
|
|||||||
|
# M4b task 01: move the text escaping into `proto`
|
||||||
|
|
||||||
|
**Branch:** `m4b` (run `git switch m4b`; `git status --short` must be empty, otherwise stop)
|
||||||
|
**Commit subject:** `proto: escape, moved from bxctl so gatewayd shows the same thing`
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
|
||||||
|
`bxctl` escapes text it did not write (model output, tool arguments, server errors) before it
|
||||||
|
reaches the terminal: control characters, zero-width and bidirectional marks, line separators and
|
||||||
|
the BOM become `\uXXXX`. M4b posts tool arguments in Mattermost, and they must be escaped by the
|
||||||
|
same rules, so the code moves to `proto`, where both can use it. **Nothing changes in what it
|
||||||
|
does.** `bxctl::escape` stays, as a re-export, so no `bxctl` code or test changes.
|
||||||
|
|
||||||
|
## Files
|
||||||
|
|
||||||
|
- Copy: `crates/proto/src/escape.rs` (complete), `crates/bxctl/src/escape.rs` (complete, replaces
|
||||||
|
the old one), `crates/proto/tests/escape.rs`
|
||||||
|
- Modify: `crates/proto/src/lib.rs` (`pub mod escape;`, after `pub mod class;`),
|
||||||
|
`docs/implementer-log.md`
|
||||||
|
|
||||||
|
`proto/src/escape.rs` is `bxctl`'s old file with a new module comment. `bxctl/src/escape.rs` becomes
|
||||||
|
one line: `pub use proto::escape::{escape_json_text, escape_model_text};`. The new test is
|
||||||
|
`bxctl`'s escape test, pointed at `proto::escape`.
|
||||||
|
|
||||||
|
## Steps
|
||||||
|
|
||||||
|
- [ ] **1. Copy.** `git switch m4b`, then
|
||||||
|
`cp docs/plans/M4b/files/crates/proto/src/escape.rs crates/proto/src/ && cp docs/plans/M4b/files/crates/bxctl/src/escape.rs crates/bxctl/src/ && cp docs/plans/M4b/files/crates/proto/tests/escape.rs crates/proto/tests/`.
|
||||||
|
Add `pub mod escape;` to `crates/proto/src/lib.rs`.
|
||||||
|
- [ ] **2. See it pass.** `cargo test -p proto --test escape` (8 passed) and `cargo test -p bxctl`
|
||||||
|
(every suite passes, unchanged).
|
||||||
|
- [ ] **3. Run the gate.** `cargo fmt --all`, then `make gate`. Expected last line: `gate: ok`.
|
||||||
|
- [ ] **4. Log and commit.**
|
||||||
|
`git add crates/proto crates/bxctl docs/implementer-log.md Cargo.lock && git commit`
|
||||||
|
|
||||||
|
## Done when
|
||||||
|
|
||||||
|
- Both crates' tests pass; `make gate` prints `gate: ok`.
|
||||||
|
|
||||||
|
## Stop and report if
|
||||||
|
|
||||||
|
- Any `bxctl` test changes result: the move must not change behaviour.
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
# M4b task 02: the `gateway.sock` messages in `proto`
|
||||||
|
|
||||||
|
**Branch:** `m4b` (run `git switch m4b`; `git status --short` must be empty, otherwise stop)
|
||||||
|
**Commit subject:** `proto: gateway, the four messages between brokerd and gatewayd`
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
|
||||||
|
One `gateway.sock` connection carries one pending approval from `brokerd` to `gatewayd`, and its
|
||||||
|
answer back (M4b spec, section 4). Every frame is a `proto` `Envelope`:
|
||||||
|
|
||||||
|
| # | From | `kind` | `final` | Body |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| 1 | `brokerd` | `approval_request` | true | `ApprovalRequest { approval: PendingApproval }` |
|
||||||
|
| 2 | `gatewayd` | `approval_posted` | false | `ApprovalPosted { posts: Vec<String> }` |
|
||||||
|
| 3 | `gatewayd` | `approval_reply` | true | `ApprovalReply { approval, approved, by, post, reason }` |
|
||||||
|
| 4 | `brokerd` | `approval_outcome` | true | `ApprovalOutcome { outcome: GatewayOutcome }` |
|
||||||
|
|
||||||
|
These are **our** format: unknown fields are errors everywhere.
|
||||||
|
|
||||||
|
## Files
|
||||||
|
|
||||||
|
- Copy: the skeleton `crates/proto/src/gateway.rs`, `crates/proto/tests/gateway_wire.rs`, and the
|
||||||
|
eight fixtures `crates/proto/tests/fixtures/wire/gateway_*.json`
|
||||||
|
- Modify: `crates/proto/src/lib.rs`, `crates/proto/src/wire.rs`, `crates/brokerd/src/broker.rs`,
|
||||||
|
`docs/implementer-log.md`
|
||||||
|
|
||||||
|
## The skeleton
|
||||||
|
|
||||||
|
Written: `MAX_REASON` (500), the four body types and `GatewayOutcome` with their serde attributes
|
||||||
|
(they are the wire format: do not change them). To fill: `is_mattermost_id` and
|
||||||
|
`ApprovalReply::check`, each with its steps above it.
|
||||||
|
|
||||||
|
## The changes, exactly
|
||||||
|
|
||||||
|
**`crates/proto/src/lib.rs`**: `pub mod gateway;` after `pub mod frame;`, and this re-export,
|
||||||
|
after the `pub use frame::…` line:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
pub use gateway::{
|
||||||
|
ApprovalOutcome, ApprovalPosted, ApprovalReply, ApprovalRequest, GatewayOutcome, MAX_REASON,
|
||||||
|
is_mattermost_id,
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
**`crates/proto/src/wire.rs`**: four variants at the end of `enum Message`, after
|
||||||
|
`GrantsReport(GrantsReport),`:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
ApprovalRequest(ApprovalRequest),
|
||||||
|
ApprovalPosted(ApprovalPosted),
|
||||||
|
ApprovalReply(ApprovalReply),
|
||||||
|
ApprovalOutcome(ApprovalOutcome),
|
||||||
|
```
|
||||||
|
|
||||||
|
and at the top, `use crate::gateway::{ApprovalOutcome, ApprovalPosted, ApprovalReply, ApprovalRequest};`.
|
||||||
|
|
||||||
|
**`crates/brokerd/src/broker.rs`**: `kind` must name every message. Add these four arms after
|
||||||
|
`Message::GrantsReport(_) => "grants_report",`, and change "all fourteen kinds" in its doc
|
||||||
|
comment to "all eighteen kinds":
|
||||||
|
|
||||||
|
```rust
|
||||||
|
Message::ApprovalRequest(_) => "approval_request",
|
||||||
|
Message::ApprovalPosted(_) => "approval_posted",
|
||||||
|
Message::ApprovalReply(_) => "approval_reply",
|
||||||
|
Message::ApprovalOutcome(_) => "approval_outcome",
|
||||||
|
```
|
||||||
|
|
||||||
|
## Steps
|
||||||
|
|
||||||
|
- [ ] **1. Copy.** `git switch m4b`, then
|
||||||
|
`cp docs/plans/M4b/files/crates/proto/src/gateway.rs crates/proto/src/ && cp docs/plans/M4b/files/crates/proto/tests/gateway_wire.rs crates/proto/tests/ && cp docs/plans/M4b/files/crates/proto/tests/fixtures/wire/gateway_*.json crates/proto/tests/fixtures/wire/`.
|
||||||
|
- [ ] **2. Make the three edits above.** `cargo build --workspace` must succeed.
|
||||||
|
- [ ] **3. See it fail.** `cargo test -p proto --test gateway_wire`. Expected: 1 fails
|
||||||
|
(`an_answer_is_checked`), 2 pass (the fixtures, since the types are given).
|
||||||
|
- [ ] **4. Fill `is_mattermost_id`, then `check`.** `cargo check -p proto` after each.
|
||||||
|
- [ ] **5. See it pass.** `cargo test -p proto --test gateway_wire`. Expected: 3 passed.
|
||||||
|
- [ ] **6. Run the gate.** `cargo fmt --all`, then `make gate`. Expected last line: `gate: ok`.
|
||||||
|
- [ ] **7. Log and commit.** `git add crates/proto crates/brokerd docs/implementer-log.md Cargo.lock && git commit`
|
||||||
|
|
||||||
|
## Done when
|
||||||
|
|
||||||
|
- `cargo test -p proto --test gateway_wire` passes; `make gate` prints `gate: ok`.
|
||||||
|
|
||||||
|
## Stop and report if
|
||||||
|
|
||||||
|
- A comment above a `todo!()` does not fit its function, or contradicts a test: quote it.
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
# M4b task 03: `bxctl deny`, another name for `refuse`
|
||||||
|
|
||||||
|
**Branch:** `m4b` (run `git switch m4b`; `git status --short` must be empty, otherwise stop)
|
||||||
|
**Commit subject:** `bxctl: deny, another name for refuse`
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
|
||||||
|
In Mattermost the owner answers `!deny <id>`; in the terminal the word should work too. `bxctl
|
||||||
|
deny` means exactly what `bxctl refuse` means, argument for argument, errors included.
|
||||||
|
|
||||||
|
## Files
|
||||||
|
|
||||||
|
- Copy: `crates/bxctl/tests/cli_deny.rs`
|
||||||
|
- Modify: `crates/bxctl/src/cli.rs`, `docs/implementer-log.md`
|
||||||
|
|
||||||
|
## The changes, exactly
|
||||||
|
|
||||||
|
In `crates/bxctl/src/cli.rs`:
|
||||||
|
|
||||||
|
1. In `parse`, `"refuse" => parse_refuse(rest, home),` becomes
|
||||||
|
`"refuse" | "deny" => parse_refuse(rest, home),`.
|
||||||
|
2. In `USAGE`, after the two lines about `bxctl refuse`, add a blank line and:
|
||||||
|
|
||||||
|
```text
|
||||||
|
bxctl deny <id> [--reason TEXT] [--admin-socket PATH]
|
||||||
|
Another name for refuse.
|
||||||
|
```
|
||||||
|
|
||||||
|
(keep the two-space and six-space indents of the lines around it).
|
||||||
|
|
||||||
|
## Steps
|
||||||
|
|
||||||
|
- [ ] **1. Copy.** `git switch m4b`, then `cp docs/plans/M4b/files/crates/bxctl/tests/cli_deny.rs crates/bxctl/tests/`
|
||||||
|
- [ ] **2. See it fail.** `cargo test -p bxctl --test cli_deny`. Expected: 2 fail.
|
||||||
|
- [ ] **3. Make the two changes.**
|
||||||
|
- [ ] **4. See it pass.** `cargo test -p bxctl`. Expected: every suite passes; `cli_deny` 2 passed.
|
||||||
|
- [ ] **5. Run the gate.** `cargo fmt --all`, then `make gate`. Expected last line: `gate: ok`.
|
||||||
|
- [ ] **6. Log and commit.** `git add crates/bxctl docs/implementer-log.md Cargo.lock && git commit`
|
||||||
|
|
||||||
|
## Done when
|
||||||
|
|
||||||
|
- `cargo test -p bxctl` passes; `make gate` prints `gate: ok`.
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
# M4b task 04: an answer records its post; the table says what is pending
|
||||||
|
|
||||||
|
**Branch:** `m4b` (run `git switch m4b`; `git status --short` must be empty, otherwise stop)
|
||||||
|
**Commit subject:** `brokerd: answers carry the Mattermost post; Table::contains`
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
|
||||||
|
When an approval is answered from Mattermost, the audit record must name the approver **and the
|
||||||
|
post** that answered (the `Approval` record has had a `post` field since M3a, always `None` so
|
||||||
|
far). And the courier (task 06) needs to ask the approval table whether an approval is still
|
||||||
|
pending.
|
||||||
|
|
||||||
|
`ledger.rs` is at 499 lines, so first `Answer` and `Answered` move to `approvals.rs`, beside
|
||||||
|
`Verdict` and `Entry`; `ledger` re-exports them, so every `crate::ledger::Answer` still works.
|
||||||
|
|
||||||
|
## Files
|
||||||
|
|
||||||
|
- Copy: `crates/brokerd/tests/ledger_post.rs`, and **new versions** of three given tests,
|
||||||
|
`crates/brokerd/tests/broker_pending.rs`, `broker_sequence.rs` and `ledger_answer.rs` (the only
|
||||||
|
change in each: `post: None,` in every `Answer::Approved` and `Answer::Refused`)
|
||||||
|
- Modify: `crates/brokerd/src/approvals.rs`, `crates/brokerd/src/ledger.rs`,
|
||||||
|
`crates/brokerd/src/admin.rs`, `docs/implementer-log.md`
|
||||||
|
|
||||||
|
## The changes, exactly
|
||||||
|
|
||||||
|
**1. Move.** Cut `enum Answer` and `struct Answered` from `ledger.rs`, with their doc comments,
|
||||||
|
and paste them into `approvals.rs`, before `pub struct Entry`. In `ledger.rs`, add
|
||||||
|
`pub use crate::approvals::{Answer, Answered};` next to `use crate::approvals::Verdict;`. In
|
||||||
|
`approvals.rs`, `use proto::{DenyReason, PendingApproval, Timestamp};` becomes
|
||||||
|
`use proto::{DecisionRecord, DenyReason, PendingApproval, Timestamp};`.
|
||||||
|
|
||||||
|
**2. `post`.** In `approvals.rs`, `Answer` becomes:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
/// An owner's answer to a pending call.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub enum Answer {
|
||||||
|
/// `by` is "bxctl" or a Mattermost user id; `post` the Mattermost post that answered.
|
||||||
|
Approved {
|
||||||
|
by: Option<String>,
|
||||||
|
post: Option<String>,
|
||||||
|
},
|
||||||
|
Refused {
|
||||||
|
by: Option<String>,
|
||||||
|
post: Option<String>,
|
||||||
|
reason: Option<String>,
|
||||||
|
},
|
||||||
|
Expired,
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
In `ledger.rs`, `answer`, step 3 becomes:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
let (answer, by, post, reason) = match &answer {
|
||||||
|
Answer::Approved { by, post } => {
|
||||||
|
(ApprovalAnswer::Approved, by.clone(), post.clone(), None)
|
||||||
|
}
|
||||||
|
Answer::Refused { by, post, reason } => (
|
||||||
|
ApprovalAnswer::Refused,
|
||||||
|
by.clone(),
|
||||||
|
post.clone(),
|
||||||
|
reason.clone(),
|
||||||
|
),
|
||||||
|
Answer::Expired => (ApprovalAnswer::Expired, None, None, None),
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
and in step 6, the `AuditEvent::Approval` field `post: None,` becomes `post,`.
|
||||||
|
|
||||||
|
In `admin.rs`, the two answers `bxctl` gives get `post: None,` after their `by: Some(BY.to_string()),`.
|
||||||
|
|
||||||
|
**3. `contains`.** In `approvals.rs`, `impl Table`, before `take`:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
/// Is approval `id` still pending? Only a hint: it may be taken the moment after.
|
||||||
|
pub fn contains(&self, id: u64) -> bool {
|
||||||
|
self.lock().contains_key(&id)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Steps
|
||||||
|
|
||||||
|
- [ ] **1. Copy.** `git switch m4b`, then
|
||||||
|
`cp docs/plans/M4b/files/crates/brokerd/tests/ledger_post.rs docs/plans/M4b/files/crates/brokerd/tests/broker_pending.rs docs/plans/M4b/files/crates/brokerd/tests/broker_sequence.rs docs/plans/M4b/files/crates/brokerd/tests/ledger_answer.rs crates/brokerd/tests/`
|
||||||
|
- [ ] **2. See it fail.** `cargo test -p brokerd --test ledger_post`. Expected: it does not
|
||||||
|
compile (`post` is not a field of `Answer`; `contains` does not exist).
|
||||||
|
- [ ] **3. Make the three changes.** Run `cargo fmt --all`.
|
||||||
|
- [ ] **4. See it pass.** `cargo test -p brokerd`. Expected: every suite passes; `ledger_post` 3
|
||||||
|
passed. `wc -l crates/brokerd/src/ledger.rs` is under 490.
|
||||||
|
- [ ] **5. Run the gate.** `make gate`. Expected last line: `gate: ok`.
|
||||||
|
- [ ] **6. Log and commit.** `git add crates/brokerd docs/implementer-log.md Cargo.lock && git commit`
|
||||||
|
|
||||||
|
## Done when
|
||||||
|
|
||||||
|
- `cargo test -p brokerd` passes; `make gate` prints `gate: ok`.
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
# M4b task 05: `[gateway]` in `brokerd.toml`
|
||||||
|
|
||||||
|
**Branch:** `m4b` (run `git switch m4b`; `git status --short` must be empty, otherwise stop)
|
||||||
|
**Commit subject:** `brokerd: config, [gateway] socket for Mattermost approvals`
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
|
||||||
|
`brokerd` carries pending approvals to `gatewayd` only when `brokerd.toml` says where (M4b spec,
|
||||||
|
section 3):
|
||||||
|
|
||||||
|
```toml
|
||||||
|
[gateway] # absent: Mattermost approvals are off, as in M3
|
||||||
|
socket = "" # empty: <home>/run/gateway/gateway.sock
|
||||||
|
```
|
||||||
|
|
||||||
|
## Files
|
||||||
|
|
||||||
|
- Copy: `crates/brokerd/tests/config_gateway.rs`, and a **new version** of the given
|
||||||
|
`crates/brokerd/tests/support/rig.rs` (its `Config` gains `gateway: None,`)
|
||||||
|
- Modify: `crates/brokerd/src/config.rs`, `docs/implementer-log.md`
|
||||||
|
|
||||||
|
## The changes, exactly
|
||||||
|
|
||||||
|
In `crates/brokerd/src/config.rs`:
|
||||||
|
|
||||||
|
1. Before `fn default_podman`, the section:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
/// `[gateway]`: where `gatewayd` takes approvals to post in Mattermost. Without the section,
|
||||||
|
/// Mattermost approvals are off and only `bxctl` answers. An empty path means the default.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Default)]
|
||||||
|
#[serde(deny_unknown_fields, default)]
|
||||||
|
pub struct Gateway {
|
||||||
|
pub socket: PathBuf,
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
2. In `struct Config`, after `pub runner: Option<Runner>,`:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
#[serde(default)]
|
||||||
|
pub gateway: Option<Gateway>,
|
||||||
|
```
|
||||||
|
|
||||||
|
3. In `impl Config`, before `audit_dir`:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
/// `gateway.sock`, when `[gateway]` is set.
|
||||||
|
pub fn gateway_socket(&self) -> Option<PathBuf> {
|
||||||
|
let gateway = self.gateway.as_ref()?;
|
||||||
|
if gateway.socket.as_os_str().is_empty() {
|
||||||
|
Some(self.paths.home.join("run/gateway/gateway.sock"))
|
||||||
|
} else {
|
||||||
|
Some(gateway.socket.clone())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Steps
|
||||||
|
|
||||||
|
- [ ] **1. Copy.** `git switch m4b`, then
|
||||||
|
`cp docs/plans/M4b/files/crates/brokerd/tests/config_gateway.rs crates/brokerd/tests/ && cp docs/plans/M4b/files/crates/brokerd/tests/support/rig.rs crates/brokerd/tests/support/`
|
||||||
|
- [ ] **2. See it fail.** `cargo test -p brokerd --test config_gateway`. Expected: it does not
|
||||||
|
compile.
|
||||||
|
- [ ] **3. Make the three changes.** Run `cargo fmt --all`.
|
||||||
|
- [ ] **4. See it pass.** `cargo test -p brokerd`. Expected: every suite passes; `config_gateway`
|
||||||
|
4 passed.
|
||||||
|
- [ ] **5. Run the gate.** `make gate`. Expected last line: `gate: ok`.
|
||||||
|
- [ ] **6. Log and commit.** `git add crates/brokerd docs/implementer-log.md Cargo.lock && git commit`
|
||||||
|
|
||||||
|
## Done when
|
||||||
|
|
||||||
|
- `cargo test -p brokerd` passes; `make gate` prints `gate: ok`.
|
||||||
@@ -0,0 +1,133 @@
|
|||||||
|
# M4b task 06: the courier
|
||||||
|
|
||||||
|
**Branch:** `m4b` (run `git switch m4b`; `git status --short` must be empty, otherwise stop)
|
||||||
|
**Commit subject:** `brokerd: courier, carrying pending approvals to gatewayd and answers back`
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
|
||||||
|
When an `ask` call enters the pending table and `[gateway]` is set, a **courier** thread carries
|
||||||
|
it to `gatewayd` over `gateway.sock` (task 02's four frames) and brings the answer back (M4b spec,
|
||||||
|
section 5):
|
||||||
|
|
||||||
|
- It stops as soon as the table no longer holds the approval (answered in `bxctl`, expired, the
|
||||||
|
requester gone), closing the connection; `gatewayd` then marks the post "no longer pending".
|
||||||
|
- If `gatewayd` cannot be reached or cannot post, it logs **once** for this approval, with the
|
||||||
|
pointer `see docs/runbook.md#gateway-unreachable`, and tries again every 5 seconds; when a later
|
||||||
|
try posts it, it logs `brokerd: approval <id> posted in Mattermost`.
|
||||||
|
- If `gatewayd` goes away after posting, it posts it again.
|
||||||
|
- On an answer, it takes the entry out of the table and answers it with `admin::answer`, exactly
|
||||||
|
as `bxctl approve` and `refuse` do, with the Mattermost user and post (task 04); then sends what
|
||||||
|
came of it. If the entry was already gone, the outcome is `Gone` and nothing is written.
|
||||||
|
|
||||||
|
The courier runs **beside** the connection thread that waits for the verdict: a scoped thread
|
||||||
|
(`std::thread::Builder::spawn_scoped`), stopped by a flag when the wait ends. So `ask`'s wait moves
|
||||||
|
into a function of its own, called inside `courier::alongside`.
|
||||||
|
|
||||||
|
## Files
|
||||||
|
|
||||||
|
- Copy: the skeleton `crates/brokerd/src/courier.rs`, `crates/brokerd/tests/courier.rs`,
|
||||||
|
`crates/brokerd/tests/support/fake_gateway.rs`
|
||||||
|
- Append: `docs/plans/M4b/files/runbook-gateway-unreachable.md` to the end of `docs/runbook.md`
|
||||||
|
- Modify: `crates/brokerd/src/lib.rs` (`pub mod courier;` after `pub mod config;`),
|
||||||
|
`crates/brokerd/src/broker.rs`, `docs/implementer-log.md`
|
||||||
|
|
||||||
|
## The skeleton
|
||||||
|
|
||||||
|
Written, because they are the protocol and its glue: `RUNBOOK`, `Timing` (5 s retry, 1 s tick),
|
||||||
|
`Attempt`, **`alongside`**, **`carry`**, **`attempt`**, **`exchange`** and `lost`. Read them first:
|
||||||
|
they call everything you write. To fill, each a few lines with the code in its comment:
|
||||||
|
`finished`, `pause`, `frames`, `answer`.
|
||||||
|
|
||||||
|
`frames` reads `gatewayd`'s frames whole on a thread of its own and hands them over a channel, so
|
||||||
|
the courier can wait with a timeout without losing half a frame (`UnixStream::peek` is not stable).
|
||||||
|
|
||||||
|
## `broker.rs`, exactly
|
||||||
|
|
||||||
|
1. `use std::sync::mpsc::RecvTimeoutError;` becomes
|
||||||
|
`use std::sync::mpsc::{Receiver, RecvTimeoutError};`, and add `use crate::courier;` after
|
||||||
|
`use crate::config::Config;`.
|
||||||
|
2. In `pending`, keep a copy of what the table lists, just before it is inserted:
|
||||||
|
`let shown = info.clone();` on the line before `let verdict = broker.table().insert(info, ask);`.
|
||||||
|
3. Replace step 4 of `pending`, from `// 4. Wait for the verdict, or for the peer to leave.` down
|
||||||
|
to the end of that `let outcome = loop { … };`, with:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
// 4. Wait for the verdict, or for the peer to leave, with a courier carrying the approval to
|
||||||
|
// Mattermost meanwhile when `[gateway]` is set.
|
||||||
|
let waited = courier::alongside(broker, &shown, || {
|
||||||
|
wait_for_verdict(broker, stream, &verdict, seq)
|
||||||
|
});
|
||||||
|
let outcome = match waited {
|
||||||
|
Waited::Verdict(outcome) => outcome,
|
||||||
|
Waited::PeerLeft => return None, // 4. the peer left while we waited
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
4. Add, before `pub fn alive`, the old step 4 as a function (the same branches, with `return`s
|
||||||
|
instead of `break`s):
|
||||||
|
|
||||||
|
```rust
|
||||||
|
/// What waiting for the verdict came to.
|
||||||
|
enum Waited {
|
||||||
|
Verdict(Result<Verdict, DenyReason>),
|
||||||
|
PeerLeft,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Wait for the verdict, or for the peer to leave. If the peer leaves and the entry is still in
|
||||||
|
/// the table, taking it ends the approval unanswered; if someone already took it, their verdict
|
||||||
|
/// is awaited.
|
||||||
|
fn wait_for_verdict(
|
||||||
|
broker: &Broker,
|
||||||
|
stream: &UnixStream,
|
||||||
|
verdict: &Receiver<Verdict>,
|
||||||
|
seq: u64,
|
||||||
|
) -> Waited {
|
||||||
|
loop {
|
||||||
|
match verdict.recv_timeout(Duration::from_secs(1)) {
|
||||||
|
Ok(verdict) => return Waited::Verdict(Ok(verdict)),
|
||||||
|
Err(RecvTimeoutError::Timeout) => {
|
||||||
|
if alive(stream) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
match broker.table().take(seq) {
|
||||||
|
Some(_) => return Waited::PeerLeft,
|
||||||
|
None => match verdict.recv() {
|
||||||
|
Ok(verdict) => return Waited::Verdict(Ok(verdict)),
|
||||||
|
Err(_) => return Waited::Verdict(Err(DenyReason::AuditUnavailable)),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// The taker dropped it unanswered.
|
||||||
|
Err(RecvTimeoutError::Disconnected) => {
|
||||||
|
return Waited::Verdict(Err(DenyReason::AuditUnavailable));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Steps
|
||||||
|
|
||||||
|
- [ ] **1. Copy.** `git switch m4b`, then
|
||||||
|
`cp docs/plans/M4b/files/crates/brokerd/src/courier.rs crates/brokerd/src/ && cp docs/plans/M4b/files/crates/brokerd/tests/courier.rs crates/brokerd/tests/ && cp docs/plans/M4b/files/crates/brokerd/tests/support/fake_gateway.rs crates/brokerd/tests/support/`
|
||||||
|
and `cat docs/plans/M4b/files/runbook-gateway-unreachable.md >> docs/runbook.md`. Add
|
||||||
|
`pub mod courier;` to `lib.rs`.
|
||||||
|
- [ ] **2. Make the four `broker.rs` changes.** `cargo build -p brokerd` must succeed.
|
||||||
|
- [ ] **3. See it fail.** `cargo test -p brokerd --test courier`. Expected: 9 fail, 1 passes
|
||||||
|
(`without_a_gateway_nothing_is_carried`), in about 10 s.
|
||||||
|
- [ ] **4. Fill `finished`, `pause`, `frames`, `answer`**, `cargo check -p brokerd` after each.
|
||||||
|
- [ ] **5. See it pass.** `cargo test -p brokerd --test courier`, three times: 10 passed each
|
||||||
|
time, in about 10 s (two tests wait out the 5 s retry on purpose). Then `cargo test -p brokerd`:
|
||||||
|
every suite passes.
|
||||||
|
- [ ] **6. Run the gate.** `cargo fmt --all`, then `make gate`. Expected last line: `gate: ok`.
|
||||||
|
- [ ] **7. Log and commit.** `git add crates/brokerd docs/runbook.md docs/implementer-log.md Cargo.lock && git commit`
|
||||||
|
|
||||||
|
## Done when
|
||||||
|
|
||||||
|
- The courier suite passes three times running; `make gate` prints `gate: ok`.
|
||||||
|
|
||||||
|
## Stop and report if
|
||||||
|
|
||||||
|
- A comment above a `todo!()` does not fit its function, or contradicts a test: quote it.
|
||||||
|
- A written function (`alongside`, `carry`, `attempt`, `exchange`) seems to need a change.
|
||||||
|
- A test hangs or takes much longer than stated.
|
||||||
@@ -0,0 +1,103 @@
|
|||||||
|
# M4b task 07: `[approvals]` in `gatewayd.toml`
|
||||||
|
|
||||||
|
**Branch:** `m4b` (run `git switch m4b`; `git status --short` must be empty, otherwise stop)
|
||||||
|
**Commit subject:** `gatewayd: config, [approvals] users and socket`
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
|
||||||
|
Who may answer approvals, and where `brokerd` brings them (M4b spec, section 3):
|
||||||
|
|
||||||
|
```toml
|
||||||
|
[approvals]
|
||||||
|
users = [] # Mattermost user ids who may answer; empty: everyone in [allow] users
|
||||||
|
socket = "" # empty: <home>/run/gateway/gateway.sock
|
||||||
|
```
|
||||||
|
|
||||||
|
An approver must also be in `[allow] users`: one who is not heard could never answer, so that is a
|
||||||
|
configuration error. The **first approver** (`approvals.users[0]`, or `allow.users[0]` when the
|
||||||
|
list is empty) gets, in direct messages, the approvals of sessions that are not in Mattermost.
|
||||||
|
|
||||||
|
## Files
|
||||||
|
|
||||||
|
- Copy: `crates/gatewayd/tests/config_approvals.rs`
|
||||||
|
- Modify: `crates/gatewayd/src/config.rs`, `docs/runbook.md`, `docs/implementer-log.md`
|
||||||
|
|
||||||
|
## The changes, exactly
|
||||||
|
|
||||||
|
In `crates/gatewayd/src/config.rs`:
|
||||||
|
|
||||||
|
1. In `struct Config`, after `pub limits: Limits,`:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
#[serde(default)]
|
||||||
|
pub approvals: ApprovalsConfig,
|
||||||
|
```
|
||||||
|
|
||||||
|
and after `struct Config`:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
/// `[approvals]`: who may answer approvals, and where `brokerd` brings them.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Default)]
|
||||||
|
#[serde(deny_unknown_fields, default)]
|
||||||
|
pub struct ApprovalsConfig {
|
||||||
|
/// Mattermost user ids; empty means everyone in `[allow] users`.
|
||||||
|
pub users: Vec<String>,
|
||||||
|
/// Empty: `<home>/run/gateway/gateway.sock`.
|
||||||
|
pub socket: PathBuf,
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
2. In `problem`, right before the `[limits]` checks (`if self.limits.queue == 0`):
|
||||||
|
|
||||||
|
```rust
|
||||||
|
for id in &self.approvals.users {
|
||||||
|
if !self.allow.users.contains(id) {
|
||||||
|
return Some(format!(
|
||||||
|
"[approvals] {id:?} is not in [allow] users: an approver who is not heard could never answer"
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
3. In `impl Config`, before `state_path`, three methods. Write them from these descriptions:
|
||||||
|
- `pub fn approvers(&self) -> &[String]`: `&self.approvals.users`, or `&self.allow.users` when
|
||||||
|
the first is empty.
|
||||||
|
- `pub fn first_approver(&self) -> Option<&str>`: the first of `approvers()`
|
||||||
|
(`.first().map(String::as_str)`).
|
||||||
|
- `pub fn gateway_socket(&self) -> PathBuf`: `self.approvals.socket`, or
|
||||||
|
`self.paths.home.join("run/gateway/gateway.sock")` when it is empty
|
||||||
|
(`as_os_str().is_empty()`).
|
||||||
|
|
||||||
|
In `docs/runbook.md`, entry `gatewayd-start-failed`, replace the **Why** and **Confirm**
|
||||||
|
paragraphs with:
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
**Why.** `gatewayd` could not read or parse `gatewayd.toml`, or a value in it is not allowed: a
|
||||||
|
`url` that is not `http://` or `https://` with a host and an optional port, an id that is not 26
|
||||||
|
characters of `a-z0-9`, an empty `[allow] users`, an id in `[approvals] users` that is not also in
|
||||||
|
`[allow] users`, or a secret with not exactly one of `credential`, `env` and `file`. Or it could
|
||||||
|
not create `<home>/gateway/`, or could not listen on `gateway.sock` (its directory is made 0700
|
||||||
|
and the socket 0600, as `brokerd`'s are; a path of 108 bytes or more cannot be a socket). It will
|
||||||
|
not guess at a configuration that decides who it answers and who approves.
|
||||||
|
|
||||||
|
**Confirm.** The line says which: `<config path>: …` for the file (unknown keys are errors; check
|
||||||
|
it against `docs/specs/2026-09-23-m4a-gateway.md`, section 3, and
|
||||||
|
`docs/specs/2026-09-24-m4b-approvals.md`, section 3), `cannot prepare <dir>: …` for a directory
|
||||||
|
(`ls -ld <dir> "$(dirname <dir>)"`), or `cannot listen on <socket>: …` for `gateway.sock`
|
||||||
|
(`Address already in use` means another `gatewayd` runs: `pgrep -x gatewayd`).
|
||||||
|
```
|
||||||
|
|
||||||
|
## Steps
|
||||||
|
|
||||||
|
- [ ] **1. Copy.** `git switch m4b`, then `cp docs/plans/M4b/files/crates/gatewayd/tests/config_approvals.rs crates/gatewayd/tests/`
|
||||||
|
- [ ] **2. See it fail.** `cargo test -p gatewayd --test config_approvals`. Expected: it does not
|
||||||
|
compile.
|
||||||
|
- [ ] **3. Make the changes.** Run `cargo fmt --all`.
|
||||||
|
- [ ] **4. See it pass.** `cargo test -p gatewayd --test config_approvals --test config`.
|
||||||
|
Expected: 4 and 7 passed.
|
||||||
|
- [ ] **5. Run the gate.** `make gate`. Expected last line: `gate: ok`.
|
||||||
|
- [ ] **6. Log and commit.** `git add crates/gatewayd docs/runbook.md docs/implementer-log.md Cargo.lock && git commit`
|
||||||
|
|
||||||
|
## Done when
|
||||||
|
|
||||||
|
- Both suites pass; `make gate` prints `gate: ok`.
|
||||||
@@ -0,0 +1,170 @@
|
|||||||
|
# M4b task 08: the leading mention, and approval commands
|
||||||
|
|
||||||
|
**Branch:** `m4b` (run `git switch m4b`; `git status --short` must be empty, otherwise stop)
|
||||||
|
**Commit subject:** `gatewayd: strip a leading mention of this bot; recognise approval commands`
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
|
||||||
|
Two changes to routing (M4b spec, section 7):
|
||||||
|
|
||||||
|
1. **The leading mention.** After M4a has decided a post is for this Boxmaker, a leading
|
||||||
|
`@<this bot>` is stripped before anything reads the message: `@boxmaker0 !approve 42` is a
|
||||||
|
command, and `@boxmaker0 summarise the log` reaches the model as `summarise the log`. Where two
|
||||||
|
Boxmakers share a thread, that is how the owner addresses one of them.
|
||||||
|
2. **Approval commands.** `!approve <id>`, `!deny <id>` and `!deny <id> <reason>` (exactly) become
|
||||||
|
a new `Route::Approval`, carrying the command, who wrote it, which post, and when. Task 15 hands
|
||||||
|
it to the approvals module; until then `serve` answers it with M4a's notice, so nothing else
|
||||||
|
changes yet. Any other `!…` is still answered with `UNKNOWN_COMMAND`.
|
||||||
|
|
||||||
|
## Files
|
||||||
|
|
||||||
|
- Copy: `crates/gatewayd/tests/sessions_mention.rs`, and **new versions** of two given tests:
|
||||||
|
`crates/gatewayd/tests/sessions.rs` (commands now route to `Approval`; the mention is stripped)
|
||||||
|
and `crates/gatewayd/tests/serve.rs`, from `serve.rs-task08` (one line: the turn's text is
|
||||||
|
`start`, no longer `@boxmaker-straylight start`)
|
||||||
|
- Modify: `crates/gatewayd/src/sessions.rs`, `crates/gatewayd/src/serve/handle.rs`,
|
||||||
|
`docs/implementer-log.md`
|
||||||
|
|
||||||
|
## The changes, exactly
|
||||||
|
|
||||||
|
In `crates/gatewayd/src/sessions.rs`:
|
||||||
|
|
||||||
|
**1.** In `enum Route`, after `Queue(Message),`:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
/// `!approve` or `!deny`, for the approvals module to judge.
|
||||||
|
Approval {
|
||||||
|
thread: Thread,
|
||||||
|
command: ApprovalCommand,
|
||||||
|
/// Who wrote it, which post it is, and when (Mattermost's `create_at`).
|
||||||
|
user: String,
|
||||||
|
post: String,
|
||||||
|
at: i64,
|
||||||
|
},
|
||||||
|
```
|
||||||
|
|
||||||
|
**2.** After `enum Route`, the command type and `approval_command`, as given:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
/// An approval command as typed: exactly `!approve <id>`, `!deny <id>` or `!deny <id> <reason>`.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub enum ApprovalCommand {
|
||||||
|
Approve(u64),
|
||||||
|
Deny(u64, Option<String>),
|
||||||
|
/// `!approve` or `!deny` in any other form.
|
||||||
|
Malformed,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The longest reason a `!deny` may give, in characters.
|
||||||
|
pub const MAX_REASON: usize = proto::MAX_REASON;
|
||||||
|
|
||||||
|
/// `!approve …` or `!deny …` as an approval command; `None` for any other text.
|
||||||
|
pub fn approval_command(text: &str) -> Option<ApprovalCommand> {
|
||||||
|
let after = text.strip_prefix('!')?;
|
||||||
|
let (word, rest) = match after.split_once(' ') {
|
||||||
|
Some((word, rest)) => (word, Some(rest)),
|
||||||
|
None => (after, None),
|
||||||
|
};
|
||||||
|
if !matches!(word, "approve" | "deny") {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let id = |s: &str| -> Option<u64> {
|
||||||
|
if s.is_empty() || s.len() > 20 || !s.bytes().all(|b| b.is_ascii_digit()) {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
s.parse().ok()
|
||||||
|
};
|
||||||
|
let parsed = match (word, rest) {
|
||||||
|
("approve", Some(rest)) => id(rest).map(ApprovalCommand::Approve),
|
||||||
|
("deny", Some(rest)) => match rest.split_once(' ') {
|
||||||
|
None => id(rest).map(|n| ApprovalCommand::Deny(n, None)),
|
||||||
|
Some((n, reason)) => {
|
||||||
|
let fits = !reason.is_empty() && reason.chars().count() <= MAX_REASON;
|
||||||
|
id(n)
|
||||||
|
.filter(|_| fits)
|
||||||
|
.map(|n| ApprovalCommand::Deny(n, Some(reason.to_string())))
|
||||||
|
}
|
||||||
|
},
|
||||||
|
_ => None,
|
||||||
|
};
|
||||||
|
Some(parsed.unwrap_or(ApprovalCommand::Malformed))
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**3.** Write `strip_mention` yourself, next to them:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
/// The text after a leading mention of this bot (`me`, lower-case), and the spaces after it. The
|
||||||
|
/// mention is matched as `named` matches names; nothing else is removed. A message that would be
|
||||||
|
/// left empty is kept as it was.
|
||||||
|
pub fn strip_mention<'a>(message: &'a str, me: &str) -> &'a str
|
||||||
|
```
|
||||||
|
|
||||||
|
1. `let Some(after) = message.strip_prefix('@') else { return message; };`
|
||||||
|
2. The name's length: `after.find(|c: char| !(c.is_ascii_alphanumeric() || matches!(c, '.' | '-' | '_'))).unwrap_or(after.len())`;
|
||||||
|
`let raw = after.get(..len).unwrap_or_default();` and
|
||||||
|
`let name = raw.trim_end_matches('.');` (a trailing dot is not part of a name).
|
||||||
|
3. `name` empty, or not `name.eq_ignore_ascii_case(me)`: return `message`.
|
||||||
|
4. `let rest = after.get(name.len()..).unwrap_or_default().trim_start_matches(' ');` (spaces
|
||||||
|
only, not newlines).
|
||||||
|
5. `if rest.is_empty() { message } else { rest }`.
|
||||||
|
|
||||||
|
**4.** In `Router::route`:
|
||||||
|
- `let message = &post.message;` becomes `let message = strip_mention(&post.message, &self.me_name);`
|
||||||
|
- The block `if let Some(after) = message.strip_prefix('!') { … }` (which answered
|
||||||
|
`M4B_COMMAND`) becomes:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
if let Some(command) = approval_command(message) {
|
||||||
|
return Route::Approval {
|
||||||
|
thread,
|
||||||
|
command,
|
||||||
|
user: post.user_id.clone(),
|
||||||
|
post: post.id.clone(),
|
||||||
|
at: post.create_at,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if message.starts_with('!') {
|
||||||
|
return Route::Reply {
|
||||||
|
thread,
|
||||||
|
text: UNKNOWN_COMMAND.to_string(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- In the final `Route::Queue`, `text: message.clone(),` becomes `text: message.to_string(),`.
|
||||||
|
- In `route`'s step comment, the part about commands now reads: "Then a leading mention of this
|
||||||
|
bot is stripped (M4b spec, section 7), and: "!!..." drops one "!" and goes on as a message;
|
||||||
|
`approval_command` -> Approval; any other "!" -> Reply UNKNOWN_COMMAND."
|
||||||
|
|
||||||
|
In `crates/gatewayd/src/serve/handle.rs`, `handle_post`'s `match route` gains, after the
|
||||||
|
`Route::Reply` arm:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
// Until the approvals module is wired in (task 15), M4a's notice.
|
||||||
|
Route::Approval { thread, .. } => {
|
||||||
|
self.post(&thread.channel, &thread.root, M4B_COMMAND)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
and its `use crate::sessions::{…}` gains `M4B_COMMAND`.
|
||||||
|
|
||||||
|
## Steps
|
||||||
|
|
||||||
|
- [ ] **1. Copy.** `git switch m4b`, then
|
||||||
|
`cp docs/plans/M4b/files/crates/gatewayd/tests/sessions_mention.rs docs/plans/M4b/files/crates/gatewayd/tests/sessions.rs crates/gatewayd/tests/ && cp docs/plans/M4b/files/crates/gatewayd/tests/serve.rs-task08 crates/gatewayd/tests/serve.rs`
|
||||||
|
- [ ] **2. See it fail.** `cargo test -p gatewayd --test sessions_mention`. Expected: it does not
|
||||||
|
compile.
|
||||||
|
- [ ] **3. Make the changes**, `strip_mention` last. Run `cargo fmt --all`.
|
||||||
|
- [ ] **4. See it pass.** `cargo test -p gatewayd --test sessions_mention --test sessions --test serve`.
|
||||||
|
Expected: 4, 8 and 6 passed.
|
||||||
|
- [ ] **5. Run the gate.** `make gate`. Expected last line: `gate: ok`.
|
||||||
|
- [ ] **6. Log and commit.** `git add crates/gatewayd docs/implementer-log.md Cargo.lock && git commit`
|
||||||
|
|
||||||
|
## Done when
|
||||||
|
|
||||||
|
- The three suites pass; `make gate` prints `gate: ok`.
|
||||||
|
|
||||||
|
## Stop and report if
|
||||||
|
|
||||||
|
- A test wants the mention kept, or stripped somewhere other than at the very start.
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
# M4b task 09: editing a post, and a username
|
||||||
|
|
||||||
|
**Branch:** `m4b` (run `git switch m4b`; `git status --short` must be empty, otherwise stop)
|
||||||
|
**Commit subject:** `gatewayd: mm, patch_post and username`
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
|
||||||
|
When an approval ends, `gatewayd` edits its post to say how, naming who answered (M4b spec,
|
||||||
|
section 6). Two REST calls, checked against Mattermost v11.11.0, and confirmed on the owner's
|
||||||
|
server (the bot may edit its own posts):
|
||||||
|
|
||||||
|
- `PUT /api/v4/posts/{post_id}/patch` with `{"message": "…"}`: replaces the post's text; the
|
||||||
|
answer is the post.
|
||||||
|
- `GET /api/v4/users/{user_id}`: the user, with `username`.
|
||||||
|
|
||||||
|
Every id that goes into a path must be a Mattermost id first, or nothing is sent.
|
||||||
|
|
||||||
|
## Files
|
||||||
|
|
||||||
|
- Copy: `crates/gatewayd/tests/mm_edit.rs`
|
||||||
|
- Modify: `crates/gatewayd/src/mm/rest.rs`, `docs/implementer-log.md`
|
||||||
|
|
||||||
|
## The changes, exactly
|
||||||
|
|
||||||
|
In `crates/gatewayd/src/mm/rest.rs`, after `struct Channel`:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
struct User {
|
||||||
|
username: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A Mattermost username: 1 to 64 characters of `a-z`, `0-9`, `.`, `-` and `_`.
|
||||||
|
fn valid_username(name: &str) -> bool {
|
||||||
|
(1..=64).contains(&name.len())
|
||||||
|
&& name.bytes().all(|b| {
|
||||||
|
b.is_ascii_lowercase() || b.is_ascii_digit() || matches!(b, b'.' | b'-' | b'_')
|
||||||
|
})
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
and two methods at the end of `impl Client`, written like `posts_since` and `create_post`:
|
||||||
|
|
||||||
|
1. `pub fn patch_post(&self, post: &str, message: &str) -> Result<(), MmError>`:
|
||||||
|
- `post` not `valid_id`: `Err(MmError::Json("a post path must be a Mattermost id".to_string()))`,
|
||||||
|
nothing sent.
|
||||||
|
- `PUT` `/api/v4/posts/{post}/patch` with the body
|
||||||
|
`serde_json::json!({ "message": message }).to_string()`, through `self.call`.
|
||||||
|
- The answer is a `Post`: `json::<Post>(&body)?.check().map(|_| ())`.
|
||||||
|
2. `pub fn username(&self, user: &str) -> Result<String, MmError>`:
|
||||||
|
- `user` not `valid_id`: `Err(MmError::Json("a user path must be a Mattermost id".to_string()))`.
|
||||||
|
- `GET` `/api/v4/users/{user}` into `User`; a username that is not `valid_username` is
|
||||||
|
`Err(MmError::Json("the user has no valid username".to_string()))`. Else `Ok(username)`.
|
||||||
|
|
||||||
|
## Steps
|
||||||
|
|
||||||
|
- [ ] **1. Copy.** `git switch m4b`, then `cp docs/plans/M4b/files/crates/gatewayd/tests/mm_edit.rs crates/gatewayd/tests/`
|
||||||
|
- [ ] **2. See it fail.** `cargo test -p gatewayd --test mm_edit`. Expected: it does not compile.
|
||||||
|
- [ ] **3. Make the changes.** Run `cargo fmt --all`.
|
||||||
|
- [ ] **4. See it pass.** `cargo test -p gatewayd --test mm_edit --test mm_rest`. Expected: 4 and
|
||||||
|
10 passed.
|
||||||
|
- [ ] **5. Run the gate.** `make gate`. Expected last line: `gate: ok`.
|
||||||
|
- [ ] **6. Log and commit.** `git add crates/gatewayd docs/implementer-log.md Cargo.lock && git commit`
|
||||||
|
|
||||||
|
## Done when
|
||||||
|
|
||||||
|
- Both suites pass; `make gate` prints `gate: ok`.
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
# M4b task 10: the text of an approval's posts
|
||||||
|
|
||||||
|
**Branch:** `m4b` (run `git switch m4b`; `git status --short` must be empty, otherwise stop)
|
||||||
|
**Commit subject:** `gatewayd: approvals::text, the posts that show an approval`
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
|
||||||
|
What an approval looks like in Mattermost (M4b spec, section 6). Pure: text in, text out.
|
||||||
|
|
||||||
|
```text
|
||||||
|
**Approval 42** · tool `shell` · grant `ops-shell` · taint private · expires 2026-09-24 14:47 UTC
|
||||||
|
```
|
||||||
|
{"command":"ls -la /srv/backups"}
|
||||||
|
```
|
||||||
|
Reply in this thread with `!approve 42` or `!deny 42 <reason>`.
|
||||||
|
```
|
||||||
|
|
||||||
|
- The arguments are escaped with `proto::escape::escape_json_text` (task 01) and fenced with a run
|
||||||
|
of backticks longer than any inside them, so nothing in them renders or mentions anyone.
|
||||||
|
- **Nothing is shortened.** Arguments over 15,000 characters come in parts (at most 4), and only
|
||||||
|
the last carries the reply line, with the count and the SHA-256 of the whole. More than that, or
|
||||||
|
a part whose post would be over 15,500 characters, is one post saying it is too long to show,
|
||||||
|
answerable only in `bxctl`.
|
||||||
|
- When an approval ends, a line is added to its last post (`Ended`, `ended_line`, `with_ending`).
|
||||||
|
|
||||||
|
## Files
|
||||||
|
|
||||||
|
- Copy: `crates/gatewayd/src/approvals/mod.rs` from `mod.rs-task10` (three lines: the module
|
||||||
|
comment and `pub mod text;`), the skeleton `crates/gatewayd/src/approvals/text.rs`, and
|
||||||
|
`crates/gatewayd/tests/approvals_text.rs`
|
||||||
|
- Modify: `crates/gatewayd/src/lib.rs` (`pub mod approvals;` first, before `pub mod config;`),
|
||||||
|
`docs/implementer-log.md`
|
||||||
|
|
||||||
|
## The skeleton
|
||||||
|
|
||||||
|
Written: the constants, `Shown`, `Ended`, and **`render`**, which builds the posts from the
|
||||||
|
helpers below (read it first). To fill, each with its code in its comment: `code`, `fence`,
|
||||||
|
`longest_run`, `minute`, `name`, `reply_line`, `ended_line`, `with_ending`.
|
||||||
|
|
||||||
|
## Steps
|
||||||
|
|
||||||
|
- [ ] **1. Copy.** `git switch m4b`, then
|
||||||
|
`mkdir -p crates/gatewayd/src/approvals && cp docs/plans/M4b/files/crates/gatewayd/src/approvals/mod.rs-task10 crates/gatewayd/src/approvals/mod.rs && cp docs/plans/M4b/files/crates/gatewayd/src/approvals/text.rs crates/gatewayd/src/approvals/ && cp docs/plans/M4b/files/crates/gatewayd/tests/approvals_text.rs crates/gatewayd/tests/`.
|
||||||
|
Add `pub mod approvals;` to `lib.rs`.
|
||||||
|
- [ ] **2. See it fail.** `cargo test -p gatewayd --test approvals_text`. Expected: it compiles and
|
||||||
|
6 tests fail.
|
||||||
|
- [ ] **3. Fill the eight functions**, `longest_run` first, `cargo check -p gatewayd` after each.
|
||||||
|
- [ ] **4. See it pass.** `cargo test -p gatewayd --test approvals_text`. Expected: 6 passed.
|
||||||
|
- [ ] **5. Run the gate.** `cargo fmt --all`, then `make gate`. Expected last line: `gate: ok`.
|
||||||
|
- [ ] **6. Log and commit.** `git add crates/gatewayd docs/implementer-log.md Cargo.lock && git commit`
|
||||||
|
|
||||||
|
## Done when
|
||||||
|
|
||||||
|
- `cargo test -p gatewayd --test approvals_text` passes; `make gate` prints `gate: ok`.
|
||||||
|
|
||||||
|
## Stop and report if
|
||||||
|
|
||||||
|
- A comment above a `todo!()` does not fit its function, or contradicts a test: quote it.
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
# M4b task 11: the approvals posted here, and the commands that answer them
|
||||||
|
|
||||||
|
**Branch:** `m4b` (run `git switch m4b`; `git status --short` must be empty, otherwise stop)
|
||||||
|
**Commit subject:** `gatewayd: approvals, the registry and the judging of commands`
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
|
||||||
|
`gatewayd` keeps the approvals it has posted and not yet seen answered. A command answers one only
|
||||||
|
if **all** of these hold, checked in this order, each refusal answered in the thread with its own
|
||||||
|
line (M4b spec, section 7):
|
||||||
|
|
||||||
|
1. its author is an approver (`NOT_APPROVER` otherwise);
|
||||||
|
2. it is well formed (`USAGE` otherwise);
|
||||||
|
3. that approval is pending here;
|
||||||
|
4. it was not too long to show (then only `bxctl` answers it);
|
||||||
|
5. the command is in the approval's own thread;
|
||||||
|
6. it was posted after the approval's last part.
|
||||||
|
|
||||||
|
An accepted answer is **taken out** of the registry as it is sent to the approval's
|
||||||
|
`gateway.sock` connection, so a second `!approve` finds nothing.
|
||||||
|
|
||||||
|
## Files
|
||||||
|
|
||||||
|
- Copy: the skeleton `crates/gatewayd/src/approvals/mod.rs` (it replaces task 10's three lines;
|
||||||
|
`pub mod text;` is still in it), and `crates/gatewayd/tests/approvals_judge.rs`
|
||||||
|
- Modify: `docs/implementer-log.md`
|
||||||
|
|
||||||
|
## The skeleton
|
||||||
|
|
||||||
|
Written: `NOT_APPROVER`, `USAGE`, `Posted`, `Judged`, `Registry` with `new` and `lock`. To fill:
|
||||||
|
`add`, `remove`, `holds`, `waiting_sessions` (one line each), and `judge`, whose seven steps are
|
||||||
|
above its `todo!()`.
|
||||||
|
|
||||||
|
## Steps
|
||||||
|
|
||||||
|
- [ ] **1. Copy.** `git switch m4b`, then
|
||||||
|
`cp docs/plans/M4b/files/crates/gatewayd/src/approvals/mod.rs crates/gatewayd/src/approvals/ && cp docs/plans/M4b/files/crates/gatewayd/tests/approvals_judge.rs crates/gatewayd/tests/`
|
||||||
|
- [ ] **2. See it fail.** `cargo test -p gatewayd --test approvals_judge`. Expected: it compiles
|
||||||
|
and 6 tests fail.
|
||||||
|
- [ ] **3. Fill the four small functions, then `judge`**, `cargo check -p gatewayd` after each.
|
||||||
|
- [ ] **4. See it pass.** `cargo test -p gatewayd --test approvals_judge --test approvals_text`.
|
||||||
|
Expected: 6 and 6 passed.
|
||||||
|
- [ ] **5. Run the gate.** `cargo fmt --all`, then `make gate`. Expected last line: `gate: ok`.
|
||||||
|
- [ ] **6. Log and commit.** `git add crates/gatewayd docs/implementer-log.md Cargo.lock && git commit`
|
||||||
|
|
||||||
|
## Done when
|
||||||
|
|
||||||
|
- Both suites pass; `make gate` prints `gate: ok`.
|
||||||
|
|
||||||
|
## Stop and report if
|
||||||
|
|
||||||
|
- A comment above a `todo!()` does not fit its function, or contradicts a test: quote it.
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
# M4b task 12: approval posts in the state file
|
||||||
|
|
||||||
|
**Branch:** `m4b` (run `git switch m4b`; `git status --short` must be empty, otherwise stop)
|
||||||
|
**Commit subject:** `gatewayd: state, the posts of open approvals, and the turn of a session`
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
|
||||||
|
Two things the serve loop needs from `state.json` (M4b spec, sections 6 and 8):
|
||||||
|
|
||||||
|
- **Approval posts.** The posts of each approval shown and not yet ended, so that at the next
|
||||||
|
start the ones left behind can be marked dead. The field is **optional**: an M4a state file
|
||||||
|
loads unchanged. Like every other field, its ids must be valid, or the file is refused.
|
||||||
|
- **The turn of a session**: where an approval of that session goes (its thread).
|
||||||
|
|
||||||
|
## Files
|
||||||
|
|
||||||
|
- Copy: `crates/gatewayd/tests/state_approvals.rs`
|
||||||
|
- Modify: `crates/gatewayd/src/state.rs`, `docs/implementer-log.md`
|
||||||
|
|
||||||
|
## The changes, exactly
|
||||||
|
|
||||||
|
In `crates/gatewayd/src/state.rs`:
|
||||||
|
|
||||||
|
**1.** In `struct StateFile`, after `in_flight: Vec<InFlight>,`:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
/// Optional, so an M4a state file loads unchanged.
|
||||||
|
#[serde(default)]
|
||||||
|
approvals: Vec<ApprovalPosts>,
|
||||||
|
```
|
||||||
|
|
||||||
|
and after `struct StateFile`:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
/// The posts of an approval shown in Mattermost and not yet ended (M4b spec, section 8).
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
#[serde(deny_unknown_fields)]
|
||||||
|
pub struct ApprovalPosts {
|
||||||
|
pub approval: u64,
|
||||||
|
pub channel: String,
|
||||||
|
/// First part first; the last is the one that is edited when it ends.
|
||||||
|
pub posts: Vec<String>,
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**2.** In `StateFile::problem`, after the `in_flight` loop and before `None`: each entry of
|
||||||
|
`approvals` must have a `valid_id` channel and at least one post, all `valid_id`; otherwise
|
||||||
|
`Some(format!("approval {}'s posts are not valid", shown.approval))`.
|
||||||
|
|
||||||
|
**3.** Four methods at the end of `impl State`, each saving (`self.save()`) when it changes
|
||||||
|
something, like the methods already there:
|
||||||
|
|
||||||
|
- `pub fn turn_of(&self, session: &str) -> Option<InFlight>`: the entry of `in_flight` with that
|
||||||
|
session, cloned.
|
||||||
|
- `pub fn add_approval(&mut self, shown: ApprovalPosts) -> Result<(), StateError>`: remove any
|
||||||
|
entry with the same `approval`, push this one, save.
|
||||||
|
- `pub fn remove_approval(&mut self, approval: u64) -> Result<(), StateError>`: remove the entries
|
||||||
|
with that `approval`; save only if one was removed.
|
||||||
|
- `pub fn take_approvals(&mut self) -> Result<Vec<ApprovalPosts>, StateError>`: take them all out
|
||||||
|
(`std::mem::take`); save only when there were some; return them. (As `take_in_flight` does.)
|
||||||
|
|
||||||
|
## Steps
|
||||||
|
|
||||||
|
- [ ] **1. Copy.** `git switch m4b`, then `cp docs/plans/M4b/files/crates/gatewayd/tests/state_approvals.rs crates/gatewayd/tests/`
|
||||||
|
- [ ] **2. See it fail.** `cargo test -p gatewayd --test state_approvals`. Expected: it does not
|
||||||
|
compile.
|
||||||
|
- [ ] **3. Make the three changes.** Run `cargo fmt --all`.
|
||||||
|
- [ ] **4. See it pass.** `cargo test -p gatewayd --test state_approvals --test state --test serve_restart`.
|
||||||
|
Expected: 4, 4 and 7 passed.
|
||||||
|
- [ ] **5. Run the gate.** `make gate`. Expected last line: `gate: ok`.
|
||||||
|
- [ ] **6. Log and commit.** `git add crates/gatewayd docs/implementer-log.md Cargo.lock && git commit`
|
||||||
|
|
||||||
|
## Done when
|
||||||
|
|
||||||
|
- The three suites pass; `make gate` prints `gate: ok`.
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
# M4b task 13: `gateway.sock`, `gatewayd`'s side
|
||||||
|
|
||||||
|
**Branch:** `m4b` (run `git switch m4b`; `git status --short` must be empty, otherwise stop)
|
||||||
|
**Commit subject:** `gatewayd: gateway, the gateway.sock listener and one thread per connection`
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
|
||||||
|
`gatewayd` listens on `gateway.sock`, a Unix socket (not TCP) in its own 0700 directory, the socket
|
||||||
|
0600, a stale socket from an earlier run replaced. Each `brokerd` connection gets a thread that
|
||||||
|
speaks the four frames of task 02 (M4b spec, sections 4 and 6).
|
||||||
|
|
||||||
|
A connection thread **only speaks the protocol**. Posting in Mattermost and writing the state file
|
||||||
|
are the serve loop's, which alone owns them (task 14): the thread asks the loop through a channel,
|
||||||
|
`ToLoop::Show` (post this; answers come on this channel) and `ToLoop::Ended` (it ended; mark it).
|
||||||
|
|
||||||
|
## Files
|
||||||
|
|
||||||
|
- Copy: the skeleton `crates/gatewayd/src/gateway.rs`, `crates/gatewayd/tests/gateway_sock.rs`
|
||||||
|
- Modify: `crates/gatewayd/src/lib.rs` (`pub mod gateway;` after `pub mod deliver;`),
|
||||||
|
`docs/implementer-log.md`
|
||||||
|
|
||||||
|
## The skeleton
|
||||||
|
|
||||||
|
Written: `Log`, `ToLoop`, `Ending`, `Timing`, `frame`, and **`connection`**, the protocol: read
|
||||||
|
frame 1, ask the loop to post, send frame 2, wait for an approver's answer or for `brokerd` to
|
||||||
|
close, send frame 3, read frame 4, tell the loop how it ended. Read it first. To fill, each with its
|
||||||
|
steps in its comment: `listen`, `serve`, `frames` (the same as `brokerd`'s courier's), `end`.
|
||||||
|
|
||||||
|
## Steps
|
||||||
|
|
||||||
|
- [ ] **1. Copy.** `git switch m4b`, then
|
||||||
|
`cp docs/plans/M4b/files/crates/gatewayd/src/gateway.rs crates/gatewayd/src/ && cp docs/plans/M4b/files/crates/gatewayd/tests/gateway_sock.rs crates/gatewayd/tests/`.
|
||||||
|
Add `pub mod gateway;` to `lib.rs`.
|
||||||
|
- [ ] **2. See it fail.** `cargo test -p gatewayd --test gateway_sock`. Expected: it compiles and 6
|
||||||
|
tests fail.
|
||||||
|
- [ ] **3. Fill `end`, `frames`, `listen`, `serve`**, `cargo check -p gatewayd` after each.
|
||||||
|
- [ ] **4. See it pass.** `cargo test -p gatewayd --test gateway_sock`, three times. Expected: 6
|
||||||
|
passed each time, in about a second.
|
||||||
|
- [ ] **5. Run the gate.** `cargo fmt --all`, then `make gate`. Expected last line: `gate: ok`.
|
||||||
|
- [ ] **6. Log and commit.** `git add crates/gatewayd docs/implementer-log.md Cargo.lock && git commit`
|
||||||
|
|
||||||
|
## Done when
|
||||||
|
|
||||||
|
- `cargo test -p gatewayd --test gateway_sock` passes three times running; `make gate` prints
|
||||||
|
`gate: ok`.
|
||||||
|
|
||||||
|
## Stop and report if
|
||||||
|
|
||||||
|
- A comment above a `todo!()` does not fit its function, or contradicts a test: quote it.
|
||||||
|
- `connection` seems to need a change: it is given.
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
# M4b task 14: the serve loop posts approvals and marks them
|
||||||
|
|
||||||
|
**Branch:** `m4b` (run `git switch m4b`; `git status --short` must be empty, otherwise stop)
|
||||||
|
**Commit subject:** `gatewayd: serve, posting approvals, marking them, and the listener`
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
|
||||||
|
The serve loop does what `gateway.sock` connections ask (M4b spec, sections 6 and 8):
|
||||||
|
|
||||||
|
- **Show**: place the approval (its session's turn in flight: that thread; otherwise the first
|
||||||
|
approver's direct messages, as a new thread), post its parts in order, record the posts in the
|
||||||
|
state file, register it for answers (task 11), and reply with the post ids, or why it could not
|
||||||
|
be posted.
|
||||||
|
- **Ended**: forget it, and add how it ended to its last post, naming who answered.
|
||||||
|
- At the **first connection after a start**, the approval posts the last run left behind are
|
||||||
|
marked: their last post is replaced by its title and the restart line.
|
||||||
|
- `run` opens `gateway.sock` before connecting; a failure stops `gatewayd` at start
|
||||||
|
(`Stop::Start`, pointer `gatewayd-start-failed`).
|
||||||
|
|
||||||
|
## Files
|
||||||
|
|
||||||
|
- Copy: the skeleton `crates/gatewayd/src/serve/approvals.rs`,
|
||||||
|
`crates/gatewayd/tests/serve_approvals.rs`, `crates/gatewayd/tests/support/brokerd_client.rs`,
|
||||||
|
and a **new version** of the given `crates/gatewayd/tests/support/fake_mm.rs` (it gains post
|
||||||
|
edits, user names, and refusing posts)
|
||||||
|
- Modify: `crates/gatewayd/src/serve/mod.rs`, `docs/implementer-log.md`
|
||||||
|
|
||||||
|
## The skeleton
|
||||||
|
|
||||||
|
Written: `Open`, **`gateway_requests`** (drain the channel, never blocking) and **`show`** (read
|
||||||
|
both first). To fill, each with its steps in its comment: `place`, `ended`, `mark_left_behind`,
|
||||||
|
`mark`.
|
||||||
|
|
||||||
|
## `serve/mod.rs`, exactly
|
||||||
|
|
||||||
|
1. `mod approvals;` before `mod handle;`. Imports: `use std::collections::HashMap;`,
|
||||||
|
`use crate::approvals::Registry;`, `use crate::gateway::{self, ToLoop};`.
|
||||||
|
2. Three fields at the end of `struct Gateway`:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
/// The approvals posted here, shared with the turns (for the notice) and judged by commands.
|
||||||
|
registry: Arc<Registry>,
|
||||||
|
/// What `gateway.sock` connections ask of the loop.
|
||||||
|
gateway_rx: Receiver<ToLoop>,
|
||||||
|
/// The last post of each approval posted here and not yet ended.
|
||||||
|
open: HashMap<u64, approvals::Open>,
|
||||||
|
```
|
||||||
|
|
||||||
|
3. `Gateway::new` gains a last parameter `gateway_rx: Receiver<ToLoop>`, and sets
|
||||||
|
`registry: Arc::new(Registry::new()), gateway_rx, open: HashMap::new(),`.
|
||||||
|
4. In `run`, right before `let client = Arc::new(Client::new(…));`:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
let listener = match gateway::listen(&config.gateway_socket()) {
|
||||||
|
Ok(listener) => listener,
|
||||||
|
Err(why) => return Stop::Start(why),
|
||||||
|
};
|
||||||
|
let (gateway_tx, gateway_rx) = mpsc::channel();
|
||||||
|
if let Err(e) = gateway::serve(
|
||||||
|
listener,
|
||||||
|
gateway_tx,
|
||||||
|
gateway::Timing::default(),
|
||||||
|
Arc::clone(&log),
|
||||||
|
) {
|
||||||
|
return Stop::Start(format!("cannot start the gateway.sock thread: {e}"));
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
and `Gateway::new(config, client, state, log)` becomes
|
||||||
|
`Gateway::new(config, client, state, log, gateway_rx)`.
|
||||||
|
5. In `connected`, inside `if !self.restarted { … }`, after the `take_in_flight` loop:
|
||||||
|
`self.mark_left_behind()?;`
|
||||||
|
6. In `event_loop`, after `self.finished()?;`: `self.gateway_requests()?;`
|
||||||
|
|
||||||
|
## Steps
|
||||||
|
|
||||||
|
- [ ] **1. Copy.** `git switch m4b`, then
|
||||||
|
`cp docs/plans/M4b/files/crates/gatewayd/src/serve/approvals.rs crates/gatewayd/src/serve/ && cp docs/plans/M4b/files/crates/gatewayd/tests/serve_approvals.rs crates/gatewayd/tests/ && cp docs/plans/M4b/files/crates/gatewayd/tests/support/brokerd_client.rs docs/plans/M4b/files/crates/gatewayd/tests/support/fake_mm.rs crates/gatewayd/tests/support/`
|
||||||
|
- [ ] **2. Make the six `serve/mod.rs` changes.** `cargo build -p gatewayd` must succeed.
|
||||||
|
- [ ] **3. See it fail.** `cargo test -p gatewayd --test serve_approvals`. Expected: 4 fail, 1
|
||||||
|
passes (the socket is there while `gatewayd` runs).
|
||||||
|
- [ ] **4. Fill `mark`, `place`, `ended`, `mark_left_behind`**, `cargo check -p gatewayd` after
|
||||||
|
each.
|
||||||
|
- [ ] **5. See it pass.** `cargo test -p gatewayd --test serve_approvals`, three times: 5 passed.
|
||||||
|
Then `cargo test -p gatewayd`: every suite passes.
|
||||||
|
- [ ] **6. Run the gate.** `cargo fmt --all`, then `make gate`. Expected last line: `gate: ok`.
|
||||||
|
- [ ] **7. Log and commit.** `git add crates/gatewayd docs/implementer-log.md Cargo.lock && git commit`
|
||||||
|
|
||||||
|
## Done when
|
||||||
|
|
||||||
|
- `cargo test -p gatewayd` passes; `make gate` prints `gate: ok`.
|
||||||
|
|
||||||
|
## Stop and report if
|
||||||
|
|
||||||
|
- A comment above a `todo!()` does not fit its function, or contradicts a test: quote it.
|
||||||
|
- `show` or `gateway_requests` seems to need a change: they are given.
|
||||||
@@ -0,0 +1,152 @@
|
|||||||
|
# M4b task 15: commands answer approvals; typing pauses; the notice
|
||||||
|
|
||||||
|
**Branch:** `m4b` (run `git switch m4b`; `git status --short` must be empty, otherwise stop)
|
||||||
|
**Commit subject:** `gatewayd: approvals answered from Mattermost; typing paused; the notice`
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
|
||||||
|
The last three pieces (M4b spec, section 7):
|
||||||
|
|
||||||
|
1. **Commands.** `Route::Approval` (task 08) goes to the registry's `judge` (task 11): an accepted
|
||||||
|
answer goes to `brokerd`; anything else is answered in the thread with its reason. M4a's notice
|
||||||
|
`M4B_COMMAND` goes away.
|
||||||
|
2. **Typing.** A turn waiting for its approval is not working: no `user_typing` for its thread
|
||||||
|
while the registry holds an approval of its session.
|
||||||
|
3. **The notice.** On `approval_pending`, the turn waits up to 10 s for `brokerd` to bring that
|
||||||
|
approval here (`Registry::was_posted`); only if it never does, it posts `waiting for approval
|
||||||
|
<id>: brokerd has not sent it here; answer it with bxctl approvals`.
|
||||||
|
|
||||||
|
## Files
|
||||||
|
|
||||||
|
- Copy: `crates/gatewayd/tests/serve_answers.rs`, `crates/gatewayd/tests/deliver_notice.rs`, and
|
||||||
|
**new versions** of three given files: `crates/gatewayd/tests/deliver.rs` (passes a `Notice`;
|
||||||
|
the new notice text), `crates/gatewayd/tests/serve.rs` (`!approve 3` is now "approval 3 is not
|
||||||
|
pending here"), `crates/gatewayd/tests/support/gateway.rs` (`Tuning` has `notice`)
|
||||||
|
- Modify: `crates/gatewayd/src/approvals/mod.rs`, `crates/gatewayd/src/deliver.rs`,
|
||||||
|
`crates/gatewayd/src/serve/mod.rs`, `crates/gatewayd/src/serve/handle.rs`,
|
||||||
|
`crates/gatewayd/src/sessions.rs`, `docs/implementer-log.md`
|
||||||
|
|
||||||
|
## The changes, exactly
|
||||||
|
|
||||||
|
**`approvals/mod.rs`**: every approval ever posted, for the notice. `Registry` gains a field
|
||||||
|
`ever: Mutex<HashSet<u64>>` (doc comment: "Every approval ever posted here, answered or not: for
|
||||||
|
the notice."; import `HashSet` beside `HashMap`); `add` also inserts `posted.approval` into it
|
||||||
|
(`self.ever.lock().unwrap_or_else(|p| p.into_inner()).insert(…)`, before inserting into `posted`);
|
||||||
|
and a new method:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
/// Was approval `id` ever posted here?
|
||||||
|
pub fn was_posted(&self, id: u64) -> bool {
|
||||||
|
self.ever
|
||||||
|
.lock()
|
||||||
|
.unwrap_or_else(|p| p.into_inner())
|
||||||
|
.contains(&id)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**`deliver.rs`**:
|
||||||
|
- `approval_text` returns
|
||||||
|
`format!("waiting for approval {approval}: brokerd has not sent it here; answer it with bxctl approvals")`.
|
||||||
|
- After it, and `use std::time::{Duration, Instant};` at the top:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
/// How long a turn waits for `brokerd` to bring its approval here before posting the notice.
|
||||||
|
pub const NOTICE_WAIT: Duration = Duration::from_secs(10);
|
||||||
|
|
||||||
|
/// Whether an approval was brought here (and so is posted already), and how long to wait for it.
|
||||||
|
pub struct Notice<'a> {
|
||||||
|
pub posted: &'a dyn Fn(u64) -> bool,
|
||||||
|
pub wait: Duration,
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
and write `impl Notice<'_> { fn arrives(&self, id: u64) -> bool }`: loop: `(self.posted)(id)`
|
||||||
|
→ true; past `Instant::now() + self.wait` (taken once, before the loop) → false; otherwise sleep
|
||||||
|
`Duration::from_millis(50).min(<the time left>)` (`saturating_duration_since`).
|
||||||
|
- `deliver` gains a last parameter `notice: &Notice`, and its `ApprovalPending` branch posts the
|
||||||
|
notice only when it does not arrive:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
// The approval post itself comes through `gateway.sock`; the notice only when it does not.
|
||||||
|
if let TurnEvent::ApprovalPending { approval, .. } = event
|
||||||
|
&& !notice.arrives(*approval)
|
||||||
|
{
|
||||||
|
post(poster, &batch.thread, approval_text(*approval), log);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**`serve/mod.rs`**: `Tuning` gains `pub notice: Duration,` (doc: "How long a turn waits for its
|
||||||
|
approval to come through `gateway.sock` before the notice."), `crate::deliver::NOTICE_WAIT` in its
|
||||||
|
`Default`; `Gateway` gains a field `notice: Duration` (doc "`Tuning::notice`."), set by a new last
|
||||||
|
parameter of `Gateway::new`, which `run` passes as `tuning.notice`.
|
||||||
|
|
||||||
|
**`serve/handle.rs`**:
|
||||||
|
- The `Route::Approval` arm becomes:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
Route::Approval {
|
||||||
|
thread,
|
||||||
|
command,
|
||||||
|
user,
|
||||||
|
post: command_post,
|
||||||
|
at,
|
||||||
|
} => {
|
||||||
|
let approvers = self.config.approvers();
|
||||||
|
let judged = self.registry.judge(
|
||||||
|
&command,
|
||||||
|
&user,
|
||||||
|
&thread.root,
|
||||||
|
&command_post,
|
||||||
|
at,
|
||||||
|
approvers,
|
||||||
|
);
|
||||||
|
if let Judged::Reply(text) = judged {
|
||||||
|
self.post(&thread.channel, &thread.root, &text);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- In `start`, the thread gets the registry and the wait, and passes a `Notice`:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
let (registry, wait) = (Arc::clone(&self.registry), self.notice);
|
||||||
|
let spawned = std::thread::Builder::new().spawn(move || {
|
||||||
|
let posted = |id: u64| registry.was_posted(id);
|
||||||
|
let notice = Notice {
|
||||||
|
posted: &posted,
|
||||||
|
wait,
|
||||||
|
};
|
||||||
|
deliver(client.as_ref(), &socket, &batch, &|line| log(line), ¬ice);
|
||||||
|
let _ = done.send(batch.session);
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
- In `typing`, before the loop: `let waiting = self.registry.waiting_sessions();`, and at the top
|
||||||
|
of the loop body: `if waiting.contains(&format!("mm-{}", thread.root)) { continue; }` (with the
|
||||||
|
comment "A turn waiting for its approval is not working: no typing in its thread.").
|
||||||
|
- Imports: `use crate::approvals::Judged;`; `Notice` beside `deliver`; `M4B_COMMAND` removed.
|
||||||
|
|
||||||
|
**`sessions.rs`**: delete `pub const M4B_COMMAND`.
|
||||||
|
|
||||||
|
## Steps
|
||||||
|
|
||||||
|
- [ ] **1. Copy.** `git switch m4b`, then
|
||||||
|
`cp docs/plans/M4b/files/crates/gatewayd/tests/serve_answers.rs docs/plans/M4b/files/crates/gatewayd/tests/deliver_notice.rs docs/plans/M4b/files/crates/gatewayd/tests/deliver.rs docs/plans/M4b/files/crates/gatewayd/tests/serve.rs crates/gatewayd/tests/ && cp docs/plans/M4b/files/crates/gatewayd/tests/support/gateway.rs crates/gatewayd/tests/support/`
|
||||||
|
- [ ] **2. See it fail.** `cargo test -p gatewayd --test serve_answers --test deliver_notice`.
|
||||||
|
Expected: they do not compile (`deliver::Notice` and `Tuning::notice` do not exist yet).
|
||||||
|
- [ ] **3. Make the changes**, one file at a time, `cargo check -p gatewayd` after each. Run
|
||||||
|
`cargo fmt --all`.
|
||||||
|
- [ ] **4. See it pass.** `cargo test -p gatewayd --test serve_answers --test deliver_notice --test deliver --test serve`,
|
||||||
|
three times: 4, 4, 9 and 6 passed. Then `cargo test -p gatewayd`: every suite passes.
|
||||||
|
- [ ] **5. Run the gate.** `make gate`. Expected last line: `gate: ok`, with about 845 tests.
|
||||||
|
- [ ] **6. Log and commit.** `git add crates/gatewayd docs/implementer-log.md Cargo.lock && git commit`
|
||||||
|
|
||||||
|
This is the last task of M4b. Stop after the commit.
|
||||||
|
|
||||||
|
## Done when
|
||||||
|
|
||||||
|
- `cargo test -p gatewayd` passes; `make gate` prints `gate: ok`.
|
||||||
|
|
||||||
|
## Stop and report if
|
||||||
|
|
||||||
|
- A test passes only sometimes, or waits much longer than stated.
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
# M4b implementation plan: approvals over Mattermost
|
||||||
|
|
||||||
|
> **For the implementing model:** do not work from this file. The owner gives you one task file at
|
||||||
|
> a time (`01-…` to `15-…`). This file is the index for the owner and the reviewer.
|
||||||
|
|
||||||
|
**Goal:** an `ask` call is posted in Mattermost, in the thread that asked for it (or, for a session
|
||||||
|
not in Mattermost, in the first approver's direct messages), and an approver answers it there with
|
||||||
|
`!approve <id>` or `!deny <id> [reason]`. The answer is recorded exactly as `bxctl`'s is, naming
|
||||||
|
the approver and the post; the post then shows what came of it. Nobody else can answer, and no
|
||||||
|
post is left looking answerable when it is not.
|
||||||
|
|
||||||
|
**Architecture:** `proto` gains the shared escaping (01) and the four `gateway.sock` messages (02);
|
||||||
|
`bxctl` gains `deny` (03). `brokerd` records the answering post (04), reads `[gateway]` (05), and
|
||||||
|
gets the **courier**, which carries each pending approval to `gatewayd` and its answer back (06).
|
||||||
|
`gatewayd` reads `[approvals]` (07), strips a leading mention of itself and recognises approval
|
||||||
|
commands (08), can edit a post and look up a name (09), renders an approval's posts (10), keeps
|
||||||
|
the approvals it posted and judges commands (11), records their posts in the state file (12),
|
||||||
|
listens on `gateway.sock` (13), posts and marks approvals from its serve loop (14), and wires the
|
||||||
|
commands, the typing pause and the fallback notice (15).
|
||||||
|
|
||||||
|
**Spec:** `docs/specs/2026-09-24-m4b-approvals.md` (approved; P16 applied to the brief). Runbook:
|
||||||
|
task 06 adds `gateway-unreachable`, task 07 widens `gatewayd-start-failed`.
|
||||||
|
|
||||||
|
**No task needs Mattermost, Podman or a network.** Everything runs against fakes: a fake `gatewayd`
|
||||||
|
for `brokerd`, and for `gatewayd` a fake Mattermost, a fake `loopd` and a fake `brokerd` client.
|
||||||
|
|
||||||
|
## Global constraints
|
||||||
|
|
||||||
|
- Everything in `AGENTS.md`, including "Lessons from earlier reviews".
|
||||||
|
- No new dependency.
|
||||||
|
- Branch `m4b`. One task, one fresh OpenCode session, one commit. Run `cargo fmt --all` before the
|
||||||
|
gate. Review happens once, after task 15.
|
||||||
|
- **New files** come as skeletons: types, constants and the glue written, `todo!()` bodies with the
|
||||||
|
code to write in the comment above. **Existing files** are changed by the exact edits in the
|
||||||
|
task, never replaced whole: a whole-file copy would overwrite an earlier task's work.
|
||||||
|
- Some tasks hand over **new versions of given test files**, because the change is in what the
|
||||||
|
test must see (a new field, a new text). Copy them over the old ones as the task says; they are
|
||||||
|
still not to be edited.
|
||||||
|
|
||||||
|
## Tasks
|
||||||
|
|
||||||
|
Every task had a reference implementation, built in this order from master with the gate at every
|
||||||
|
step (tip T26), its given tests run against it, and mutations of it caught by them (tip T28); then
|
||||||
|
the reference was moved off this machine (tip T18). Every skeleton was checked to compile at its
|
||||||
|
task's end state and fail its tests, and every comment above a `todo!()` was read beside its
|
||||||
|
signature (tip T29).
|
||||||
|
|
||||||
|
| # | File | Delivers | Tests | Check |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| 01 | `01-proto-escape.md` | `proto::escape`, moved from `bxctl` | `proto/tests/escape.rs` | reference; `bxctl` unchanged |
|
||||||
|
| 02 | `02-proto-gateway-wire.md` | the four `gateway.sock` messages | `gateway_wire.rs`, 8 fixtures | reference |
|
||||||
|
| 03 | `03-bxctl-deny.md` | `bxctl deny` | `cli_deny.rs` | reference |
|
||||||
|
| 04 | `04-brokerd-answer-post.md` | answers record the post; `Table::contains` | `ledger_post.rs`; 3 given tests updated | reference |
|
||||||
|
| 05 | `05-brokerd-config-gateway.md` | `[gateway]` | `config_gateway.rs`; `rig.rs` updated | reference |
|
||||||
|
| 06 | `06-brokerd-courier.md` | the courier | `courier.rs`, `support/fake_gateway.rs` | reference; 10 mutations caught; no test hangs |
|
||||||
|
| 07 | `07-gatewayd-config-approvals.md` | `[approvals]` | `config_approvals.rs` | reference |
|
||||||
|
| 08 | `08-gatewayd-mention-commands.md` | the leading mention; `Route::Approval` | `sessions_mention.rs`; `sessions.rs`, `serve.rs` updated | reference; 7 mutations, 5 caught, 2 that cannot change behaviour |
|
||||||
|
| 09 | `09-gatewayd-mm-edit.md` | `patch_post`, `username` | `mm_edit.rs` | reference; the bot's edit checked on the owner's server |
|
||||||
|
| 10 | `10-gatewayd-approval-text.md` | the posts' text | `approvals_text.rs` | reference; 7 mutations caught |
|
||||||
|
| 11 | `11-gatewayd-approvals-registry.md` | the registry, `judge` | `approvals_judge.rs` | reference; 6 mutations caught |
|
||||||
|
| 12 | `12-gatewayd-state-approvals.md` | approval posts in `state.json`; `turn_of` | `state_approvals.rs` | reference |
|
||||||
|
| 13 | `13-gatewayd-gateway-sock.md` | the listener and connection threads | `gateway_sock.rs` | reference; 6 mutations, 5 caught, 1 that cannot change behaviour |
|
||||||
|
| 14 | `14-gatewayd-serve-approvals.md` | posting, marking, the restart, the listener in `run` | `serve_approvals.rs`, `support/brokerd_client.rs`; `fake_mm.rs` updated | reference; 5 mutations caught |
|
||||||
|
| 15 | `15-gatewayd-answers.md` | commands judged, typing paused, the notice | `serve_answers.rs`, `deliver_notice.rs`; `deliver.rs`, `serve.rs`, `support/gateway.rs` updated | reference; 4 mutations caught |
|
||||||
|
|
||||||
|
At the end of task 15: about 845 tests (768 before task 01).
|
||||||
|
|
||||||
|
## Running it
|
||||||
|
|
||||||
|
```sh
|
||||||
|
git switch m4b
|
||||||
|
BOXMAKER_MODEL=straylight/ornith-1.5-35b-a3b tools/run-plan.sh docs/plans/M4b
|
||||||
|
```
|
||||||
|
|
||||||
|
Keep the OpenCode TUI closed while it runs.
|
||||||
@@ -0,0 +1,235 @@
|
|||||||
|
//! The courier: one pending approval carried to `gatewayd` over `gateway.sock`, and its answer
|
||||||
|
//! back (M4b spec, section 5). It runs beside the connection thread that waits for the verdict,
|
||||||
|
//! and is one more taker of the table's entry, answering it exactly as `bxctl` does.
|
||||||
|
|
||||||
|
use std::os::unix::net::UnixStream;
|
||||||
|
use std::path::Path;
|
||||||
|
use std::sync::atomic::{AtomicBool, Ordering};
|
||||||
|
use std::sync::mpsc::{self, Receiver, RecvTimeoutError};
|
||||||
|
use std::time::{Duration, Instant};
|
||||||
|
|
||||||
|
use proto::{
|
||||||
|
ApprovalOutcome, ApprovalReply, ApprovalRequest, DecisionRecord, DenyReason, Envelope,
|
||||||
|
FrameError, GatewayOutcome, Message, PendingApproval, Timestamp, read_frame,
|
||||||
|
};
|
||||||
|
|
||||||
|
use crate::admin;
|
||||||
|
use crate::broker::{Broker, send};
|
||||||
|
use crate::ledger::Answer;
|
||||||
|
|
||||||
|
pub const RUNBOOK: &str = "see docs/runbook.md#gateway-unreachable";
|
||||||
|
|
||||||
|
/// How long to wait before trying again, and how often to look at the table meanwhile.
|
||||||
|
#[derive(Debug, Clone, Copy)]
|
||||||
|
pub struct Timing {
|
||||||
|
pub retry: Duration,
|
||||||
|
pub tick: Duration,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for Timing {
|
||||||
|
fn default() -> Self {
|
||||||
|
Timing {
|
||||||
|
retry: Duration::from_secs(5),
|
||||||
|
tick: Duration::from_secs(1),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// How one attempt ended.
|
||||||
|
enum Attempt {
|
||||||
|
/// The approval is answered or no longer pending: the courier is done.
|
||||||
|
Done,
|
||||||
|
/// It was never posted: `gatewayd` could not be reached, or could not post.
|
||||||
|
Unreachable(String),
|
||||||
|
/// It was posted, then `gatewayd` went away or broke the protocol.
|
||||||
|
Lost(String),
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Run `wait` (the connection thread's wait for the verdict) with a courier beside it, when
|
||||||
|
/// `[gateway]` is set. The courier stops when `wait` returns.
|
||||||
|
pub fn alongside<T>(broker: &Broker, info: &PendingApproval, wait: impl FnOnce() -> T) -> T {
|
||||||
|
let Some(socket) = broker.cfg().gateway_socket() else {
|
||||||
|
return wait();
|
||||||
|
};
|
||||||
|
let stop = AtomicBool::new(false);
|
||||||
|
std::thread::scope(|scope| {
|
||||||
|
let started = std::thread::Builder::new().spawn_scoped(scope, || {
|
||||||
|
carry(broker, &socket, info, &stop, Timing::default())
|
||||||
|
});
|
||||||
|
if let Err(e) = started {
|
||||||
|
broker.log(&format!(
|
||||||
|
"brokerd: approval {}: cannot start its courier: {e}; bxctl can still answer it",
|
||||||
|
info.approval
|
||||||
|
));
|
||||||
|
}
|
||||||
|
let out = wait();
|
||||||
|
stop.store(true, Ordering::SeqCst);
|
||||||
|
out
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Carry approval `info` to `gatewayd` until it is answered or no longer pending.
|
||||||
|
pub fn carry(
|
||||||
|
broker: &Broker,
|
||||||
|
socket: &Path,
|
||||||
|
info: &PendingApproval,
|
||||||
|
stop: &AtomicBool,
|
||||||
|
timing: Timing,
|
||||||
|
) {
|
||||||
|
let id = info.approval;
|
||||||
|
let mut warned = false;
|
||||||
|
loop {
|
||||||
|
if finished(broker, id, stop) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
match attempt(broker, socket, info, stop, timing, warned) {
|
||||||
|
Attempt::Done => return,
|
||||||
|
Attempt::Unreachable(why) => {
|
||||||
|
if !warned {
|
||||||
|
warned = true;
|
||||||
|
broker.log(&format!(
|
||||||
|
"brokerd: approval {id}: gatewayd is not reachable at {}: {why}; bxctl can still answer it\n{RUNBOOK}",
|
||||||
|
socket.display()
|
||||||
|
));
|
||||||
|
}
|
||||||
|
pause(broker, id, stop, timing);
|
||||||
|
}
|
||||||
|
Attempt::Lost(why) => {
|
||||||
|
broker.log(&format!("brokerd: approval {id}: {why}; posting it again"));
|
||||||
|
pause(broker, id, stop, timing);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The courier's work is over: stopped, or the entry has left the table.
|
||||||
|
fn finished(broker: &Broker, id: u64, stop: &AtomicBool) -> bool {
|
||||||
|
// `stop.load(Ordering::SeqCst) || !broker.table().contains(id)`.
|
||||||
|
todo!()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Wait `timing.retry`, in ticks, unless the courier's work ends first.
|
||||||
|
fn pause(broker: &Broker, id: u64, stop: &AtomicBool, timing: Timing) {
|
||||||
|
// `let until = Instant::now() + timing.retry;` then, while `Instant::now() < until` and not
|
||||||
|
// `finished(broker, id, stop)`, sleep
|
||||||
|
// `timing.tick.min(until.saturating_duration_since(Instant::now()))`.
|
||||||
|
todo!()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Every frame `gatewayd` sends, read whole on a thread of its own, so the courier can wait with
|
||||||
|
/// a timeout without losing half a frame. Shutting the stream down ends the thread.
|
||||||
|
fn frames(stream: &UnixStream) -> std::io::Result<Receiver<Result<Envelope, FrameError>>> {
|
||||||
|
// 1. `let mut reader = stream.try_clone()?;` and `let (tx, rx) = mpsc::channel();`.
|
||||||
|
// 2. `std::thread::Builder::new().spawn(move || loop { ... })?`, where the loop body is: `let
|
||||||
|
// frame = read_frame(&mut reader); let last = frame.is_err(); if tx.send(frame).is_err() ||
|
||||||
|
// last { return; }`.
|
||||||
|
// 3. `Ok(rx)`.
|
||||||
|
todo!()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One connection: post, then wait for the answer, looking at the table every tick.
|
||||||
|
fn attempt(
|
||||||
|
broker: &Broker,
|
||||||
|
socket: &Path,
|
||||||
|
info: &PendingApproval,
|
||||||
|
stop: &AtomicBool,
|
||||||
|
timing: Timing,
|
||||||
|
warned: bool,
|
||||||
|
) -> Attempt {
|
||||||
|
let mut stream = match UnixStream::connect(socket) {
|
||||||
|
Ok(stream) => stream,
|
||||||
|
Err(e) => return Attempt::Unreachable(e.to_string()),
|
||||||
|
};
|
||||||
|
let rx = match frames(&stream) {
|
||||||
|
Ok(rx) => rx,
|
||||||
|
Err(e) => return Attempt::Unreachable(e.to_string()),
|
||||||
|
};
|
||||||
|
let ended = exchange(broker, &mut stream, &rx, info, stop, timing, warned);
|
||||||
|
let _ = stream.shutdown(std::net::Shutdown::Both);
|
||||||
|
ended
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Frames 1 to 4 of section 4 on an open connection.
|
||||||
|
fn exchange(
|
||||||
|
broker: &Broker,
|
||||||
|
stream: &mut UnixStream,
|
||||||
|
rx: &Receiver<Result<Envelope, FrameError>>,
|
||||||
|
info: &PendingApproval,
|
||||||
|
stop: &AtomicBool,
|
||||||
|
timing: Timing,
|
||||||
|
warned: bool,
|
||||||
|
) -> Attempt {
|
||||||
|
let id = info.approval;
|
||||||
|
let request = Message::ApprovalRequest(ApprovalRequest {
|
||||||
|
approval: info.clone(),
|
||||||
|
});
|
||||||
|
if !send(stream, 1, true, request) {
|
||||||
|
return Attempt::Unreachable("the request could not be sent".to_string());
|
||||||
|
}
|
||||||
|
let mut posted = false;
|
||||||
|
loop {
|
||||||
|
let frame = match rx.recv_timeout(timing.tick) {
|
||||||
|
Ok(Ok(frame)) => frame,
|
||||||
|
Err(RecvTimeoutError::Timeout) => {
|
||||||
|
if finished(broker, id, stop) {
|
||||||
|
return Attempt::Done;
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
Ok(Err(_)) | Err(RecvTimeoutError::Disconnected) => {
|
||||||
|
return lost(posted, "gatewayd went away");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
match (frame.id, frame.r#final, frame.msg) {
|
||||||
|
(1, false, Message::ApprovalPosted(_)) if !posted => {
|
||||||
|
posted = true;
|
||||||
|
if warned {
|
||||||
|
broker.log(&format!("brokerd: approval {id} posted in Mattermost"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
(1, true, Message::Error(e)) if !posted => {
|
||||||
|
return Attempt::Unreachable(format!("gatewayd could not post it: {}", e.detail));
|
||||||
|
}
|
||||||
|
(1, true, Message::ApprovalReply(reply)) if posted => {
|
||||||
|
if reply.approval != id {
|
||||||
|
return lost(posted, "gatewayd answered another approval");
|
||||||
|
}
|
||||||
|
if let Err(why) = reply.check() {
|
||||||
|
return lost(posted, &format!("gatewayd sent a bad answer ({why})"));
|
||||||
|
}
|
||||||
|
let outcome = answer(broker, id, reply);
|
||||||
|
let _ = send(
|
||||||
|
stream,
|
||||||
|
2,
|
||||||
|
true,
|
||||||
|
Message::ApprovalOutcome(ApprovalOutcome { outcome }),
|
||||||
|
);
|
||||||
|
return Attempt::Done;
|
||||||
|
}
|
||||||
|
_ => return lost(posted, "gatewayd broke the protocol"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn lost(posted: bool, why: &str) -> Attempt {
|
||||||
|
if posted {
|
||||||
|
Attempt::Lost(why.to_string())
|
||||||
|
} else {
|
||||||
|
Attempt::Unreachable(why.to_string())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Take the entry and answer it as `bxctl` would, with who answered and in which post.
|
||||||
|
fn answer(broker: &Broker, id: u64, reply: ApprovalReply) -> GatewayOutcome {
|
||||||
|
// 1. `let Some(entry) = broker.table().take(id) else { return GatewayOutcome::Gone {}; };`
|
||||||
|
// 2. `let (by, post) = (Some(reply.by), Some(reply.post));` and the `Answer`: `Answer::Approved
|
||||||
|
// { by, post }` when `reply.approved`, else `Answer::Refused { by, post, reason:
|
||||||
|
// reply.reason }`.
|
||||||
|
// 3. `match (reply.approved, admin::answer(broker, entry, answer, Timestamp::now()))`: (true,
|
||||||
|
// DecisionRecord::Denied { reason }) -> `GatewayOutcome::Denied { reason }`; (true, _) ->
|
||||||
|
// `GatewayOutcome::Runs {}`; (false, DecisionRecord::Denied { reason:
|
||||||
|
// DenyReason::ApprovalRefused }) -> `GatewayOutcome::Refused {}`; (false,
|
||||||
|
// DecisionRecord::Denied { reason }) -> `GatewayOutcome::Denied { reason }` (the refusal
|
||||||
|
// could not be recorded); (false, _) -> `GatewayOutcome::Refused {}`.
|
||||||
|
todo!()
|
||||||
|
}
|
||||||
@@ -0,0 +1,232 @@
|
|||||||
|
//! A call an `ask` grant matched: the pending frame, the table entry, the wait, and a requester
|
||||||
|
//! that goes away. The tests answer entries by hand, as `admin` will. Do not edit.
|
||||||
|
|
||||||
|
#[path = "support/client.rs"]
|
||||||
|
mod client;
|
||||||
|
#[path = "support/rig.rs"]
|
||||||
|
mod rig;
|
||||||
|
#[path = "support/runtime.rs"]
|
||||||
|
mod runtime;
|
||||||
|
#[path = "support/sink.rs"]
|
||||||
|
mod sink;
|
||||||
|
#[path = "support/tmp.rs"]
|
||||||
|
mod tmp;
|
||||||
|
|
||||||
|
use std::os::unix::net::UnixStream;
|
||||||
|
use std::sync::Arc;
|
||||||
|
use std::time::{Duration, Instant};
|
||||||
|
|
||||||
|
use brokerd::broker::{self, Broker, GONE};
|
||||||
|
use brokerd::ledger::Answer;
|
||||||
|
use client::{Serve, next, open};
|
||||||
|
use proto::{
|
||||||
|
AuditEvent, DataClass, DecisionRecord, DenyReason, Message, ResultStatus, Timestamp,
|
||||||
|
ToolResponse,
|
||||||
|
};
|
||||||
|
use rig::{Rig, grant_text, request};
|
||||||
|
use runtime::Recording;
|
||||||
|
|
||||||
|
fn ask_notes(rig: &Rig, extra: &str) {
|
||||||
|
rig.grant(
|
||||||
|
"notes",
|
||||||
|
&grant_text("read_file", "ask", extra, "paths = [\"/n\"]"),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Sends a call and reads its pending frame. Returns the connection and the frame's values.
|
||||||
|
fn start(broker: &Arc<Broker>, arguments: &str) -> (UnixStream, u64, Timestamp) {
|
||||||
|
let req = request("s1", 9, "read_file", arguments);
|
||||||
|
let mut stream = open(broker, broker::handle, 9, Message::ToolRequest(req));
|
||||||
|
let env = next(&mut stream);
|
||||||
|
assert_eq!((env.id, env.r#final), (9, false), "{env:?}");
|
||||||
|
match env.msg {
|
||||||
|
Message::ToolResponse(ToolResponse::PendingApproval { approval, expires }) => {
|
||||||
|
(stream, approval, expires)
|
||||||
|
}
|
||||||
|
other => panic!("{other:?}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// What `admin` does for an entry it took: record the answer, send the verdict.
|
||||||
|
fn answer_by_hand(broker: &Broker, id: u64, answer: Answer) -> DecisionRecord {
|
||||||
|
let entry = broker.table().take(id).expect("the entry is pending");
|
||||||
|
answer_entry(broker, entry, answer)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn answer_entry(
|
||||||
|
broker: &Broker,
|
||||||
|
entry: brokerd::approvals::Entry,
|
||||||
|
answer: Answer,
|
||||||
|
) -> DecisionRecord {
|
||||||
|
let grants = broker.grants();
|
||||||
|
let done = broker.ledger().answer(
|
||||||
|
entry.ask,
|
||||||
|
entry.info.approval,
|
||||||
|
answer,
|
||||||
|
&grants,
|
||||||
|
Timestamp::now(),
|
||||||
|
);
|
||||||
|
entry.reply.send(done.verdict).unwrap();
|
||||||
|
done.outcome
|
||||||
|
}
|
||||||
|
|
||||||
|
fn approved() -> Answer {
|
||||||
|
Answer::Approved {
|
||||||
|
by: Some("bxctl".to_string()),
|
||||||
|
post: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn final_answer(stream: &mut UnixStream) -> ToolResponse {
|
||||||
|
let env = next(stream);
|
||||||
|
assert!(env.r#final, "{env:?}");
|
||||||
|
match env.msg {
|
||||||
|
Message::ToolResponse(r) => r,
|
||||||
|
other => panic!("{other:?}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn eventually(what: &str, mut done: impl FnMut() -> bool) {
|
||||||
|
let until = Instant::now() + Duration::from_secs(10);
|
||||||
|
while !done() {
|
||||||
|
assert!(Instant::now() < until, "never happened: {what}");
|
||||||
|
std::thread::sleep(Duration::from_millis(20));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn an_ask_call_waits_is_listed_and_runs_when_approved() {
|
||||||
|
let rig = Rig::new("pending-approve");
|
||||||
|
ask_notes(&rig, "");
|
||||||
|
let rt = Recording::answering("the notes");
|
||||||
|
let broker = rig.broker(&rt);
|
||||||
|
let before = Timestamp::now();
|
||||||
|
// Spaced JSON: the table shows the arguments as policy parsed them, not as sent.
|
||||||
|
let (mut stream, approval, expires) = start(&broker, r#"{ "path" : "/n/a" }"#);
|
||||||
|
assert_eq!(approval, 0, "the approval id is the decision's seq");
|
||||||
|
let items = broker.table().list();
|
||||||
|
assert_eq!(items.len(), 1);
|
||||||
|
let item = &items[0];
|
||||||
|
assert_eq!(item.approval, 0);
|
||||||
|
assert_eq!((item.session.as_str(), item.call.0), ("s1", 9));
|
||||||
|
assert_eq!(item.tool, "read_file");
|
||||||
|
assert_eq!(item.arguments, r#"{"path":"/n/a"}"#);
|
||||||
|
assert_eq!(
|
||||||
|
(item.grant.as_str(), item.taint),
|
||||||
|
("notes", DataClass::Private)
|
||||||
|
);
|
||||||
|
assert!(item.created >= before);
|
||||||
|
assert_eq!(item.expires, expires);
|
||||||
|
assert_eq!(
|
||||||
|
expires.unix_millis() - item.created.unix_millis(),
|
||||||
|
900_000,
|
||||||
|
"now plus ttl_ms"
|
||||||
|
);
|
||||||
|
assert_eq!(rt.count(), 0, "nothing runs while it waits");
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
answer_by_hand(&broker, 0, approved()),
|
||||||
|
DecisionRecord::Ask {}
|
||||||
|
);
|
||||||
|
assert!(matches!(
|
||||||
|
final_answer(&mut stream),
|
||||||
|
ToolResponse::Result { .. }
|
||||||
|
));
|
||||||
|
assert_eq!(rt.count(), 1);
|
||||||
|
let events = rig.events();
|
||||||
|
assert!(
|
||||||
|
matches!(
|
||||||
|
events.as_slice(),
|
||||||
|
[
|
||||||
|
AuditEvent::Decision { .. },
|
||||||
|
AuditEvent::Approval { decision: 0, .. },
|
||||||
|
AuditEvent::Result { decision: 0, .. }
|
||||||
|
]
|
||||||
|
),
|
||||||
|
"{events:?}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_expiry_is_the_grants_when_that_is_earlier() {
|
||||||
|
let rig = Rig::new("pending-grant-expiry");
|
||||||
|
let soon = Timestamp::from_unix_millis(Timestamp::now().unix_millis() + 60_000).unwrap();
|
||||||
|
ask_notes(&rig, &format!("expires = \"{}\"", soon.to_rfc3339()));
|
||||||
|
let rt = Recording::answering("x");
|
||||||
|
let broker = rig.broker(&rt);
|
||||||
|
let (_stream, _, expires) = start(&broker, r#"{"path":"/n/a"}"#);
|
||||||
|
assert_eq!(expires, soon);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_denied_verdict_reaches_the_requester_and_nothing_runs() {
|
||||||
|
let rig = Rig::new("pending-refuse");
|
||||||
|
ask_notes(&rig, "");
|
||||||
|
let rt = Recording::answering("x");
|
||||||
|
let broker = rig.broker(&rt);
|
||||||
|
let (mut stream, approval, _) = start(&broker, r#"{"path":"/n/a"}"#);
|
||||||
|
let refused = Answer::Refused {
|
||||||
|
by: Some("bxctl".to_string()),
|
||||||
|
post: None,
|
||||||
|
reason: None,
|
||||||
|
};
|
||||||
|
answer_by_hand(&broker, approval, refused);
|
||||||
|
assert_eq!(
|
||||||
|
final_answer(&mut stream),
|
||||||
|
ToolResponse::Denied {
|
||||||
|
reason: DenyReason::ApprovalRefused
|
||||||
|
}
|
||||||
|
);
|
||||||
|
assert_eq!(rt.count(), 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_requester_that_leaves_takes_its_own_entry_and_nothing_is_written() {
|
||||||
|
let rig = Rig::new("pending-leave");
|
||||||
|
ask_notes(&rig, "");
|
||||||
|
let rt = Recording::answering("x");
|
||||||
|
let broker = rig.broker(&rt);
|
||||||
|
let (stream, _, _) = start(&broker, r#"{"path":"/n/a"}"#);
|
||||||
|
drop(stream);
|
||||||
|
// The waiting thread looks at its connection every second.
|
||||||
|
eventually("the entry is removed", || broker.table().list().is_empty());
|
||||||
|
std::thread::sleep(Duration::from_millis(200));
|
||||||
|
assert_eq!(rig.events().len(), 1, "only the decision");
|
||||||
|
let report = brokerd::audit::verify_dir(&rig.cfg.audit_dir()).unwrap();
|
||||||
|
assert_eq!(report.abandoned, [0]);
|
||||||
|
assert!(report.failure.is_none());
|
||||||
|
assert_eq!(rt.count(), 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_requester_gone_at_the_last_look_runs_nothing_and_closes_the_call() {
|
||||||
|
let rig = Rig::new("pending-lastlook");
|
||||||
|
ask_notes(&rig, "");
|
||||||
|
let rt = Recording::answering("x");
|
||||||
|
let broker = rig.broker(&rt);
|
||||||
|
let (stream, approval, _) = start(&broker, r#"{"path":"/n/a"}"#);
|
||||||
|
// Take the entry first, so the waiting thread cannot take it back when it sees the
|
||||||
|
// connection gone; then approve.
|
||||||
|
let entry = broker.table().take(approval).unwrap();
|
||||||
|
drop(stream);
|
||||||
|
answer_entry(&broker, entry, approved());
|
||||||
|
eventually("the call is closed", || rig.events().len() == 3);
|
||||||
|
match &rig.events()[2] {
|
||||||
|
AuditEvent::Result {
|
||||||
|
status,
|
||||||
|
decision,
|
||||||
|
sha256,
|
||||||
|
bytes,
|
||||||
|
..
|
||||||
|
} => {
|
||||||
|
assert_eq!((*status, *decision), (ResultStatus::Failed, 0));
|
||||||
|
assert_eq!(*sha256, proto::sha256(GONE.as_bytes()).unwrap());
|
||||||
|
assert_eq!(*bytes, GONE.len() as u64);
|
||||||
|
}
|
||||||
|
other => panic!("{other:?}"),
|
||||||
|
}
|
||||||
|
assert_eq!(GONE, "the requester went away");
|
||||||
|
assert_eq!(rt.count(), 0);
|
||||||
|
let report = brokerd::audit::verify_dir(&rig.cfg.audit_dir()).unwrap();
|
||||||
|
assert!(report.unfinished.is_empty() && report.abandoned.is_empty());
|
||||||
|
}
|
||||||
@@ -0,0 +1,242 @@
|
|||||||
|
//! Properties over sequences of calls, through `broker::handle` with many threads at once:
|
||||||
|
//! the log verifies and no `seq` repeats; taint never goes down; every `Result` follows the
|
||||||
|
//! record that let its call run; the runtime sees a call only after `allowed` or an approval.
|
||||||
|
//! Do not edit.
|
||||||
|
|
||||||
|
#[path = "support/client.rs"]
|
||||||
|
mod client;
|
||||||
|
#[path = "support/rig.rs"]
|
||||||
|
mod rig;
|
||||||
|
#[path = "support/runtime.rs"]
|
||||||
|
mod runtime;
|
||||||
|
#[path = "support/sink.rs"]
|
||||||
|
mod sink;
|
||||||
|
#[path = "support/tmp.rs"]
|
||||||
|
mod tmp;
|
||||||
|
|
||||||
|
use std::collections::BTreeMap;
|
||||||
|
use std::sync::Arc;
|
||||||
|
use std::sync::atomic::{AtomicBool, Ordering};
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
use brokerd::ledger::Answer;
|
||||||
|
use client::{Serve, call};
|
||||||
|
use proto::{
|
||||||
|
AuditEvent, AuditRecord, CallId, DataClass, DecisionRecord, Message, SessionId, Timestamp,
|
||||||
|
ToolResponse,
|
||||||
|
};
|
||||||
|
use rig::{Rig, grant_text, request};
|
||||||
|
use runtime::Recording;
|
||||||
|
|
||||||
|
fn grants(rig: &Rig) {
|
||||||
|
let with = |class: &str, max: &str| {
|
||||||
|
grant_text("read_file", "auto", "", "")
|
||||||
|
.replace(
|
||||||
|
"result_class = \"private\"",
|
||||||
|
&format!("result_class = \"{class}\""),
|
||||||
|
)
|
||||||
|
.replace("max_taint = \"secret\"", &format!("max_taint = \"{max}\""))
|
||||||
|
};
|
||||||
|
rig.grant("notes", &(with("private", "secret") + "paths = [\"/n\"]\n"));
|
||||||
|
rig.grant("keys", &(with("secret", "secret") + "paths = [\"/k\"]\n"));
|
||||||
|
// Stops applying once a session has read a secret.
|
||||||
|
rig.grant(
|
||||||
|
"public",
|
||||||
|
&(with("public", "private") + "paths = [\"/p\"]\n"),
|
||||||
|
);
|
||||||
|
rig.grant(
|
||||||
|
"never",
|
||||||
|
&grant_text("read_file", "deny", "", "paths = [\"/d\"]"),
|
||||||
|
);
|
||||||
|
rig.grant(
|
||||||
|
"asked",
|
||||||
|
&grant_text("read_file", "ask", "", "paths = [\"/a\"]"),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Which call ran on what authority: (session, call) of every Decision that allowed and every
|
||||||
|
/// Approval whose re-decision let the call run, keyed by the decision's seq.
|
||||||
|
fn check_sequence(records: &[AuditRecord]) -> usize {
|
||||||
|
let mut may_run: BTreeMap<u64, (SessionId, CallId)> = BTreeMap::new();
|
||||||
|
let mut taint: BTreeMap<String, DataClass> = BTreeMap::new();
|
||||||
|
let mut results = 0;
|
||||||
|
for (i, r) in records.iter().enumerate() {
|
||||||
|
assert_eq!(r.seq, i as u64, "no seq repeats or skips");
|
||||||
|
match &r.event {
|
||||||
|
AuditEvent::Decision {
|
||||||
|
session,
|
||||||
|
call,
|
||||||
|
outcome: DecisionRecord::Allowed {},
|
||||||
|
..
|
||||||
|
} => {
|
||||||
|
may_run.insert(r.seq, (session.clone(), *call));
|
||||||
|
}
|
||||||
|
AuditEvent::Approval {
|
||||||
|
session,
|
||||||
|
call,
|
||||||
|
decision,
|
||||||
|
outcome: DecisionRecord::Allowed {} | DecisionRecord::Ask {},
|
||||||
|
..
|
||||||
|
} => {
|
||||||
|
may_run.insert(*decision, (session.clone(), *call));
|
||||||
|
}
|
||||||
|
AuditEvent::Result {
|
||||||
|
session,
|
||||||
|
call,
|
||||||
|
decision,
|
||||||
|
taint_after,
|
||||||
|
..
|
||||||
|
} => {
|
||||||
|
results += 1;
|
||||||
|
let allowed = may_run.remove(decision);
|
||||||
|
assert_eq!(
|
||||||
|
allowed,
|
||||||
|
Some((session.clone(), *call)),
|
||||||
|
"seq {}: a Result with no record letting its call run",
|
||||||
|
r.seq
|
||||||
|
);
|
||||||
|
let before = taint.insert(session.as_str().to_string(), *taint_after);
|
||||||
|
assert!(
|
||||||
|
before.is_none_or(|b| b <= *taint_after),
|
||||||
|
"seq {}: taint went down",
|
||||||
|
r.seq
|
||||||
|
);
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
assert!(
|
||||||
|
may_run.is_empty(),
|
||||||
|
"calls allowed but never finished: {may_run:?}"
|
||||||
|
);
|
||||||
|
results
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn eight_threads_two_sessions_fifty_calls_each() {
|
||||||
|
let rig = Rig::new("sequence-many");
|
||||||
|
grants(&rig);
|
||||||
|
let rt = Recording::answering("content");
|
||||||
|
let broker = rig.broker(&rt);
|
||||||
|
let paths = ["/n/x", "/k/x", "/p/x", "/d/x", "/none/x"];
|
||||||
|
let threads: Vec<_> = (0..8u64)
|
||||||
|
.map(|t| {
|
||||||
|
let broker = Arc::clone(&broker);
|
||||||
|
std::thread::spawn(move || {
|
||||||
|
let session = if t % 2 == 0 { "s-even" } else { "s-odd" };
|
||||||
|
for i in 0..50u64 {
|
||||||
|
let path = paths[((t + i) % 5) as usize];
|
||||||
|
let args = format!(r#"{{"path":"{path}"}}"#);
|
||||||
|
let req = request(session, t * 1000 + i, "read_file", &args);
|
||||||
|
let frames = call(&broker, req);
|
||||||
|
assert_eq!(frames.len(), 1);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
for t in threads {
|
||||||
|
t.join().unwrap();
|
||||||
|
}
|
||||||
|
let report = brokerd::audit::verify_dir(&rig.cfg.audit_dir()).unwrap();
|
||||||
|
assert!(report.failure.is_none(), "{:?}", report.failure);
|
||||||
|
assert!(report.unfinished.is_empty());
|
||||||
|
let records = rig.records();
|
||||||
|
let decisions = records
|
||||||
|
.iter()
|
||||||
|
.filter(|r| matches!(r.event, AuditEvent::Decision { .. }))
|
||||||
|
.count();
|
||||||
|
assert_eq!(decisions, 400);
|
||||||
|
let results = check_sequence(&records);
|
||||||
|
assert_eq!(
|
||||||
|
rt.count(),
|
||||||
|
results,
|
||||||
|
"the runtime saw exactly the allowed calls"
|
||||||
|
);
|
||||||
|
assert!(results > 0 && results < 400);
|
||||||
|
// Both sessions read a secret.
|
||||||
|
for s in ["s-even", "s-odd"] {
|
||||||
|
let state = rig.state().read(&SessionId::new(s).unwrap()).unwrap();
|
||||||
|
assert_eq!(state.taint, DataClass::Secret);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn approved_and_refused_calls_run_only_after_an_approval() {
|
||||||
|
let rig = Rig::new("sequence-ask");
|
||||||
|
grants(&rig);
|
||||||
|
let rt = Recording::answering("content");
|
||||||
|
let broker = rig.broker(&rt);
|
||||||
|
// An owner who approves even ids and refuses odd ones, as fast as they appear.
|
||||||
|
let stop = Arc::new(AtomicBool::new(false));
|
||||||
|
let owner = {
|
||||||
|
let (broker, stop) = (Arc::clone(&broker), Arc::clone(&stop));
|
||||||
|
std::thread::spawn(move || {
|
||||||
|
while !stop.load(Ordering::SeqCst) {
|
||||||
|
for item in broker.table().list() {
|
||||||
|
let Some(entry) = broker.table().take(item.approval) else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
let answer = if item.approval % 2 == 0 {
|
||||||
|
Answer::Approved {
|
||||||
|
by: None,
|
||||||
|
post: None,
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Answer::Refused {
|
||||||
|
by: None,
|
||||||
|
post: None,
|
||||||
|
reason: None,
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let grants = broker.grants();
|
||||||
|
let done = broker.ledger().answer(
|
||||||
|
entry.ask,
|
||||||
|
item.approval,
|
||||||
|
answer,
|
||||||
|
&grants,
|
||||||
|
Timestamp::now(),
|
||||||
|
);
|
||||||
|
entry.reply.send(done.verdict).unwrap();
|
||||||
|
}
|
||||||
|
std::thread::sleep(Duration::from_millis(2));
|
||||||
|
}
|
||||||
|
})
|
||||||
|
};
|
||||||
|
let threads: Vec<_> = (0..4u64)
|
||||||
|
.map(|t| {
|
||||||
|
let broker = Arc::clone(&broker);
|
||||||
|
std::thread::spawn(move || {
|
||||||
|
for i in 0..10u64 {
|
||||||
|
let path = if i % 2 == 0 { "/a/x" } else { "/n/x" };
|
||||||
|
let args = format!(r#"{{"path":"{path}"}}"#);
|
||||||
|
let frames = call(&broker, request("s1", t * 100 + i, "read_file", &args));
|
||||||
|
let last = frames.last().unwrap();
|
||||||
|
assert!(
|
||||||
|
matches!(
|
||||||
|
&last.msg,
|
||||||
|
Message::ToolResponse(
|
||||||
|
ToolResponse::Result { .. } | ToolResponse::Denied { .. }
|
||||||
|
)
|
||||||
|
),
|
||||||
|
"{last:?}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
for t in threads {
|
||||||
|
t.join().unwrap();
|
||||||
|
}
|
||||||
|
stop.store(true, Ordering::SeqCst);
|
||||||
|
owner.join().unwrap();
|
||||||
|
let records = rig.records();
|
||||||
|
let approvals = records
|
||||||
|
.iter()
|
||||||
|
.filter(|r| matches!(r.event, AuditEvent::Approval { .. }))
|
||||||
|
.count();
|
||||||
|
assert_eq!(approvals, 20, "one Approval for each of the twenty asks");
|
||||||
|
let results = check_sequence(&records);
|
||||||
|
assert_eq!(rt.count(), results);
|
||||||
|
let report = brokerd::audit::verify_dir(&rig.cfg.audit_dir()).unwrap();
|
||||||
|
assert!(report.failure.is_none() && report.abandoned.is_empty());
|
||||||
|
}
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
//! `[gateway]` in `brokerd.toml`: absent means Mattermost approvals are off; an empty socket is
|
||||||
|
//! the default under `home` (M4b spec, section 3). Do not edit.
|
||||||
|
|
||||||
|
use std::path::PathBuf;
|
||||||
|
|
||||||
|
use brokerd::config::Config;
|
||||||
|
|
||||||
|
fn parse(text: &str) -> Config {
|
||||||
|
Config::parse(&format!("[paths]\nhome = \"/srv/bx\"\n{text}")).unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn without_the_section_approvals_stay_in_bxctl() {
|
||||||
|
let config = parse("");
|
||||||
|
assert_eq!(config.gateway, None);
|
||||||
|
assert_eq!(config.gateway_socket(), None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn an_empty_section_or_socket_is_the_default() {
|
||||||
|
let default = Some(PathBuf::from("/srv/bx/run/gateway/gateway.sock"));
|
||||||
|
assert_eq!(parse("[gateway]\n").gateway_socket(), default);
|
||||||
|
assert_eq!(
|
||||||
|
parse("[gateway]\nsocket = \"\"\n").gateway_socket(),
|
||||||
|
default
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_socket_path_is_used_as_given() {
|
||||||
|
let config = parse("[gateway]\nsocket = \"/run/bx/gw.sock\"\n");
|
||||||
|
assert_eq!(
|
||||||
|
config.gateway_socket(),
|
||||||
|
Some(PathBuf::from("/run/bx/gw.sock"))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn unknown_keys_are_errors() {
|
||||||
|
assert!(Config::parse("[gateway]\nsockets = \"/x\"\n").is_err());
|
||||||
|
assert!(Config::parse("[gateway]\nsocket = \"/x\"\nenabled = true\n").is_err());
|
||||||
|
assert!(Config::parse("[gatewy]\nsocket = \"/x\"\n").is_err());
|
||||||
|
}
|
||||||
@@ -0,0 +1,447 @@
|
|||||||
|
//! The courier carries a pending approval to `gatewayd` and its answer back, against a fake
|
||||||
|
//! `gatewayd` on a real socket (M4b spec, section 5). Do not edit.
|
||||||
|
|
||||||
|
#[path = "support/client.rs"]
|
||||||
|
mod client;
|
||||||
|
#[path = "support/fake_gateway.rs"]
|
||||||
|
mod fake_gateway;
|
||||||
|
#[path = "support/rig.rs"]
|
||||||
|
mod rig;
|
||||||
|
#[path = "support/runtime.rs"]
|
||||||
|
mod runtime;
|
||||||
|
#[path = "support/sink.rs"]
|
||||||
|
mod sink;
|
||||||
|
#[path = "support/tmp.rs"]
|
||||||
|
mod tmp;
|
||||||
|
|
||||||
|
use std::os::unix::net::UnixStream;
|
||||||
|
use std::sync::Arc;
|
||||||
|
use std::sync::atomic::AtomicBool;
|
||||||
|
use std::time::{Duration, Instant};
|
||||||
|
|
||||||
|
use brokerd::broker::{self, Broker};
|
||||||
|
use brokerd::config::Gateway;
|
||||||
|
use brokerd::courier::{self, Timing};
|
||||||
|
use brokerd::ledger::Answer;
|
||||||
|
use client::{Serve, next, open};
|
||||||
|
use fake_gateway::{FakeGateway, KYLE, POST};
|
||||||
|
use proto::{
|
||||||
|
ApprovalAnswer, ApprovalReply, AuditEvent, DenyReason, GatewayOutcome, Message, Timestamp,
|
||||||
|
ToolResponse,
|
||||||
|
};
|
||||||
|
use rig::{Rig, grant_text, request};
|
||||||
|
use runtime::Recording;
|
||||||
|
|
||||||
|
const WAIT: Duration = Duration::from_secs(5);
|
||||||
|
|
||||||
|
/// A rig whose `[gateway]` is a fake gatewayd in its home, and an `ask` grant for /n.
|
||||||
|
fn rig_with_gateway(tag: &str) -> (Rig, FakeGateway) {
|
||||||
|
let mut rig = Rig::new(tag);
|
||||||
|
let socket = rig.dir.path().join("gw.sock");
|
||||||
|
let fake = FakeGateway::start(&socket);
|
||||||
|
rig.cfg.gateway = Some(Gateway { socket });
|
||||||
|
rig.grant(
|
||||||
|
"notes",
|
||||||
|
&grant_text("read_file", "ask", "", "paths = [\"/n\"]"),
|
||||||
|
);
|
||||||
|
(rig, fake)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A call that waits for approval; its connection and the approval id.
|
||||||
|
fn ask(broker: &Arc<Broker>) -> (UnixStream, u64) {
|
||||||
|
let mut stream = open(
|
||||||
|
broker,
|
||||||
|
broker::handle,
|
||||||
|
9,
|
||||||
|
Message::ToolRequest(request("s1", 9, "read_file", r#"{"path":"/n/a"}"#)),
|
||||||
|
);
|
||||||
|
match next(&mut stream).msg {
|
||||||
|
Message::ToolResponse(ToolResponse::PendingApproval { approval, .. }) => (stream, approval),
|
||||||
|
other => panic!("{other:?}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn final_answer(stream: &mut UnixStream) -> ToolResponse {
|
||||||
|
let env = next(stream);
|
||||||
|
assert!(env.r#final, "{env:?}");
|
||||||
|
match env.msg {
|
||||||
|
Message::ToolResponse(r) => r,
|
||||||
|
other => panic!("{other:?}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The `Approval` record's (answer, by, post, reason).
|
||||||
|
fn approval_record(
|
||||||
|
rig: &Rig,
|
||||||
|
) -> (
|
||||||
|
ApprovalAnswer,
|
||||||
|
Option<String>,
|
||||||
|
Option<String>,
|
||||||
|
Option<String>,
|
||||||
|
) {
|
||||||
|
let events = rig.events();
|
||||||
|
let found = events.iter().find_map(|e| match e {
|
||||||
|
AuditEvent::Approval {
|
||||||
|
answer,
|
||||||
|
by,
|
||||||
|
post,
|
||||||
|
reason,
|
||||||
|
..
|
||||||
|
} => Some((*answer, by.clone(), post.clone(), reason.clone())),
|
||||||
|
_ => None,
|
||||||
|
});
|
||||||
|
found.expect("an Approval record")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn some(s: &str) -> Option<String> {
|
||||||
|
Some(s.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Sets the courier's stop flag when dropped, so a failing assertion ends the courier too.
|
||||||
|
struct StopOnDrop<'a>(&'a AtomicBool);
|
||||||
|
|
||||||
|
impl Drop for StopOnDrop<'_> {
|
||||||
|
fn drop(&mut self) {
|
||||||
|
self.0.store(true, std::sync::atomic::Ordering::SeqCst);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The courier must end by itself within five seconds.
|
||||||
|
fn ends_by_itself<T>(courier: &std::thread::ScopedJoinHandle<'_, T>) {
|
||||||
|
let until = Instant::now() + WAIT;
|
||||||
|
while !courier.is_finished() {
|
||||||
|
assert!(Instant::now() < until, "the courier did not stop");
|
||||||
|
std::thread::sleep(Duration::from_millis(20));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn approved_in_mattermost_it_runs_and_the_record_names_who_and_where() {
|
||||||
|
let (rig, fake) = rig_with_gateway("courier-approve");
|
||||||
|
let rt = Recording::answering("the notes");
|
||||||
|
let broker = rig.broker(&rt);
|
||||||
|
let (mut stream, id) = ask(&broker);
|
||||||
|
let mut conn = fake.next(WAIT).expect("the approval reaches gatewayd");
|
||||||
|
assert_eq!(
|
||||||
|
(conn.request.approval, conn.request.tool.as_str()),
|
||||||
|
(id, "read_file")
|
||||||
|
);
|
||||||
|
assert_eq!(conn.request.arguments, r#"{"path":"/n/a"}"#);
|
||||||
|
conn.posted();
|
||||||
|
conn.approve();
|
||||||
|
assert_eq!(conn.outcome(), Some(GatewayOutcome::Runs {}));
|
||||||
|
assert!(matches!(
|
||||||
|
final_answer(&mut stream),
|
||||||
|
ToolResponse::Result { .. }
|
||||||
|
));
|
||||||
|
assert_eq!(rt.count(), 1);
|
||||||
|
assert_eq!(
|
||||||
|
approval_record(&rig),
|
||||||
|
(ApprovalAnswer::Approved, some(KYLE), some(POST), None)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn refused_in_mattermost_it_is_denied_with_the_reason() {
|
||||||
|
let (rig, fake) = rig_with_gateway("courier-refuse");
|
||||||
|
let rt = Recording::answering("x");
|
||||||
|
let broker = rig.broker(&rt);
|
||||||
|
let (mut stream, _) = ask(&broker);
|
||||||
|
let mut conn = fake.next(WAIT).unwrap();
|
||||||
|
conn.posted();
|
||||||
|
conn.refuse("not on a Friday");
|
||||||
|
assert_eq!(conn.outcome(), Some(GatewayOutcome::Refused {}));
|
||||||
|
assert_eq!(
|
||||||
|
final_answer(&mut stream),
|
||||||
|
ToolResponse::Denied {
|
||||||
|
reason: DenyReason::ApprovalRefused
|
||||||
|
}
|
||||||
|
);
|
||||||
|
assert_eq!(rt.count(), 0);
|
||||||
|
assert_eq!(
|
||||||
|
approval_record(&rig),
|
||||||
|
(
|
||||||
|
ApprovalAnswer::Refused,
|
||||||
|
some(KYLE),
|
||||||
|
some(POST),
|
||||||
|
some("not on a Friday")
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn approved_after_the_grant_is_gone_it_is_denied_on_the_recheck() {
|
||||||
|
let (rig, fake) = rig_with_gateway("courier-recheck");
|
||||||
|
let rt = Recording::answering("x");
|
||||||
|
let broker = rig.broker(&rt);
|
||||||
|
let (mut stream, _) = ask(&broker);
|
||||||
|
let mut conn = fake.next(WAIT).unwrap();
|
||||||
|
conn.posted();
|
||||||
|
rig.remove_grant("notes");
|
||||||
|
conn.approve();
|
||||||
|
assert_eq!(
|
||||||
|
conn.outcome(),
|
||||||
|
Some(GatewayOutcome::Denied {
|
||||||
|
reason: DenyReason::NoGrant
|
||||||
|
})
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
final_answer(&mut stream),
|
||||||
|
ToolResponse::Denied {
|
||||||
|
reason: DenyReason::NoGrant
|
||||||
|
}
|
||||||
|
);
|
||||||
|
assert_eq!(rt.count(), 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn answered_in_bxctl_the_courier_closes_without_an_outcome() {
|
||||||
|
let (rig, fake) = rig_with_gateway("courier-bxctl");
|
||||||
|
let rt = Recording::answering("x");
|
||||||
|
let broker = rig.broker(&rt);
|
||||||
|
let (mut stream, id) = ask(&broker);
|
||||||
|
let mut conn = fake.next(WAIT).unwrap();
|
||||||
|
conn.posted();
|
||||||
|
let entry = broker.table().take(id).unwrap();
|
||||||
|
brokerd::admin::answer(
|
||||||
|
&broker,
|
||||||
|
entry,
|
||||||
|
Answer::Approved {
|
||||||
|
by: some("bxctl"),
|
||||||
|
post: None,
|
||||||
|
},
|
||||||
|
Timestamp::now(),
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
conn.closed_within(WAIT),
|
||||||
|
"the courier closes, and sends nothing"
|
||||||
|
);
|
||||||
|
assert!(matches!(
|
||||||
|
final_answer(&mut stream),
|
||||||
|
ToolResponse::Result { .. }
|
||||||
|
));
|
||||||
|
assert_eq!(approval_record(&rig).1, some("bxctl"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn an_answer_after_it_ended_is_gone() {
|
||||||
|
let rig = Rig::new("courier-gone");
|
||||||
|
rig.grant(
|
||||||
|
"notes",
|
||||||
|
&grant_text("read_file", "ask", "", "paths = [\"/n\"]"),
|
||||||
|
);
|
||||||
|
let rt = Recording::answering("x");
|
||||||
|
let broker = rig.broker(&rt);
|
||||||
|
let (_stream, id) = ask(&broker);
|
||||||
|
// The courier is run by hand here, so that the answer can arrive after the entry is taken.
|
||||||
|
let socket = rig.dir.path().join("gw.sock");
|
||||||
|
let fake = FakeGateway::start(&socket);
|
||||||
|
let info = broker.table().list().remove(0);
|
||||||
|
let stop = AtomicBool::new(false);
|
||||||
|
std::thread::scope(|scope| {
|
||||||
|
let courier = scope.spawn(|| {
|
||||||
|
courier::carry(
|
||||||
|
&broker,
|
||||||
|
&socket,
|
||||||
|
&info,
|
||||||
|
&stop,
|
||||||
|
Timing {
|
||||||
|
retry: Duration::from_millis(100),
|
||||||
|
tick: Duration::from_secs(5),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
});
|
||||||
|
let _guard = StopOnDrop(&stop);
|
||||||
|
let mut conn = fake.next(WAIT).unwrap();
|
||||||
|
conn.posted();
|
||||||
|
let entry = broker.table().take(id).unwrap();
|
||||||
|
brokerd::admin::answer(&broker, entry, Answer::Expired, Timestamp::now());
|
||||||
|
conn.approve();
|
||||||
|
assert_eq!(conn.outcome(), Some(GatewayOutcome::Gone {}));
|
||||||
|
ends_by_itself(&courier);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn if_gatewayd_goes_away_the_approval_is_posted_again() {
|
||||||
|
let (rig, fake) = rig_with_gateway("courier-again");
|
||||||
|
let rt = Recording::answering("x");
|
||||||
|
let broker = rig.broker(&rt);
|
||||||
|
let (mut stream, id) = ask(&broker);
|
||||||
|
let mut first = fake.next(WAIT).unwrap();
|
||||||
|
first.posted();
|
||||||
|
first.close();
|
||||||
|
let mut second = fake.next(Duration::from_secs(10)).expect("posted again");
|
||||||
|
assert_eq!(second.request.approval, id);
|
||||||
|
second.posted();
|
||||||
|
second.approve();
|
||||||
|
assert_eq!(second.outcome(), Some(GatewayOutcome::Runs {}));
|
||||||
|
assert!(matches!(
|
||||||
|
final_answer(&mut stream),
|
||||||
|
ToolResponse::Result { .. }
|
||||||
|
));
|
||||||
|
assert!(
|
||||||
|
rig.lines.all().iter().any(|l| l.contains(&format!(
|
||||||
|
"approval {id}: gatewayd went away; posting it again"
|
||||||
|
))),
|
||||||
|
"{:?}",
|
||||||
|
rig.lines.all()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_bad_answer_answers_nothing() {
|
||||||
|
let (rig, fake) = rig_with_gateway("courier-bad");
|
||||||
|
let rt = Recording::answering("x");
|
||||||
|
let broker = rig.broker(&rt);
|
||||||
|
let (_stream, id) = ask(&broker);
|
||||||
|
for bad in [
|
||||||
|
ApprovalReply {
|
||||||
|
approval: id + 1,
|
||||||
|
approved: true,
|
||||||
|
by: KYLE.to_string(),
|
||||||
|
post: POST.to_string(),
|
||||||
|
reason: None,
|
||||||
|
},
|
||||||
|
ApprovalReply {
|
||||||
|
approval: id,
|
||||||
|
approved: true,
|
||||||
|
by: "kyle".to_string(),
|
||||||
|
post: POST.to_string(),
|
||||||
|
reason: None,
|
||||||
|
},
|
||||||
|
ApprovalReply {
|
||||||
|
approval: id,
|
||||||
|
approved: false,
|
||||||
|
by: KYLE.to_string(),
|
||||||
|
post: POST.to_string(),
|
||||||
|
reason: Some("x".repeat(501)),
|
||||||
|
},
|
||||||
|
] {
|
||||||
|
let mut conn = fake.next(Duration::from_secs(10)).unwrap();
|
||||||
|
conn.posted();
|
||||||
|
conn.reply(bad);
|
||||||
|
assert_eq!(conn.outcome(), None, "no outcome for a bad answer");
|
||||||
|
assert!(broker.table().contains(id), "still pending");
|
||||||
|
}
|
||||||
|
assert_eq!(rt.count(), 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn unreachable_at_first_then_posted_and_said_once() {
|
||||||
|
let rig = Rig::new("courier-late");
|
||||||
|
rig.grant(
|
||||||
|
"notes",
|
||||||
|
&grant_text("read_file", "ask", "", "paths = [\"/n\"]"),
|
||||||
|
);
|
||||||
|
let rt = Recording::answering("x");
|
||||||
|
let broker = rig.broker(&rt);
|
||||||
|
let (_stream, id) = ask(&broker);
|
||||||
|
let socket = rig.dir.path().join("gw.sock");
|
||||||
|
let info = broker.table().list().remove(0);
|
||||||
|
let stop = AtomicBool::new(false);
|
||||||
|
let timing = Timing {
|
||||||
|
retry: Duration::from_millis(100),
|
||||||
|
tick: Duration::from_millis(20),
|
||||||
|
};
|
||||||
|
std::thread::scope(|scope| {
|
||||||
|
let courier = scope.spawn(|| courier::carry(&broker, &socket, &info, &stop, timing));
|
||||||
|
let _guard = StopOnDrop(&stop);
|
||||||
|
std::thread::sleep(Duration::from_millis(500));
|
||||||
|
let fake = FakeGateway::start(&socket);
|
||||||
|
let mut conn = fake.next(WAIT).unwrap();
|
||||||
|
conn.posted();
|
||||||
|
let until = Instant::now() + WAIT;
|
||||||
|
while !rig
|
||||||
|
.lines
|
||||||
|
.all()
|
||||||
|
.iter()
|
||||||
|
.any(|l| l.contains("posted in Mattermost"))
|
||||||
|
{
|
||||||
|
assert!(Instant::now() < until, "{:?}", rig.lines.all());
|
||||||
|
std::thread::sleep(Duration::from_millis(20));
|
||||||
|
}
|
||||||
|
broker.table().take(id);
|
||||||
|
ends_by_itself(&courier);
|
||||||
|
});
|
||||||
|
let lines = rig.lines.all();
|
||||||
|
let unreachable: Vec<&String> = lines
|
||||||
|
.iter()
|
||||||
|
.filter(|l| l.contains("gatewayd is not reachable"))
|
||||||
|
.collect();
|
||||||
|
assert_eq!(
|
||||||
|
unreachable.len(),
|
||||||
|
1,
|
||||||
|
"said once, not every retry: {lines:?}"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
unreachable[0].starts_with(&format!(
|
||||||
|
"brokerd: approval {id}: gatewayd is not reachable at "
|
||||||
|
)),
|
||||||
|
"{lines:?}"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
unreachable[0]
|
||||||
|
.ends_with("; bxctl can still answer it\nsee docs/runbook.md#gateway-unreachable"),
|
||||||
|
"{lines:?}"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
lines.contains(&format!("brokerd: approval {id} posted in Mattermost")),
|
||||||
|
"{lines:?}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn if_gatewayd_cannot_post_it_is_tried_again() {
|
||||||
|
let (rig, fake) = rig_with_gateway("courier-cannot");
|
||||||
|
let rt = Recording::answering("x");
|
||||||
|
let broker = rig.broker(&rt);
|
||||||
|
let (mut stream, _) = ask(&broker);
|
||||||
|
fake.next(WAIT).unwrap().cannot_post();
|
||||||
|
let mut conn = fake.next(Duration::from_secs(10)).expect("tried again");
|
||||||
|
conn.posted();
|
||||||
|
conn.approve();
|
||||||
|
assert_eq!(conn.outcome(), Some(GatewayOutcome::Runs {}));
|
||||||
|
assert!(matches!(
|
||||||
|
final_answer(&mut stream),
|
||||||
|
ToolResponse::Result { .. }
|
||||||
|
));
|
||||||
|
assert!(
|
||||||
|
rig.lines
|
||||||
|
.all()
|
||||||
|
.iter()
|
||||||
|
.any(|l| l.contains("gatewayd could not post it: Mattermost is down"))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn without_a_gateway_nothing_is_carried() {
|
||||||
|
let rig = Rig::new("courier-none");
|
||||||
|
rig.grant(
|
||||||
|
"notes",
|
||||||
|
&grant_text("read_file", "ask", "", "paths = [\"/n\"]"),
|
||||||
|
);
|
||||||
|
let socket = rig.dir.path().join("gw.sock");
|
||||||
|
let fake = FakeGateway::start(&socket);
|
||||||
|
let rt = Recording::answering("x");
|
||||||
|
let broker = rig.broker(&rt);
|
||||||
|
let (mut stream, id) = ask(&broker);
|
||||||
|
assert!(
|
||||||
|
fake.next(Duration::from_millis(500)).is_none(),
|
||||||
|
"no [gateway]: no connection"
|
||||||
|
);
|
||||||
|
let entry = broker.table().take(id).unwrap();
|
||||||
|
brokerd::admin::answer(
|
||||||
|
&broker,
|
||||||
|
entry,
|
||||||
|
Answer::Approved {
|
||||||
|
by: some("bxctl"),
|
||||||
|
post: None,
|
||||||
|
},
|
||||||
|
Timestamp::now(),
|
||||||
|
);
|
||||||
|
assert!(matches!(
|
||||||
|
final_answer(&mut stream),
|
||||||
|
ToolResponse::Result { .. }
|
||||||
|
));
|
||||||
|
}
|
||||||
@@ -0,0 +1,264 @@
|
|||||||
|
//! The ledger's second step: an approval decides again and is recorded; a refusal and an expiry
|
||||||
|
//! are recorded as denials. Do not edit.
|
||||||
|
|
||||||
|
#[path = "support/build.rs"]
|
||||||
|
mod build;
|
||||||
|
#[path = "support/rig.rs"]
|
||||||
|
mod rig;
|
||||||
|
#[path = "support/sink.rs"]
|
||||||
|
mod sink;
|
||||||
|
#[path = "support/tmp.rs"]
|
||||||
|
mod tmp;
|
||||||
|
|
||||||
|
use brokerd::approvals::Verdict;
|
||||||
|
use brokerd::ledger::{Answer, Answered, Decided, Grants, Ledger};
|
||||||
|
use brokerd::policy::{Ask, Label};
|
||||||
|
use build::{grant, now, read, set};
|
||||||
|
use proto::{
|
||||||
|
ApprovalAnswer, AuditEvent, CallId, DataClass, DecisionRecord, DenyReason, Mode, SessionId,
|
||||||
|
};
|
||||||
|
use rig::Rig;
|
||||||
|
|
||||||
|
fn asking() -> Grants {
|
||||||
|
Ok(set(vec![grant("n", "read_file", Mode::Ask).paths(&["/n"])]))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn pending(ledger: &Ledger) -> Ask {
|
||||||
|
match ledger.decide(read("/n/deep/a"), &asking(), now()) {
|
||||||
|
Decided::Ask { ask, seq: 0, .. } => ask,
|
||||||
|
other => panic!("{other:?}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn bxctl() -> Answer {
|
||||||
|
Answer::Approved {
|
||||||
|
by: Some("bxctl".to_string()),
|
||||||
|
post: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn denied(reason: DenyReason) -> DecisionRecord {
|
||||||
|
DecisionRecord::Denied { reason }
|
||||||
|
}
|
||||||
|
|
||||||
|
fn verdict_reason(answered: &Answered) -> Option<DenyReason> {
|
||||||
|
match &answered.verdict {
|
||||||
|
Verdict::Run(_) => None,
|
||||||
|
Verdict::Denied(reason) => Some(*reason),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The one `Approval` record, as (answer, by, reason, outcome, grant).
|
||||||
|
type Row = (
|
||||||
|
ApprovalAnswer,
|
||||||
|
Option<String>,
|
||||||
|
Option<String>,
|
||||||
|
DecisionRecord,
|
||||||
|
Option<String>,
|
||||||
|
);
|
||||||
|
|
||||||
|
fn approval(rig: &Rig) -> Row {
|
||||||
|
let events = rig.events();
|
||||||
|
assert_eq!(events.len(), 2, "a decision and one approval: {events:?}");
|
||||||
|
match &events[1] {
|
||||||
|
AuditEvent::Approval {
|
||||||
|
session,
|
||||||
|
call,
|
||||||
|
decision,
|
||||||
|
answer,
|
||||||
|
by,
|
||||||
|
post,
|
||||||
|
reason,
|
||||||
|
outcome,
|
||||||
|
grant,
|
||||||
|
..
|
||||||
|
} => {
|
||||||
|
assert_eq!(session, &SessionId::new("s1").unwrap());
|
||||||
|
assert_eq!((*call, *decision, post), (CallId(1), 0, &None));
|
||||||
|
(
|
||||||
|
*answer,
|
||||||
|
by.clone(),
|
||||||
|
reason.clone(),
|
||||||
|
outcome.clone(),
|
||||||
|
grant.clone(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
other => panic!("{other:?}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn an_approval_that_still_asks_lets_the_call_run() {
|
||||||
|
let rig = Rig::new("answer-ask");
|
||||||
|
let ledger = rig.ledger();
|
||||||
|
let ask = pending(&ledger);
|
||||||
|
let answered = ledger.answer(ask, 0, bxctl(), &asking(), now());
|
||||||
|
match &answered.verdict {
|
||||||
|
Verdict::Run(decision) => assert_eq!(decision.grant(), "n"),
|
||||||
|
other => panic!("{other:?}"),
|
||||||
|
}
|
||||||
|
assert_eq!(answered.outcome, DecisionRecord::Ask {});
|
||||||
|
let row = approval(&rig);
|
||||||
|
assert_eq!(
|
||||||
|
row,
|
||||||
|
(
|
||||||
|
ApprovalAnswer::Approved,
|
||||||
|
Some("bxctl".to_string()),
|
||||||
|
None,
|
||||||
|
DecisionRecord::Ask {},
|
||||||
|
Some("n".to_string())
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn an_approval_under_a_grant_that_is_now_auto_records_allowed() {
|
||||||
|
let rig = Rig::new("answer-auto");
|
||||||
|
let ledger = rig.ledger();
|
||||||
|
let ask = pending(&ledger);
|
||||||
|
let now_auto = Ok(set(vec![
|
||||||
|
grant("n", "read_file", Mode::Auto).paths(&["/n"]),
|
||||||
|
]));
|
||||||
|
let answered = ledger.answer(ask, 0, bxctl(), &now_auto, now());
|
||||||
|
assert!(matches!(answered.verdict, Verdict::Run(_)));
|
||||||
|
assert_eq!(answered.outcome, DecisionRecord::Allowed {});
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_approval_names_the_grant_matched_now() {
|
||||||
|
let rig = Rig::new("answer-grant");
|
||||||
|
let ledger = rig.ledger();
|
||||||
|
let ask = pending(&ledger);
|
||||||
|
let wider = Ok(set(vec![
|
||||||
|
grant("n", "read_file", Mode::Ask).paths(&["/n"]),
|
||||||
|
grant("z-deep", "read_file", Mode::Ask).paths(&["/n/deep"]),
|
||||||
|
]));
|
||||||
|
let answered = ledger.answer(ask, 0, bxctl(), &wider, now());
|
||||||
|
match &answered.verdict {
|
||||||
|
Verdict::Run(decision) => assert_eq!(decision.grant(), "z-deep"),
|
||||||
|
other => panic!("{other:?}"),
|
||||||
|
}
|
||||||
|
assert_eq!(approval(&rig).4.as_deref(), Some("z-deep"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn an_approval_after_the_grant_is_gone_is_denied() {
|
||||||
|
let rig = Rig::new("answer-gone");
|
||||||
|
let ledger = rig.ledger();
|
||||||
|
let ask = pending(&ledger);
|
||||||
|
let answered = ledger.answer(ask, 0, bxctl(), &Ok(set(Vec::new())), now());
|
||||||
|
assert_eq!(verdict_reason(&answered), Some(DenyReason::NoGrant));
|
||||||
|
assert_eq!(answered.outcome, denied(DenyReason::NoGrant));
|
||||||
|
let row = approval(&rig);
|
||||||
|
assert_eq!(
|
||||||
|
(row.0, row.3, row.4),
|
||||||
|
(ApprovalAnswer::Approved, denied(DenyReason::NoGrant), None)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn an_approval_after_the_taint_rose_is_denied() {
|
||||||
|
let rig = Rig::new("answer-taint");
|
||||||
|
let ledger = rig.ledger();
|
||||||
|
let low = || {
|
||||||
|
Ok(set(vec![
|
||||||
|
grant("n", "read_file", Mode::Ask)
|
||||||
|
.paths(&["/n"])
|
||||||
|
.max_taint(DataClass::Private),
|
||||||
|
]))
|
||||||
|
};
|
||||||
|
let ask = match ledger.decide(read("/n/a"), &low(), now()) {
|
||||||
|
Decided::Ask { ask, .. } => ask,
|
||||||
|
other => panic!("{other:?}"),
|
||||||
|
};
|
||||||
|
// Another call of the session read a secret while this one waited.
|
||||||
|
let s1 = SessionId::new("s1").unwrap();
|
||||||
|
let label = Label {
|
||||||
|
class: DataClass::Secret,
|
||||||
|
untrusted: false,
|
||||||
|
};
|
||||||
|
rig.state()
|
||||||
|
.raise(&s1, rig.state().read(&s1).unwrap(), label)
|
||||||
|
.unwrap();
|
||||||
|
let answered = ledger.answer(ask, 0, bxctl(), &low(), now());
|
||||||
|
assert_eq!(verdict_reason(&answered), Some(DenyReason::TaintTooHigh));
|
||||||
|
match &rig.events()[1] {
|
||||||
|
AuditEvent::Approval { taint, outcome, .. } => {
|
||||||
|
assert_eq!(*taint, DataClass::Secret, "the state at the re-decision");
|
||||||
|
assert_eq!(*outcome, denied(DenyReason::TaintTooHigh));
|
||||||
|
}
|
||||||
|
other => panic!("{other:?}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn an_approval_with_invalid_grants_is_denied() {
|
||||||
|
let rig = Rig::new("answer-invalid");
|
||||||
|
let ledger = rig.ledger();
|
||||||
|
let ask = pending(&ledger);
|
||||||
|
let answered = ledger.answer(ask, 0, bxctl(), &Err(Vec::new()), now());
|
||||||
|
assert_eq!(verdict_reason(&answered), Some(DenyReason::GrantsInvalid));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_refusal_is_recorded_with_the_owners_reason() {
|
||||||
|
let rig = Rig::new("answer-refuse");
|
||||||
|
let ledger = rig.ledger();
|
||||||
|
let ask = pending(&ledger);
|
||||||
|
let refused = Answer::Refused {
|
||||||
|
by: Some("bxctl".to_string()),
|
||||||
|
post: None,
|
||||||
|
reason: Some("not now".to_string()),
|
||||||
|
};
|
||||||
|
let answered = ledger.answer(ask, 0, refused, &asking(), now());
|
||||||
|
assert_eq!(verdict_reason(&answered), Some(DenyReason::ApprovalRefused));
|
||||||
|
assert_eq!(
|
||||||
|
approval(&rig),
|
||||||
|
(
|
||||||
|
ApprovalAnswer::Refused,
|
||||||
|
Some("bxctl".to_string()),
|
||||||
|
Some("not now".to_string()),
|
||||||
|
denied(DenyReason::ApprovalRefused),
|
||||||
|
None
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn an_expiry_is_recorded_by_nobody() {
|
||||||
|
let rig = Rig::new("answer-expire");
|
||||||
|
let ledger = rig.ledger();
|
||||||
|
let ask = pending(&ledger);
|
||||||
|
let answered = ledger.answer(ask, 0, Answer::Expired, &asking(), now());
|
||||||
|
assert_eq!(verdict_reason(&answered), Some(DenyReason::ApprovalExpired));
|
||||||
|
assert_eq!(
|
||||||
|
approval(&rig),
|
||||||
|
(
|
||||||
|
ApprovalAnswer::Expired,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
denied(DenyReason::ApprovalExpired),
|
||||||
|
None
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn an_approval_that_cannot_be_recorded_does_not_run() {
|
||||||
|
let rig = Rig::new("answer-norecord");
|
||||||
|
let ledger = rig.ledger();
|
||||||
|
let ask = pending(&ledger);
|
||||||
|
rig.switch.fail(true);
|
||||||
|
let answered = ledger.answer(ask, 0, bxctl(), &asking(), now());
|
||||||
|
assert_eq!(
|
||||||
|
verdict_reason(&answered),
|
||||||
|
Some(DenyReason::AuditUnavailable)
|
||||||
|
);
|
||||||
|
assert_eq!(answered.outcome, denied(DenyReason::AuditUnavailable));
|
||||||
|
assert_eq!(rig.events().len(), 1, "only the decision");
|
||||||
|
assert!(
|
||||||
|
!rig.lines
|
||||||
|
.with("see docs/runbook.md#audit-unavailable")
|
||||||
|
.is_empty()
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,118 @@
|
|||||||
|
//! An answer from Mattermost records who answered and in which post, approved or refused; the
|
||||||
|
//! table says whether an approval is still pending (M4b spec, section 5). Do not edit.
|
||||||
|
|
||||||
|
#[path = "support/build.rs"]
|
||||||
|
mod build;
|
||||||
|
#[path = "support/rig.rs"]
|
||||||
|
mod rig;
|
||||||
|
#[path = "support/sink.rs"]
|
||||||
|
mod sink;
|
||||||
|
#[path = "support/tmp.rs"]
|
||||||
|
mod tmp;
|
||||||
|
|
||||||
|
use brokerd::approvals::Table;
|
||||||
|
use brokerd::ledger::{Answer, Decided, Grants, Ledger};
|
||||||
|
use brokerd::policy::Ask;
|
||||||
|
use build::{grant, now, read, set};
|
||||||
|
use proto::{ApprovalAnswer, AuditEvent, CallId, DataClass, Mode, PendingApproval, SessionId};
|
||||||
|
use rig::Rig;
|
||||||
|
|
||||||
|
const KYLE: &str = "k0000000000000000000000000";
|
||||||
|
const POST: &str = "c0000000000000000000000001";
|
||||||
|
|
||||||
|
fn asking() -> Grants {
|
||||||
|
Ok(set(vec![grant("n", "read_file", Mode::Ask).paths(&["/n"])]))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn pending(ledger: &Ledger) -> Ask {
|
||||||
|
match ledger.decide(read("/n/deep/a"), &asking(), now()) {
|
||||||
|
Decided::Ask { ask, seq: 0, .. } => ask,
|
||||||
|
other => panic!("{other:?}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The one `Approval` record's (answer, by, post, reason).
|
||||||
|
fn recorded(
|
||||||
|
rig: &Rig,
|
||||||
|
) -> (
|
||||||
|
ApprovalAnswer,
|
||||||
|
Option<String>,
|
||||||
|
Option<String>,
|
||||||
|
Option<String>,
|
||||||
|
) {
|
||||||
|
let events = rig.events();
|
||||||
|
match &events[1] {
|
||||||
|
AuditEvent::Approval {
|
||||||
|
answer,
|
||||||
|
by,
|
||||||
|
post,
|
||||||
|
reason,
|
||||||
|
..
|
||||||
|
} => (*answer, by.clone(), post.clone(), reason.clone()),
|
||||||
|
other => panic!("{other:?}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn an_approval_from_mattermost_names_the_user_and_the_post() {
|
||||||
|
let rig = Rig::new("post-approve");
|
||||||
|
let ledger = rig.ledger();
|
||||||
|
let answer = Answer::Approved {
|
||||||
|
by: Some(KYLE.to_string()),
|
||||||
|
post: Some(POST.to_string()),
|
||||||
|
};
|
||||||
|
ledger.answer(pending(&ledger), 0, answer, &asking(), now());
|
||||||
|
assert_eq!(
|
||||||
|
recorded(&rig),
|
||||||
|
(
|
||||||
|
ApprovalAnswer::Approved,
|
||||||
|
Some(KYLE.to_string()),
|
||||||
|
Some(POST.to_string()),
|
||||||
|
None
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_refusal_from_mattermost_names_them_and_keeps_the_reason() {
|
||||||
|
let rig = Rig::new("post-refuse");
|
||||||
|
let ledger = rig.ledger();
|
||||||
|
let answer = Answer::Refused {
|
||||||
|
by: Some(KYLE.to_string()),
|
||||||
|
post: Some(POST.to_string()),
|
||||||
|
reason: Some("not on a Friday".to_string()),
|
||||||
|
};
|
||||||
|
ledger.answer(pending(&ledger), 0, answer, &asking(), now());
|
||||||
|
assert_eq!(
|
||||||
|
recorded(&rig),
|
||||||
|
(
|
||||||
|
ApprovalAnswer::Refused,
|
||||||
|
Some(KYLE.to_string()),
|
||||||
|
Some(POST.to_string()),
|
||||||
|
Some("not on a Friday".to_string())
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_table_says_what_is_pending() {
|
||||||
|
let rig = Rig::new("post-table");
|
||||||
|
let ledger = rig.ledger();
|
||||||
|
let table = Table::new();
|
||||||
|
assert!(!table.contains(0));
|
||||||
|
let info = PendingApproval {
|
||||||
|
approval: 0,
|
||||||
|
session: SessionId::new("s1").unwrap(),
|
||||||
|
call: CallId(1),
|
||||||
|
tool: "read_file".to_string(),
|
||||||
|
arguments: "{}".to_string(),
|
||||||
|
grant: "n".to_string(),
|
||||||
|
taint: DataClass::Private,
|
||||||
|
created: now(),
|
||||||
|
expires: now(),
|
||||||
|
};
|
||||||
|
let _verdict = table.insert(info, pending(&ledger));
|
||||||
|
assert!(table.contains(0) && !table.contains(1));
|
||||||
|
assert!(table.take(0).is_some());
|
||||||
|
assert!(!table.contains(0));
|
||||||
|
}
|
||||||
@@ -0,0 +1,150 @@
|
|||||||
|
//! A fake `gatewayd` on a Unix socket: it accepts each courier's connection, reads the approval,
|
||||||
|
//! and lets the test post it, answer it, close, or read the outcome. Do not edit.
|
||||||
|
|
||||||
|
#![allow(dead_code)] // each test file uses a different part of this module
|
||||||
|
|
||||||
|
use std::os::unix::net::{UnixListener, UnixStream};
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
use std::sync::mpsc::{self, Receiver};
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
use proto::{
|
||||||
|
ApprovalPosted, ApprovalReply, Envelope, ErrorCode, GatewayOutcome, Message, PROTOCOL_VERSION,
|
||||||
|
PendingApproval, WireError, read_frame, write_frame,
|
||||||
|
};
|
||||||
|
|
||||||
|
pub const KYLE: &str = "k0000000000000000000000000";
|
||||||
|
pub const POST: &str = "c0000000000000000000000001";
|
||||||
|
|
||||||
|
pub struct FakeGateway {
|
||||||
|
pub socket: PathBuf,
|
||||||
|
conns: Receiver<Conn>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One courier's connection, its request already read.
|
||||||
|
pub struct Conn {
|
||||||
|
stream: UnixStream,
|
||||||
|
pub request: PendingApproval,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn frame(id: u64, last: bool, msg: Message) -> Envelope {
|
||||||
|
Envelope {
|
||||||
|
v: PROTOCOL_VERSION,
|
||||||
|
id,
|
||||||
|
r#final: last,
|
||||||
|
msg,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl FakeGateway {
|
||||||
|
/// Listen on `socket` (created now).
|
||||||
|
pub fn start(socket: &Path) -> FakeGateway {
|
||||||
|
let listener = UnixListener::bind(socket).unwrap();
|
||||||
|
let (tx, conns) = mpsc::channel();
|
||||||
|
std::thread::spawn(move || {
|
||||||
|
for stream in listener.incoming() {
|
||||||
|
let Ok(mut stream) = stream else { continue };
|
||||||
|
let Ok(env) = read_frame(&mut stream) else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
let Message::ApprovalRequest(req) = env.msg else {
|
||||||
|
panic!("not a request: {env:?}")
|
||||||
|
};
|
||||||
|
assert_eq!((env.id, env.r#final), (1, true));
|
||||||
|
if tx
|
||||||
|
.send(Conn {
|
||||||
|
stream,
|
||||||
|
request: req.approval,
|
||||||
|
})
|
||||||
|
.is_err()
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
FakeGateway {
|
||||||
|
socket: socket.to_path_buf(),
|
||||||
|
conns,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The next courier connection within `wait`, or `None`.
|
||||||
|
pub fn next(&self, wait: Duration) -> Option<Conn> {
|
||||||
|
self.conns.recv_timeout(wait).ok()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Conn {
|
||||||
|
pub fn posted(&mut self) {
|
||||||
|
let posts = vec![POST.to_string()];
|
||||||
|
write_frame(
|
||||||
|
&mut self.stream,
|
||||||
|
&frame(1, false, Message::ApprovalPosted(ApprovalPosted { posts })),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn cannot_post(&mut self) {
|
||||||
|
let e = WireError {
|
||||||
|
code: ErrorCode::Internal,
|
||||||
|
detail: "Mattermost is down".to_string(),
|
||||||
|
};
|
||||||
|
write_frame(&mut self.stream, &frame(1, true, Message::Error(e))).unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn reply(&mut self, reply: ApprovalReply) {
|
||||||
|
let _ = write_frame(
|
||||||
|
&mut self.stream,
|
||||||
|
&frame(1, true, Message::ApprovalReply(reply)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn approve(&mut self) {
|
||||||
|
let approval = self.request.approval;
|
||||||
|
self.reply(ApprovalReply {
|
||||||
|
approval,
|
||||||
|
approved: true,
|
||||||
|
by: KYLE.to_string(),
|
||||||
|
post: POST.to_string(),
|
||||||
|
reason: None,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn refuse(&mut self, reason: &str) {
|
||||||
|
let approval = self.request.approval;
|
||||||
|
self.reply(ApprovalReply {
|
||||||
|
approval,
|
||||||
|
approved: false,
|
||||||
|
by: KYLE.to_string(),
|
||||||
|
post: POST.to_string(),
|
||||||
|
reason: Some(reason.to_string()),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Frame 4 within five seconds, or `None` if the courier closed without one.
|
||||||
|
pub fn outcome(&mut self) -> Option<GatewayOutcome> {
|
||||||
|
self.stream
|
||||||
|
.set_read_timeout(Some(Duration::from_secs(5)))
|
||||||
|
.unwrap();
|
||||||
|
match read_frame(&mut self.stream) {
|
||||||
|
Ok(env) => {
|
||||||
|
assert_eq!((env.id, env.r#final), (2, true));
|
||||||
|
match env.msg {
|
||||||
|
Message::ApprovalOutcome(o) => Some(o.outcome),
|
||||||
|
other => panic!("not an outcome: {other:?}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(_) => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Did the courier close the connection within `wait`, without sending anything?
|
||||||
|
pub fn closed_within(&mut self, wait: Duration) -> bool {
|
||||||
|
self.stream.set_read_timeout(Some(wait)).unwrap();
|
||||||
|
matches!(read_frame(&mut self.stream), Err(proto::FrameError::Closed))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn close(self) {
|
||||||
|
let _ = self.stream.shutdown(std::net::Shutdown::Both);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,120 @@
|
|||||||
|
//! A home for ledger and broker tests: grants, audit log, session state, a flaky audit sink and
|
||||||
|
//! a log to read. Do not edit.
|
||||||
|
//!
|
||||||
|
//! Included with `#[path = "support/rig.rs"] mod rig;`, beside `tmp` and `sink`. The broker
|
||||||
|
//! tests add `client`.
|
||||||
|
|
||||||
|
#![allow(dead_code)] // each test file uses a different part of this module
|
||||||
|
|
||||||
|
use std::path::PathBuf;
|
||||||
|
|
||||||
|
use brokerd::audit::Writer;
|
||||||
|
use brokerd::config::{Approvals, Config, Paths, Sockets};
|
||||||
|
use brokerd::ledger::Ledger;
|
||||||
|
use brokerd::state::StateStore;
|
||||||
|
use proto::{AuditEvent, AuditRecord, CallId, SessionId, ToolRequest};
|
||||||
|
|
||||||
|
use crate::sink::{Flaky, Lines, Switch};
|
||||||
|
use crate::tmp::TempDir;
|
||||||
|
|
||||||
|
pub struct Rig {
|
||||||
|
pub dir: TempDir,
|
||||||
|
pub cfg: Config,
|
||||||
|
pub switch: Switch,
|
||||||
|
pub lines: Lines,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Rig {
|
||||||
|
pub fn new(tag: &str) -> Rig {
|
||||||
|
Rig::with_ttl(tag, 900_000)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn with_ttl(tag: &str, ttl_ms: u64) -> Rig {
|
||||||
|
let dir = TempDir::new(tag);
|
||||||
|
let grants = dir.path().join("grants");
|
||||||
|
std::fs::create_dir_all(&grants).unwrap();
|
||||||
|
let cfg = Config {
|
||||||
|
paths: Paths {
|
||||||
|
home: dir.path().to_path_buf(),
|
||||||
|
grants,
|
||||||
|
},
|
||||||
|
sockets: Sockets::default(),
|
||||||
|
approvals: Approvals { ttl_ms },
|
||||||
|
runner: None,
|
||||||
|
gateway: None,
|
||||||
|
};
|
||||||
|
Rig {
|
||||||
|
dir,
|
||||||
|
cfg,
|
||||||
|
switch: Switch::default(),
|
||||||
|
lines: Lines::default(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn state(&self) -> StateStore {
|
||||||
|
StateStore::new(&self.cfg.state_dir())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Opens the audit log (once: the writer holds its lock) behind the flaky sink.
|
||||||
|
pub fn ledger(&self) -> Ledger {
|
||||||
|
let opened = Writer::open(&self.cfg.audit_dir(), false).unwrap();
|
||||||
|
let sink = Flaky {
|
||||||
|
writer: opened.writer,
|
||||||
|
switch: self.switch.clone(),
|
||||||
|
};
|
||||||
|
Ledger::new(Box::new(sink), self.state(), self.lines.sink())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Writes `grants/<id>.toml`.
|
||||||
|
pub fn grant(&self, id: &str, text: &str) {
|
||||||
|
std::fs::write(self.cfg.paths.grants.join(format!("{id}.toml")), text).unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn remove_grant(&self, id: &str) {
|
||||||
|
std::fs::remove_file(self.cfg.paths.grants.join(format!("{id}.toml"))).unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn state_file(&self, session: &str) -> PathBuf {
|
||||||
|
self.cfg.state_dir().join(format!("{session}.json"))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Every record in the audit log, in order.
|
||||||
|
pub fn records(&self) -> Vec<AuditRecord> {
|
||||||
|
let dir = self.cfg.audit_dir();
|
||||||
|
let mut names: Vec<String> = std::fs::read_dir(&dir)
|
||||||
|
.unwrap()
|
||||||
|
.map(|e| e.unwrap().file_name().into_string().unwrap())
|
||||||
|
.filter(|n| n.ends_with(".jsonl"))
|
||||||
|
.collect();
|
||||||
|
names.sort();
|
||||||
|
let mut out = Vec::new();
|
||||||
|
for name in names {
|
||||||
|
let text = std::fs::read_to_string(dir.join(name)).unwrap();
|
||||||
|
for line in text.lines() {
|
||||||
|
out.push(serde_json::from_str(line).unwrap());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn events(&self) -> Vec<AuditEvent> {
|
||||||
|
self.records().into_iter().map(|r| r.event).collect()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A grant file's text. `extra` goes before `[constraints]`, `constraints` after it.
|
||||||
|
pub fn grant_text(tool: &str, mode: &str, extra: &str, constraints: &str) -> String {
|
||||||
|
format!(
|
||||||
|
"tool = \"{tool}\"\nmode = \"{mode}\"\nmax_taint = \"secret\"\nresult_class = \"private\"\n\
|
||||||
|
untrusted = false\n{extra}\n[constraints]\n{constraints}\n"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn request(session: &str, call: u64, tool: &str, arguments: &str) -> ToolRequest {
|
||||||
|
ToolRequest {
|
||||||
|
session: SessionId::new(session).unwrap(),
|
||||||
|
call: CallId(call),
|
||||||
|
tool: tool.to_string(),
|
||||||
|
arguments: arguments.to_string(),
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
//! The text-escape helpers, from `proto` (moved there in M4b so `gatewayd` shows the same thing).
|
||||||
|
|
||||||
|
pub use proto::escape::{escape_json_text, escape_model_text};
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
//! `bxctl deny` is another name for `bxctl refuse`: every argument list means the same under both
|
||||||
|
//! (M4b spec, section 2). Do not edit.
|
||||||
|
|
||||||
|
use bxctl::cli::{USAGE, parse};
|
||||||
|
use std::path::Path;
|
||||||
|
|
||||||
|
fn under(command: &str, rest: &[&str]) -> Vec<String> {
|
||||||
|
std::iter::once(command)
|
||||||
|
.chain(rest.iter().copied())
|
||||||
|
.map(str::to_string)
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn deny_parses_as_refuse() {
|
||||||
|
let cases: &[&[&str]] = &[
|
||||||
|
&["41"],
|
||||||
|
&["41", "--reason", "not on a Friday"],
|
||||||
|
&["--reason", "no", "41"],
|
||||||
|
&["41", "--admin-socket", "/tmp/a.sock"],
|
||||||
|
&["41", "--reason", "x", "--admin-socket", "/tmp/a.sock"],
|
||||||
|
&[],
|
||||||
|
&["x41"],
|
||||||
|
&["41", "--reason"],
|
||||||
|
&["41", "42"],
|
||||||
|
&["41", "--bogus"],
|
||||||
|
];
|
||||||
|
for rest in cases {
|
||||||
|
let home = Path::new("/srv/bx");
|
||||||
|
assert_eq!(
|
||||||
|
parse(&under("deny", rest), home),
|
||||||
|
parse(&under("refuse", rest), home),
|
||||||
|
"{rest:?}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn usage_names_deny() {
|
||||||
|
assert!(USAGE.contains("bxctl deny <id>"), "{USAGE}");
|
||||||
|
assert!(USAGE.contains("bxctl refuse <id>"), "{USAGE}");
|
||||||
|
}
|
||||||
@@ -0,0 +1,105 @@
|
|||||||
|
//! Approvals in Mattermost (M4b spec, sections 6 and 7): the text of the posts, and the
|
||||||
|
//! approvals this `gatewayd` has posted and the commands that answer them.
|
||||||
|
|
||||||
|
pub mod text;
|
||||||
|
|
||||||
|
use std::collections::HashMap;
|
||||||
|
use std::sync::mpsc::Sender;
|
||||||
|
use std::sync::{Mutex, MutexGuard};
|
||||||
|
|
||||||
|
use proto::ApprovalReply;
|
||||||
|
|
||||||
|
use crate::sessions::ApprovalCommand;
|
||||||
|
|
||||||
|
pub const NOT_APPROVER: &str = "only an approver can answer approvals";
|
||||||
|
pub const USAGE: &str = "use !approve <id> or !deny <id> <reason>";
|
||||||
|
|
||||||
|
/// One approval posted here and not yet answered.
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub struct Posted {
|
||||||
|
pub approval: u64,
|
||||||
|
/// The session whose turn waits for it.
|
||||||
|
pub session: String,
|
||||||
|
/// The thread it is answered in.
|
||||||
|
pub channel: String,
|
||||||
|
pub root: String,
|
||||||
|
/// The last post's `create_at`: only a command after it counts.
|
||||||
|
pub shown_at: i64,
|
||||||
|
/// False when the arguments were too long to show: only `bxctl` answers it.
|
||||||
|
pub answerable: bool,
|
||||||
|
/// Where an accepted answer goes: the thread of its `gateway.sock` connection.
|
||||||
|
pub answer: Sender<ApprovalReply>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// What a command came to.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub enum Judged {
|
||||||
|
/// The answer went to `brokerd`'s connection; the approval post will show the outcome.
|
||||||
|
Sent,
|
||||||
|
/// Refused: this line goes in the thread.
|
||||||
|
Reply(String),
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The approvals posted here, by id.
|
||||||
|
#[derive(Debug, Default)]
|
||||||
|
pub struct Registry {
|
||||||
|
posted: Mutex<HashMap<u64, Posted>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Registry {
|
||||||
|
pub fn new() -> Registry {
|
||||||
|
Registry::default()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn lock(&self) -> MutexGuard<'_, HashMap<u64, Posted>> {
|
||||||
|
self.posted.lock().unwrap_or_else(|p| p.into_inner())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn add(&self, posted: Posted) {
|
||||||
|
// `self.lock().insert(posted.approval, posted);`
|
||||||
|
todo!()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Take it out: it ended, or its connection is done with it.
|
||||||
|
pub fn remove(&self, id: u64) -> Option<Posted> {
|
||||||
|
// `self.lock().remove(&id)`
|
||||||
|
todo!()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn holds(&self, id: u64) -> bool {
|
||||||
|
// `self.lock().contains_key(&id)`
|
||||||
|
todo!()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The sessions with an approval waiting here: their turns are waiting, not working.
|
||||||
|
pub fn waiting_sessions(&self) -> Vec<String> {
|
||||||
|
// The `session` of every entry: `self.lock().values().map(|p|
|
||||||
|
// p.session.clone()).collect()`.
|
||||||
|
todo!()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Judge a command from `user`, posted as `post` at `at` in the thread `root`, in the order of
|
||||||
|
/// the spec's section 7. An accepted answer is taken out and sent; nothing else changes.
|
||||||
|
pub fn judge(
|
||||||
|
&self,
|
||||||
|
command: &ApprovalCommand,
|
||||||
|
user: &str,
|
||||||
|
root: &str,
|
||||||
|
post: &str,
|
||||||
|
at: i64,
|
||||||
|
approvers: &[String],
|
||||||
|
) -> Judged {
|
||||||
|
// In this order; each refusal is `Judged::Reply(<text>)` and changes nothing:
|
||||||
|
// 1. `user` not in `approvers`: NOT_APPROVER.
|
||||||
|
// 2. The command: Approve(id) is (id, approved true, reason None); Deny(id, reason) is (id,
|
||||||
|
// false, reason.clone()); Malformed: USAGE.
|
||||||
|
// 3. `let mut posted = self.lock();` Not in `posted`: "approval {id} is not pending here".
|
||||||
|
// 4. `!entry.answerable`: "approval {id} can only be answered with bxctl approvals".
|
||||||
|
// 5. `entry.root != root`: "approval {id} is answered in its own thread".
|
||||||
|
// 6. `at <= entry.shown_at`: "approval {id} was answered before it was shown in full".
|
||||||
|
// 7. Take it out (`posted.remove(&id)`) and send `ApprovalReply { approval: id, approved,
|
||||||
|
// by: user, post, reason }` on its `answer`: Ok -> `Judged::Sent`; Err (the connection
|
||||||
|
// is gone) -> "approval {id} is not pending here".
|
||||||
|
todo!()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
//! Approvals in Mattermost (M4b spec, sections 6 and 7): the text of the posts, and the
|
||||||
|
//! approvals this `gatewayd` has posted and the commands that answer them.
|
||||||
|
|
||||||
|
pub mod text;
|
||||||
@@ -0,0 +1,157 @@
|
|||||||
|
//! The text of an approval's posts, and of the line added when it ends (M4b spec, section 6).
|
||||||
|
//! Nothing here is shortened: arguments too long to show whole are not shown at all.
|
||||||
|
|
||||||
|
use proto::escape::escape_json_text;
|
||||||
|
use proto::{DenyReason, PendingApproval, Timestamp, sha256};
|
||||||
|
|
||||||
|
/// Arguments are split into parts of at most this many characters.
|
||||||
|
pub const PART_CHARS: usize = 15_000;
|
||||||
|
/// At most this many parts; more are answerable only in `bxctl`.
|
||||||
|
pub const MAX_PARTS: usize = 4;
|
||||||
|
/// The longest a part's post may be, leaving room for the line added when it ends.
|
||||||
|
pub const MAX_PART_POST: usize = 15_500;
|
||||||
|
|
||||||
|
/// An approval's posts, in order. Only an answer after the last counts; when `answerable` is
|
||||||
|
/// false, no command answers it.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub struct Shown {
|
||||||
|
pub posts: Vec<String>,
|
||||||
|
pub answerable: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// How an approval ended, for the line added to its last post.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub enum Ended {
|
||||||
|
Runs { by: String },
|
||||||
|
Denied { by: String, reason: DenyReason },
|
||||||
|
Refused { by: String, reason: Option<String> },
|
||||||
|
NoLonger,
|
||||||
|
Restarted,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `text` as inline code, with a backtick run longer than any inside it.
|
||||||
|
pub fn code(text: &str) -> String {
|
||||||
|
// `let ticks = "`".repeat(longest_run(text) + 1);`, and a pad of one space on both sides when
|
||||||
|
// `text` starts or ends with a backtick (CommonMark needs it), else none:
|
||||||
|
// `format!("{ticks}{pad}{text}{pad}{ticks}")`.
|
||||||
|
todo!()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `text` as a fenced block, with a fence longer than any backtick run inside it (at least 3).
|
||||||
|
pub fn fence(text: &str) -> String {
|
||||||
|
// `let ticks = "`".repeat((longest_run(text) + 1).max(3));` then
|
||||||
|
// `format!("{ticks}\n{text}\n{ticks}")`.
|
||||||
|
todo!()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn longest_run(text: &str) -> usize {
|
||||||
|
// The longest run of consecutive backticks in `text`: walk `text.chars()`, counting up on a
|
||||||
|
// backtick and back to 0 on anything else, keeping the most seen.
|
||||||
|
todo!()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `2026-09-24 14:47 UTC`.
|
||||||
|
fn minute(at: Timestamp) -> String {
|
||||||
|
// `let rfc = at.to_rfc3339();` ("2026-09-24T14:47:10.000Z"); then `format!("{} {} UTC",
|
||||||
|
// rfc.get(..10).unwrap_or_default(), rfc.get(11..16).unwrap_or_default())`.
|
||||||
|
todo!()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A serde name (`private`, `no_grant`), or the debug form if there is none.
|
||||||
|
fn name<T: serde::Serialize + std::fmt::Debug>(value: &T) -> String {
|
||||||
|
// `match serde_json::to_value(value)`: `Ok(serde_json::Value::String(s))` -> s; anything else
|
||||||
|
// -> `format!("{value:?}")`.
|
||||||
|
todo!()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn reply_line(id: u64) -> String {
|
||||||
|
// `format!("Reply in this thread with `!approve {id}` or `!deny {id} <reason>`.")`.
|
||||||
|
todo!()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The posts that show `info`.
|
||||||
|
pub fn render(info: &PendingApproval) -> Shown {
|
||||||
|
let id = info.approval;
|
||||||
|
let args = escape_json_text(&info.arguments);
|
||||||
|
let header = format!(
|
||||||
|
"**Approval {id}** · tool {} · grant {} · taint {} · expires {}",
|
||||||
|
code(&escape_json_text(&info.tool)),
|
||||||
|
code(&escape_json_text(&info.grant)),
|
||||||
|
name(&info.taint),
|
||||||
|
minute(info.expires),
|
||||||
|
);
|
||||||
|
let chars: Vec<char> = args.chars().collect();
|
||||||
|
let parts: Vec<String> = if chars.is_empty() {
|
||||||
|
vec![String::new()]
|
||||||
|
} else {
|
||||||
|
chars
|
||||||
|
.chunks(PART_CHARS)
|
||||||
|
.map(|c| c.iter().collect())
|
||||||
|
.collect()
|
||||||
|
};
|
||||||
|
let count = parts.len();
|
||||||
|
let posts: Vec<String> = if count == 1 {
|
||||||
|
vec![format!("{header}\n{}\n{}", fence(&args), reply_line(id))]
|
||||||
|
} else {
|
||||||
|
let hash = sha256(args.as_bytes())
|
||||||
|
.map(|h| h.to_hex())
|
||||||
|
.unwrap_or_default();
|
||||||
|
parts
|
||||||
|
.iter()
|
||||||
|
.enumerate()
|
||||||
|
.map(|(i, part)| {
|
||||||
|
let n = i + 1;
|
||||||
|
let top = if n == 1 {
|
||||||
|
format!("{header}\nPart 1 of {count} of the arguments:")
|
||||||
|
} else {
|
||||||
|
format!("**Approval {id}**, part {n} of {count} of the arguments:")
|
||||||
|
};
|
||||||
|
let mut post = format!("{top}\n{}", fence(part));
|
||||||
|
if n == count {
|
||||||
|
post.push_str(&format!(
|
||||||
|
"\nApproval {id}: {count} parts, {} characters, sha256 {hash} (of the arguments as shown).\n{}",
|
||||||
|
chars.len(),
|
||||||
|
reply_line(id)
|
||||||
|
));
|
||||||
|
}
|
||||||
|
post
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
};
|
||||||
|
if count > MAX_PARTS || posts.iter().any(|p| p.chars().count() > MAX_PART_POST) {
|
||||||
|
let hash = sha256(args.as_bytes())
|
||||||
|
.map(|h| h.to_hex())
|
||||||
|
.unwrap_or_default();
|
||||||
|
let post = format!(
|
||||||
|
"{header}\nThe arguments are {} characters (sha256 {hash}), too long to show here; answer it with `bxctl approvals`.",
|
||||||
|
chars.len()
|
||||||
|
);
|
||||||
|
return Shown {
|
||||||
|
posts: vec![post],
|
||||||
|
answerable: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
Shown {
|
||||||
|
posts,
|
||||||
|
answerable: true,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The line added to the last post when the approval ends. `by` is `@username`, or the id.
|
||||||
|
pub fn ended_line(ended: &Ended) -> String {
|
||||||
|
// A `match ended`, one line each, exactly (`by` as given):
|
||||||
|
// 1. Runs: "✅ Approved by {by}: the call runs."
|
||||||
|
// 2. Denied: "Approved by {by}, but denied on the re-check: {name(reason)}."
|
||||||
|
// 3. Refused with Some(reason): "❌ Refused by {by}: {code(&escape_json_text(reason))}."
|
||||||
|
// 4. Refused with None: "❌ Refused by {by}: no reason given."
|
||||||
|
// 5. NoLonger: "No longer pending (answered elsewhere, expired, or its turn ended)."
|
||||||
|
// 6. Restarted: "No longer answerable here: gatewayd restarted. If it is still pending it is
|
||||||
|
// posted again."
|
||||||
|
todo!()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The last post's text with the ending line added.
|
||||||
|
pub fn with_ending(last: &str, ended: &Ended) -> String {
|
||||||
|
// `format!("{last}\n\n{}", ended_line(ended))`.
|
||||||
|
todo!()
|
||||||
|
}
|
||||||
@@ -0,0 +1,224 @@
|
|||||||
|
//! `gateway.sock`: the listener, and one thread per `brokerd` connection (M4b spec, sections 4
|
||||||
|
//! and 6). A connection thread speaks the protocol only; everything that touches Mattermost or the
|
||||||
|
//! state file is asked of the serve loop, which alone owns them.
|
||||||
|
|
||||||
|
use std::os::unix::fs::{DirBuilderExt, FileTypeExt, PermissionsExt};
|
||||||
|
use std::os::unix::net::{UnixListener, UnixStream};
|
||||||
|
use std::path::Path;
|
||||||
|
use std::sync::Arc;
|
||||||
|
use std::sync::mpsc::{self, Receiver, RecvTimeoutError, Sender, TryRecvError};
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
use proto::{
|
||||||
|
ApprovalPosted, ApprovalReply, Envelope, ErrorCode, FrameError, GatewayOutcome, Message,
|
||||||
|
PROTOCOL_VERSION, PendingApproval, WireError, read_frame, write_frame,
|
||||||
|
};
|
||||||
|
|
||||||
|
/// A log line printer, shared by the threads.
|
||||||
|
pub type Log = Arc<dyn Fn(&str) + Send + Sync>;
|
||||||
|
|
||||||
|
/// What a connection thread asks of the serve loop.
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub enum ToLoop {
|
||||||
|
/// Post this approval; an accepted answer comes back on `answer`, and the post ids (or why
|
||||||
|
/// it could not be posted) on `shown`.
|
||||||
|
Show {
|
||||||
|
info: PendingApproval,
|
||||||
|
answer: Sender<ApprovalReply>,
|
||||||
|
shown: Sender<Result<Vec<String>, String>>,
|
||||||
|
},
|
||||||
|
/// It ended: edit its last post, and forget it.
|
||||||
|
Ended { approval: u64, ending: Ending },
|
||||||
|
}
|
||||||
|
|
||||||
|
/// How an approval ended, as far as this `gatewayd` knows.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub enum Ending {
|
||||||
|
/// Answered here, by `by`, and `brokerd` said what came of it.
|
||||||
|
Answered {
|
||||||
|
by: String,
|
||||||
|
reason: Option<String>,
|
||||||
|
outcome: GatewayOutcome,
|
||||||
|
},
|
||||||
|
/// `brokerd` closed first, or said nothing after the answer.
|
||||||
|
NoLonger,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// How long to wait: for the first frame, for the loop to post, for `brokerd`'s outcome; and how
|
||||||
|
/// often to look for an answer meanwhile.
|
||||||
|
#[derive(Debug, Clone, Copy)]
|
||||||
|
pub struct Timing {
|
||||||
|
pub first_frame: Duration,
|
||||||
|
pub posting: Duration,
|
||||||
|
pub outcome: Duration,
|
||||||
|
pub tick: Duration,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for Timing {
|
||||||
|
fn default() -> Self {
|
||||||
|
Timing {
|
||||||
|
first_frame: Duration::from_secs(10),
|
||||||
|
posting: Duration::from_secs(120),
|
||||||
|
outcome: Duration::from_secs(30),
|
||||||
|
tick: Duration::from_millis(100),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Listen on `socket`: its directory made 0700, a stale socket removed, the socket made 0600.
|
||||||
|
pub fn listen(socket: &Path) -> Result<UnixListener, String> {
|
||||||
|
// 1. The directory: `socket.parent()`, which must not be empty, else "cannot prepare <socket>:
|
||||||
|
// a socket needs a directory".
|
||||||
|
// 2. `std::fs::DirBuilder::new().recursive(true).mode(0o700).create(dir)`, then
|
||||||
|
// `set_permissions(dir, 0o700)`; an error is "cannot prepare <dir>: <e>".
|
||||||
|
// 3. `std::fs::symlink_metadata(socket)`: a socket (`file_type().is_socket()`) is removed
|
||||||
|
// (stale); anything else there is "cannot listen on <socket>: something else is there";
|
||||||
|
// nothing there is fine.
|
||||||
|
// 4. `UnixListener::bind(socket)`, then `set_permissions(socket, 0o600)`; an error is "cannot
|
||||||
|
// listen on <socket>: <e>".
|
||||||
|
// 5. Ok(listener). Paths are shown with `.display()`.
|
||||||
|
todo!()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Accept connections on a thread of their own, each served on its own thread.
|
||||||
|
pub fn serve(
|
||||||
|
listener: UnixListener,
|
||||||
|
to_loop: Sender<ToLoop>,
|
||||||
|
timing: Timing,
|
||||||
|
log: Log,
|
||||||
|
) -> std::io::Result<()> {
|
||||||
|
// Spawn (with `std::thread::Builder`, `?`) a thread that loops over `listener.incoming()`,
|
||||||
|
// skipping errors. For each stream: clone `to_loop` and `log`, and spawn a thread running
|
||||||
|
// `connection(stream, &to_loop, timing, &log)`; if that spawn fails, log "gatewayd: cannot
|
||||||
|
// start a thread for an approval: <e>". Then Ok(()).
|
||||||
|
todo!()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn frame(id: u64, last: bool, msg: Message) -> Envelope {
|
||||||
|
Envelope {
|
||||||
|
v: PROTOCOL_VERSION,
|
||||||
|
id,
|
||||||
|
r#final: last,
|
||||||
|
msg,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Every frame `brokerd` sends, read whole on a thread of its own; shutting the stream down ends it.
|
||||||
|
fn frames(stream: &UnixStream) -> std::io::Result<Receiver<Result<Envelope, FrameError>>> {
|
||||||
|
// As in `brokerd`'s courier: `let mut reader = stream.try_clone()?;`, a channel, and a thread
|
||||||
|
// (`std::thread::Builder`, `?`) looping `let frame = read_frame(&mut reader); let last =
|
||||||
|
// frame.is_err(); if tx.send(frame).is_err() || last { return; }`. Then `Ok(rx)`.
|
||||||
|
todo!()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One `brokerd` connection, frames 1 to 4 of the spec's section 4.
|
||||||
|
pub fn connection(mut stream: UnixStream, to_loop: &Sender<ToLoop>, timing: Timing, log: &Log) {
|
||||||
|
// 1. The request, within `first_frame`.
|
||||||
|
let _ = stream.set_read_timeout(Some(timing.first_frame));
|
||||||
|
let info = match read_frame(&mut stream) {
|
||||||
|
Ok(Envelope {
|
||||||
|
id: 1,
|
||||||
|
r#final: true,
|
||||||
|
msg: Message::ApprovalRequest(req),
|
||||||
|
..
|
||||||
|
}) => req.approval,
|
||||||
|
_ => return,
|
||||||
|
};
|
||||||
|
let _ = stream.set_read_timeout(None);
|
||||||
|
let id = info.approval;
|
||||||
|
// 2. The loop posts it.
|
||||||
|
let (answer_tx, answer_rx) = mpsc::channel();
|
||||||
|
let (shown_tx, shown_rx) = mpsc::channel();
|
||||||
|
if to_loop
|
||||||
|
.send(ToLoop::Show {
|
||||||
|
info,
|
||||||
|
answer: answer_tx,
|
||||||
|
shown: shown_tx,
|
||||||
|
})
|
||||||
|
.is_err()
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let posts = match shown_rx.recv_timeout(timing.posting) {
|
||||||
|
Ok(Ok(posts)) => posts,
|
||||||
|
Ok(Err(why)) => {
|
||||||
|
let e = WireError {
|
||||||
|
code: ErrorCode::Internal,
|
||||||
|
detail: why,
|
||||||
|
};
|
||||||
|
let _ = write_frame(&mut stream, &frame(1, true, Message::Error(e)));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
Err(_) => {
|
||||||
|
let e = WireError {
|
||||||
|
code: ErrorCode::Internal,
|
||||||
|
detail: "gatewayd did not post it in time".to_string(),
|
||||||
|
};
|
||||||
|
let _ = write_frame(&mut stream, &frame(1, true, Message::Error(e)));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let rx = match frames(&stream) {
|
||||||
|
Ok(rx) => rx,
|
||||||
|
Err(_) => return end(to_loop, id, Ending::NoLonger),
|
||||||
|
};
|
||||||
|
if write_frame(
|
||||||
|
&mut stream,
|
||||||
|
&frame(1, false, Message::ApprovalPosted(ApprovalPosted { posts })),
|
||||||
|
)
|
||||||
|
.is_err()
|
||||||
|
{
|
||||||
|
return end(to_loop, id, Ending::NoLonger);
|
||||||
|
}
|
||||||
|
// 3. An answer from an approver, or brokerd closing first.
|
||||||
|
let mut answers = Some(answer_rx);
|
||||||
|
let reply = loop {
|
||||||
|
if let Some(answers_rx) = &answers {
|
||||||
|
match answers_rx.recv_timeout(timing.tick) {
|
||||||
|
Ok(reply) => break reply,
|
||||||
|
Err(RecvTimeoutError::Timeout) => {}
|
||||||
|
Err(RecvTimeoutError::Disconnected) => answers = None,
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
std::thread::sleep(timing.tick);
|
||||||
|
}
|
||||||
|
match rx.try_recv() {
|
||||||
|
Err(TryRecvError::Empty) => {}
|
||||||
|
// brokerd closed (the approval ended elsewhere), or sent what it may not.
|
||||||
|
_ => {
|
||||||
|
let _ = stream.shutdown(std::net::Shutdown::Both);
|
||||||
|
return end(to_loop, id, Ending::NoLonger);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let (by, reason) = (reply.by.clone(), reply.reason.clone());
|
||||||
|
if write_frame(&mut stream, &frame(1, true, Message::ApprovalReply(reply))).is_err() {
|
||||||
|
return end(to_loop, id, Ending::NoLonger);
|
||||||
|
}
|
||||||
|
// 4. What came of it.
|
||||||
|
let ending = match rx.recv_timeout(timing.outcome) {
|
||||||
|
Ok(Ok(Envelope {
|
||||||
|
id: 2,
|
||||||
|
r#final: true,
|
||||||
|
msg: Message::ApprovalOutcome(o),
|
||||||
|
..
|
||||||
|
})) => Ending::Answered {
|
||||||
|
by,
|
||||||
|
reason,
|
||||||
|
outcome: o.outcome,
|
||||||
|
},
|
||||||
|
_ => {
|
||||||
|
(log)(&format!(
|
||||||
|
"gatewayd: approval {id}: brokerd said nothing after the answer"
|
||||||
|
));
|
||||||
|
Ending::NoLonger
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let _ = stream.shutdown(std::net::Shutdown::Both);
|
||||||
|
end(to_loop, id, ending);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn end(to_loop: &Sender<ToLoop>, approval: u64, ending: Ending) {
|
||||||
|
// `let _ = to_loop.send(ToLoop::Ended { approval, ending });`
|
||||||
|
todo!()
|
||||||
|
}
|
||||||
@@ -0,0 +1,143 @@
|
|||||||
|
//! The serve loop's part in approvals (M4b spec, sections 6 and 8): posting what a `gateway.sock`
|
||||||
|
//! connection brings, marking it when it ends, and marking what a restart left behind. The loop
|
||||||
|
//! alone touches Mattermost and the state file; connection threads only ask.
|
||||||
|
|
||||||
|
use std::sync::mpsc::Sender;
|
||||||
|
|
||||||
|
use proto::{ApprovalReply, GatewayOutcome, PendingApproval};
|
||||||
|
|
||||||
|
use crate::approvals::Posted;
|
||||||
|
use crate::approvals::text::{self, Ended};
|
||||||
|
use crate::gateway::{Ending, ToLoop};
|
||||||
|
use crate::serve::{Gateway, Stop};
|
||||||
|
use crate::state::ApprovalPosts;
|
||||||
|
|
||||||
|
/// An approval's last post, the one that is edited when it ends.
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub(super) struct Open {
|
||||||
|
pub(super) last_post: String,
|
||||||
|
pub(super) last_text: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Gateway {
|
||||||
|
/// Everything the connection threads asked since the last look, never blocking.
|
||||||
|
pub(super) fn gateway_requests(&mut self) -> Result<(), Stop> {
|
||||||
|
while let Ok(asked) = self.gateway_rx.try_recv() {
|
||||||
|
match asked {
|
||||||
|
ToLoop::Show {
|
||||||
|
info,
|
||||||
|
answer,
|
||||||
|
shown,
|
||||||
|
} => {
|
||||||
|
let result = self.show(&info, answer)?;
|
||||||
|
let _ = shown.send(result);
|
||||||
|
}
|
||||||
|
ToLoop::Ended { approval, ending } => self.ended(approval, ending)?,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Where an approval goes: its session's turn in flight, or the first approver's direct
|
||||||
|
/// messages as a new thread (an empty root).
|
||||||
|
fn place(&self, info: &PendingApproval) -> Result<(String, String), String> {
|
||||||
|
// 1. `self.state.turn_of(info.session.as_str())`: if Some(turn), `Ok((turn.channel,
|
||||||
|
// turn.root))`.
|
||||||
|
// 2. Otherwise the first approver's direct messages, as a new thread:
|
||||||
|
// `self.config.first_approver()` (None is Err("no approver is configured")), then
|
||||||
|
// `self.client.direct_channel(&self.me.id, first)` (an Err(e) is Err(format!("no direct
|
||||||
|
// channel with the first approver: {e}"))). `Ok((channel, String::new()))`.
|
||||||
|
todo!()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Post an approval's parts in order, record them, and register it for answers. An `Err`
|
||||||
|
/// inside is why it could not be posted; the outer one stops `gatewayd` (the state file).
|
||||||
|
fn show(
|
||||||
|
&mut self,
|
||||||
|
info: &PendingApproval,
|
||||||
|
answer: Sender<ApprovalReply>,
|
||||||
|
) -> Result<Result<Vec<String>, String>, Stop> {
|
||||||
|
let id = info.approval;
|
||||||
|
let (channel, mut root) = match self.place(info) {
|
||||||
|
Ok(place) => place,
|
||||||
|
Err(why) => return Ok(Err(why)),
|
||||||
|
};
|
||||||
|
let shown = text::render(info);
|
||||||
|
let mut posts = Vec::new();
|
||||||
|
let mut shown_at = 0;
|
||||||
|
for body in &shown.posts {
|
||||||
|
match self.client.create_post(&channel, &root, body) {
|
||||||
|
Ok(post) => {
|
||||||
|
if root.is_empty() {
|
||||||
|
root = post.id.clone();
|
||||||
|
}
|
||||||
|
shown_at = post.create_at;
|
||||||
|
posts.push(post.id);
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
// Parts already posted must not look answerable.
|
||||||
|
if let (Some(last), Some(text)) =
|
||||||
|
(posts.last(), shown.posts.get(posts.len().wrapping_sub(1)))
|
||||||
|
{
|
||||||
|
self.mark(id, last, &text::with_ending(text, &Ended::NoLonger));
|
||||||
|
}
|
||||||
|
return Ok(Err(format!("cannot post approval {id}: {e}")));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
self.state.add_approval(ApprovalPosts {
|
||||||
|
approval: id,
|
||||||
|
channel: channel.clone(),
|
||||||
|
posts: posts.clone(),
|
||||||
|
})?;
|
||||||
|
self.registry.add(Posted {
|
||||||
|
approval: id,
|
||||||
|
session: info.session.as_str().to_string(),
|
||||||
|
channel,
|
||||||
|
root,
|
||||||
|
shown_at,
|
||||||
|
answerable: shown.answerable,
|
||||||
|
answer,
|
||||||
|
});
|
||||||
|
if let (Some(last_post), Some(last_text)) = (posts.last(), shown.posts.last()) {
|
||||||
|
self.open.insert(
|
||||||
|
id,
|
||||||
|
Open {
|
||||||
|
last_post: last_post.clone(),
|
||||||
|
last_text: last_text.clone(),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
Ok(Ok(posts))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// An approval ended: forget it, and add how it ended to its last post.
|
||||||
|
fn ended(&mut self, id: u64, ending: Ending) -> Result<(), Stop> {
|
||||||
|
// 1. `self.registry.remove(id);` and `self.state.remove_approval(id)?;`.
|
||||||
|
// 2. `let Some(open) = self.open.remove(&id) else { return Ok(()); };`
|
||||||
|
// 3. The `Ended`: Ending::NoLonger -> Ended::NoLonger. Ending::Answered { by, reason,
|
||||||
|
// outcome } -> `by` becomes "@<username>" from `self.client.username(&by)`, or stays the
|
||||||
|
// id if that fails; then outcome Runs -> Ended::Runs { by }, Denied { reason } ->
|
||||||
|
// Ended::Denied { by, reason }, Refused -> Ended::Refused { by, reason }, Gone ->
|
||||||
|
// Ended::NoLonger.
|
||||||
|
// 4. `self.mark(id, &open.last_post, &text::with_ending(&open.last_text, &ended));` and
|
||||||
|
// Ok(()).
|
||||||
|
todo!()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// At the first connection after a start: the approval posts the last run left are no longer
|
||||||
|
/// answerable. Their last post is replaced by its title and the restart line.
|
||||||
|
pub(super) fn mark_left_behind(&mut self) -> Result<(), Stop> {
|
||||||
|
// For each `left` in `self.state.take_approvals()?`: if it has a last post,
|
||||||
|
// `self.mark(left.approval, last, &body)` with `body = format!("**Approval {}**\n\n{}",
|
||||||
|
// left.approval, text::ended_line(&Ended::Restarted))`. Then Ok(()).
|
||||||
|
todo!()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Edit a post; a failure is logged, not retried.
|
||||||
|
fn mark(&self, id: u64, post: &str, body: &str) {
|
||||||
|
// `if let Err(e) = self.client.patch_post(post, body)`: log "gatewayd: cannot mark approval
|
||||||
|
// {id} as ended: {e}" with `(self.log)(&line)`.
|
||||||
|
todo!()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,166 @@
|
|||||||
|
//! Which commands answer an approval, in the order of the M4b spec, section 7; everything else is
|
||||||
|
//! answered with its reason and changes nothing. Do not edit.
|
||||||
|
|
||||||
|
use std::sync::mpsc::{self, Receiver};
|
||||||
|
|
||||||
|
use gatewayd::approvals::{Judged, NOT_APPROVER, Posted, Registry, USAGE};
|
||||||
|
use gatewayd::sessions::ApprovalCommand::{self, Approve, Deny, Malformed};
|
||||||
|
use proto::ApprovalReply;
|
||||||
|
|
||||||
|
const KYLE: &str = "k0000000000000000000000000";
|
||||||
|
const HERMES: &str = "h0000000000000000000000000";
|
||||||
|
const DM: &str = "d0000000000000000000000000";
|
||||||
|
const ROOT: &str = "r0000000000000000000000000";
|
||||||
|
const ELSEWHERE: &str = "e0000000000000000000000000";
|
||||||
|
const CMD: &str = "c0000000000000000000000001";
|
||||||
|
|
||||||
|
fn approvers() -> Vec<String> {
|
||||||
|
vec![KYLE.to_string()]
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A registry with approval 42 posted in ROOT, shown at 100.
|
||||||
|
fn posted(answerable: bool) -> (Registry, Receiver<ApprovalReply>) {
|
||||||
|
let registry = Registry::new();
|
||||||
|
let (tx, rx) = mpsc::channel();
|
||||||
|
registry.add(Posted {
|
||||||
|
approval: 42,
|
||||||
|
session: format!("mm-{ROOT}"),
|
||||||
|
channel: DM.to_string(),
|
||||||
|
root: ROOT.to_string(),
|
||||||
|
shown_at: 100,
|
||||||
|
answerable,
|
||||||
|
answer: tx,
|
||||||
|
});
|
||||||
|
(registry, rx)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn judge(r: &Registry, command: &ApprovalCommand, user: &str, root: &str, at: i64) -> Judged {
|
||||||
|
r.judge(command, user, root, CMD, at, &approvers())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn an_approver_in_the_thread_after_the_post_answers_it() {
|
||||||
|
let (r, rx) = posted(true);
|
||||||
|
assert_eq!(judge(&r, &Approve(42), KYLE, ROOT, 101), Judged::Sent);
|
||||||
|
assert_eq!(
|
||||||
|
rx.try_recv().unwrap(),
|
||||||
|
ApprovalReply {
|
||||||
|
approval: 42,
|
||||||
|
approved: true,
|
||||||
|
by: KYLE.to_string(),
|
||||||
|
post: CMD.to_string(),
|
||||||
|
reason: None
|
||||||
|
}
|
||||||
|
);
|
||||||
|
assert!(!r.holds(42), "answered once");
|
||||||
|
assert_eq!(
|
||||||
|
judge(&r, &Approve(42), KYLE, ROOT, 102),
|
||||||
|
Judged::Reply("approval 42 is not pending here".to_string())
|
||||||
|
);
|
||||||
|
assert!(rx.try_recv().is_err(), "a second answer goes nowhere");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_refusal_carries_its_reason() {
|
||||||
|
let (r, rx) = posted(true);
|
||||||
|
assert_eq!(
|
||||||
|
judge(
|
||||||
|
&r,
|
||||||
|
&Deny(42, Some("not on a Friday".to_string())),
|
||||||
|
KYLE,
|
||||||
|
ROOT,
|
||||||
|
101
|
||||||
|
),
|
||||||
|
Judged::Sent
|
||||||
|
);
|
||||||
|
let got = rx.try_recv().unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
(got.approved, got.reason.as_deref()),
|
||||||
|
(false, Some("not on a Friday"))
|
||||||
|
);
|
||||||
|
let (r, rx) = posted(true);
|
||||||
|
assert_eq!(judge(&r, &Deny(42, None), KYLE, ROOT, 101), Judged::Sent);
|
||||||
|
assert_eq!(rx.try_recv().unwrap().reason, None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn everything_else_is_refused_with_its_reason_in_order() {
|
||||||
|
let cases: &[(&ApprovalCommand, &str, &str, i64, &str)] = &[
|
||||||
|
(&Approve(42), HERMES, ROOT, 101, NOT_APPROVER),
|
||||||
|
(&Malformed, HERMES, ROOT, 101, NOT_APPROVER),
|
||||||
|
(&Malformed, KYLE, ROOT, 101, USAGE),
|
||||||
|
(
|
||||||
|
&Approve(7),
|
||||||
|
KYLE,
|
||||||
|
ROOT,
|
||||||
|
101,
|
||||||
|
"approval 7 is not pending here",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
&Approve(42),
|
||||||
|
KYLE,
|
||||||
|
ELSEWHERE,
|
||||||
|
101,
|
||||||
|
"approval 42 is answered in its own thread",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
&Approve(42),
|
||||||
|
KYLE,
|
||||||
|
ROOT,
|
||||||
|
100,
|
||||||
|
"approval 42 was answered before it was shown in full",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
&Approve(42),
|
||||||
|
KYLE,
|
||||||
|
ROOT,
|
||||||
|
50,
|
||||||
|
"approval 42 was answered before it was shown in full",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
&Approve(42),
|
||||||
|
KYLE,
|
||||||
|
ELSEWHERE,
|
||||||
|
50,
|
||||||
|
"approval 42 is answered in its own thread",
|
||||||
|
),
|
||||||
|
];
|
||||||
|
for (command, user, root, at, want) in cases {
|
||||||
|
let (r, rx) = posted(true);
|
||||||
|
assert_eq!(
|
||||||
|
judge(&r, command, user, root, *at),
|
||||||
|
Judged::Reply(want.to_string()),
|
||||||
|
"{command:?} {user} {root} {at}"
|
||||||
|
);
|
||||||
|
assert!(r.holds(42), "still pending after {command:?}");
|
||||||
|
assert!(rx.try_recv().is_err(), "nothing sent for {command:?}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn too_long_to_show_is_answered_only_in_bxctl() {
|
||||||
|
let (r, rx) = posted(false);
|
||||||
|
assert_eq!(
|
||||||
|
judge(&r, &Approve(42), KYLE, ROOT, 101),
|
||||||
|
Judged::Reply("approval 42 can only be answered with bxctl approvals".to_string())
|
||||||
|
);
|
||||||
|
assert!(r.holds(42) && rx.try_recv().is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn an_answer_whose_connection_is_gone_is_not_pending() {
|
||||||
|
let (r, rx) = posted(true);
|
||||||
|
drop(rx);
|
||||||
|
assert_eq!(
|
||||||
|
judge(&r, &Approve(42), KYLE, ROOT, 101),
|
||||||
|
Judged::Reply("approval 42 is not pending here".to_string())
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_waiting_sessions_and_removal() {
|
||||||
|
let (r, _rx) = posted(true);
|
||||||
|
assert_eq!(r.waiting_sessions(), [format!("mm-{ROOT}")]);
|
||||||
|
assert_eq!(r.remove(42).map(|p| p.approval), Some(42));
|
||||||
|
assert!(r.waiting_sessions().is_empty() && !r.holds(42) && r.remove(42).is_none());
|
||||||
|
}
|
||||||
@@ -0,0 +1,197 @@
|
|||||||
|
//! The text of an approval's posts and of its ending line (M4b spec, section 6). Do not edit.
|
||||||
|
|
||||||
|
use gatewayd::approvals::text::{
|
||||||
|
Ended, MAX_PART_POST, MAX_PARTS, PART_CHARS, code, ended_line, fence, render, with_ending,
|
||||||
|
};
|
||||||
|
use proto::{CallId, DataClass, DenyReason, PendingApproval, SessionId, Timestamp, sha256};
|
||||||
|
|
||||||
|
/// The escape for one code point, built here so this file holds no such character itself.
|
||||||
|
fn esc(code: u32) -> String {
|
||||||
|
format!("{}u{code:04x}", char::from(92))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn info(arguments: &str) -> PendingApproval {
|
||||||
|
PendingApproval {
|
||||||
|
approval: 42,
|
||||||
|
session: SessionId::new("mm-r0000000000000000000000001").unwrap(),
|
||||||
|
call: CallId(3),
|
||||||
|
tool: "shell".to_string(),
|
||||||
|
arguments: arguments.to_string(),
|
||||||
|
grant: "ops-shell".to_string(),
|
||||||
|
taint: DataClass::Private,
|
||||||
|
created: Timestamp::parse("2026-09-24T14:32:10.000Z").unwrap(),
|
||||||
|
expires: Timestamp::parse("2026-09-24T14:47:10.000Z").unwrap(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn one_part_exactly() {
|
||||||
|
let shown = render(&info(
|
||||||
|
r#"{"command":"ls -la /srv/backups","timeout_ms":10000}"#,
|
||||||
|
));
|
||||||
|
assert!(shown.answerable);
|
||||||
|
assert_eq!(
|
||||||
|
shown.posts,
|
||||||
|
[
|
||||||
|
"**Approval 42** · tool `shell` · grant `ops-shell` · taint private · expires 2026-09-24 14:47 UTC\n```\n{\"command\":\"ls -la /srv/backups\",\"timeout_ms\":10000}\n```\nReply in this thread with `!approve 42` or `!deny 42 <reason>`."
|
||||||
|
]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn fences_and_code_outrun_backticks() {
|
||||||
|
assert_eq!(fence("a"), "```\na\n```");
|
||||||
|
assert_eq!(fence("a ``` b ```` c"), "`````\na ``` b ```` c\n`````");
|
||||||
|
assert_eq!(code("ops"), "`ops`");
|
||||||
|
assert_eq!(code("a`b"), "``a`b``");
|
||||||
|
assert_eq!(code("`x"), "`` `x ``");
|
||||||
|
let shown = render(&info(r#"{"command":"echo ```` done"}"#));
|
||||||
|
assert!(
|
||||||
|
shown.posts[0].contains("\n`````\n{\"command\":\"echo ```` done\"}\n`````\n"),
|
||||||
|
"{}",
|
||||||
|
shown.posts[0]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn what_must_not_reach_the_reader_raw_is_escaped() {
|
||||||
|
let raw = "a\u{7}b\u{202e}c\u{200b}d\u{2028}e\u{feff}f\nnewline";
|
||||||
|
let shown = render(&info(&format!("{{\"command\":\"{raw}\"}}")));
|
||||||
|
let post = &shown.posts[0];
|
||||||
|
for bad in ['\u{7}', '\u{202e}', '\u{200b}', '\u{2028}', '\u{feff}'] {
|
||||||
|
assert!(!post.contains(bad), "{bad:?} reached the post");
|
||||||
|
}
|
||||||
|
let want = format!(
|
||||||
|
"a{}b{}c{}d{}e{}f{}newline",
|
||||||
|
esc(0x7),
|
||||||
|
esc(0x202e),
|
||||||
|
esc(0x200b),
|
||||||
|
esc(0x2028),
|
||||||
|
esc(0xfeff),
|
||||||
|
esc(0xa)
|
||||||
|
);
|
||||||
|
assert!(post.contains(&want), "{post}");
|
||||||
|
let mut odd = info("{}");
|
||||||
|
odd.grant = "g`rant\u{202e}".to_string();
|
||||||
|
assert!(
|
||||||
|
render(&odd).posts[0].contains(&format!("grant ``g`rant{}``", esc(0x202e))),
|
||||||
|
"{}",
|
||||||
|
render(&odd).posts[0]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn long_arguments_come_in_parts_with_a_hash() {
|
||||||
|
let args = format!("{{\"content\":\"{}\"}}", "é".repeat(PART_CHARS * 2));
|
||||||
|
let shown = render(&info(&args));
|
||||||
|
assert!(shown.answerable);
|
||||||
|
assert_eq!(shown.posts.len(), 3);
|
||||||
|
let total = args.chars().count();
|
||||||
|
assert!(shown.posts[0].starts_with("**Approval 42** · tool `shell` · grant `ops-shell` · taint private · expires 2026-09-24 14:47 UTC\nPart 1 of 3 of the arguments:\n```\n{\"content\":\""));
|
||||||
|
assert!(shown.posts[1].starts_with("**Approval 42**, part 2 of 3 of the arguments:\n```\n"));
|
||||||
|
let hash = sha256(args.as_bytes()).unwrap().to_hex();
|
||||||
|
let last = &shown.posts[2];
|
||||||
|
assert!(last.ends_with(&format!("\nApproval 42: 3 parts, {total} characters, sha256 {hash} (of the arguments as shown).\nReply in this thread with `!approve 42` or `!deny 42 <reason>`.")), "{last}");
|
||||||
|
assert!(
|
||||||
|
!shown.posts[0].contains("Reply in this thread")
|
||||||
|
&& !shown.posts[1].contains("Reply in this thread")
|
||||||
|
);
|
||||||
|
let shown_again: String = shown
|
||||||
|
.posts
|
||||||
|
.iter()
|
||||||
|
.map(|p| {
|
||||||
|
let body = p.split_once("\n```\n").unwrap().1;
|
||||||
|
body.split_once("\n```").unwrap().0.to_string()
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
assert_eq!(
|
||||||
|
shown_again, args,
|
||||||
|
"the parts, joined, are the arguments: nothing lost"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
shown
|
||||||
|
.posts
|
||||||
|
.iter()
|
||||||
|
.all(|p| p.chars().count() <= MAX_PART_POST)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn too_long_to_show_is_answerable_only_in_bxctl() {
|
||||||
|
let args = "x".repeat(PART_CHARS * MAX_PARTS + 1);
|
||||||
|
let shown = render(&info(&args));
|
||||||
|
assert!(!shown.answerable);
|
||||||
|
let hash = sha256(args.as_bytes()).unwrap().to_hex();
|
||||||
|
assert_eq!(
|
||||||
|
shown.posts,
|
||||||
|
[format!(
|
||||||
|
"**Approval 42** · tool `shell` · grant `ops-shell` · taint private · expires 2026-09-24 14:47 UTC\nThe arguments are {} characters (sha256 {hash}), too long to show here; answer it with `bxctl approvals`.",
|
||||||
|
args.len()
|
||||||
|
)]
|
||||||
|
);
|
||||||
|
let exactly = "x".repeat(PART_CHARS * MAX_PARTS);
|
||||||
|
assert!(
|
||||||
|
render(&info(&exactly)).answerable,
|
||||||
|
"four full parts still show"
|
||||||
|
);
|
||||||
|
let ticks = format!("{}{}", "`".repeat(PART_CHARS - 10), "x".repeat(20));
|
||||||
|
assert!(
|
||||||
|
!render(&info(&ticks)).answerable,
|
||||||
|
"a fence that would overflow the post is not shown"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn ending_lines() {
|
||||||
|
let kyle = "@kyle".to_string();
|
||||||
|
assert_eq!(
|
||||||
|
ended_line(&Ended::Runs { by: kyle.clone() }),
|
||||||
|
"✅ Approved by @kyle: the call runs."
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
ended_line(&Ended::Denied {
|
||||||
|
by: kyle.clone(),
|
||||||
|
reason: DenyReason::GrantExpired
|
||||||
|
}),
|
||||||
|
"Approved by @kyle, but denied on the re-check: grant_expired."
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
ended_line(&Ended::Refused {
|
||||||
|
by: kyle.clone(),
|
||||||
|
reason: None
|
||||||
|
}),
|
||||||
|
"❌ Refused by @kyle: no reason given."
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
ended_line(&Ended::Refused {
|
||||||
|
by: kyle.clone(),
|
||||||
|
reason: Some("not @channel `now`".to_string())
|
||||||
|
}),
|
||||||
|
"❌ Refused by @kyle: `` not @channel `now` ``."
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
ended_line(&Ended::NoLonger),
|
||||||
|
"No longer pending (answered elsewhere, expired, or its turn ended)."
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
ended_line(&Ended::Restarted),
|
||||||
|
"No longer answerable here: gatewayd restarted. If it is still pending it is posted again."
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
with_ending("post", &Ended::NoLonger),
|
||||||
|
"post\n\nNo longer pending (answered elsewhere, expired, or its turn ended)."
|
||||||
|
);
|
||||||
|
let longest = render(&info(&"y".repeat(PART_CHARS))).posts.remove(0);
|
||||||
|
let ended = with_ending(
|
||||||
|
&longest,
|
||||||
|
&Ended::Refused {
|
||||||
|
by: format!("@{}", "u".repeat(64)),
|
||||||
|
reason: Some("r".repeat(500)),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
ended.chars().count() <= 16_383,
|
||||||
|
"an ended post still fits: {}",
|
||||||
|
ended.chars().count()
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
//! `[approvals]` in `gatewayd.toml`: who may answer, the first approver, and `gateway.sock`
|
||||||
|
//! (M4b spec, section 3). Do not edit.
|
||||||
|
|
||||||
|
use std::path::PathBuf;
|
||||||
|
|
||||||
|
use gatewayd::config::Config;
|
||||||
|
|
||||||
|
const KYLE: &str = "k0000000000000000000000000";
|
||||||
|
const HERMES: &str = "h0000000000000000000000000";
|
||||||
|
const OTHER: &str = "o0000000000000000000000000";
|
||||||
|
|
||||||
|
fn text(approvals: &str) -> String {
|
||||||
|
format!(
|
||||||
|
"[mattermost]\nurl = \"https://chat.example\"\n[secrets.mattermost_token]\nenv = \"T\"\n[allow]\nusers = [\"{KYLE}\", \"{HERMES}\"]\n[paths]\nhome = \"/srv/bx\"\n{approvals}"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn config(approvals: &str) -> Config {
|
||||||
|
let c = Config::parse(&text(approvals)).unwrap();
|
||||||
|
assert_eq!(c.problem(), None);
|
||||||
|
c
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn without_the_section_everyone_allowed_may_answer() {
|
||||||
|
let c = config("");
|
||||||
|
assert_eq!(c.approvers(), [KYLE, HERMES]);
|
||||||
|
assert_eq!(c.first_approver(), Some(KYLE));
|
||||||
|
assert_eq!(
|
||||||
|
c.gateway_socket(),
|
||||||
|
PathBuf::from("/srv/bx/run/gateway/gateway.sock")
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
config("[approvals]\nusers = []\n").approvers(),
|
||||||
|
[KYLE, HERMES]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_list_narrows_who_may_answer() {
|
||||||
|
let c = config(&format!(
|
||||||
|
"[approvals]\nusers = [\"{HERMES}\"]\nsocket = \"/run/gw.sock\"\n"
|
||||||
|
));
|
||||||
|
assert_eq!(c.approvers(), [HERMES]);
|
||||||
|
assert_eq!(c.first_approver(), Some(HERMES));
|
||||||
|
assert_eq!(c.gateway_socket(), PathBuf::from("/run/gw.sock"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn an_approver_must_be_heard() {
|
||||||
|
let c = Config::parse(&text(&format!(
|
||||||
|
"[approvals]\nusers = [\"{KYLE}\", \"{OTHER}\"]\n"
|
||||||
|
)))
|
||||||
|
.unwrap();
|
||||||
|
let why = c.problem().expect("refused");
|
||||||
|
assert_eq!(
|
||||||
|
why,
|
||||||
|
format!(
|
||||||
|
"[approvals] \"{OTHER}\" is not in [allow] users: an approver who is not heard could never answer"
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn unknown_keys_are_errors() {
|
||||||
|
assert!(Config::parse(&text("[approvals]\napprovers = []\n")).is_err());
|
||||||
|
assert!(Config::parse(&text("[approvals]\nusers = []\nttl = 5\n")).is_err());
|
||||||
|
}
|
||||||
@@ -0,0 +1,313 @@
|
|||||||
|
//! A turn on `loop.sock` and its answer in the thread, against a fake `loopd`: the answer, long
|
||||||
|
//! answers, approvals, errors, an unknown session, and a loop that is not there (M4a spec, section
|
||||||
|
//! 8; M4b spec, section 7, for the notice). Do not edit.
|
||||||
|
|
||||||
|
#[path = "support/fake_loop.rs"]
|
||||||
|
mod fake_loop;
|
||||||
|
#[path = "support/tmp.rs"]
|
||||||
|
mod tmp;
|
||||||
|
|
||||||
|
use std::sync::Mutex;
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
use fake_loop::{Reply, done, error, event, serve_loop};
|
||||||
|
use gatewayd::deliver::{EMPTY_ANSWER, LOOP_DOWN, MAX_POST, Notice, Poster, deliver, split_answer};
|
||||||
|
use gatewayd::mm::MmError;
|
||||||
|
use gatewayd::sessions::{Batch, Thread};
|
||||||
|
use proto::{DataClass, ErrorCode, SessionId, Timestamp, TurnEvent};
|
||||||
|
use tmp::TempDir;
|
||||||
|
|
||||||
|
const DM: &str = "d0000000000000000000000000";
|
||||||
|
const ROOT: &str = "r0000000000000000000000000";
|
||||||
|
|
||||||
|
#[derive(Default)]
|
||||||
|
struct Record {
|
||||||
|
posts: Mutex<Vec<(String, String, String)>>,
|
||||||
|
fail: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Poster for Record {
|
||||||
|
fn post(&self, channel: &str, root: &str, text: &str) -> Result<(), MmError> {
|
||||||
|
if self.fail {
|
||||||
|
return Err(MmError::Status(500, "down".to_string()));
|
||||||
|
}
|
||||||
|
self.posts
|
||||||
|
.lock()
|
||||||
|
.unwrap()
|
||||||
|
.push((channel.to_string(), root.to_string(), text.to_string()));
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Record {
|
||||||
|
fn texts(&self) -> Vec<String> {
|
||||||
|
let posts = self.posts.lock().unwrap();
|
||||||
|
assert!(
|
||||||
|
posts.iter().all(|(c, r, _)| c == DM && r == ROOT),
|
||||||
|
"every post in the thread"
|
||||||
|
);
|
||||||
|
posts.iter().map(|(_, _, t)| t.clone()).collect()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn batch(resume: bool, text: &str) -> Batch {
|
||||||
|
Batch {
|
||||||
|
session: SessionId::new(&format!("mm-{ROOT}")).unwrap(),
|
||||||
|
thread: Thread {
|
||||||
|
channel: DM.to_string(),
|
||||||
|
root: ROOT.to_string(),
|
||||||
|
},
|
||||||
|
resume,
|
||||||
|
text: text.to_string(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn run(dir: &TempDir, poster: &Record, batch: &Batch) -> Vec<String> {
|
||||||
|
let log = Mutex::new(Vec::new());
|
||||||
|
let never = |_: u64| false;
|
||||||
|
let notice = Notice {
|
||||||
|
posted: &never,
|
||||||
|
wait: Duration::ZERO,
|
||||||
|
};
|
||||||
|
deliver(
|
||||||
|
poster,
|
||||||
|
&dir.path().join("loop.sock"),
|
||||||
|
batch,
|
||||||
|
&|line| log.lock().unwrap().push(line.to_string()),
|
||||||
|
¬ice,
|
||||||
|
);
|
||||||
|
log.into_inner().unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_answer_is_posted_and_nothing_else() {
|
||||||
|
let dir = TempDir::new("deliver-answer");
|
||||||
|
let turns = serve_loop(&dir.path().join("loop.sock"), |_, _| {
|
||||||
|
vec![
|
||||||
|
event(TurnEvent::Progress {
|
||||||
|
total: 10,
|
||||||
|
cache: 0,
|
||||||
|
processed: 10,
|
||||||
|
}),
|
||||||
|
event(TurnEvent::Reasoning {
|
||||||
|
text: "private thoughts".to_string(),
|
||||||
|
}),
|
||||||
|
event(TurnEvent::ToolCallStarted {
|
||||||
|
name: "read_file".to_string(),
|
||||||
|
}),
|
||||||
|
event(TurnEvent::ToolResult {
|
||||||
|
name: "read_file".to_string(),
|
||||||
|
class: DataClass::Private,
|
||||||
|
truncated: false,
|
||||||
|
}),
|
||||||
|
event(TurnEvent::Content {
|
||||||
|
text: "The ans".to_string(),
|
||||||
|
}),
|
||||||
|
done("The answer."),
|
||||||
|
]
|
||||||
|
});
|
||||||
|
let poster = Record::default();
|
||||||
|
let log = run(&dir, &poster, &batch(true, "one\n\ntwo"));
|
||||||
|
assert_eq!(poster.texts(), ["The answer."]);
|
||||||
|
assert!(log.is_empty(), "{log:?}");
|
||||||
|
let turn = turns.recv_timeout(Duration::from_secs(5)).unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
(turn.session.as_str(), turn.content.as_str(), turn.resume),
|
||||||
|
(format!("mm-{ROOT}").as_str(), "one\n\ntwo", true)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn an_approval_is_announced_once_before_the_answer() {
|
||||||
|
let dir = TempDir::new("deliver-approval");
|
||||||
|
let expires = Timestamp::from_unix_millis(1_758_650_000_000).unwrap();
|
||||||
|
let _turns = serve_loop(&dir.path().join("loop.sock"), move |_, _| {
|
||||||
|
vec![
|
||||||
|
event(TurnEvent::ApprovalPending {
|
||||||
|
approval: 42,
|
||||||
|
tool: "shell".to_string(),
|
||||||
|
expires,
|
||||||
|
}),
|
||||||
|
done("done"),
|
||||||
|
]
|
||||||
|
});
|
||||||
|
let poster = Record::default();
|
||||||
|
run(&dir, &poster, &batch(false, "go"));
|
||||||
|
assert_eq!(
|
||||||
|
poster.texts(),
|
||||||
|
[
|
||||||
|
"waiting for approval 42: brokerd has not sent it here; answer it with bxctl approvals",
|
||||||
|
"done"
|
||||||
|
]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn errors_are_posted_with_their_code() {
|
||||||
|
let dir = TempDir::new("deliver-error");
|
||||||
|
let _turns = serve_loop(&dir.path().join("loop.sock"), |_, _| {
|
||||||
|
vec![error(
|
||||||
|
ErrorCode::Inference,
|
||||||
|
"the model server failed\nsee docs/runbook.md#loopd-selftest-failed",
|
||||||
|
)]
|
||||||
|
});
|
||||||
|
let poster = Record::default();
|
||||||
|
run(&dir, &poster, &batch(false, "go"));
|
||||||
|
assert_eq!(
|
||||||
|
poster.texts(),
|
||||||
|
["Error: inference: the model server failed\nsee docs/runbook.md#loopd-selftest-failed"]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_reply_in_a_thread_loopd_does_not_know_creates_the_session() {
|
||||||
|
let dir = TempDir::new("deliver-unknown");
|
||||||
|
let turns = serve_loop(&dir.path().join("loop.sock"), |n, _| {
|
||||||
|
if n == 0 {
|
||||||
|
vec![error(ErrorCode::NoSuchSession, "no such session")]
|
||||||
|
} else {
|
||||||
|
vec![done("hello")]
|
||||||
|
}
|
||||||
|
});
|
||||||
|
let poster = Record::default();
|
||||||
|
run(&dir, &poster, &batch(true, "hi"));
|
||||||
|
assert_eq!(poster.texts(), ["hello"]);
|
||||||
|
let first = turns.recv_timeout(Duration::from_secs(5)).unwrap();
|
||||||
|
let second = turns.recv_timeout(Duration::from_secs(5)).unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
(first.resume, second.resume, second.content.as_str()),
|
||||||
|
(true, false, "hi")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_new_session_is_not_retried() {
|
||||||
|
let dir = TempDir::new("deliver-noretry");
|
||||||
|
let turns = serve_loop(&dir.path().join("loop.sock"), |_, _| {
|
||||||
|
vec![error(ErrorCode::NoSuchSession, "odd")]
|
||||||
|
});
|
||||||
|
let poster = Record::default();
|
||||||
|
run(&dir, &poster, &batch(false, "hi"));
|
||||||
|
assert_eq!(poster.texts(), ["Error: no_such_session: odd"]);
|
||||||
|
assert!(turns.recv_timeout(Duration::from_secs(5)).is_ok());
|
||||||
|
assert!(
|
||||||
|
turns.recv_timeout(Duration::from_millis(200)).is_err(),
|
||||||
|
"one turn only"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_loop_that_is_not_there_or_goes_away() {
|
||||||
|
let dir = TempDir::new("deliver-down");
|
||||||
|
let poster = Record::default();
|
||||||
|
let log = run(&dir, &poster, &batch(false, "hi"));
|
||||||
|
assert_eq!(poster.texts(), [LOOP_DOWN]);
|
||||||
|
assert_eq!(log.len(), 1, "{log:?}");
|
||||||
|
assert!(
|
||||||
|
log[0].starts_with(&format!("gatewayd: mm-{ROOT}: cannot connect to ")),
|
||||||
|
"{log:?}"
|
||||||
|
);
|
||||||
|
|
||||||
|
for (n, replies) in [
|
||||||
|
vec![
|
||||||
|
event(TurnEvent::Content {
|
||||||
|
text: "x".to_string(),
|
||||||
|
}),
|
||||||
|
Reply::Close,
|
||||||
|
],
|
||||||
|
vec![Reply::Bytes(b"\x00\x00\x00\x05{bad}".to_vec())],
|
||||||
|
vec![
|
||||||
|
Reply::Frame(proto::Envelope {
|
||||||
|
v: 1,
|
||||||
|
id: 2,
|
||||||
|
r#final: true,
|
||||||
|
msg: proto::Message::Ok(proto::Empty {}),
|
||||||
|
}),
|
||||||
|
done("late"),
|
||||||
|
],
|
||||||
|
]
|
||||||
|
.into_iter()
|
||||||
|
.enumerate()
|
||||||
|
{
|
||||||
|
let dir = TempDir::new(&format!("deliver-early-{n}"));
|
||||||
|
let replies = Mutex::new(Some(replies));
|
||||||
|
let _turns = serve_loop(&dir.path().join("loop.sock"), move |_, _| {
|
||||||
|
replies.lock().unwrap().take().unwrap_or_default()
|
||||||
|
});
|
||||||
|
let poster = Record::default();
|
||||||
|
let log = run(&dir, &poster, &batch(false, "hi"));
|
||||||
|
assert_eq!(poster.texts(), [LOOP_DOWN], "case {n}");
|
||||||
|
assert_eq!(log.len(), 1, "case {n}: {log:?}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_post_that_fails_is_logged() {
|
||||||
|
let dir = TempDir::new("deliver-postfail");
|
||||||
|
let _turns = serve_loop(&dir.path().join("loop.sock"), |_, _| vec![done("lost")]);
|
||||||
|
let poster = Record {
|
||||||
|
fail: true,
|
||||||
|
..Record::default()
|
||||||
|
};
|
||||||
|
let log = run(&dir, &poster, &batch(false, "hi"));
|
||||||
|
assert_eq!(
|
||||||
|
log,
|
||||||
|
[format!(
|
||||||
|
"gatewayd: cannot post in {DM} (thread {ROOT}): status 500: \"down\""
|
||||||
|
)]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn long_answers_are_split_at_newlines() {
|
||||||
|
let short = "a".repeat(MAX_POST);
|
||||||
|
assert_eq!(split_answer(&short), [short.as_str()]);
|
||||||
|
let over = "a".repeat(MAX_POST + 1);
|
||||||
|
assert_eq!(split_answer(&over), ["a".repeat(MAX_POST), "a".to_string()]);
|
||||||
|
let lines = format!(
|
||||||
|
"{}\n{}\n{}",
|
||||||
|
"a".repeat(10_000),
|
||||||
|
"b".repeat(5_000),
|
||||||
|
"c".repeat(2_000)
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
split_answer(&lines),
|
||||||
|
[
|
||||||
|
format!("{}\n{}", "a".repeat(10_000), "b".repeat(5_000)),
|
||||||
|
"c".repeat(2_000)
|
||||||
|
]
|
||||||
|
);
|
||||||
|
let wide = "é".repeat(MAX_POST + 5);
|
||||||
|
let parts = split_answer(&wide);
|
||||||
|
assert_eq!(
|
||||||
|
parts.iter().map(|p| p.chars().count()).collect::<Vec<_>>(),
|
||||||
|
[MAX_POST, 5],
|
||||||
|
"characters, not bytes"
|
||||||
|
);
|
||||||
|
let leading = format!("\n{}", "x".repeat(MAX_POST + 1));
|
||||||
|
let parts = split_answer(&leading);
|
||||||
|
assert!(
|
||||||
|
parts
|
||||||
|
.iter()
|
||||||
|
.all(|p| !p.is_empty() && p.chars().count() <= MAX_POST),
|
||||||
|
"{:?}",
|
||||||
|
parts.iter().map(|p| p.len()).collect::<Vec<_>>()
|
||||||
|
);
|
||||||
|
assert_eq!(parts.concat(), leading, "a hard cut drops nothing");
|
||||||
|
assert_eq!(split_answer(""), [EMPTY_ANSWER]);
|
||||||
|
assert_eq!(split_answer(" \n "), [EMPTY_ANSWER]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_long_answer_is_posted_in_order() {
|
||||||
|
let dir = TempDir::new("deliver-long");
|
||||||
|
let answer = format!("{}\n{}", "a".repeat(MAX_POST - 1), "b".repeat(MAX_POST));
|
||||||
|
let sent = answer.clone();
|
||||||
|
let _turns = serve_loop(&dir.path().join("loop.sock"), move |_, _| vec![done(&sent)]);
|
||||||
|
let poster = Record::default();
|
||||||
|
run(&dir, &poster, &batch(false, "hi"));
|
||||||
|
assert_eq!(
|
||||||
|
poster.texts(),
|
||||||
|
["a".repeat(MAX_POST - 1), "b".repeat(MAX_POST)]
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
//! The notice for an approval that `brokerd` did not bring: posted only when it has not arrived
|
||||||
|
//! within the wait (M4b spec, section 7). Do not edit.
|
||||||
|
|
||||||
|
#[path = "support/fake_loop.rs"]
|
||||||
|
mod fake_loop;
|
||||||
|
#[path = "support/tmp.rs"]
|
||||||
|
mod tmp;
|
||||||
|
|
||||||
|
use std::sync::Mutex;
|
||||||
|
use std::time::{Duration, Instant};
|
||||||
|
|
||||||
|
use fake_loop::{done, event, serve_loop};
|
||||||
|
use gatewayd::deliver::{Notice, Poster, deliver};
|
||||||
|
use gatewayd::mm::MmError;
|
||||||
|
use gatewayd::sessions::{Batch, Thread};
|
||||||
|
use proto::{SessionId, Timestamp, TurnEvent};
|
||||||
|
use tmp::TempDir;
|
||||||
|
|
||||||
|
const DM: &str = "d0000000000000000000000000";
|
||||||
|
const ROOT: &str = "r0000000000000000000000000";
|
||||||
|
const NOTICE: &str =
|
||||||
|
"waiting for approval 42: brokerd has not sent it here; answer it with bxctl approvals";
|
||||||
|
|
||||||
|
#[derive(Default)]
|
||||||
|
struct Record(Mutex<Vec<String>>);
|
||||||
|
|
||||||
|
impl Poster for Record {
|
||||||
|
fn post(&self, _: &str, _: &str, text: &str) -> Result<(), MmError> {
|
||||||
|
self.0.lock().unwrap().push(text.to_string());
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A turn whose loop says approval 42 is pending, then answers; what was posted, and how long it took.
|
||||||
|
fn turn(tag: &str, posted: &dyn Fn(u64) -> bool, wait: Duration) -> (Vec<String>, Duration) {
|
||||||
|
let dir = TempDir::new(tag);
|
||||||
|
let socket = dir.path().join("loop.sock");
|
||||||
|
let expires = Timestamp::parse("2026-09-24T08:20:00.000Z").unwrap();
|
||||||
|
let _turns = serve_loop(&socket, move |_, _| {
|
||||||
|
vec![
|
||||||
|
event(TurnEvent::ApprovalPending {
|
||||||
|
approval: 42,
|
||||||
|
tool: "read_file".to_string(),
|
||||||
|
expires,
|
||||||
|
}),
|
||||||
|
done("done"),
|
||||||
|
]
|
||||||
|
});
|
||||||
|
let batch = Batch {
|
||||||
|
session: SessionId::new(&format!("mm-{ROOT}")).unwrap(),
|
||||||
|
thread: Thread {
|
||||||
|
channel: DM.to_string(),
|
||||||
|
root: ROOT.to_string(),
|
||||||
|
},
|
||||||
|
resume: false,
|
||||||
|
text: "go".to_string(),
|
||||||
|
};
|
||||||
|
let poster = Record::default();
|
||||||
|
let started = Instant::now();
|
||||||
|
deliver(&poster, &socket, &batch, &|_| {}, &Notice { posted, wait });
|
||||||
|
let took = started.elapsed();
|
||||||
|
(poster.0.into_inner().unwrap(), took)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn brought_already_no_notice_and_no_wait() {
|
||||||
|
let (posts, took) = turn("notice-there", &|id| id == 42, Duration::from_secs(5));
|
||||||
|
assert_eq!(posts, ["done"]);
|
||||||
|
assert!(took < Duration::from_secs(1), "{took:?}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn brought_during_the_wait_no_notice() {
|
||||||
|
let started = Instant::now();
|
||||||
|
let later = move |id: u64| id == 42 && started.elapsed() > Duration::from_millis(300);
|
||||||
|
let (posts, took) = turn("notice-later", &later, Duration::from_secs(5));
|
||||||
|
assert_eq!(posts, ["done"]);
|
||||||
|
assert!(took < Duration::from_secs(2), "{took:?}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn never_brought_the_notice_after_the_wait() {
|
||||||
|
let (posts, took) = turn("notice-never", &|_| false, Duration::from_millis(400));
|
||||||
|
assert_eq!(posts, [NOTICE, "done"]);
|
||||||
|
assert!(
|
||||||
|
took >= Duration::from_millis(400) && took < Duration::from_secs(3),
|
||||||
|
"{took:?}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn another_approval_brought_does_not_count() {
|
||||||
|
let (posts, _) = turn("notice-other", &|id| id == 7, Duration::from_millis(200));
|
||||||
|
assert_eq!(posts, [NOTICE, "done"]);
|
||||||
|
}
|
||||||
@@ -0,0 +1,288 @@
|
|||||||
|
//! `gateway.sock` from `gatewayd`'s side: the socket's permissions, and one connection's frames,
|
||||||
|
//! with the test playing both the serve loop and `brokerd` (M4b spec, sections 4 and 6). Do not
|
||||||
|
//! edit.
|
||||||
|
|
||||||
|
#[path = "support/tmp.rs"]
|
||||||
|
mod tmp;
|
||||||
|
|
||||||
|
use std::os::unix::fs::PermissionsExt;
|
||||||
|
use std::os::unix::net::UnixStream;
|
||||||
|
use std::path::Path;
|
||||||
|
use std::sync::mpsc::{self, Receiver};
|
||||||
|
use std::sync::{Arc, Mutex};
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
use gatewayd::gateway::{Ending, Timing, ToLoop, listen, serve};
|
||||||
|
use proto::{
|
||||||
|
ApprovalOutcome, ApprovalReply, ApprovalRequest, CallId, DataClass, Envelope, ErrorCode,
|
||||||
|
GatewayOutcome, Message, PROTOCOL_VERSION, PendingApproval, SessionId, Timestamp, read_frame,
|
||||||
|
write_frame,
|
||||||
|
};
|
||||||
|
use tmp::TempDir;
|
||||||
|
|
||||||
|
const KYLE: &str = "k0000000000000000000000000";
|
||||||
|
const POST: &str = "p0000000000000000000000001";
|
||||||
|
const CMD: &str = "c0000000000000000000000001";
|
||||||
|
const WAIT: Duration = Duration::from_secs(5);
|
||||||
|
|
||||||
|
fn info(approval: u64) -> PendingApproval {
|
||||||
|
PendingApproval {
|
||||||
|
approval,
|
||||||
|
session: SessionId::new("mm-r0000000000000000000000001").unwrap(),
|
||||||
|
call: CallId(3),
|
||||||
|
tool: "shell".to_string(),
|
||||||
|
arguments: "{}".to_string(),
|
||||||
|
grant: "g".to_string(),
|
||||||
|
taint: DataClass::Private,
|
||||||
|
created: Timestamp::parse("2026-09-24T08:05:00.000Z").unwrap(),
|
||||||
|
expires: Timestamp::parse("2026-09-24T08:20:00.000Z").unwrap(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn frame(id: u64, last: bool, msg: Message) -> Envelope {
|
||||||
|
Envelope {
|
||||||
|
v: PROTOCOL_VERSION,
|
||||||
|
id,
|
||||||
|
r#final: last,
|
||||||
|
msg,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn timing() -> Timing {
|
||||||
|
Timing {
|
||||||
|
first_frame: Duration::from_millis(500),
|
||||||
|
posting: WAIT,
|
||||||
|
outcome: Duration::from_millis(500),
|
||||||
|
tick: Duration::from_millis(20),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A listening gatewayd side: the socket and what it asks of the loop.
|
||||||
|
fn start(
|
||||||
|
dir: &TempDir,
|
||||||
|
) -> (
|
||||||
|
std::path::PathBuf,
|
||||||
|
Receiver<ToLoop>,
|
||||||
|
Arc<Mutex<Vec<String>>>,
|
||||||
|
) {
|
||||||
|
let socket = dir.path().join("run/gateway/gateway.sock");
|
||||||
|
let listener = listen(&socket).unwrap();
|
||||||
|
let (tx, rx) = mpsc::channel();
|
||||||
|
let lines = Arc::new(Mutex::new(Vec::new()));
|
||||||
|
let sink = Arc::clone(&lines);
|
||||||
|
serve(
|
||||||
|
listener,
|
||||||
|
tx,
|
||||||
|
timing(),
|
||||||
|
Arc::new(move |l: &str| sink.lock().unwrap().push(l.to_string())),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
(socket, rx, lines)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// brokerd: connect and send the request.
|
||||||
|
fn request(socket: &Path, approval: u64) -> UnixStream {
|
||||||
|
let mut s = UnixStream::connect(socket).unwrap();
|
||||||
|
s.set_read_timeout(Some(WAIT)).unwrap();
|
||||||
|
write_frame(
|
||||||
|
&mut s,
|
||||||
|
&frame(
|
||||||
|
1,
|
||||||
|
true,
|
||||||
|
Message::ApprovalRequest(ApprovalRequest {
|
||||||
|
approval: info(approval),
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
s
|
||||||
|
}
|
||||||
|
|
||||||
|
fn reply(approval: u64) -> ApprovalReply {
|
||||||
|
ApprovalReply {
|
||||||
|
approval,
|
||||||
|
approved: true,
|
||||||
|
by: KYLE.to_string(),
|
||||||
|
post: CMD.to_string(),
|
||||||
|
reason: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_socket_is_private_and_a_stale_one_is_replaced() {
|
||||||
|
let dir = TempDir::new("gw-perms");
|
||||||
|
let socket = dir.path().join("run/gateway/gateway.sock");
|
||||||
|
drop(listen(&socket).unwrap());
|
||||||
|
let listener = listen(&socket).expect("a stale socket is replaced");
|
||||||
|
let mode = |p: &Path| std::fs::metadata(p).unwrap().permissions().mode() & 0o777;
|
||||||
|
assert_eq!(
|
||||||
|
(mode(&socket), mode(socket.parent().unwrap())),
|
||||||
|
(0o600, 0o700)
|
||||||
|
);
|
||||||
|
drop(listener);
|
||||||
|
let file = dir.path().join("run/gateway/not-a-socket");
|
||||||
|
std::fs::write(&file, "x").unwrap();
|
||||||
|
let why = listen(&file).unwrap_err();
|
||||||
|
assert!(
|
||||||
|
why.starts_with(&format!("cannot listen on {}", file.display())),
|
||||||
|
"{why}"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
dir.path().join("run/gateway/not-a-socket").exists(),
|
||||||
|
"a file that is not a socket is left alone"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn posted_answered_and_ended() {
|
||||||
|
let dir = TempDir::new("gw-answer");
|
||||||
|
let (socket, to_loop, _) = start(&dir);
|
||||||
|
let mut brokerd = request(&socket, 42);
|
||||||
|
let (answer, shown) = match to_loop.recv_timeout(WAIT).unwrap() {
|
||||||
|
ToLoop::Show {
|
||||||
|
info: got,
|
||||||
|
answer,
|
||||||
|
shown,
|
||||||
|
} => {
|
||||||
|
assert_eq!(got, info(42));
|
||||||
|
(answer, shown)
|
||||||
|
}
|
||||||
|
other => panic!("{other:?}"),
|
||||||
|
};
|
||||||
|
shown.send(Ok(vec![POST.to_string()])).unwrap();
|
||||||
|
let posted = read_frame(&mut brokerd).unwrap();
|
||||||
|
assert_eq!((posted.id, posted.r#final), (1, false));
|
||||||
|
assert!(
|
||||||
|
matches!(posted.msg, Message::ApprovalPosted(ref p) if p.posts == [POST]),
|
||||||
|
"{posted:?}"
|
||||||
|
);
|
||||||
|
answer.send(reply(42)).unwrap();
|
||||||
|
let answered = read_frame(&mut brokerd).unwrap();
|
||||||
|
assert_eq!(answered, frame(1, true, Message::ApprovalReply(reply(42))));
|
||||||
|
write_frame(
|
||||||
|
&mut brokerd,
|
||||||
|
&frame(
|
||||||
|
2,
|
||||||
|
true,
|
||||||
|
Message::ApprovalOutcome(ApprovalOutcome {
|
||||||
|
outcome: GatewayOutcome::Runs {},
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
match to_loop.recv_timeout(WAIT).unwrap() {
|
||||||
|
ToLoop::Ended { approval, ending } => {
|
||||||
|
assert_eq!(approval, 42);
|
||||||
|
assert_eq!(
|
||||||
|
ending,
|
||||||
|
Ending::Answered {
|
||||||
|
by: KYLE.to_string(),
|
||||||
|
reason: None,
|
||||||
|
outcome: GatewayOutcome::Runs {}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
other => panic!("{other:?}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn brokerd_closing_first_ends_it_as_no_longer_pending() {
|
||||||
|
let dir = TempDir::new("gw-closed");
|
||||||
|
let (socket, to_loop, _) = start(&dir);
|
||||||
|
let mut brokerd = request(&socket, 7);
|
||||||
|
let ToLoop::Show { shown, answer, .. } = to_loop.recv_timeout(WAIT).unwrap() else {
|
||||||
|
panic!()
|
||||||
|
};
|
||||||
|
shown.send(Ok(vec![POST.to_string()])).unwrap();
|
||||||
|
read_frame(&mut brokerd).unwrap();
|
||||||
|
drop(brokerd);
|
||||||
|
match to_loop.recv_timeout(WAIT).unwrap() {
|
||||||
|
ToLoop::Ended { approval, ending } => assert_eq!((approval, ending), (7, Ending::NoLonger)),
|
||||||
|
other => panic!("{other:?}"),
|
||||||
|
}
|
||||||
|
assert!(
|
||||||
|
answer.send(reply(7)).is_err(),
|
||||||
|
"nobody listens for an answer any more"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn an_approval_that_cannot_be_posted_is_an_error_to_brokerd() {
|
||||||
|
let dir = TempDir::new("gw-cannot");
|
||||||
|
let (socket, to_loop, _) = start(&dir);
|
||||||
|
let mut brokerd = request(&socket, 8);
|
||||||
|
let ToLoop::Show { shown, .. } = to_loop.recv_timeout(WAIT).unwrap() else {
|
||||||
|
panic!()
|
||||||
|
};
|
||||||
|
shown.send(Err("Mattermost is down".to_string())).unwrap();
|
||||||
|
let got = read_frame(&mut brokerd).unwrap();
|
||||||
|
assert_eq!((got.id, got.r#final), (1, true));
|
||||||
|
match got.msg {
|
||||||
|
Message::Error(e) => assert_eq!(
|
||||||
|
(e.code, e.detail.as_str()),
|
||||||
|
(ErrorCode::Internal, "Mattermost is down")
|
||||||
|
),
|
||||||
|
other => panic!("{other:?}"),
|
||||||
|
}
|
||||||
|
assert!(
|
||||||
|
to_loop.recv_timeout(Duration::from_millis(300)).is_err(),
|
||||||
|
"nothing more for the loop"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn no_outcome_after_the_answer_is_no_longer_pending_and_logged() {
|
||||||
|
let dir = TempDir::new("gw-silent");
|
||||||
|
let (socket, to_loop, lines) = start(&dir);
|
||||||
|
let mut brokerd = request(&socket, 9);
|
||||||
|
let ToLoop::Show { shown, answer, .. } = to_loop.recv_timeout(WAIT).unwrap() else {
|
||||||
|
panic!()
|
||||||
|
};
|
||||||
|
shown.send(Ok(vec![POST.to_string()])).unwrap();
|
||||||
|
read_frame(&mut brokerd).unwrap();
|
||||||
|
answer.send(reply(9)).unwrap();
|
||||||
|
read_frame(&mut brokerd).unwrap();
|
||||||
|
match to_loop.recv_timeout(WAIT).unwrap() {
|
||||||
|
ToLoop::Ended { approval, ending } => assert_eq!((approval, ending), (9, Ending::NoLonger)),
|
||||||
|
other => panic!("{other:?}"),
|
||||||
|
}
|
||||||
|
assert_eq!(
|
||||||
|
*lines.lock().unwrap(),
|
||||||
|
["gatewayd: approval 9: brokerd said nothing after the answer"]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn anything_but_a_request_first_is_dropped() {
|
||||||
|
let dir = TempDir::new("gw-bad");
|
||||||
|
let (socket, to_loop, _) = start(&dir);
|
||||||
|
for first in [
|
||||||
|
frame(1, true, Message::ApprovalReply(reply(1))),
|
||||||
|
frame(
|
||||||
|
2,
|
||||||
|
true,
|
||||||
|
Message::ApprovalRequest(ApprovalRequest { approval: info(1) }),
|
||||||
|
),
|
||||||
|
frame(
|
||||||
|
1,
|
||||||
|
false,
|
||||||
|
Message::ApprovalRequest(ApprovalRequest { approval: info(1) }),
|
||||||
|
),
|
||||||
|
] {
|
||||||
|
let mut s = UnixStream::connect(&socket).unwrap();
|
||||||
|
s.set_read_timeout(Some(WAIT)).unwrap();
|
||||||
|
write_frame(&mut s, &first).unwrap();
|
||||||
|
assert!(
|
||||||
|
read_frame(&mut s).is_err(),
|
||||||
|
"closed without an answer: {first:?}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
let silent = UnixStream::connect(&socket).unwrap();
|
||||||
|
std::thread::sleep(Duration::from_millis(700));
|
||||||
|
drop(silent);
|
||||||
|
assert!(
|
||||||
|
to_loop.recv_timeout(Duration::from_millis(300)).is_err(),
|
||||||
|
"nothing reached the loop"
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,129 @@
|
|||||||
|
//! Editing a post and looking up a username, against a scripted server (M4b spec, sections 6 and
|
||||||
|
//! 7). Do not edit.
|
||||||
|
|
||||||
|
#[path = "support/http_server.rs"]
|
||||||
|
mod http_server;
|
||||||
|
#[path = "support/tls_server.rs"]
|
||||||
|
mod tls_server;
|
||||||
|
|
||||||
|
use std::net::SocketAddr;
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
use gatewayd::config::ServerUrl;
|
||||||
|
use gatewayd::mm::MmError;
|
||||||
|
use gatewayd::mm::rest::Client;
|
||||||
|
use gatewayd::net::Connector;
|
||||||
|
use gatewayd::secrets::Secret;
|
||||||
|
use http_server::{reply, serve_http};
|
||||||
|
use serde_json::json;
|
||||||
|
|
||||||
|
const BOT: &str = "b0000000000000000000000000";
|
||||||
|
const KYLE: &str = "k0000000000000000000000000";
|
||||||
|
const DM: &str = "d0000000000000000000000000";
|
||||||
|
const POST: &str = "p0000000000000000000000001";
|
||||||
|
const WAIT: Duration = Duration::from_secs(5);
|
||||||
|
|
||||||
|
fn client(addr: SocketAddr) -> Client {
|
||||||
|
let url = ServerUrl {
|
||||||
|
tls: false,
|
||||||
|
host: "localhost".to_string(),
|
||||||
|
port: addr.port(),
|
||||||
|
};
|
||||||
|
Client::new(
|
||||||
|
Connector::new(url, None).unwrap(),
|
||||||
|
Secret::new("TOKEN".to_string()),
|
||||||
|
WAIT,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_post_is_patched_with_its_new_text() {
|
||||||
|
let (addr, rx) = serve_http(None, |_, r| {
|
||||||
|
let text = r.json()["message"].as_str().unwrap().to_string();
|
||||||
|
let post = json!({"id": POST, "user_id": BOT, "channel_id": DM, "root_id": "", "message": text, "create_at": 5, "is_pinned": false});
|
||||||
|
reply(200, "", &post.to_string())
|
||||||
|
});
|
||||||
|
client(addr)
|
||||||
|
.patch_post(
|
||||||
|
POST,
|
||||||
|
"**Approval 42**\n✅ Approved by @kyle: the call runs.",
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
let r = rx.recv_timeout(WAIT).unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
(r.method.as_str(), r.path.as_str()),
|
||||||
|
("PUT", format!("/api/v4/posts/{POST}/patch").as_str())
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
r.head.contains("Authorization: Bearer TOKEN\r\n"),
|
||||||
|
"{}",
|
||||||
|
r.head
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
r.json(),
|
||||||
|
json!({"message": "**Approval 42**\n✅ Approved by @kyle: the call runs."})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_post_that_is_not_an_id_is_never_sent() {
|
||||||
|
let (addr, rx) = serve_http(None, |_, _| reply(200, "", "{}"));
|
||||||
|
for bad in ["../users/me", "", "P0000000000000000000000001"] {
|
||||||
|
assert!(
|
||||||
|
matches!(client(addr).patch_post(bad, "x"), Err(MmError::Json(_))),
|
||||||
|
"{bad:?}"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
matches!(client(addr).username(bad), Err(MmError::Json(_))),
|
||||||
|
"{bad:?}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
std::thread::sleep(Duration::from_millis(100));
|
||||||
|
assert!(rx.try_recv().is_err(), "nothing was sent");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_refused_edit_is_an_error() {
|
||||||
|
let (addr, _rx) = serve_http(None, |_, _| {
|
||||||
|
reply(
|
||||||
|
403,
|
||||||
|
"",
|
||||||
|
r#"{"id":"api.post.update_post.permissions_details.app_error"}"#,
|
||||||
|
)
|
||||||
|
});
|
||||||
|
assert!(matches!(
|
||||||
|
client(addr).patch_post(POST, "x"),
|
||||||
|
Err(MmError::Auth(403))
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_username_of_a_user() {
|
||||||
|
let (addr, rx) = serve_http(None, |_, _| {
|
||||||
|
reply(
|
||||||
|
200,
|
||||||
|
"",
|
||||||
|
&json!({"id": KYLE, "username": "kyle", "first_name": "K", "roles": "system_user"})
|
||||||
|
.to_string(),
|
||||||
|
)
|
||||||
|
});
|
||||||
|
assert_eq!(client(addr).username(KYLE).unwrap(), "kyle");
|
||||||
|
let r = rx.recv_timeout(WAIT).unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
(r.method.as_str(), r.path.as_str()),
|
||||||
|
("GET", format!("/api/v4/users/{KYLE}").as_str())
|
||||||
|
);
|
||||||
|
for bad in [
|
||||||
|
json!({"id": KYLE}),
|
||||||
|
json!({"id": KYLE, "username": ""}),
|
||||||
|
json!({"id": KYLE, "username": "Kyle\n@channel"}),
|
||||||
|
json!({"id": KYLE, "username": "x".repeat(65)}),
|
||||||
|
] {
|
||||||
|
let body = bad.to_string();
|
||||||
|
let (addr, _rx) = serve_http(None, move |_, _| reply(200, "", &body));
|
||||||
|
assert!(
|
||||||
|
matches!(client(addr).username(KYLE), Err(MmError::Json(_))),
|
||||||
|
"{bad}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,214 @@
|
|||||||
|
//! `gatewayd` end to end, against a fake Mattermost and a fake `loopd`: who is answered, where,
|
||||||
|
//! and how (M4a spec, sections 7 and 8). Do not edit.
|
||||||
|
|
||||||
|
#[path = "support/fake_loop.rs"]
|
||||||
|
mod fake_loop;
|
||||||
|
#[path = "support/fake_mm.rs"]
|
||||||
|
mod fake_mm;
|
||||||
|
#[path = "support/gateway.rs"]
|
||||||
|
mod gateway;
|
||||||
|
#[path = "support/tmp.rs"]
|
||||||
|
mod tmp;
|
||||||
|
|
||||||
|
use std::sync::{Mutex, mpsc};
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
use fake_loop::{done, serve_loop};
|
||||||
|
use fake_mm::{BOT, BOT_NAME, DM, EVE, EVE_DM, FakeMm, KYLE, id, post};
|
||||||
|
use gateway::{OTHER, SHARED, WAIT, config, loop_dir, read_state, start, up};
|
||||||
|
use gatewayd::serve::Stop;
|
||||||
|
use gatewayd::sessions::BUSY;
|
||||||
|
use tmp::TempDir;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_direct_message_is_answered_in_its_thread_while_typing() {
|
||||||
|
let home = TempDir::new("serve-dm");
|
||||||
|
let (mm, running, turns, mut ws) = up(&home, Duration::from_millis(600));
|
||||||
|
let p1 = id('p', 1);
|
||||||
|
ws.posted(&post(&p1, KYLE, DM, "", "hello there", 5), "D");
|
||||||
|
let turn = turns.recv_timeout(WAIT).unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
(turn.session.as_str(), turn.content.as_str(), turn.resume),
|
||||||
|
(format!("mm-{p1}").as_str(), "hello there", false)
|
||||||
|
);
|
||||||
|
let typing = ws.typing_within(Duration::from_millis(500));
|
||||||
|
assert!(typing.len() >= 2, "{typing:?}");
|
||||||
|
assert!(
|
||||||
|
typing.iter().all(|(c, p)| c == DM && *p == p1),
|
||||||
|
"{typing:?}"
|
||||||
|
);
|
||||||
|
let posts = mm.wait_posts(1, WAIT);
|
||||||
|
assert_eq!(
|
||||||
|
posts,
|
||||||
|
[(
|
||||||
|
DM.to_string(),
|
||||||
|
p1.clone(),
|
||||||
|
"answer to hello there".to_string()
|
||||||
|
)]
|
||||||
|
);
|
||||||
|
// Typing sent just before the answer may still be on its way; after that, it stops.
|
||||||
|
ws.typing_within(Duration::from_millis(300));
|
||||||
|
assert!(
|
||||||
|
ws.typing_within(Duration::from_millis(400)).is_empty(),
|
||||||
|
"typing stops after the answer"
|
||||||
|
);
|
||||||
|
let log = running.log();
|
||||||
|
assert!(
|
||||||
|
log.iter()
|
||||||
|
.any(|l| l == &format!("gatewayd: connected to {} as {BOT_NAME}", mm.url())),
|
||||||
|
"{log:?}"
|
||||||
|
);
|
||||||
|
assert!(matches!(running.finish(), Stop::Asked));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn anyone_else_gets_nothing_at_all() {
|
||||||
|
let home = TempDir::new("serve-stranger");
|
||||||
|
let (mm, running, turns, mut ws) = up(&home, Duration::ZERO);
|
||||||
|
let p1 = id('p', 1);
|
||||||
|
ws.posted(&post(&p1, EVE, EVE_DM, "", "secret words", 5), "D");
|
||||||
|
let mut own = post(&id('p', 2), BOT, DM, "", "my own post", 6);
|
||||||
|
own["user_id"] = serde_json::json!(BOT);
|
||||||
|
ws.posted(&own, "D");
|
||||||
|
let log = running.wait_log("not allowed");
|
||||||
|
assert!(turns.recv_timeout(Duration::from_millis(300)).is_err());
|
||||||
|
assert!(ws.typing_within(Duration::from_millis(200)).is_empty());
|
||||||
|
assert!(mm.posts().is_empty());
|
||||||
|
assert!(
|
||||||
|
log.contains(&format!(
|
||||||
|
"gatewayd: ignored post {p1} from {EVE}: not allowed"
|
||||||
|
)),
|
||||||
|
"{log:?}"
|
||||||
|
);
|
||||||
|
assert!(!log.iter().any(|l| l.contains("secret words")), "{log:?}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn messages_during_a_turn_go_together_in_the_next() {
|
||||||
|
let home = TempDir::new("serve-burst");
|
||||||
|
loop_dir(&home);
|
||||||
|
let (release_tx, release_rx) = mpsc::channel::<()>();
|
||||||
|
let release = Mutex::new(release_rx);
|
||||||
|
let turns = serve_loop(&home.path().join("run/loop/loop.sock"), move |n, turn| {
|
||||||
|
if n == 0 {
|
||||||
|
let _ = release.lock().unwrap().recv_timeout(WAIT);
|
||||||
|
}
|
||||||
|
vec![done(&format!("answer to {}", turn.content))]
|
||||||
|
});
|
||||||
|
let mm = FakeMm::start();
|
||||||
|
let _running = start(config(&home, &mm.url(), ""));
|
||||||
|
let mut ws = mm.next_ws(WAIT);
|
||||||
|
let p1 = id('p', 1);
|
||||||
|
ws.posted(&post(&p1, KYLE, DM, "", "one", 5), "D");
|
||||||
|
assert_eq!(turns.recv_timeout(WAIT).unwrap().content, "one");
|
||||||
|
let saved = read_state(&home);
|
||||||
|
assert_eq!(
|
||||||
|
saved["in_flight"],
|
||||||
|
serde_json::json!([{"session": format!("mm-{p1}"), "channel": DM, "root": p1}])
|
||||||
|
);
|
||||||
|
ws.posted(&post(&id('p', 2), KYLE, DM, &p1, "two", 6), "D");
|
||||||
|
ws.posted(&post(&id('p', 3), KYLE, DM, &p1, "three", 7), "D");
|
||||||
|
std::thread::sleep(Duration::from_millis(200));
|
||||||
|
release_tx.send(()).unwrap();
|
||||||
|
let second = turns.recv_timeout(WAIT).unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
(
|
||||||
|
second.session.as_str(),
|
||||||
|
second.content.as_str(),
|
||||||
|
second.resume
|
||||||
|
),
|
||||||
|
(format!("mm-{p1}").as_str(), "two\n\nthree", true)
|
||||||
|
);
|
||||||
|
let posts = mm.wait_posts(2, WAIT);
|
||||||
|
let texts: Vec<&str> = posts.iter().map(|(_, _, t)| t.as_str()).collect();
|
||||||
|
assert_eq!(texts, ["answer to one", "answer to two\n\nthree"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_full_queue_says_busy() {
|
||||||
|
let home = TempDir::new("serve-busy");
|
||||||
|
loop_dir(&home);
|
||||||
|
let (release_tx, release_rx) = mpsc::channel::<()>();
|
||||||
|
let release = Mutex::new(release_rx);
|
||||||
|
let _turns = serve_loop(&home.path().join("run/loop/loop.sock"), move |_, _| {
|
||||||
|
let _ = release.lock().unwrap().recv_timeout(WAIT);
|
||||||
|
vec![done("ok")]
|
||||||
|
});
|
||||||
|
let mm = FakeMm::start();
|
||||||
|
let _running = start(config(&home, &mm.url(), "queue = 1"));
|
||||||
|
let mut ws = mm.next_ws(WAIT);
|
||||||
|
let p1 = id('p', 1);
|
||||||
|
for (n, text) in ["run", "waits", "too many"].iter().enumerate() {
|
||||||
|
let n = u32::try_from(n).unwrap();
|
||||||
|
let root = if n == 0 { String::new() } else { p1.clone() };
|
||||||
|
ws.posted(
|
||||||
|
&post(&id('p', n + 1), KYLE, DM, &root, text, i64::from(n) + 5),
|
||||||
|
"D",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
let posts = mm.wait_posts(1, WAIT);
|
||||||
|
assert_eq!(posts, [(DM.to_string(), p1, BUSY.to_string())]);
|
||||||
|
release_tx.send(()).unwrap();
|
||||||
|
release_tx.send(()).unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn channels_are_answered_only_when_named_or_in_our_thread() {
|
||||||
|
let home = TempDir::new("serve-channel");
|
||||||
|
let (mm, _running, turns, mut ws) = up(&home, Duration::ZERO);
|
||||||
|
let p1 = id('p', 1);
|
||||||
|
ws.posted(
|
||||||
|
&post(&id('p', 9), KYLE, SHARED, "", "hello everyone", 4),
|
||||||
|
"O",
|
||||||
|
);
|
||||||
|
ws.posted(
|
||||||
|
&post(
|
||||||
|
&id('p', 8),
|
||||||
|
KYLE,
|
||||||
|
OTHER,
|
||||||
|
"",
|
||||||
|
"@boxmaker-straylight elsewhere",
|
||||||
|
4,
|
||||||
|
),
|
||||||
|
"O",
|
||||||
|
);
|
||||||
|
ws.posted(
|
||||||
|
&post(&p1, KYLE, SHARED, "", "@boxmaker-straylight start", 5),
|
||||||
|
"O",
|
||||||
|
);
|
||||||
|
assert_eq!(turns.recv_timeout(WAIT).unwrap().content, "start");
|
||||||
|
mm.wait_posts(1, WAIT);
|
||||||
|
ws.posted(
|
||||||
|
&post(&id('p', 2), KYLE, SHARED, &p1, "@hermes your turn", 6),
|
||||||
|
"O",
|
||||||
|
);
|
||||||
|
ws.posted(&post(&id('p', 3), KYLE, SHARED, &p1, "and more", 7), "O");
|
||||||
|
let next = turns.recv_timeout(WAIT).unwrap();
|
||||||
|
assert_eq!((next.content.as_str(), next.resume), ("and more", true));
|
||||||
|
let posts = mm.wait_posts(2, WAIT);
|
||||||
|
assert!(
|
||||||
|
posts.iter().all(|(c, r, _)| c == SHARED && *r == p1),
|
||||||
|
"{posts:?}"
|
||||||
|
);
|
||||||
|
assert!(turns.recv_timeout(Duration::from_millis(200)).is_err());
|
||||||
|
let saved = read_state(&home);
|
||||||
|
assert!(saved["channels"].get(OTHER).is_none(), "{saved}");
|
||||||
|
assert_eq!(saved["threads"], serde_json::json!([p1]));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn commands_are_answered_without_a_turn() {
|
||||||
|
let home = TempDir::new("serve-command");
|
||||||
|
let (mm, _running, turns, mut ws) = up(&home, Duration::ZERO);
|
||||||
|
let p1 = id('p', 1);
|
||||||
|
ws.posted(&post(&p1, KYLE, DM, "", "!approve 3", 5), "D");
|
||||||
|
assert_eq!(
|
||||||
|
mm.wait_posts(1, WAIT),
|
||||||
|
[(
|
||||||
|
DM.to_string(),
|
||||||
|
p1,
|
||||||
|
"approval 3 is not pending here".to_string()
|
||||||
|
)]
|
||||||
|
);
|
||||||
|
assert!(turns.recv_timeout(Duration::from_millis(200)).is_err());
|
||||||
|
}
|
||||||
@@ -0,0 +1,210 @@
|
|||||||
|
//! `gatewayd` end to end, against a fake Mattermost and a fake `loopd`: who is answered, where,
|
||||||
|
//! and how (M4a spec, sections 7 and 8). Do not edit.
|
||||||
|
|
||||||
|
#[path = "support/fake_loop.rs"]
|
||||||
|
mod fake_loop;
|
||||||
|
#[path = "support/fake_mm.rs"]
|
||||||
|
mod fake_mm;
|
||||||
|
#[path = "support/gateway.rs"]
|
||||||
|
mod gateway;
|
||||||
|
#[path = "support/tmp.rs"]
|
||||||
|
mod tmp;
|
||||||
|
|
||||||
|
use std::sync::{Mutex, mpsc};
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
use fake_loop::{done, serve_loop};
|
||||||
|
use fake_mm::{BOT, BOT_NAME, DM, EVE, EVE_DM, FakeMm, KYLE, id, post};
|
||||||
|
use gateway::{OTHER, SHARED, WAIT, config, loop_dir, read_state, start, up};
|
||||||
|
use gatewayd::serve::Stop;
|
||||||
|
use gatewayd::sessions::{BUSY, M4B_COMMAND};
|
||||||
|
use tmp::TempDir;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_direct_message_is_answered_in_its_thread_while_typing() {
|
||||||
|
let home = TempDir::new("serve-dm");
|
||||||
|
let (mm, running, turns, mut ws) = up(&home, Duration::from_millis(600));
|
||||||
|
let p1 = id('p', 1);
|
||||||
|
ws.posted(&post(&p1, KYLE, DM, "", "hello there", 5), "D");
|
||||||
|
let turn = turns.recv_timeout(WAIT).unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
(turn.session.as_str(), turn.content.as_str(), turn.resume),
|
||||||
|
(format!("mm-{p1}").as_str(), "hello there", false)
|
||||||
|
);
|
||||||
|
let typing = ws.typing_within(Duration::from_millis(500));
|
||||||
|
assert!(typing.len() >= 2, "{typing:?}");
|
||||||
|
assert!(
|
||||||
|
typing.iter().all(|(c, p)| c == DM && *p == p1),
|
||||||
|
"{typing:?}"
|
||||||
|
);
|
||||||
|
let posts = mm.wait_posts(1, WAIT);
|
||||||
|
assert_eq!(
|
||||||
|
posts,
|
||||||
|
[(
|
||||||
|
DM.to_string(),
|
||||||
|
p1.clone(),
|
||||||
|
"answer to hello there".to_string()
|
||||||
|
)]
|
||||||
|
);
|
||||||
|
// Typing sent just before the answer may still be on its way; after that, it stops.
|
||||||
|
ws.typing_within(Duration::from_millis(300));
|
||||||
|
assert!(
|
||||||
|
ws.typing_within(Duration::from_millis(400)).is_empty(),
|
||||||
|
"typing stops after the answer"
|
||||||
|
);
|
||||||
|
let log = running.log();
|
||||||
|
assert!(
|
||||||
|
log.iter()
|
||||||
|
.any(|l| l == &format!("gatewayd: connected to {} as {BOT_NAME}", mm.url())),
|
||||||
|
"{log:?}"
|
||||||
|
);
|
||||||
|
assert!(matches!(running.finish(), Stop::Asked));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn anyone_else_gets_nothing_at_all() {
|
||||||
|
let home = TempDir::new("serve-stranger");
|
||||||
|
let (mm, running, turns, mut ws) = up(&home, Duration::ZERO);
|
||||||
|
let p1 = id('p', 1);
|
||||||
|
ws.posted(&post(&p1, EVE, EVE_DM, "", "secret words", 5), "D");
|
||||||
|
let mut own = post(&id('p', 2), BOT, DM, "", "my own post", 6);
|
||||||
|
own["user_id"] = serde_json::json!(BOT);
|
||||||
|
ws.posted(&own, "D");
|
||||||
|
let log = running.wait_log("not allowed");
|
||||||
|
assert!(turns.recv_timeout(Duration::from_millis(300)).is_err());
|
||||||
|
assert!(ws.typing_within(Duration::from_millis(200)).is_empty());
|
||||||
|
assert!(mm.posts().is_empty());
|
||||||
|
assert!(
|
||||||
|
log.contains(&format!(
|
||||||
|
"gatewayd: ignored post {p1} from {EVE}: not allowed"
|
||||||
|
)),
|
||||||
|
"{log:?}"
|
||||||
|
);
|
||||||
|
assert!(!log.iter().any(|l| l.contains("secret words")), "{log:?}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn messages_during_a_turn_go_together_in_the_next() {
|
||||||
|
let home = TempDir::new("serve-burst");
|
||||||
|
loop_dir(&home);
|
||||||
|
let (release_tx, release_rx) = mpsc::channel::<()>();
|
||||||
|
let release = Mutex::new(release_rx);
|
||||||
|
let turns = serve_loop(&home.path().join("run/loop/loop.sock"), move |n, turn| {
|
||||||
|
if n == 0 {
|
||||||
|
let _ = release.lock().unwrap().recv_timeout(WAIT);
|
||||||
|
}
|
||||||
|
vec![done(&format!("answer to {}", turn.content))]
|
||||||
|
});
|
||||||
|
let mm = FakeMm::start();
|
||||||
|
let _running = start(config(&home, &mm.url(), ""));
|
||||||
|
let mut ws = mm.next_ws(WAIT);
|
||||||
|
let p1 = id('p', 1);
|
||||||
|
ws.posted(&post(&p1, KYLE, DM, "", "one", 5), "D");
|
||||||
|
assert_eq!(turns.recv_timeout(WAIT).unwrap().content, "one");
|
||||||
|
let saved = read_state(&home);
|
||||||
|
assert_eq!(
|
||||||
|
saved["in_flight"],
|
||||||
|
serde_json::json!([{"session": format!("mm-{p1}"), "channel": DM, "root": p1}])
|
||||||
|
);
|
||||||
|
ws.posted(&post(&id('p', 2), KYLE, DM, &p1, "two", 6), "D");
|
||||||
|
ws.posted(&post(&id('p', 3), KYLE, DM, &p1, "three", 7), "D");
|
||||||
|
std::thread::sleep(Duration::from_millis(200));
|
||||||
|
release_tx.send(()).unwrap();
|
||||||
|
let second = turns.recv_timeout(WAIT).unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
(
|
||||||
|
second.session.as_str(),
|
||||||
|
second.content.as_str(),
|
||||||
|
second.resume
|
||||||
|
),
|
||||||
|
(format!("mm-{p1}").as_str(), "two\n\nthree", true)
|
||||||
|
);
|
||||||
|
let posts = mm.wait_posts(2, WAIT);
|
||||||
|
let texts: Vec<&str> = posts.iter().map(|(_, _, t)| t.as_str()).collect();
|
||||||
|
assert_eq!(texts, ["answer to one", "answer to two\n\nthree"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_full_queue_says_busy() {
|
||||||
|
let home = TempDir::new("serve-busy");
|
||||||
|
loop_dir(&home);
|
||||||
|
let (release_tx, release_rx) = mpsc::channel::<()>();
|
||||||
|
let release = Mutex::new(release_rx);
|
||||||
|
let _turns = serve_loop(&home.path().join("run/loop/loop.sock"), move |_, _| {
|
||||||
|
let _ = release.lock().unwrap().recv_timeout(WAIT);
|
||||||
|
vec![done("ok")]
|
||||||
|
});
|
||||||
|
let mm = FakeMm::start();
|
||||||
|
let _running = start(config(&home, &mm.url(), "queue = 1"));
|
||||||
|
let mut ws = mm.next_ws(WAIT);
|
||||||
|
let p1 = id('p', 1);
|
||||||
|
for (n, text) in ["run", "waits", "too many"].iter().enumerate() {
|
||||||
|
let n = u32::try_from(n).unwrap();
|
||||||
|
let root = if n == 0 { String::new() } else { p1.clone() };
|
||||||
|
ws.posted(
|
||||||
|
&post(&id('p', n + 1), KYLE, DM, &root, text, i64::from(n) + 5),
|
||||||
|
"D",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
let posts = mm.wait_posts(1, WAIT);
|
||||||
|
assert_eq!(posts, [(DM.to_string(), p1, BUSY.to_string())]);
|
||||||
|
release_tx.send(()).unwrap();
|
||||||
|
release_tx.send(()).unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn channels_are_answered_only_when_named_or_in_our_thread() {
|
||||||
|
let home = TempDir::new("serve-channel");
|
||||||
|
let (mm, _running, turns, mut ws) = up(&home, Duration::ZERO);
|
||||||
|
let p1 = id('p', 1);
|
||||||
|
ws.posted(
|
||||||
|
&post(&id('p', 9), KYLE, SHARED, "", "hello everyone", 4),
|
||||||
|
"O",
|
||||||
|
);
|
||||||
|
ws.posted(
|
||||||
|
&post(
|
||||||
|
&id('p', 8),
|
||||||
|
KYLE,
|
||||||
|
OTHER,
|
||||||
|
"",
|
||||||
|
"@boxmaker-straylight elsewhere",
|
||||||
|
4,
|
||||||
|
),
|
||||||
|
"O",
|
||||||
|
);
|
||||||
|
ws.posted(
|
||||||
|
&post(&p1, KYLE, SHARED, "", "@boxmaker-straylight start", 5),
|
||||||
|
"O",
|
||||||
|
);
|
||||||
|
assert_eq!(turns.recv_timeout(WAIT).unwrap().content, "start");
|
||||||
|
mm.wait_posts(1, WAIT);
|
||||||
|
ws.posted(
|
||||||
|
&post(&id('p', 2), KYLE, SHARED, &p1, "@hermes your turn", 6),
|
||||||
|
"O",
|
||||||
|
);
|
||||||
|
ws.posted(&post(&id('p', 3), KYLE, SHARED, &p1, "and more", 7), "O");
|
||||||
|
let next = turns.recv_timeout(WAIT).unwrap();
|
||||||
|
assert_eq!((next.content.as_str(), next.resume), ("and more", true));
|
||||||
|
let posts = mm.wait_posts(2, WAIT);
|
||||||
|
assert!(
|
||||||
|
posts.iter().all(|(c, r, _)| c == SHARED && *r == p1),
|
||||||
|
"{posts:?}"
|
||||||
|
);
|
||||||
|
assert!(turns.recv_timeout(Duration::from_millis(200)).is_err());
|
||||||
|
let saved = read_state(&home);
|
||||||
|
assert!(saved["channels"].get(OTHER).is_none(), "{saved}");
|
||||||
|
assert_eq!(saved["threads"], serde_json::json!([p1]));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn commands_are_answered_without_a_turn() {
|
||||||
|
let home = TempDir::new("serve-command");
|
||||||
|
let (mm, _running, turns, mut ws) = up(&home, Duration::ZERO);
|
||||||
|
let p1 = id('p', 1);
|
||||||
|
ws.posted(&post(&p1, KYLE, DM, "", "!approve 3", 5), "D");
|
||||||
|
assert_eq!(
|
||||||
|
mm.wait_posts(1, WAIT),
|
||||||
|
[(DM.to_string(), p1, M4B_COMMAND.to_string())]
|
||||||
|
);
|
||||||
|
assert!(turns.recv_timeout(Duration::from_millis(200)).is_err());
|
||||||
|
}
|
||||||
@@ -0,0 +1,248 @@
|
|||||||
|
//! Approvals answered from Mattermost end to end: `!approve` and `!deny` from an approver in the
|
||||||
|
//! approval's thread reach `brokerd`, the post shows what came of it, typing pauses while the turn
|
||||||
|
//! waits, and everything else is answered with its reason; the notice comes only when `brokerd`
|
||||||
|
//! never brought the approval (M4b spec, section 7). Do not edit.
|
||||||
|
|
||||||
|
#[path = "support/brokerd_client.rs"]
|
||||||
|
mod brokerd_client;
|
||||||
|
#[path = "support/fake_loop.rs"]
|
||||||
|
mod fake_loop;
|
||||||
|
#[path = "support/fake_mm.rs"]
|
||||||
|
mod fake_mm;
|
||||||
|
#[path = "support/gateway.rs"]
|
||||||
|
mod gateway;
|
||||||
|
#[path = "support/tmp.rs"]
|
||||||
|
mod tmp;
|
||||||
|
|
||||||
|
use std::path::PathBuf;
|
||||||
|
use std::sync::{Mutex, mpsc};
|
||||||
|
use std::time::{Duration, Instant};
|
||||||
|
|
||||||
|
use brokerd_client::{Asking, ask, pending};
|
||||||
|
use fake_loop::{done, event, serve_loop};
|
||||||
|
use fake_mm::{DM, EVE, FakeMm, KYLE, WsPeer, id, post};
|
||||||
|
use gateway::{Running, WAIT, loop_dir, start};
|
||||||
|
use gatewayd::approvals::NOT_APPROVER;
|
||||||
|
use gatewayd::config::Config;
|
||||||
|
use proto::{GatewayOutcome, Timestamp, Turn, TurnEvent};
|
||||||
|
use tmp::TempDir;
|
||||||
|
|
||||||
|
/// KYLE and EVE may talk; only KYLE approves.
|
||||||
|
fn config(home: &TempDir, url: &str) -> Config {
|
||||||
|
let text = format!(
|
||||||
|
"[mattermost]\nurl = \"{url}\"\n[secrets.mattermost_token]\nenv = \"NOT_READ_BY_RUN\"\n[allow]\nusers = [\"{KYLE}\", \"{EVE}\"]\n[approvals]\nusers = [\"{KYLE}\"]\n[paths]\nhome = \"{}\"\n[limits]\ntyping_every_ms = 100\n",
|
||||||
|
home.path().display()
|
||||||
|
);
|
||||||
|
Config::parse(&text).unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A loop whose first turn waits for `go`, then sends `before` and answers "done".
|
||||||
|
fn held(home: &TempDir, before: Vec<TurnEvent>) -> (mpsc::Receiver<Turn>, mpsc::Sender<()>) {
|
||||||
|
loop_dir(home);
|
||||||
|
let (go_tx, go_rx) = mpsc::channel::<()>();
|
||||||
|
let go = Mutex::new(go_rx);
|
||||||
|
let before = Mutex::new(Some(before));
|
||||||
|
let turns = serve_loop(&home.path().join("run/loop/loop.sock"), move |_, _| {
|
||||||
|
let _ = go.lock().unwrap().recv_timeout(Duration::from_secs(20));
|
||||||
|
let mut replies: Vec<_> = before
|
||||||
|
.lock()
|
||||||
|
.unwrap()
|
||||||
|
.take()
|
||||||
|
.unwrap_or_default()
|
||||||
|
.into_iter()
|
||||||
|
.map(event)
|
||||||
|
.collect();
|
||||||
|
replies.push(done("done"));
|
||||||
|
replies
|
||||||
|
});
|
||||||
|
(turns, go_tx)
|
||||||
|
}
|
||||||
|
|
||||||
|
struct Stage {
|
||||||
|
_home: TempDir,
|
||||||
|
mm: FakeMm,
|
||||||
|
_running: Running,
|
||||||
|
ws: WsPeer,
|
||||||
|
go: mpsc::Sender<()>,
|
||||||
|
socket: PathBuf,
|
||||||
|
root: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// gatewayd up, a direct message from KYLE whose turn is held, and the approval's socket.
|
||||||
|
fn stage(tag: &str, before: Vec<TurnEvent>) -> Stage {
|
||||||
|
let home = TempDir::new(tag);
|
||||||
|
let (turns, go) = held(&home, before);
|
||||||
|
let mm = FakeMm::start();
|
||||||
|
let running = start(config(&home, &mm.url()));
|
||||||
|
let mut ws = mm.next_ws(WAIT);
|
||||||
|
running.wait_log("gatewayd: connected to ");
|
||||||
|
let root = id('p', 1);
|
||||||
|
ws.posted(&post(&root, KYLE, DM, "", "read my notes", 5), "D");
|
||||||
|
turns.recv_timeout(WAIT).expect("the turn started");
|
||||||
|
let socket = home.path().join("run/gateway/gateway.sock");
|
||||||
|
Stage {
|
||||||
|
_home: home,
|
||||||
|
mm,
|
||||||
|
_running: running,
|
||||||
|
ws,
|
||||||
|
go,
|
||||||
|
socket,
|
||||||
|
root,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Stage {
|
||||||
|
fn ask(&self, approval: u64) -> Asking {
|
||||||
|
ask(
|
||||||
|
&self.socket,
|
||||||
|
pending(approval, &format!("mm-{}", self.root), r#"{"path":"/n/a"}"#),
|
||||||
|
)
|
||||||
|
.unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn command(&mut self, n: u32, user: &str, root: &str, text: &str, at: i64) {
|
||||||
|
self.ws
|
||||||
|
.posted(&post(&id('c', n), user, DM, root, text, at), "D");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The text posted in the thread after the approval, within five seconds.
|
||||||
|
fn replies(&self, after: usize) -> Vec<String> {
|
||||||
|
let posts = self.mm.wait_posts(after + 1, WAIT);
|
||||||
|
posts[after..].iter().map(|(_, _, t)| t.clone()).collect()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn wait_until(what: &str, mut done: impl FnMut() -> bool) {
|
||||||
|
let until = Instant::now() + WAIT;
|
||||||
|
while !done() {
|
||||||
|
assert!(Instant::now() < until, "never happened: {what}");
|
||||||
|
std::thread::sleep(Duration::from_millis(20));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn approved_by_the_approver_the_post_says_it_runs_and_typing_pauses_meanwhile() {
|
||||||
|
let mut s = stage("ans-approve", vec![]);
|
||||||
|
s.ws.typing_within(Duration::from_millis(300));
|
||||||
|
let mut asking = s.ask(42);
|
||||||
|
s.ws.typing_within(Duration::from_millis(200));
|
||||||
|
assert!(
|
||||||
|
s.ws.typing_within(Duration::from_millis(400)).is_empty(),
|
||||||
|
"no typing while it waits for approval"
|
||||||
|
);
|
||||||
|
let root = s.root.clone();
|
||||||
|
s.command(1, KYLE, &root, "!approve 42", 5);
|
||||||
|
let reply = asking.answer(WAIT).expect("the answer reaches brokerd");
|
||||||
|
assert_eq!(
|
||||||
|
(
|
||||||
|
reply.approval,
|
||||||
|
reply.approved,
|
||||||
|
reply.by.as_str(),
|
||||||
|
reply.post.clone(),
|
||||||
|
reply.reason.clone()
|
||||||
|
),
|
||||||
|
(42, true, KYLE, id('c', 1), None)
|
||||||
|
);
|
||||||
|
asking.outcome(GatewayOutcome::Runs {});
|
||||||
|
let edits = s.mm.wait_edits(1, WAIT);
|
||||||
|
assert_eq!(edits[0].0, FakeMm::post_id(0));
|
||||||
|
assert!(
|
||||||
|
edits[0]
|
||||||
|
.1
|
||||||
|
.ends_with("\n\n✅ Approved by @kyle: the call runs."),
|
||||||
|
"{}",
|
||||||
|
edits[0].1
|
||||||
|
);
|
||||||
|
assert_eq!(s.mm.posts().len(), 1, "nothing else posted in the thread");
|
||||||
|
wait_until("typing again", || {
|
||||||
|
!s.ws.typing_within(Duration::from_millis(300)).is_empty()
|
||||||
|
});
|
||||||
|
s.go.send(()).unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn refused_with_a_reason_the_post_says_so() {
|
||||||
|
let mut s = stage("ans-deny", vec![]);
|
||||||
|
let mut asking = s.ask(42);
|
||||||
|
let root = s.root.clone();
|
||||||
|
s.command(1, KYLE, &root, "@boxmaker-straylight !deny 42 not now", 5);
|
||||||
|
let reply = asking.answer(WAIT).expect("the answer reaches brokerd");
|
||||||
|
assert_eq!(
|
||||||
|
(reply.approved, reply.reason.as_deref()),
|
||||||
|
(false, Some("not now"))
|
||||||
|
);
|
||||||
|
asking.outcome(GatewayOutcome::Refused {});
|
||||||
|
let edits = s.mm.wait_edits(1, WAIT);
|
||||||
|
assert!(
|
||||||
|
edits[0].1.ends_with("\n\n❌ Refused by @kyle: `not now`."),
|
||||||
|
"{}",
|
||||||
|
edits[0].1
|
||||||
|
);
|
||||||
|
s.go.send(()).unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn nobody_else_and_nothing_else_answers_it() {
|
||||||
|
let mut s = stage("ans-refused", vec![]);
|
||||||
|
let mut asking = s.ask(42);
|
||||||
|
let root = s.root.clone();
|
||||||
|
let elsewhere = id('p', 9);
|
||||||
|
s.command(1, EVE, &root, "!approve 42", 5);
|
||||||
|
s.command(2, KYLE, &elsewhere, "!approve 42", 6);
|
||||||
|
s.command(3, KYLE, &root, "!approve 42", 1);
|
||||||
|
s.command(4, KYLE, &root, "!approve 7", 7);
|
||||||
|
s.command(5, KYLE, &root, "!approve 42 please", 8);
|
||||||
|
assert_eq!(
|
||||||
|
s.replies(1)[..5],
|
||||||
|
[
|
||||||
|
NOT_APPROVER.to_string(),
|
||||||
|
"approval 42 is answered in its own thread".to_string(),
|
||||||
|
"approval 42 was answered before it was shown in full".to_string(),
|
||||||
|
"approval 7 is not pending here".to_string(),
|
||||||
|
"use !approve <id> or !deny <id> <reason>".to_string(),
|
||||||
|
]
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
asking.answer(Duration::from_millis(300)),
|
||||||
|
None,
|
||||||
|
"nothing reached brokerd"
|
||||||
|
);
|
||||||
|
s.command(6, KYLE, &root, "!approve 42", 9);
|
||||||
|
assert!(
|
||||||
|
asking.answer(WAIT).is_some(),
|
||||||
|
"the approver, in the thread, after the post"
|
||||||
|
);
|
||||||
|
asking.outcome(GatewayOutcome::Runs {});
|
||||||
|
s.go.send(()).unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_notice_only_when_brokerd_never_brought_it() {
|
||||||
|
let expires = Timestamp::parse("2026-09-24T08:20:00.000Z").unwrap();
|
||||||
|
let pending_event = TurnEvent::ApprovalPending {
|
||||||
|
approval: 42,
|
||||||
|
tool: "read_file".to_string(),
|
||||||
|
expires,
|
||||||
|
};
|
||||||
|
let s = stage("ans-notice", vec![pending_event.clone()]);
|
||||||
|
s.go.send(()).unwrap();
|
||||||
|
let posts = s.mm.wait_posts(2, WAIT);
|
||||||
|
let texts: Vec<&str> = posts.iter().map(|(_, _, t)| t.as_str()).collect();
|
||||||
|
assert_eq!(
|
||||||
|
texts,
|
||||||
|
[
|
||||||
|
"waiting for approval 42: brokerd has not sent it here; answer it with bxctl approvals",
|
||||||
|
"done"
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
let s = stage("ans-no-notice", vec![pending_event]);
|
||||||
|
let asking = s.ask(42);
|
||||||
|
s.go.send(()).unwrap();
|
||||||
|
let posts = s.mm.wait_posts(2, WAIT);
|
||||||
|
assert!(posts[0].2.starts_with("**Approval 42**"), "{posts:?}");
|
||||||
|
assert_eq!(posts[1].2, "done");
|
||||||
|
std::thread::sleep(Duration::from_millis(500));
|
||||||
|
assert_eq!(s.mm.posts().len(), 2, "no notice: {:?}", s.mm.posts());
|
||||||
|
asking.close();
|
||||||
|
}
|
||||||
@@ -0,0 +1,199 @@
|
|||||||
|
//! The serve loop posts what `gateway.sock` brings, where it belongs, and marks it when it ends
|
||||||
|
//! or when a restart left it behind (M4b spec, sections 6 and 8). Do not edit.
|
||||||
|
|
||||||
|
#[path = "support/brokerd_client.rs"]
|
||||||
|
mod brokerd_client;
|
||||||
|
#[path = "support/fake_loop.rs"]
|
||||||
|
mod fake_loop;
|
||||||
|
#[path = "support/fake_mm.rs"]
|
||||||
|
mod fake_mm;
|
||||||
|
#[path = "support/gateway.rs"]
|
||||||
|
mod gateway;
|
||||||
|
#[path = "support/tmp.rs"]
|
||||||
|
mod tmp;
|
||||||
|
|
||||||
|
use std::path::PathBuf;
|
||||||
|
use std::sync::{Mutex, mpsc};
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
use brokerd_client::{ask, pending};
|
||||||
|
use fake_loop::{done, serve_loop};
|
||||||
|
use fake_mm::{DM, FakeMm, KYLE, id, post};
|
||||||
|
use gateway::{WAIT, config, loop_dir, read_state, start};
|
||||||
|
use tmp::TempDir;
|
||||||
|
|
||||||
|
fn socket(home: &TempDir) -> PathBuf {
|
||||||
|
home.path().join("run/gateway/gateway.sock")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A turn that waits until the returned sender says so.
|
||||||
|
fn held_loop(home: &TempDir) -> mpsc::Sender<()> {
|
||||||
|
loop_dir(home);
|
||||||
|
let (release_tx, release_rx) = mpsc::channel::<()>();
|
||||||
|
let release = Mutex::new(release_rx);
|
||||||
|
let _turns = serve_loop(&home.path().join("run/loop/loop.sock"), move |_, _| {
|
||||||
|
let _ = release
|
||||||
|
.lock()
|
||||||
|
.unwrap()
|
||||||
|
.recv_timeout(Duration::from_secs(20));
|
||||||
|
vec![done("done")]
|
||||||
|
});
|
||||||
|
release_tx
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn an_approval_goes_into_the_asking_thread_and_is_marked_when_it_ends() {
|
||||||
|
let home = TempDir::new("sa-thread");
|
||||||
|
let release = held_loop(&home);
|
||||||
|
let mm = FakeMm::start();
|
||||||
|
let _running = start(config(&home, &mm.url(), ""));
|
||||||
|
let mut ws = mm.next_ws(WAIT);
|
||||||
|
let p1 = id('p', 1);
|
||||||
|
ws.posted(&post(&p1, KYLE, DM, "", "read my notes", 5), "D");
|
||||||
|
let until = std::time::Instant::now() + WAIT;
|
||||||
|
let path = home.path().join("gateway/state.json");
|
||||||
|
let in_flight = || -> bool {
|
||||||
|
let Ok(text) = std::fs::read_to_string(&path) else {
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
let v: serde_json::Value = serde_json::from_str(&text).unwrap();
|
||||||
|
!v["in_flight"].as_array().unwrap().is_empty()
|
||||||
|
};
|
||||||
|
while !in_flight() {
|
||||||
|
assert!(std::time::Instant::now() < until, "the turn never started");
|
||||||
|
std::thread::sleep(Duration::from_millis(20));
|
||||||
|
}
|
||||||
|
let asking = ask(
|
||||||
|
&socket(&home),
|
||||||
|
pending(42, &format!("mm-{p1}"), r#"{"path":"/n/a"}"#),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
let posts = mm.posts();
|
||||||
|
assert_eq!(posts.len(), 1, "{posts:?}");
|
||||||
|
let (channel, root, text) = &posts[0];
|
||||||
|
assert_eq!(
|
||||||
|
(channel.as_str(), root.as_str()),
|
||||||
|
(DM, p1.as_str()),
|
||||||
|
"in the asking thread"
|
||||||
|
);
|
||||||
|
assert!(text.starts_with("**Approval 42** · tool `read_file` · grant `notes` · taint private · expires 2026-09-24 08:20 UTC\n```\n{\"path\":\"/n/a\"}\n```\n"), "{text}");
|
||||||
|
assert_eq!(asking.posts, [FakeMm::post_id(0)]);
|
||||||
|
assert_eq!(
|
||||||
|
read_state(&home)["approvals"],
|
||||||
|
serde_json::json!([{"approval": 42, "channel": DM, "posts": [FakeMm::post_id(0)]}])
|
||||||
|
);
|
||||||
|
asking.close();
|
||||||
|
let edits = mm.wait_edits(1, WAIT);
|
||||||
|
assert_eq!(edits.len(), 1, "{edits:?}");
|
||||||
|
assert_eq!(edits[0].0, FakeMm::post_id(0));
|
||||||
|
assert_eq!(
|
||||||
|
edits[0].1,
|
||||||
|
format!("{text}\n\nNo longer pending (answered elsewhere, expired, or its turn ended).")
|
||||||
|
);
|
||||||
|
let until = std::time::Instant::now() + WAIT;
|
||||||
|
while !read_state(&home)["approvals"]
|
||||||
|
.as_array()
|
||||||
|
.unwrap()
|
||||||
|
.is_empty()
|
||||||
|
{
|
||||||
|
assert!(std::time::Instant::now() < until, "still recorded");
|
||||||
|
std::thread::sleep(Duration::from_millis(20));
|
||||||
|
}
|
||||||
|
release.send(()).unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn without_a_turn_in_flight_it_goes_to_the_first_approvers_direct_messages_in_parts() {
|
||||||
|
let home = TempDir::new("sa-dm");
|
||||||
|
loop_dir(&home);
|
||||||
|
let mm = FakeMm::start();
|
||||||
|
let _running = start(config(&home, &mm.url(), ""));
|
||||||
|
let _ws = mm.next_ws(WAIT);
|
||||||
|
let long = format!("{{\"content\":\"{}\"}}", "x".repeat(20_000));
|
||||||
|
let asking = ask(&socket(&home), pending(7, "chat-1789700000-1", &long)).unwrap();
|
||||||
|
let posts = mm.posts();
|
||||||
|
assert_eq!(posts.len(), 2, "two parts");
|
||||||
|
assert_eq!(
|
||||||
|
(posts[0].0.as_str(), posts[0].1.as_str()),
|
||||||
|
(DM, ""),
|
||||||
|
"a new thread in the direct channel"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
(posts[1].0.as_str(), posts[1].1.clone()),
|
||||||
|
(DM, FakeMm::post_id(0)),
|
||||||
|
"the next part in its thread"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
posts[1]
|
||||||
|
.2
|
||||||
|
.contains("Reply in this thread with `!approve 7`")
|
||||||
|
);
|
||||||
|
assert_eq!(asking.posts, [FakeMm::post_id(0), FakeMm::post_id(1)]);
|
||||||
|
asking.close();
|
||||||
|
let edits = mm.wait_edits(1, WAIT);
|
||||||
|
assert_eq!(
|
||||||
|
edits[0].0,
|
||||||
|
FakeMm::post_id(1),
|
||||||
|
"only the last part is edited"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn an_approval_that_cannot_be_posted_is_refused_to_brokerd() {
|
||||||
|
let home = TempDir::new("sa-refused");
|
||||||
|
loop_dir(&home);
|
||||||
|
let mm = FakeMm::start();
|
||||||
|
let _running = start(config(&home, &mm.url(), ""));
|
||||||
|
let _ws = mm.next_ws(WAIT);
|
||||||
|
mm.refuse_posts(true);
|
||||||
|
let why = ask(&socket(&home), pending(8, "chat-1789700000-1", "{}"))
|
||||||
|
.err()
|
||||||
|
.expect("refused");
|
||||||
|
assert!(
|
||||||
|
why.starts_with("cannot post approval 8: status 400"),
|
||||||
|
"{why}"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
read_state(&home)["approvals"]
|
||||||
|
.as_array()
|
||||||
|
.unwrap()
|
||||||
|
.is_empty()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_restart_marks_the_posts_it_left_behind() {
|
||||||
|
let home = TempDir::new("sa-restart");
|
||||||
|
let (a, b) = (id('q', 1), id('q', 2));
|
||||||
|
let state = serde_json::json!({
|
||||||
|
"channels": {}, "recent": [], "threads": [], "in_flight": [],
|
||||||
|
"approvals": [{"approval": 5, "channel": DM, "posts": [a, b]}]
|
||||||
|
});
|
||||||
|
home.write("gateway/state.json", &state.to_string());
|
||||||
|
loop_dir(&home);
|
||||||
|
let mm = FakeMm::start();
|
||||||
|
let _running = start(config(&home, &mm.url(), ""));
|
||||||
|
let _ws = mm.next_ws(WAIT);
|
||||||
|
let edits = mm.wait_edits(1, WAIT);
|
||||||
|
assert_eq!(edits, [(b, "**Approval 5**\n\nNo longer answerable here: gatewayd restarted. If it is still pending it is posted again.".to_string())]);
|
||||||
|
assert!(
|
||||||
|
read_state(&home)["approvals"]
|
||||||
|
.as_array()
|
||||||
|
.unwrap()
|
||||||
|
.is_empty()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_socket_is_there_while_gatewayd_runs() {
|
||||||
|
let home = TempDir::new("sa-socket");
|
||||||
|
loop_dir(&home);
|
||||||
|
let mm = FakeMm::start();
|
||||||
|
let running = start(config(&home, &mm.url(), ""));
|
||||||
|
let _ws = mm.next_ws(WAIT);
|
||||||
|
use std::os::unix::fs::{FileTypeExt, PermissionsExt};
|
||||||
|
let meta = std::fs::metadata(socket(&home)).unwrap();
|
||||||
|
assert!(meta.file_type().is_socket());
|
||||||
|
assert_eq!(meta.permissions().mode() & 0o777, 0o600);
|
||||||
|
drop(running);
|
||||||
|
}
|
||||||
@@ -0,0 +1,314 @@
|
|||||||
|
//! Which posts become turns, in which session, and how messages wait for a running turn (M4a spec,
|
||||||
|
//! section 7, including its table of examples). Do not edit.
|
||||||
|
|
||||||
|
use gatewayd::mm::Post;
|
||||||
|
use gatewayd::sessions::{
|
||||||
|
ApprovalCommand, BUSY, Batch, Ignored, Message, Pushed, Queues, Route, Router, Thread,
|
||||||
|
UNKNOWN_COMMAND, named,
|
||||||
|
};
|
||||||
|
use proto::SessionId;
|
||||||
|
|
||||||
|
const BOT: &str = "b0000000000000000000000000";
|
||||||
|
const KYLE: &str = "k0000000000000000000000000";
|
||||||
|
const EVE: &str = "e0000000000000000000000000";
|
||||||
|
const DM: &str = "d0000000000000000000000000";
|
||||||
|
const SHARED: &str = "c0000000000000000000000000";
|
||||||
|
const OTHER: &str = "o0000000000000000000000000";
|
||||||
|
const ROOT: &str = "r0000000000000000000000000";
|
||||||
|
const POST: &str = "p0000000000000000000000000";
|
||||||
|
|
||||||
|
fn router() -> Router {
|
||||||
|
Router::new(
|
||||||
|
BOT,
|
||||||
|
"boxmaker-straylight",
|
||||||
|
&[KYLE.to_string()],
|
||||||
|
&[SHARED.to_string()],
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn post(channel: &str, root: &str, message: &str) -> Post {
|
||||||
|
Post {
|
||||||
|
id: POST.to_string(),
|
||||||
|
user_id: KYLE.to_string(),
|
||||||
|
channel_id: channel.to_string(),
|
||||||
|
root_id: root.to_string(),
|
||||||
|
message: message.to_string(),
|
||||||
|
create_at: 5,
|
||||||
|
delete_at: 0,
|
||||||
|
kind: String::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn known(root: &str) -> bool {
|
||||||
|
root == ROOT
|
||||||
|
}
|
||||||
|
|
||||||
|
fn unknown(_: &str) -> bool {
|
||||||
|
false
|
||||||
|
}
|
||||||
|
|
||||||
|
fn queued(route: Route) -> Message {
|
||||||
|
match route {
|
||||||
|
Route::Queue(m) => m,
|
||||||
|
other => panic!("not queued: {other:?}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_examples_in_the_spec() {
|
||||||
|
let r = router();
|
||||||
|
let cases = [
|
||||||
|
("", "@boxmaker-straylight summarise the audit log", true),
|
||||||
|
(ROOT, "and the older files?", true),
|
||||||
|
(ROOT, "@hermes what do you think?", false),
|
||||||
|
("", "@boxmaker-straylight @hermes compare notes", true),
|
||||||
|
("", "@boxmaker-straylightx hello", false),
|
||||||
|
("", "@channel standup in five", false),
|
||||||
|
];
|
||||||
|
for (root, message, yes) in cases {
|
||||||
|
let got = r.route(&post(SHARED, root, message), "O", &known);
|
||||||
|
assert_eq!(matches!(got, Route::Queue(_)), yes, "{message}: {got:?}");
|
||||||
|
if !yes {
|
||||||
|
assert_eq!(got, Route::Ignore(Ignored::NotForUs), "{message}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn naming() {
|
||||||
|
assert_eq!(named("@Boxmaker-Straylight, look"), ["boxmaker-straylight"]);
|
||||||
|
assert_eq!(named("ask @boxmaker-straylight."), ["boxmaker-straylight"]);
|
||||||
|
assert_eq!(named("@a.b_c-d... and @e"), ["a.b_c-d", "e"]);
|
||||||
|
assert_eq!(named("@ alone, @@x, trailing @"), ["x"]);
|
||||||
|
assert_eq!(named("ünïcødé @ʙob @bob"), ["bob"]);
|
||||||
|
assert!(named("no names here").is_empty());
|
||||||
|
let r = router();
|
||||||
|
for message in [
|
||||||
|
"hi @BOXMAKER-STRAYLIGHT",
|
||||||
|
"@boxmaker-straylight.",
|
||||||
|
"(@boxmaker-straylight)",
|
||||||
|
] {
|
||||||
|
assert!(
|
||||||
|
matches!(
|
||||||
|
r.route(&post(SHARED, "", message), "P", &unknown),
|
||||||
|
Route::Queue(_)
|
||||||
|
),
|
||||||
|
"{message}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn replies_in_our_thread_that_name_everyone_are_still_ours() {
|
||||||
|
let r = router();
|
||||||
|
for message in ["@here any news?", "@all done", "thanks @channel"] {
|
||||||
|
assert!(
|
||||||
|
matches!(
|
||||||
|
r.route(&post(SHARED, ROOT, message), "O", &known),
|
||||||
|
Route::Queue(_)
|
||||||
|
),
|
||||||
|
"{message}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
let got = r.route(
|
||||||
|
&post(
|
||||||
|
SHARED,
|
||||||
|
"q0000000000000000000000000",
|
||||||
|
"a reply in someone else's thread",
|
||||||
|
),
|
||||||
|
"O",
|
||||||
|
&known,
|
||||||
|
);
|
||||||
|
assert_eq!(got, Route::Ignore(Ignored::NotForUs));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn who_and_where() {
|
||||||
|
let r = router();
|
||||||
|
let mut own = post(DM, "", "hello");
|
||||||
|
own.user_id = BOT.to_string();
|
||||||
|
assert_eq!(r.route(&own, "D", &unknown), Route::Ignore(Ignored::Own));
|
||||||
|
let mut system = post(DM, "", "joined");
|
||||||
|
system.kind = "system_join_channel".to_string();
|
||||||
|
assert_eq!(
|
||||||
|
r.route(&system, "D", &unknown),
|
||||||
|
Route::Ignore(Ignored::System)
|
||||||
|
);
|
||||||
|
let mut stranger = post(DM, "", "@boxmaker-straylight hello");
|
||||||
|
stranger.user_id = EVE.to_string();
|
||||||
|
assert_eq!(
|
||||||
|
r.route(&stranger, "D", &unknown),
|
||||||
|
Route::Ignore(Ignored::NotAllowed)
|
||||||
|
);
|
||||||
|
let mut stranger_cmd = post(DM, "", "!approve 1");
|
||||||
|
stranger_cmd.user_id = EVE.to_string();
|
||||||
|
assert_eq!(
|
||||||
|
r.route(&stranger_cmd, "D", &unknown),
|
||||||
|
Route::Ignore(Ignored::NotAllowed)
|
||||||
|
);
|
||||||
|
let naming = "@boxmaker-straylight hello";
|
||||||
|
assert_eq!(
|
||||||
|
r.route(&post(OTHER, "", naming), "O", &unknown),
|
||||||
|
Route::Ignore(Ignored::NotForUs)
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
r.route(&post(SHARED, "", naming), "X", &unknown),
|
||||||
|
Route::Ignore(Ignored::NotForUs)
|
||||||
|
);
|
||||||
|
assert!(matches!(
|
||||||
|
r.route(&post(SHARED, "", naming), "G", &unknown),
|
||||||
|
Route::Queue(_)
|
||||||
|
));
|
||||||
|
assert!(matches!(
|
||||||
|
r.route(&post(DM, "", "no name needed"), "D", &unknown),
|
||||||
|
Route::Queue(_)
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn sessions_and_threads() {
|
||||||
|
let r = router();
|
||||||
|
let top = queued(r.route(&post(DM, "", "hello"), "D", &unknown));
|
||||||
|
assert_eq!(top.session.as_str(), format!("mm-{POST}"));
|
||||||
|
assert_eq!(
|
||||||
|
top.thread,
|
||||||
|
Thread {
|
||||||
|
channel: DM.to_string(),
|
||||||
|
root: POST.to_string()
|
||||||
|
}
|
||||||
|
);
|
||||||
|
assert!(!top.resume && !top.joins_thread);
|
||||||
|
assert_eq!(top.text, "hello");
|
||||||
|
let reply = queued(r.route(&post(DM, ROOT, "more"), "D", &unknown));
|
||||||
|
assert_eq!(reply.session.as_str(), format!("mm-{ROOT}"));
|
||||||
|
assert_eq!(reply.thread.root, ROOT);
|
||||||
|
assert!(reply.resume);
|
||||||
|
let channel = queued(r.route(&post(SHARED, "", "@boxmaker-straylight hi"), "O", &unknown));
|
||||||
|
assert!(channel.joins_thread && !channel.resume);
|
||||||
|
assert_eq!(channel.text, "hi", "the leading mention is stripped (M4b)");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn commands() {
|
||||||
|
let r = router();
|
||||||
|
let thread = Thread {
|
||||||
|
channel: DM.to_string(),
|
||||||
|
root: ROOT.to_string(),
|
||||||
|
};
|
||||||
|
for (message, command) in [
|
||||||
|
("!approve 42", ApprovalCommand::Approve(42)),
|
||||||
|
(
|
||||||
|
"!deny 42 not now",
|
||||||
|
ApprovalCommand::Deny(42, Some("not now".to_string())),
|
||||||
|
),
|
||||||
|
("!deny", ApprovalCommand::Malformed),
|
||||||
|
(
|
||||||
|
"@boxmaker-straylight !approve 7",
|
||||||
|
ApprovalCommand::Approve(7),
|
||||||
|
),
|
||||||
|
] {
|
||||||
|
assert_eq!(
|
||||||
|
r.route(&post(DM, ROOT, message), "D", &unknown),
|
||||||
|
Route::Approval {
|
||||||
|
thread: thread.clone(),
|
||||||
|
command,
|
||||||
|
user: KYLE.to_string(),
|
||||||
|
post: POST.to_string(),
|
||||||
|
at: 5,
|
||||||
|
},
|
||||||
|
"{message}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
for message in ["!approved", "!help", "!"] {
|
||||||
|
assert_eq!(
|
||||||
|
r.route(&post(DM, ROOT, message), "D", &unknown),
|
||||||
|
Route::Reply {
|
||||||
|
thread: thread.clone(),
|
||||||
|
text: UNKNOWN_COMMAND.to_string(),
|
||||||
|
},
|
||||||
|
"{message}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
assert_eq!(
|
||||||
|
queued(r.route(&post(DM, ROOT, "!!approve is a word"), "D", &unknown)).text,
|
||||||
|
"!approve is a word"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
queued(r.route(&post(DM, ROOT, "!!"), "D", &unknown)).text,
|
||||||
|
"!"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
queued(r.route(&post(DM, ROOT, " !help"), "D", &unknown)).text,
|
||||||
|
" !help"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn message(root: &str, resume: bool, text: &str) -> Message {
|
||||||
|
Message {
|
||||||
|
session: SessionId::new(&format!("mm-{root}")).unwrap(),
|
||||||
|
thread: Thread {
|
||||||
|
channel: DM.to_string(),
|
||||||
|
root: root.to_string(),
|
||||||
|
},
|
||||||
|
resume,
|
||||||
|
text: text.to_string(),
|
||||||
|
joins_thread: false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn messages_wait_for_the_running_turn_and_go_together() {
|
||||||
|
let mut q = Queues::new(3);
|
||||||
|
let first = message(ROOT, false, "one");
|
||||||
|
let Pushed::Start(batch) = q.push(first.clone()) else {
|
||||||
|
panic!("not started")
|
||||||
|
};
|
||||||
|
assert_eq!(
|
||||||
|
batch,
|
||||||
|
Batch {
|
||||||
|
session: first.session.clone(),
|
||||||
|
thread: first.thread.clone(),
|
||||||
|
resume: false,
|
||||||
|
text: "one".to_string()
|
||||||
|
}
|
||||||
|
);
|
||||||
|
assert_eq!(q.push(message(ROOT, true, "two")), Pushed::Waiting);
|
||||||
|
assert_eq!(q.push(message(ROOT, true, "three\nlines")), Pushed::Waiting);
|
||||||
|
let other = message(POST, false, "elsewhere");
|
||||||
|
assert!(
|
||||||
|
matches!(q.push(other.clone()), Pushed::Start(_)),
|
||||||
|
"another session starts at once"
|
||||||
|
);
|
||||||
|
assert_eq!(q.running(), 2);
|
||||||
|
let mut roots: Vec<String> = q.threads().into_iter().map(|t| t.root).collect();
|
||||||
|
roots.sort();
|
||||||
|
assert_eq!(roots, [POST, ROOT]);
|
||||||
|
let next = q.finish(&first.session).unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
(next.text.as_str(), next.resume),
|
||||||
|
("two\n\nthree\nlines", true)
|
||||||
|
);
|
||||||
|
assert_eq!(q.finish(&first.session), None);
|
||||||
|
assert_eq!(q.finish(&other.session), None);
|
||||||
|
assert_eq!(q.running(), 0);
|
||||||
|
assert!(
|
||||||
|
matches!(q.push(message(ROOT, true, "later")), Pushed::Start(_)),
|
||||||
|
"idle again"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_full_queue_drops_the_message() {
|
||||||
|
let mut q = Queues::new(2);
|
||||||
|
let m = message(ROOT, false, "run");
|
||||||
|
assert!(matches!(q.push(m.clone()), Pushed::Start(_)));
|
||||||
|
assert_eq!(q.push(message(ROOT, true, "a")), Pushed::Waiting);
|
||||||
|
assert_eq!(q.push(message(ROOT, true, "b")), Pushed::Waiting);
|
||||||
|
assert_eq!(
|
||||||
|
q.push(message(ROOT, true, "c")),
|
||||||
|
Pushed::Full(m.thread.clone())
|
||||||
|
);
|
||||||
|
assert_eq!(q.finish(&m.session).unwrap().text, "a\n\nb");
|
||||||
|
assert!(!BUSY.is_empty());
|
||||||
|
assert_eq!(q.finish(&SessionId::new("mm-never").unwrap()), None);
|
||||||
|
}
|
||||||
@@ -0,0 +1,142 @@
|
|||||||
|
//! A leading mention of this bot is stripped before anything reads a message, and approval
|
||||||
|
//! commands are recognised exactly (M4b spec, section 7). Do not edit.
|
||||||
|
|
||||||
|
use gatewayd::mm::Post;
|
||||||
|
use gatewayd::sessions::{
|
||||||
|
ApprovalCommand, MAX_REASON, Route, Router, approval_command, strip_mention,
|
||||||
|
};
|
||||||
|
|
||||||
|
const ME: &str = "boxmaker0";
|
||||||
|
const BOT: &str = "b0000000000000000000000000";
|
||||||
|
const KYLE: &str = "k0000000000000000000000000";
|
||||||
|
const SHARED: &str = "c0000000000000000000000000";
|
||||||
|
const ROOT: &str = "r0000000000000000000000000";
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_leading_mention_is_stripped() {
|
||||||
|
assert_eq!(
|
||||||
|
strip_mention("@boxmaker0 summarise the log", ME),
|
||||||
|
"summarise the log"
|
||||||
|
);
|
||||||
|
assert_eq!(strip_mention("@BoxMaker0 !approve 42", ME), "!approve 42");
|
||||||
|
assert_eq!(
|
||||||
|
strip_mention("@boxmaker0 @hermes compare notes", ME),
|
||||||
|
"@hermes compare notes"
|
||||||
|
);
|
||||||
|
assert_eq!(strip_mention("@boxmaker0, look", ME), ", look");
|
||||||
|
assert_eq!(
|
||||||
|
strip_mention("@boxmaker0\nsecond line", ME),
|
||||||
|
"\nsecond line",
|
||||||
|
"only spaces go with it"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn anything_else_is_kept() {
|
||||||
|
for message in [
|
||||||
|
"@boxmaker0",
|
||||||
|
"@boxmaker0 ",
|
||||||
|
"@boxmaker0x hello",
|
||||||
|
"@hermes @boxmaker0 hello",
|
||||||
|
"hello @boxmaker0",
|
||||||
|
" @boxmaker0 hello",
|
||||||
|
"@ boxmaker0 hello",
|
||||||
|
"@@boxmaker0 hello",
|
||||||
|
"",
|
||||||
|
] {
|
||||||
|
assert_eq!(strip_mention(message, ME), message, "{message:?}");
|
||||||
|
}
|
||||||
|
assert_eq!(
|
||||||
|
strip_mention("@boxmaker0. hi", ME),
|
||||||
|
". hi",
|
||||||
|
"the trailing dot is not part of the name"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn approval_commands_exactly() {
|
||||||
|
use ApprovalCommand::{Approve, Deny, Malformed};
|
||||||
|
let cases: &[(&str, Option<ApprovalCommand>)] = &[
|
||||||
|
("!approve 42", Some(Approve(42))),
|
||||||
|
("!approve 0", Some(Approve(0))),
|
||||||
|
("!approve 18446744073709551615", Some(Approve(u64::MAX))),
|
||||||
|
("!deny 42", Some(Deny(42, None))),
|
||||||
|
(
|
||||||
|
"!deny 42 not on a Friday",
|
||||||
|
Some(Deny(42, Some("not on a Friday".to_string()))),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"!deny 42 two spaces",
|
||||||
|
Some(Deny(42, Some(" two spaces".to_string()))),
|
||||||
|
),
|
||||||
|
("!approve", Some(Malformed)),
|
||||||
|
("!approve 42", Some(Malformed)),
|
||||||
|
("!approve 42 ", Some(Malformed)),
|
||||||
|
("!approve 42 now", Some(Malformed)),
|
||||||
|
("!approve +42", Some(Malformed)),
|
||||||
|
("!approve 4x2", Some(Malformed)),
|
||||||
|
("!approve 18446744073709551616", Some(Malformed)),
|
||||||
|
("!deny", Some(Malformed)),
|
||||||
|
("!deny 42 ", Some(Malformed)),
|
||||||
|
("!deny x reason", Some(Malformed)),
|
||||||
|
("!approved 42", None),
|
||||||
|
("!denied 42", None),
|
||||||
|
("!help", None),
|
||||||
|
("approve 42", None),
|
||||||
|
("!Approve 42", None),
|
||||||
|
];
|
||||||
|
for (text, want) in cases {
|
||||||
|
assert_eq!(&approval_command(text), want, "{text:?}");
|
||||||
|
}
|
||||||
|
let long = format!("!deny 42 {}", "é".repeat(MAX_REASON));
|
||||||
|
assert!(
|
||||||
|
matches!(approval_command(&long), Some(Deny(42, Some(_)))),
|
||||||
|
"characters, not bytes"
|
||||||
|
);
|
||||||
|
let over = format!("!deny 42 {}", "x".repeat(MAX_REASON + 1));
|
||||||
|
assert_eq!(approval_command(&over), Some(Malformed));
|
||||||
|
}
|
||||||
|
|
||||||
|
fn post(message: &str, root: &str) -> Post {
|
||||||
|
Post {
|
||||||
|
id: "p0000000000000000000000001".to_string(),
|
||||||
|
user_id: KYLE.to_string(),
|
||||||
|
channel_id: SHARED.to_string(),
|
||||||
|
root_id: root.to_string(),
|
||||||
|
message: message.to_string(),
|
||||||
|
create_at: 9,
|
||||||
|
delete_at: 0,
|
||||||
|
kind: String::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn in_a_shared_thread_the_mention_picks_the_bot() {
|
||||||
|
let me = Router::new(BOT, ME, &[KYLE.to_string()], &[SHARED.to_string()]);
|
||||||
|
let ours = |root: &str| root == ROOT;
|
||||||
|
let addressed = me.route(&post("@boxmaker0 !approve 42", ROOT), "O", &ours);
|
||||||
|
assert!(
|
||||||
|
matches!(
|
||||||
|
addressed,
|
||||||
|
Route::Approval {
|
||||||
|
command: ApprovalCommand::Approve(42),
|
||||||
|
..
|
||||||
|
}
|
||||||
|
),
|
||||||
|
"{addressed:?}"
|
||||||
|
);
|
||||||
|
let other = me.route(&post("@boxmaker1 !approve 42", ROOT), "O", &ours);
|
||||||
|
assert!(
|
||||||
|
matches!(other, Route::Ignore(_)),
|
||||||
|
"addressed to another bot: {other:?}"
|
||||||
|
);
|
||||||
|
let bare = me.route(&post("!approve 42", ROOT), "O", &ours);
|
||||||
|
assert!(
|
||||||
|
matches!(bare, Route::Approval { .. }),
|
||||||
|
"a bare command in our thread is ours too: {bare:?}"
|
||||||
|
);
|
||||||
|
match me.route(&post("@boxmaker0 hello there", ""), "O", &ours) {
|
||||||
|
Route::Queue(m) => assert_eq!(m.text, "hello there"),
|
||||||
|
other => panic!("{other:?}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
//! The approval posts in the state file, for marking them at a restart; an M4a state file still
|
||||||
|
//! loads; and the turn of a session (M4b spec, sections 6 and 8). Do not edit.
|
||||||
|
|
||||||
|
#[path = "support/tmp.rs"]
|
||||||
|
mod tmp;
|
||||||
|
|
||||||
|
use gatewayd::state::{ApprovalPosts, InFlight, State, StateError};
|
||||||
|
use tmp::TempDir;
|
||||||
|
|
||||||
|
const DM: &str = "d0000000000000000000000000";
|
||||||
|
const ROOT: &str = "r0000000000000000000000000";
|
||||||
|
|
||||||
|
fn id(n: u32) -> String {
|
||||||
|
format!("p{n:025}")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn shown(approval: u64, posts: &[u32]) -> ApprovalPosts {
|
||||||
|
ApprovalPosts {
|
||||||
|
approval,
|
||||||
|
channel: DM.to_string(),
|
||||||
|
posts: posts.iter().map(|n| id(*n)).collect(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn approval_posts_survive_a_restart_and_are_taken_once() {
|
||||||
|
let dir = TempDir::new("state-approvals");
|
||||||
|
let path = dir.path().join("gateway/state.json");
|
||||||
|
let mut s = State::load(&path).unwrap();
|
||||||
|
s.add_approval(shown(42, &[1, 2])).unwrap();
|
||||||
|
s.add_approval(shown(43, &[3])).unwrap();
|
||||||
|
s.add_approval(shown(42, &[4])).unwrap();
|
||||||
|
s.remove_approval(43).unwrap();
|
||||||
|
s.remove_approval(99).unwrap();
|
||||||
|
let mut again = State::load(&path).unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
again.take_approvals().unwrap(),
|
||||||
|
[shown(42, &[4])],
|
||||||
|
"the later posting of 42 replaced the first"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
State::load(&path)
|
||||||
|
.unwrap()
|
||||||
|
.take_approvals()
|
||||||
|
.unwrap()
|
||||||
|
.is_empty(),
|
||||||
|
"taking is saved"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn an_m4a_state_file_loads() {
|
||||||
|
let dir = TempDir::new("state-m4a");
|
||||||
|
let text = format!(r#"{{"channels":{{"{DM}":5}},"recent":[],"threads":[],"in_flight":[]}}"#);
|
||||||
|
let path = dir.write("state.json", &text);
|
||||||
|
let mut s = State::load(&path).unwrap();
|
||||||
|
assert_eq!(s.since(DM), Some(5));
|
||||||
|
assert!(s.take_approvals().unwrap().is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn bad_approval_posts_are_refused() {
|
||||||
|
let dir = TempDir::new("state-approvals-bad");
|
||||||
|
for (n, approvals) in [
|
||||||
|
r#"[{"approval":1,"channel":"../x","posts":["p0000000000000000000000001"]}]"#,
|
||||||
|
r#"[{"approval":1,"channel":"d0000000000000000000000000","posts":[]}]"#,
|
||||||
|
r#"[{"approval":1,"channel":"d0000000000000000000000000","posts":["short"]}]"#,
|
||||||
|
r#"[{"approval":1,"channel":"d0000000000000000000000000","posts":["p0000000000000000000000001"],"extra":1}]"#,
|
||||||
|
r#"[{"approval":-1,"channel":"d0000000000000000000000000","posts":["p0000000000000000000000001"]}]"#,
|
||||||
|
]
|
||||||
|
.iter()
|
||||||
|
.enumerate()
|
||||||
|
{
|
||||||
|
let text = format!(r#"{{"channels":{{}},"recent":[],"threads":[],"in_flight":[],"approvals":{approvals}}}"#);
|
||||||
|
let path = dir.write(&format!("s{n}.json"), &text);
|
||||||
|
assert!(matches!(State::load(&path), Err(StateError::Read(..))), "{approvals}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_turn_of_a_session() {
|
||||||
|
let dir = TempDir::new("state-turn-of");
|
||||||
|
let mut s = State::load(&dir.path().join("state.json")).unwrap();
|
||||||
|
let turn = InFlight {
|
||||||
|
session: format!("mm-{ROOT}"),
|
||||||
|
channel: DM.to_string(),
|
||||||
|
root: ROOT.to_string(),
|
||||||
|
};
|
||||||
|
assert_eq!(s.turn_of(&turn.session), None);
|
||||||
|
s.start_turn(turn.clone()).unwrap();
|
||||||
|
assert_eq!(s.turn_of(&turn.session), Some(turn.clone()));
|
||||||
|
assert_eq!(s.turn_of("mm-other"), None);
|
||||||
|
s.end_turn(&turn.session).unwrap();
|
||||||
|
assert_eq!(s.turn_of(&turn.session), None);
|
||||||
|
}
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
//! `brokerd`'s side of one `gateway.sock` connection, for tests: ask, read what comes back,
|
||||||
|
//! answer with an outcome, or close. Do not edit.
|
||||||
|
|
||||||
|
#![allow(dead_code)] // each test file uses a different part of this module
|
||||||
|
|
||||||
|
use std::os::unix::net::UnixStream;
|
||||||
|
use std::path::Path;
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
use proto::{
|
||||||
|
ApprovalOutcome, ApprovalReply, ApprovalRequest, CallId, DataClass, Envelope, GatewayOutcome,
|
||||||
|
Message, PROTOCOL_VERSION, PendingApproval, SessionId, Timestamp, read_frame, write_frame,
|
||||||
|
};
|
||||||
|
|
||||||
|
pub struct Asking {
|
||||||
|
stream: UnixStream,
|
||||||
|
pub posts: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// An approval of `session`, with these arguments.
|
||||||
|
pub fn pending(approval: u64, session: &str, arguments: &str) -> PendingApproval {
|
||||||
|
PendingApproval {
|
||||||
|
approval,
|
||||||
|
session: SessionId::new(session).unwrap(),
|
||||||
|
call: CallId(3),
|
||||||
|
tool: "read_file".to_string(),
|
||||||
|
arguments: arguments.to_string(),
|
||||||
|
grant: "notes".to_string(),
|
||||||
|
taint: DataClass::Private,
|
||||||
|
created: Timestamp::parse("2026-09-24T08:05:00.000Z").unwrap(),
|
||||||
|
expires: Timestamp::parse("2026-09-24T08:20:00.000Z").unwrap(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn frame(id: u64, last: bool, msg: Message) -> Envelope {
|
||||||
|
Envelope {
|
||||||
|
v: PROTOCOL_VERSION,
|
||||||
|
id,
|
||||||
|
r#final: last,
|
||||||
|
msg,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Connect and ask; `Ok` with the posts, or `Err` with gatewayd's error detail.
|
||||||
|
pub fn ask(socket: &Path, info: PendingApproval) -> Result<Asking, String> {
|
||||||
|
let mut stream = UnixStream::connect(socket).map_err(|e| e.to_string())?;
|
||||||
|
stream
|
||||||
|
.set_read_timeout(Some(Duration::from_secs(10)))
|
||||||
|
.unwrap();
|
||||||
|
write_frame(
|
||||||
|
&mut stream,
|
||||||
|
&frame(
|
||||||
|
1,
|
||||||
|
true,
|
||||||
|
Message::ApprovalRequest(ApprovalRequest { approval: info }),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
match read_frame(&mut stream).map_err(|e| e.to_string())?.msg {
|
||||||
|
Message::ApprovalPosted(p) => Ok(Asking {
|
||||||
|
stream,
|
||||||
|
posts: p.posts,
|
||||||
|
}),
|
||||||
|
Message::Error(e) => Err(e.detail),
|
||||||
|
other => panic!("{other:?}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Asking {
|
||||||
|
/// The answer, within `wait`, or `None`.
|
||||||
|
pub fn answer(&mut self, wait: Duration) -> Option<ApprovalReply> {
|
||||||
|
self.stream.set_read_timeout(Some(wait)).unwrap();
|
||||||
|
match read_frame(&mut self.stream) {
|
||||||
|
Ok(Envelope {
|
||||||
|
msg: Message::ApprovalReply(r),
|
||||||
|
..
|
||||||
|
}) => Some(r),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn outcome(&mut self, outcome: GatewayOutcome) {
|
||||||
|
write_frame(
|
||||||
|
&mut self.stream,
|
||||||
|
&frame(
|
||||||
|
2,
|
||||||
|
true,
|
||||||
|
Message::ApprovalOutcome(ApprovalOutcome { outcome }),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn close(self) {
|
||||||
|
let _ = self.stream.shutdown(std::net::Shutdown::Both);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,381 @@
|
|||||||
|
//! A fake Mattermost on 127.0.0.1, plain TCP: the four REST calls `gatewayd` makes, and the
|
||||||
|
//! WebSocket, whose events the test sends and whose requests it reads. Do not edit.
|
||||||
|
|
||||||
|
#![allow(dead_code)] // each test file uses a different part of this module
|
||||||
|
|
||||||
|
use std::collections::HashMap;
|
||||||
|
use std::io::{Read, Write};
|
||||||
|
use std::net::{Shutdown, SocketAddr, TcpListener, TcpStream};
|
||||||
|
use std::sync::{Arc, Mutex, mpsc};
|
||||||
|
use std::time::{Duration, Instant};
|
||||||
|
|
||||||
|
use gatewayd::ws::handshake::accept_for;
|
||||||
|
use serde_json::{Value, json};
|
||||||
|
|
||||||
|
pub const BOT: &str = "b0000000000000000000000000";
|
||||||
|
pub const BOT_NAME: &str = "boxmaker-straylight";
|
||||||
|
pub const KYLE: &str = "k0000000000000000000000000";
|
||||||
|
pub const EVE: &str = "e0000000000000000000000000";
|
||||||
|
/// The direct channel between the bot and Kyle, and between the bot and anyone else.
|
||||||
|
pub const DM: &str = "d0000000000000000000000000";
|
||||||
|
pub const EVE_DM: &str = "f0000000000000000000000000";
|
||||||
|
|
||||||
|
#[derive(Default)]
|
||||||
|
struct Inner {
|
||||||
|
/// Status for `users/me`: 200 unless a test sets another.
|
||||||
|
me_status: u16,
|
||||||
|
/// The body for `channels/<id>/posts?since=`, by channel.
|
||||||
|
since: HashMap<String, Value>,
|
||||||
|
/// Every post made: channel, root, message.
|
||||||
|
posts: Vec<(String, String, String)>,
|
||||||
|
/// Every REST call: method and path.
|
||||||
|
calls: Vec<(String, String)>,
|
||||||
|
/// Every edit: the post and its new text.
|
||||||
|
edits: Vec<(String, String)>,
|
||||||
|
/// When set, `POST /posts` is refused with 400.
|
||||||
|
refuse_posts: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct FakeMm {
|
||||||
|
pub addr: SocketAddr,
|
||||||
|
inner: Arc<Mutex<Inner>>,
|
||||||
|
sockets: Mutex<mpsc::Receiver<WsPeer>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One WebSocket connection from `gatewayd`.
|
||||||
|
pub struct WsPeer {
|
||||||
|
writer: TcpStream,
|
||||||
|
/// The text of every text frame `gatewayd` sends.
|
||||||
|
pub texts: mpsc::Receiver<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A post as Mattermost sends it.
|
||||||
|
pub fn post(
|
||||||
|
id: &str,
|
||||||
|
user: &str,
|
||||||
|
channel: &str,
|
||||||
|
root: &str,
|
||||||
|
message: &str,
|
||||||
|
create_at: i64,
|
||||||
|
) -> Value {
|
||||||
|
json!({
|
||||||
|
"id": id, "create_at": create_at, "update_at": create_at, "delete_at": 0, "user_id": user,
|
||||||
|
"channel_id": channel, "root_id": root, "message": message, "type": "", "props": {}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn id(prefix: char, n: u32) -> String {
|
||||||
|
format!("{prefix}{n:025}")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn frame(opcode: u8, payload: &[u8]) -> Vec<u8> {
|
||||||
|
let mut out = vec![0x80 | opcode];
|
||||||
|
match payload.len() {
|
||||||
|
n if n < 126 => out.push(n as u8),
|
||||||
|
n => {
|
||||||
|
out.push(126);
|
||||||
|
out.extend_from_slice(&(n as u16).to_be_bytes());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out.extend_from_slice(payload);
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
impl WsPeer {
|
||||||
|
pub fn event(&mut self, value: &Value) {
|
||||||
|
let _ = self
|
||||||
|
.writer
|
||||||
|
.write_all(&frame(0x1, value.to_string().as_bytes()));
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn posted(&mut self, post: &Value, channel_type: &str) {
|
||||||
|
let data = json!({"post": post.to_string(), "channel_type": channel_type, "team_id": ""});
|
||||||
|
self.event(&json!({"event": "posted", "data": data, "broadcast": {}, "seq": 1}));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// End the connection without a close frame.
|
||||||
|
pub fn drop_connection(self) {
|
||||||
|
let _ = self.writer.shutdown(Shutdown::Both);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The `user_typing` requests received within `wait`, as (channel, parent).
|
||||||
|
pub fn typing_within(&self, wait: Duration) -> Vec<(String, String)> {
|
||||||
|
let until = Instant::now() + wait;
|
||||||
|
let mut got = Vec::new();
|
||||||
|
while let Ok(text) = self
|
||||||
|
.texts
|
||||||
|
.recv_timeout(until.saturating_duration_since(Instant::now()))
|
||||||
|
{
|
||||||
|
let v: Value = serde_json::from_str(&text).unwrap();
|
||||||
|
if v["action"] == "user_typing" {
|
||||||
|
let data = &v["data"];
|
||||||
|
got.push((
|
||||||
|
data["channel_id"].as_str().unwrap().to_string(),
|
||||||
|
data["parent_id"].as_str().unwrap().to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
got
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn read_head(stream: &mut TcpStream) -> Option<String> {
|
||||||
|
let mut head = Vec::new();
|
||||||
|
let mut byte = [0u8; 1];
|
||||||
|
while !head.ends_with(b"\r\n\r\n") {
|
||||||
|
if stream.read(&mut byte).ok()? == 0 {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
head.push(byte[0]);
|
||||||
|
}
|
||||||
|
String::from_utf8(head).ok()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Unmask the client's frames and send each text on `tx`, until the connection ends.
|
||||||
|
fn read_frames(mut stream: TcpStream, tx: mpsc::Sender<String>) {
|
||||||
|
let mut exact = |n: usize| -> Option<Vec<u8>> {
|
||||||
|
let mut buf = vec![0u8; n];
|
||||||
|
stream.read_exact(&mut buf).ok().map(|()| buf)
|
||||||
|
};
|
||||||
|
loop {
|
||||||
|
let Some(head) = exact(2) else { return };
|
||||||
|
let len = match head[1] & 0x7F {
|
||||||
|
126 => u16::from_be_bytes(exact(2).unwrap().try_into().unwrap()) as usize,
|
||||||
|
127 => return,
|
||||||
|
n => n as usize,
|
||||||
|
};
|
||||||
|
let Some(mask) = exact(4) else { return };
|
||||||
|
let Some(raw) = exact(len) else { return };
|
||||||
|
let payload: Vec<u8> = raw
|
||||||
|
.iter()
|
||||||
|
.zip(mask.iter().cycle())
|
||||||
|
.map(|(b, m)| b ^ m)
|
||||||
|
.collect();
|
||||||
|
if head[0] & 0x0F == 0x1 {
|
||||||
|
let _ = tx.send(String::from_utf8(payload).unwrap());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl FakeMm {
|
||||||
|
pub fn start() -> FakeMm {
|
||||||
|
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
|
||||||
|
let addr = listener.local_addr().unwrap();
|
||||||
|
let inner = Arc::new(Mutex::new(Inner {
|
||||||
|
me_status: 200,
|
||||||
|
..Inner::default()
|
||||||
|
}));
|
||||||
|
let (ws_tx, ws_rx) = mpsc::channel();
|
||||||
|
let shared = Arc::clone(&inner);
|
||||||
|
std::thread::spawn(move || {
|
||||||
|
for stream in listener.incoming() {
|
||||||
|
let Ok(stream) = stream else { continue };
|
||||||
|
let (inner, ws_tx) = (Arc::clone(&shared), ws_tx.clone());
|
||||||
|
std::thread::spawn(move || connection(stream, &inner, &ws_tx));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
FakeMm {
|
||||||
|
addr,
|
||||||
|
inner,
|
||||||
|
sockets: Mutex::new(ws_rx),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn url(&self) -> String {
|
||||||
|
format!("http://127.0.0.1:{}", self.addr.port())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn refuse_token(&self) {
|
||||||
|
self.inner.lock().unwrap().me_status = 401;
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn set_since(&self, channel: &str, posts: &[Value]) {
|
||||||
|
let order: Vec<Value> = posts.iter().map(|p| p["id"].clone()).collect();
|
||||||
|
let map: serde_json::Map<String, Value> = posts
|
||||||
|
.iter()
|
||||||
|
.map(|p| (p["id"].as_str().unwrap().to_string(), p.clone()))
|
||||||
|
.collect();
|
||||||
|
self.inner
|
||||||
|
.lock()
|
||||||
|
.unwrap()
|
||||||
|
.since
|
||||||
|
.insert(channel.to_string(), json!({"order": order, "posts": map}));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The next WebSocket `gatewayd` opens, after its hello.
|
||||||
|
pub fn next_ws(&self, wait: Duration) -> WsPeer {
|
||||||
|
self.sockets
|
||||||
|
.lock()
|
||||||
|
.unwrap()
|
||||||
|
.recv_timeout(wait)
|
||||||
|
.expect("no WebSocket connection")
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn posts(&self) -> Vec<(String, String, String)> {
|
||||||
|
self.inner.lock().unwrap().posts.clone()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Wait until at least `n` posts were made, for at most `wait`.
|
||||||
|
pub fn wait_posts(&self, n: usize, wait: Duration) -> Vec<(String, String, String)> {
|
||||||
|
let until = Instant::now() + wait;
|
||||||
|
while self.posts().len() < n && Instant::now() < until {
|
||||||
|
std::thread::sleep(Duration::from_millis(10));
|
||||||
|
}
|
||||||
|
self.posts()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn calls(&self) -> Vec<(String, String)> {
|
||||||
|
self.inner.lock().unwrap().calls.clone()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The id of the n-th post made (from 0).
|
||||||
|
pub fn post_id(n: usize) -> String {
|
||||||
|
id('x', u32::try_from(n).unwrap())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn edits(&self) -> Vec<(String, String)> {
|
||||||
|
self.inner.lock().unwrap().edits.clone()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Wait until at least `n` edits were made, for at most `wait`.
|
||||||
|
pub fn wait_edits(&self, n: usize, wait: Duration) -> Vec<(String, String)> {
|
||||||
|
let until = Instant::now() + wait;
|
||||||
|
while self.edits().len() < n && Instant::now() < until {
|
||||||
|
std::thread::sleep(Duration::from_millis(10));
|
||||||
|
}
|
||||||
|
self.edits()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn refuse_posts(&self, refuse: bool) {
|
||||||
|
self.inner.lock().unwrap().refuse_posts = refuse;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn connection(mut stream: TcpStream, inner: &Mutex<Inner>, ws_tx: &mpsc::Sender<WsPeer>) {
|
||||||
|
let Some(head) = read_head(&mut stream) else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let mut words = head.split_whitespace();
|
||||||
|
let (method, path) = (
|
||||||
|
words.next().unwrap_or("").to_string(),
|
||||||
|
words.next().unwrap_or("").to_string(),
|
||||||
|
);
|
||||||
|
if path == "/api/v4/websocket" {
|
||||||
|
let key = head
|
||||||
|
.lines()
|
||||||
|
.find_map(|l| l.strip_prefix("Sec-WebSocket-Key: "))
|
||||||
|
.unwrap_or("")
|
||||||
|
.trim()
|
||||||
|
.to_string();
|
||||||
|
let reply = format!(
|
||||||
|
"HTTP/1.1 101 Switching Protocols\r\nUpgrade: websocket\r\nConnection: Upgrade\r\nSec-WebSocket-Accept: {}\r\n\r\n",
|
||||||
|
accept_for(&key)
|
||||||
|
);
|
||||||
|
let _ = stream.write_all(reply.as_bytes());
|
||||||
|
let _ = stream.write_all(&frame(
|
||||||
|
0x1,
|
||||||
|
br#"{"event":"hello","data":{},"broadcast":{},"seq":0}"#,
|
||||||
|
));
|
||||||
|
let (tx, texts) = mpsc::channel();
|
||||||
|
let reader = stream.try_clone().unwrap();
|
||||||
|
std::thread::spawn(move || read_frames(reader, tx));
|
||||||
|
let _ = ws_tx.send(WsPeer {
|
||||||
|
writer: stream,
|
||||||
|
texts,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let length = head
|
||||||
|
.lines()
|
||||||
|
.find_map(|l| {
|
||||||
|
l.to_ascii_lowercase()
|
||||||
|
.strip_prefix("content-length:")
|
||||||
|
.map(|v| v.trim().parse::<usize>().unwrap_or(0))
|
||||||
|
})
|
||||||
|
.unwrap_or(0);
|
||||||
|
let mut body = vec![0u8; length];
|
||||||
|
let _ = stream.read_exact(&mut body);
|
||||||
|
let (status, answer) = rest(inner, &method, &path, &body);
|
||||||
|
let text = answer.to_string();
|
||||||
|
let reply = format!(
|
||||||
|
"HTTP/1.1 {status} X\r\nContent-Type: application/json\r\nContent-Length: {}\r\n\r\n{text}",
|
||||||
|
text.len()
|
||||||
|
);
|
||||||
|
let _ = stream.write_all(reply.as_bytes());
|
||||||
|
}
|
||||||
|
|
||||||
|
fn rest(inner: &Mutex<Inner>, method: &str, path: &str, body: &[u8]) -> (u16, Value) {
|
||||||
|
let mut inner = inner.lock().unwrap();
|
||||||
|
inner.calls.push((method.to_string(), path.to_string()));
|
||||||
|
match (method, path) {
|
||||||
|
("GET", "/api/v4/users/me") if inner.me_status == 200 => {
|
||||||
|
(200, json!({"id": BOT, "username": BOT_NAME}))
|
||||||
|
}
|
||||||
|
("GET", "/api/v4/users/me") => (
|
||||||
|
inner.me_status,
|
||||||
|
json!({"id": "api.context.session_expired.app_error"}),
|
||||||
|
),
|
||||||
|
("POST", "/api/v4/channels/direct") => {
|
||||||
|
let users: Vec<String> = serde_json::from_slice(body).unwrap();
|
||||||
|
let channel = if users.iter().any(|u| u == KYLE) {
|
||||||
|
DM
|
||||||
|
} else {
|
||||||
|
EVE_DM
|
||||||
|
};
|
||||||
|
(201, json!({"id": channel, "type": "D"}))
|
||||||
|
}
|
||||||
|
("POST", "/api/v4/posts") if inner.refuse_posts => {
|
||||||
|
(400, json!({"id": "api.post.create_post.refused"}))
|
||||||
|
}
|
||||||
|
("POST", "/api/v4/posts") => {
|
||||||
|
let p: Value = serde_json::from_slice(body).unwrap();
|
||||||
|
let n = u32::try_from(inner.posts.len()).unwrap();
|
||||||
|
let (channel, root, message) = (
|
||||||
|
p["channel_id"].as_str().unwrap(),
|
||||||
|
p["root_id"].as_str().unwrap(),
|
||||||
|
p["message"].as_str().unwrap(),
|
||||||
|
);
|
||||||
|
inner
|
||||||
|
.posts
|
||||||
|
.push((channel.to_string(), root.to_string(), message.to_string()));
|
||||||
|
(201, post(&id('x', n), BOT, channel, root, message, 1))
|
||||||
|
}
|
||||||
|
("GET", p) if p.contains("/posts?since=") => {
|
||||||
|
let channel = p
|
||||||
|
.trim_start_matches("/api/v4/channels/")
|
||||||
|
.split('/')
|
||||||
|
.next()
|
||||||
|
.unwrap_or("");
|
||||||
|
(
|
||||||
|
200,
|
||||||
|
inner
|
||||||
|
.since
|
||||||
|
.get(channel)
|
||||||
|
.cloned()
|
||||||
|
.unwrap_or(json!({"order": [], "posts": {}})),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
("PUT", p) if p.starts_with("/api/v4/posts/") && p.ends_with("/patch") => {
|
||||||
|
let post_id = p
|
||||||
|
.trim_start_matches("/api/v4/posts/")
|
||||||
|
.trim_end_matches("/patch")
|
||||||
|
.to_string();
|
||||||
|
let message = serde_json::from_slice::<Value>(body).unwrap()["message"]
|
||||||
|
.as_str()
|
||||||
|
.unwrap()
|
||||||
|
.to_string();
|
||||||
|
inner.edits.push((post_id.clone(), message.clone()));
|
||||||
|
(200, post(&post_id, BOT, DM, "", &message, 1))
|
||||||
|
}
|
||||||
|
("GET", p) if p.starts_with("/api/v4/users/") => {
|
||||||
|
let user = p.trim_start_matches("/api/v4/users/");
|
||||||
|
let name = if user == KYLE {
|
||||||
|
"kyle".to_string()
|
||||||
|
} else {
|
||||||
|
format!("user-{}", &user[..4])
|
||||||
|
};
|
||||||
|
(200, json!({"id": user, "username": name}))
|
||||||
|
}
|
||||||
|
_ => (404, json!({"message": "not found"})),
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,142 @@
|
|||||||
|
//! Running `gatewayd`'s serve loop in a test, against the fake Mattermost and the fake `loopd`.
|
||||||
|
//! Do not edit.
|
||||||
|
|
||||||
|
#![allow(dead_code)] // each test file uses a different part of this module
|
||||||
|
|
||||||
|
use std::sync::atomic::{AtomicBool, Ordering};
|
||||||
|
use std::sync::{Arc, Mutex, mpsc};
|
||||||
|
use std::thread::JoinHandle;
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
use gatewayd::config::Config;
|
||||||
|
use gatewayd::secrets::Secret;
|
||||||
|
use gatewayd::serve::{Stop, Tuning, run};
|
||||||
|
use proto::Turn;
|
||||||
|
|
||||||
|
use crate::fake_loop::{done, serve_loop};
|
||||||
|
use crate::fake_mm::{FakeMm, KYLE, WsPeer};
|
||||||
|
use crate::tmp::TempDir;
|
||||||
|
|
||||||
|
pub const SHARED: &str = "c0000000000000000000000000";
|
||||||
|
pub const OTHER: &str = "o0000000000000000000000000";
|
||||||
|
pub const WAIT: Duration = Duration::from_secs(5);
|
||||||
|
|
||||||
|
pub struct Running {
|
||||||
|
pub stop: Arc<AtomicBool>,
|
||||||
|
pub log: Arc<Mutex<Vec<String>>>,
|
||||||
|
pub handle: Option<JoinHandle<Stop>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Running {
|
||||||
|
pub fn log(&self) -> Vec<String> {
|
||||||
|
self.log.lock().unwrap().clone()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn wait_log(&self, part: &str) -> Vec<String> {
|
||||||
|
let until = std::time::Instant::now() + WAIT;
|
||||||
|
while !self.log().iter().any(|l| l.contains(part)) {
|
||||||
|
assert!(
|
||||||
|
std::time::Instant::now() < until,
|
||||||
|
"no log line with {part:?}: {:?}",
|
||||||
|
self.log()
|
||||||
|
);
|
||||||
|
std::thread::sleep(Duration::from_millis(10));
|
||||||
|
}
|
||||||
|
self.log()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The `Stop` `run` returns by itself within 5 s; after that it is stopped, and the test fails.
|
||||||
|
pub fn join_within(mut self) -> Stop {
|
||||||
|
let handle = self.handle.take().unwrap();
|
||||||
|
let until = std::time::Instant::now() + WAIT;
|
||||||
|
while !handle.is_finished() && std::time::Instant::now() < until {
|
||||||
|
std::thread::sleep(Duration::from_millis(10));
|
||||||
|
}
|
||||||
|
self.stop.store(true, Ordering::SeqCst);
|
||||||
|
let stop = handle.join().unwrap();
|
||||||
|
assert!(!matches!(stop, Stop::Asked), "run did not stop by itself");
|
||||||
|
stop
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn finish(mut self) -> Stop {
|
||||||
|
self.stop.store(true, Ordering::SeqCst);
|
||||||
|
self.handle.take().unwrap().join().unwrap()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Drop for Running {
|
||||||
|
fn drop(&mut self) {
|
||||||
|
self.stop.store(true, Ordering::SeqCst);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn config(home: &TempDir, url: &str, extra: &str) -> Config {
|
||||||
|
let text = format!(
|
||||||
|
r#"
|
||||||
|
[mattermost]
|
||||||
|
url = "{url}"
|
||||||
|
[secrets.mattermost_token]
|
||||||
|
env = "NOT_READ_BY_RUN"
|
||||||
|
[allow]
|
||||||
|
users = ["{KYLE}"]
|
||||||
|
channels = ["{SHARED}"]
|
||||||
|
[paths]
|
||||||
|
home = "{}"
|
||||||
|
[limits]
|
||||||
|
typing_every_ms = 100
|
||||||
|
{extra}
|
||||||
|
"#,
|
||||||
|
home.path().display()
|
||||||
|
);
|
||||||
|
Config::parse(&text).unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn start(config: Config) -> Running {
|
||||||
|
let stop = Arc::new(AtomicBool::new(false));
|
||||||
|
let log = Arc::new(Mutex::new(Vec::new()));
|
||||||
|
let tuning = Tuning {
|
||||||
|
backoff: vec![Duration::from_millis(50)],
|
||||||
|
poll: Duration::from_millis(20),
|
||||||
|
rest_timeout: WAIT,
|
||||||
|
notice: Duration::from_millis(300),
|
||||||
|
};
|
||||||
|
let (s, l) = (Arc::clone(&stop), Arc::clone(&log));
|
||||||
|
let handle = std::thread::spawn(move || {
|
||||||
|
let sink: gatewayd::serve::Log =
|
||||||
|
Arc::new(move |line: &str| l.lock().unwrap().push(line.to_string()));
|
||||||
|
run(config, Secret::new("TOKEN".to_string()), tuning, sink, &s)
|
||||||
|
});
|
||||||
|
Running {
|
||||||
|
stop,
|
||||||
|
log,
|
||||||
|
handle: Some(handle),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A fake loop that answers every turn with "answer to <content>", after `delay`.
|
||||||
|
pub fn answering(home: &TempDir, delay: Duration) -> mpsc::Receiver<Turn> {
|
||||||
|
serve_loop(&home.path().join("run/loop/loop.sock"), move |_, turn| {
|
||||||
|
std::thread::sleep(delay);
|
||||||
|
vec![done(&format!("answer to {}", turn.content))]
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn read_state(home: &TempDir) -> serde_json::Value {
|
||||||
|
let text = std::fs::read_to_string(home.path().join("gateway/state.json")).unwrap();
|
||||||
|
serde_json::from_str(&text).unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn loop_dir(home: &TempDir) {
|
||||||
|
std::fs::create_dir_all(home.path().join("run/loop")).unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Start with a fake Mattermost and a fake loop; the first WebSocket is returned.
|
||||||
|
pub fn up(home: &TempDir, delay: Duration) -> (FakeMm, Running, mpsc::Receiver<Turn>, WsPeer) {
|
||||||
|
loop_dir(home);
|
||||||
|
let turns = answering(home, delay);
|
||||||
|
let mm = FakeMm::start();
|
||||||
|
let running = start(config(home, &mm.url(), ""));
|
||||||
|
let ws = mm.next_ws(WAIT);
|
||||||
|
running.wait_log("gatewayd: connected to ");
|
||||||
|
(mm, running, turns, ws)
|
||||||
|
}
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
//! Text from outside (model output, tool arguments, server errors) made safe to show: control
|
||||||
|
//! characters, zero-width and bidirectional marks, line separators and the BOM become `\uXXXX`.
|
||||||
|
//! Used by `bxctl` for the terminal and by `gatewayd` for Mattermost, so both show the same.
|
||||||
|
|
||||||
|
// The control bytes, the zero-width and Unicode line/paragraph separators, and the BOM. A code
|
||||||
|
// point in any of these ranges must never reach the terminal raw.
|
||||||
|
const ESCAPE_RANGES: &[(u32, u32)] = &[
|
||||||
|
(0x0000, 0x001f),
|
||||||
|
(0x007f, 0x009f),
|
||||||
|
(0x200b, 0x200f),
|
||||||
|
(0x2028, 0x202e),
|
||||||
|
(0x2060, 0x2069),
|
||||||
|
(0xfeff, 0xfeff),
|
||||||
|
];
|
||||||
|
|
||||||
|
pub fn escape_json_text(s: &str) -> String {
|
||||||
|
escape(s, false)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn escape_model_text(s: &str) -> String {
|
||||||
|
escape(s, true)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn escape(s: &str, model_text: bool) -> String {
|
||||||
|
let mut out = String::with_capacity(s.len());
|
||||||
|
for c in s.chars() {
|
||||||
|
let cp = u32::from(c);
|
||||||
|
if should_escape(c, model_text) {
|
||||||
|
out.push_str(&format!("\\u{:04x}", cp));
|
||||||
|
} else {
|
||||||
|
out.push(c);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
// A control point is escaped, except that model text keeps \n and \t.
|
||||||
|
fn should_escape(c: char, model_text: bool) -> bool {
|
||||||
|
let cp = u32::from(c);
|
||||||
|
if ESCAPE_RANGES.iter().any(|(lo, hi)| cp >= *lo && cp <= *hi) {
|
||||||
|
!(model_text && (cp == 0x09 || cp == 0x0a))
|
||||||
|
} else {
|
||||||
|
false
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
//! The messages on `gateway.sock`, between `brokerd` and `gatewayd`: one connection carries one
|
||||||
|
//! pending approval to Mattermost and its answer back (M4b spec, section 4).
|
||||||
|
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
use crate::wire::DenyReason;
|
||||||
|
use crate::wire::PendingApproval;
|
||||||
|
|
||||||
|
/// The most characters a refusal's reason may have.
|
||||||
|
pub const MAX_REASON: usize = 500;
|
||||||
|
|
||||||
|
/// `brokerd` → `gatewayd`, first: the approval to post.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
#[serde(deny_unknown_fields)]
|
||||||
|
pub struct ApprovalRequest {
|
||||||
|
pub approval: PendingApproval,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `gatewayd` → `brokerd`: it is posted, in these posts, first part first.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
#[serde(deny_unknown_fields)]
|
||||||
|
pub struct ApprovalPosted {
|
||||||
|
pub posts: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `gatewayd` → `brokerd`: an approver answered, with this post.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
#[serde(deny_unknown_fields)]
|
||||||
|
pub struct ApprovalReply {
|
||||||
|
pub approval: u64,
|
||||||
|
pub approved: bool,
|
||||||
|
pub by: String,
|
||||||
|
pub post: String,
|
||||||
|
pub reason: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `brokerd` → `gatewayd`, last: what came of the answer.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
#[serde(deny_unknown_fields)]
|
||||||
|
pub struct ApprovalOutcome {
|
||||||
|
pub outcome: GatewayOutcome,
|
||||||
|
}
|
||||||
|
|
||||||
|
// JSON: {"outcome":"denied","reason":"no_grant"} ; the tag sits beside the fields
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
#[serde(tag = "outcome", rename_all = "snake_case", deny_unknown_fields)]
|
||||||
|
pub enum GatewayOutcome {
|
||||||
|
/// Approved, and the grants as they are now still allow it.
|
||||||
|
Runs {},
|
||||||
|
/// Approved, but denied on the re-check, or the answer could not be recorded.
|
||||||
|
Denied { reason: DenyReason },
|
||||||
|
/// The refusal is recorded.
|
||||||
|
Refused {},
|
||||||
|
/// It was no longer pending when the answer came.
|
||||||
|
Gone {},
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A Mattermost id: 26 characters of `a-z0-9`.
|
||||||
|
pub fn is_mattermost_id(id: &str) -> bool {
|
||||||
|
// `id.len() == 26` and every byte `b.is_ascii_lowercase() || b.is_ascii_digit()`.
|
||||||
|
todo!()
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ApprovalReply {
|
||||||
|
/// `by` and `post` are Mattermost ids and the reason is at most `MAX_REASON` characters; the
|
||||||
|
/// first problem otherwise.
|
||||||
|
pub fn check(&self) -> Result<(), String> {
|
||||||
|
// In this order, each an Err with exactly this text:
|
||||||
|
// 1. `!is_mattermost_id(&self.by)`: "the answer's `by` is not a Mattermost id".
|
||||||
|
// 2. `!is_mattermost_id(&self.post)`: "the answer's `post` is not a Mattermost id".
|
||||||
|
// 3. a reason with more than `MAX_REASON` characters (`r.chars().count()`, not bytes):
|
||||||
|
// `format!("the reason is over {MAX_REASON} characters")`.
|
||||||
|
// 4. Ok(()).
|
||||||
|
todo!()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,150 @@
|
|||||||
|
//! Tests for `proto::escape`: text the model wrote is printed as data. Do not edit.
|
||||||
|
//!
|
||||||
|
//! The expected escapes are built by `esc`, never spelled out, so that nothing that handles this
|
||||||
|
//! file can turn one into the character it stands for.
|
||||||
|
|
||||||
|
use proto::escape::{escape_json_text, escape_model_text};
|
||||||
|
|
||||||
|
const BACKSLASH: char = '\\';
|
||||||
|
|
||||||
|
/// The escape for one code point: a backslash, `u`, and four lowercase hex digits.
|
||||||
|
fn esc(code: u32) -> String {
|
||||||
|
format!("{BACKSLASH}u{code:04x}")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn ch(code: u32) -> char {
|
||||||
|
char::from_u32(code).unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Every code point that must be escaped, as inclusive ranges.
|
||||||
|
const HIDDEN: [(u32, u32); 6] = [
|
||||||
|
(0x0000, 0x001f),
|
||||||
|
(0x007f, 0x009f),
|
||||||
|
(0x200b, 0x200f),
|
||||||
|
(0x2028, 0x202e),
|
||||||
|
(0x2060, 0x2069),
|
||||||
|
(0xfeff, 0xfeff),
|
||||||
|
];
|
||||||
|
|
||||||
|
fn hidden(code: u32) -> bool {
|
||||||
|
HIDDEN.iter().any(|(lo, hi)| (*lo..=*hi).contains(&code))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Walks every code point below U+11000, not a sample: each is either escaped exactly or left
|
||||||
|
/// exactly as it is.
|
||||||
|
#[test]
|
||||||
|
fn every_listed_code_point_is_escaped_and_no_other() {
|
||||||
|
let mut escaped = 0;
|
||||||
|
for code in 0..0x11000u32 {
|
||||||
|
let Some(c) = char::from_u32(code) else {
|
||||||
|
continue; // the surrogates are not characters
|
||||||
|
};
|
||||||
|
let text = format!("a{c}b");
|
||||||
|
let got = escape_json_text(&text);
|
||||||
|
if hidden(code) {
|
||||||
|
escaped += 1;
|
||||||
|
assert_eq!(got, format!("a{}b", esc(code)), "U+{code:04X}");
|
||||||
|
} else {
|
||||||
|
assert_eq!(got, text, "U+{code:04X} must pass through");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
assert_eq!(escaped, 32 + 33 + 5 + 7 + 10 + 1, "the six ranges, in full");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_edges_of_each_range() {
|
||||||
|
for (lo, hi) in HIDDEN {
|
||||||
|
assert_eq!(escape_json_text(&ch(lo).to_string()), esc(lo));
|
||||||
|
assert_eq!(escape_json_text(&ch(hi).to_string()), esc(hi));
|
||||||
|
if lo > 0 {
|
||||||
|
let before = ch(lo - 1).to_string();
|
||||||
|
assert_eq!(escape_json_text(&before), before, "U+{:04X}", lo - 1);
|
||||||
|
}
|
||||||
|
let after = ch(hi + 1).to_string();
|
||||||
|
assert_eq!(escape_json_text(&after), after, "U+{:04X}", hi + 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn hex_digits_are_lowercase_and_there_are_always_four() {
|
||||||
|
assert_eq!(escape_json_text("\x1b"), esc(0x1b));
|
||||||
|
assert!(escape_json_text("\x1b").ends_with("001b"));
|
||||||
|
assert!(escape_json_text("\0").ends_with("0000"));
|
||||||
|
assert!(escape_json_text(&ch(0xfeff).to_string()).ends_with("feff"));
|
||||||
|
assert!(escape_json_text(&ch(0x202e).to_string()).ends_with("202e"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn an_escape_sequence_cannot_reach_the_terminal() {
|
||||||
|
let text = "before\x1b[8mhidden\x1b[0m\x07after";
|
||||||
|
let got = escape_json_text(text);
|
||||||
|
assert!(!got.contains('\x1b') && !got.contains('\x07'), "{got:?}");
|
||||||
|
assert_eq!(
|
||||||
|
got,
|
||||||
|
format!(
|
||||||
|
"before{}[8mhidden{}[0m{}after",
|
||||||
|
esc(0x1b),
|
||||||
|
esc(0x1b),
|
||||||
|
esc(0x07)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_path_cannot_be_shown_backwards() {
|
||||||
|
// U+202E makes a terminal draw what follows from right to left.
|
||||||
|
let text = format!("/home/kyle/notes/{}dm.terces", ch(0x202e));
|
||||||
|
assert_eq!(
|
||||||
|
escape_json_text(&text),
|
||||||
|
format!("/home/kyle/notes/{}dm.terces", esc(0x202e))
|
||||||
|
);
|
||||||
|
let text = format!("a{}b{}c", ch(0x200b), ch(0x2066));
|
||||||
|
assert_eq!(
|
||||||
|
escape_json_text(&text),
|
||||||
|
format!("a{}b{}c", esc(0x200b), esc(0x2066))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn ordinary_text_is_unchanged() {
|
||||||
|
for text in [
|
||||||
|
"",
|
||||||
|
"plain",
|
||||||
|
r#"{"command":"ls -l","cwd":"/home/kyle"}"#,
|
||||||
|
"naïve café 日本語 🙂",
|
||||||
|
"a backslash \\ and a quote \" stay as they are",
|
||||||
|
] {
|
||||||
|
assert_eq!(escape_json_text(text), text);
|
||||||
|
assert_eq!(escape_model_text(text), text);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn json_text_escapes_newline_and_tab_but_model_text_keeps_them() {
|
||||||
|
let text = "one\n\ttwo\r\n";
|
||||||
|
assert_eq!(
|
||||||
|
escape_json_text(text),
|
||||||
|
format!("one{}{}two{}{}", esc(0x0a), esc(0x09), esc(0x0d), esc(0x0a))
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
escape_model_text(text),
|
||||||
|
format!("one\n\ttwo{}\n", esc(0x0d)),
|
||||||
|
"only newline and tab pass; a carriage return could overwrite the line"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn model_text_escapes_everything_else_the_same_way() {
|
||||||
|
for (lo, hi) in HIDDEN {
|
||||||
|
for code in lo..=hi {
|
||||||
|
if code == 0x0a || code == 0x09 {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
assert_eq!(
|
||||||
|
escape_model_text(&ch(code).to_string()),
|
||||||
|
esc(code),
|
||||||
|
"U+{code:04X}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+1
@@ -0,0 +1 @@
|
|||||||
|
{"v":1,"id":2,"final":true,"msg":{"kind":"approval_outcome","body":{"outcome":{"outcome":"denied","reason":"no_grant"}}}}
|
||||||
+1
@@ -0,0 +1 @@
|
|||||||
|
{"v":1,"id":2,"final":true,"msg":{"kind":"approval_outcome","body":{"outcome":{"outcome":"gone"}}}}
|
||||||
+1
@@ -0,0 +1 @@
|
|||||||
|
{"v":1,"id":2,"final":true,"msg":{"kind":"approval_outcome","body":{"outcome":{"outcome":"refused"}}}}
|
||||||
+1
@@ -0,0 +1 @@
|
|||||||
|
{"v":1,"id":2,"final":true,"msg":{"kind":"approval_outcome","body":{"outcome":{"outcome":"runs"}}}}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
{"v":1,"id":1,"final":false,"msg":{"kind":"approval_posted","body":{"posts":["p0000000000000000000000001","p0000000000000000000000002"]}}}
|
||||||
+1
@@ -0,0 +1 @@
|
|||||||
|
{"v":1,"id":1,"final":true,"msg":{"kind":"approval_reply","body":{"approval":41,"approved":true,"by":"k0000000000000000000000000","post":"c0000000000000000000000001","reason":null}}}
|
||||||
+1
@@ -0,0 +1 @@
|
|||||||
|
{"v":1,"id":1,"final":true,"msg":{"kind":"approval_reply","body":{"approval":41,"approved":false,"by":"k0000000000000000000000000","post":"c0000000000000000000000001","reason":"not on a Friday"}}}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
{"v":1,"id":1,"final":true,"msg":{"kind":"approval_request","body":{"approval":{"approval":41,"session":"mm-r0000000000000000000000001","call":3,"tool":"shell","arguments":"{\"command\":\"ls -la /srv\"}","grant":"shell-srv","taint":"private","created":"2026-09-24T08:05:00.000Z","expires":"2026-09-24T08:20:00.000Z"}}}}
|
||||||
@@ -0,0 +1,187 @@
|
|||||||
|
//! The four messages of `gateway.sock` against byte-exact fixtures, every object in them refusing
|
||||||
|
//! an unknown key, and the checks on an answer (M4b spec, section 4). Do not edit these or the
|
||||||
|
//! fixtures.
|
||||||
|
|
||||||
|
use proto::{
|
||||||
|
ApprovalOutcome, ApprovalPosted, ApprovalReply, ApprovalRequest, CallId, DataClass, DenyReason,
|
||||||
|
Envelope, GatewayOutcome, MAX_REASON, Message, PendingApproval, SessionId, Timestamp,
|
||||||
|
is_mattermost_id,
|
||||||
|
};
|
||||||
|
use serde_json::Value;
|
||||||
|
|
||||||
|
const BY: &str = "k0000000000000000000000000";
|
||||||
|
const POST: &str = "c0000000000000000000000001";
|
||||||
|
|
||||||
|
fn fixture(name: &str) -> String {
|
||||||
|
let path = format!("{}/tests/fixtures/wire/{name}", env!("CARGO_MANIFEST_DIR"));
|
||||||
|
let text = std::fs::read_to_string(&path).unwrap_or_else(|e| panic!("{path}: {e}"));
|
||||||
|
text.trim_end_matches('\n').to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The fixture must decode to `want`, and `want` must encode to exactly the fixture's bytes.
|
||||||
|
fn check(name: &str, id: u64, last: bool, msg: Message) {
|
||||||
|
let want = Envelope {
|
||||||
|
v: 1,
|
||||||
|
id,
|
||||||
|
r#final: last,
|
||||||
|
msg,
|
||||||
|
};
|
||||||
|
let text = fixture(name);
|
||||||
|
let got: Envelope = serde_json::from_str(&text).unwrap_or_else(|e| panic!("{name}: {e}"));
|
||||||
|
assert_eq!(got, want, "{name}: decoded value");
|
||||||
|
assert_eq!(
|
||||||
|
serde_json::to_string(&want).unwrap(),
|
||||||
|
text,
|
||||||
|
"{name}: encoded bytes"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn pending() -> PendingApproval {
|
||||||
|
PendingApproval {
|
||||||
|
approval: 41,
|
||||||
|
session: SessionId::new("mm-r0000000000000000000000001").unwrap(),
|
||||||
|
call: CallId(3),
|
||||||
|
tool: "shell".to_string(),
|
||||||
|
arguments: r#"{"command":"ls -la /srv"}"#.to_string(),
|
||||||
|
grant: "shell-srv".to_string(),
|
||||||
|
taint: DataClass::Private,
|
||||||
|
created: Timestamp::parse("2026-09-24T08:05:00.000Z").unwrap(),
|
||||||
|
expires: Timestamp::parse("2026-09-24T08:20:00.000Z").unwrap(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn reply(approved: bool, reason: Option<&str>) -> ApprovalReply {
|
||||||
|
ApprovalReply {
|
||||||
|
approval: 41,
|
||||||
|
approved,
|
||||||
|
by: BY.to_string(),
|
||||||
|
post: POST.to_string(),
|
||||||
|
reason: reason.map(str::to_string),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_four_messages() {
|
||||||
|
check(
|
||||||
|
"gateway_request.json",
|
||||||
|
1,
|
||||||
|
true,
|
||||||
|
Message::ApprovalRequest(ApprovalRequest {
|
||||||
|
approval: pending(),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
let posts = vec![
|
||||||
|
"p0000000000000000000000001".to_string(),
|
||||||
|
"p0000000000000000000000002".to_string(),
|
||||||
|
];
|
||||||
|
check(
|
||||||
|
"gateway_posted.json",
|
||||||
|
1,
|
||||||
|
false,
|
||||||
|
Message::ApprovalPosted(ApprovalPosted { posts }),
|
||||||
|
);
|
||||||
|
check(
|
||||||
|
"gateway_reply_approved.json",
|
||||||
|
1,
|
||||||
|
true,
|
||||||
|
Message::ApprovalReply(reply(true, None)),
|
||||||
|
);
|
||||||
|
check(
|
||||||
|
"gateway_reply_refused.json",
|
||||||
|
1,
|
||||||
|
true,
|
||||||
|
Message::ApprovalReply(reply(false, Some("not on a Friday"))),
|
||||||
|
);
|
||||||
|
for (name, outcome) in [
|
||||||
|
("gateway_outcome_runs.json", GatewayOutcome::Runs {}),
|
||||||
|
(
|
||||||
|
"gateway_outcome_denied.json",
|
||||||
|
GatewayOutcome::Denied {
|
||||||
|
reason: DenyReason::NoGrant,
|
||||||
|
},
|
||||||
|
),
|
||||||
|
("gateway_outcome_refused.json", GatewayOutcome::Refused {}),
|
||||||
|
("gateway_outcome_gone.json", GatewayOutcome::Gone {}),
|
||||||
|
] {
|
||||||
|
check(
|
||||||
|
name,
|
||||||
|
2,
|
||||||
|
true,
|
||||||
|
Message::ApprovalOutcome(ApprovalOutcome { outcome }),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Every copy of `value` with one extra key in one object.
|
||||||
|
fn with_one_unknown_key(value: &Value) -> Vec<Value> {
|
||||||
|
let mut out = Vec::new();
|
||||||
|
if let Value::Object(map) = value {
|
||||||
|
let mut extended = map.clone();
|
||||||
|
extended.insert("zz_unknown".to_string(), Value::Bool(true));
|
||||||
|
out.push(Value::Object(extended));
|
||||||
|
for (key, child) in map {
|
||||||
|
for changed in with_one_unknown_key(child) {
|
||||||
|
let mut copy = map.clone();
|
||||||
|
copy.insert(key.clone(), changed);
|
||||||
|
out.push(Value::Object(copy));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn every_object_refuses_an_unknown_key() {
|
||||||
|
for (name, objects) in [
|
||||||
|
("gateway_request.json", 4),
|
||||||
|
("gateway_posted.json", 3),
|
||||||
|
("gateway_reply_approved.json", 3),
|
||||||
|
("gateway_reply_refused.json", 3),
|
||||||
|
("gateway_outcome_runs.json", 4),
|
||||||
|
("gateway_outcome_denied.json", 4),
|
||||||
|
("gateway_outcome_refused.json", 4),
|
||||||
|
("gateway_outcome_gone.json", 4),
|
||||||
|
] {
|
||||||
|
let value: Value = serde_json::from_str(&fixture(name)).unwrap();
|
||||||
|
let variations = with_one_unknown_key(&value);
|
||||||
|
assert_eq!(variations.len(), objects, "{name}: objects walked");
|
||||||
|
for changed in variations {
|
||||||
|
assert!(
|
||||||
|
serde_json::from_value::<Envelope>(changed.clone()).is_err(),
|
||||||
|
"{name}: accepted {changed}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn an_answer_is_checked() {
|
||||||
|
assert_eq!(reply(true, None).check(), Ok(()));
|
||||||
|
assert_eq!(reply(false, Some(&"x".repeat(MAX_REASON))).check(), Ok(()));
|
||||||
|
assert_eq!(
|
||||||
|
reply(false, Some(&"é".repeat(MAX_REASON))).check(),
|
||||||
|
Ok(()),
|
||||||
|
"characters, not bytes"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
reply(false, Some(&"x".repeat(MAX_REASON + 1)))
|
||||||
|
.check()
|
||||||
|
.is_err()
|
||||||
|
);
|
||||||
|
for bad in [
|
||||||
|
"",
|
||||||
|
"K0000000000000000000000000",
|
||||||
|
"k000000000000000000000000",
|
||||||
|
"k00000000000000000000000000",
|
||||||
|
"../../../../../../../../etc",
|
||||||
|
] {
|
||||||
|
let mut r = reply(true, None);
|
||||||
|
r.by = bad.to_string();
|
||||||
|
assert!(r.check().is_err(), "by {bad:?}");
|
||||||
|
let mut r = reply(true, None);
|
||||||
|
r.post = bad.to_string();
|
||||||
|
assert!(r.check().is_err(), "post {bad:?}");
|
||||||
|
assert!(!is_mattermost_id(bad), "{bad:?}");
|
||||||
|
}
|
||||||
|
assert!(is_mattermost_id(BY) && is_mattermost_id(POST));
|
||||||
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
|
||||||
|
## gateway-unreachable
|
||||||
|
|
||||||
|
**What you see.** `brokerd` prints `brokerd: approval <id>: gatewayd is not reachable at <socket>:
|
||||||
|
<reason>; bxctl can still answer it`, then this entry, once per approval. No approval post
|
||||||
|
appears in Mattermost. `bxctl approvals` lists the approval as usual.
|
||||||
|
|
||||||
|
**Why.** `brokerd.toml` has a `[gateway]` section, so each pending approval is carried to
|
||||||
|
`gatewayd` to be posted in Mattermost. The courier could not connect to `gateway.sock`, or
|
||||||
|
`gatewayd` answered that it could not post (Mattermost unreachable). The approval is not lost: it
|
||||||
|
stays pending, `bxctl` can answer it, and the courier tries again every 5 seconds until it is
|
||||||
|
posted or answered. When a later try works, `brokerd` prints `brokerd: approval <id> posted in
|
||||||
|
Mattermost`.
|
||||||
|
|
||||||
|
**Confirm.**
|
||||||
|
|
||||||
|
```sh
|
||||||
|
ls -l <socket> # gatewayd creates it at start; [approvals] socket in gatewayd.toml
|
||||||
|
pgrep -x gatewayd # is it running?
|
||||||
|
```
|
||||||
|
|
||||||
|
and `gatewayd`'s own output: `cannot reach …` there means Mattermost is the problem (see
|
||||||
|
[mattermost-unreachable](#mattermost-unreachable)).
|
||||||
|
|
||||||
|
**Fix.** Start `gatewayd`, or make `[gateway] socket` in `brokerd.toml` and `[approvals] socket`
|
||||||
|
in `gatewayd.toml` name the same path (both default to `<home>/run/gateway/gateway.sock`). To turn
|
||||||
|
Mattermost approvals off, remove `[gateway]` from `brokerd.toml` and restart `brokerd`.
|
||||||
|
|
||||||
|
**Check.** Trigger an `ask` call: its approval is posted in Mattermost, and `!approve <id>` in
|
||||||
|
its thread answers it.
|
||||||
|
|
||||||
|
Where two Boxmakers share a channel thread, address approval commands to one of them
|
||||||
|
(`@<bot> !approve <id>`): a bare `!approve <id>` is taken by every Boxmaker with that id pending
|
||||||
|
in that thread.
|
||||||
Reference in New Issue
Block a user