brokerd: inherit parent taint for a new subagent
Co-Authored-By: ornith-1.5-35b-a3b <agent@wntrmute.dev>
This commit is contained in:
@@ -72,7 +72,9 @@ impl StateStore {
|
|||||||
let path = self.path(session);
|
let path = self.path(session);
|
||||||
let text = match fs::read_to_string(&path) {
|
let text = match fs::read_to_string(&path) {
|
||||||
Ok(text) => text,
|
Ok(text) => text,
|
||||||
Err(err) if err.kind() == io::ErrorKind::NotFound => return Ok(SessionState::default()),
|
Err(err) if err.kind() == io::ErrorKind::NotFound => {
|
||||||
|
return inherit_or_default(self, session);
|
||||||
|
}
|
||||||
Err(err) => return Err(StateError::Unreadable(path, err.to_string())),
|
Err(err) => return Err(StateError::Unreadable(path, err.to_string())),
|
||||||
};
|
};
|
||||||
let file = match serde_json::from_str::<StateFile>(&text) {
|
let file = match serde_json::from_str::<StateFile>(&text) {
|
||||||
@@ -140,6 +142,31 @@ impl StateStore {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A missing `sub-<parent>-<n>` file copies the parent's taint and untrusted flag (M6 spec 3.4).
|
||||||
|
fn inherit_or_default(store: &StateStore, session: &SessionId) -> Result<SessionState, StateError> {
|
||||||
|
let Some(parent) = parent_of_sub(session) else {
|
||||||
|
return Ok(SessionState::default());
|
||||||
|
};
|
||||||
|
let inherited = store.read(&parent)?;
|
||||||
|
store.raise(
|
||||||
|
session,
|
||||||
|
SessionState::default(),
|
||||||
|
Label {
|
||||||
|
class: inherited.taint,
|
||||||
|
untrusted: inherited.untrusted,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parent_of_sub(session: &SessionId) -> Option<SessionId> {
|
||||||
|
let rest = session.as_str().strip_prefix("sub-")?;
|
||||||
|
let (parent, n) = rest.rsplit_once('-')?;
|
||||||
|
if n.is_empty() || !n.bytes().all(|b| b.is_ascii_digit()) {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
SessionId::new(parent).ok()
|
||||||
|
}
|
||||||
|
|
||||||
/// One line on disk: the taint and the untrusted flag.
|
/// One line on disk: the taint and the untrusted flag.
|
||||||
#[derive(Serialize, Deserialize)]
|
#[derive(Serialize, Deserialize)]
|
||||||
#[serde(deny_unknown_fields)]
|
#[serde(deny_unknown_fields)]
|
||||||
|
|||||||
@@ -0,0 +1,71 @@
|
|||||||
|
//! A `sub-*` session copies its parent's taint. Do not edit.
|
||||||
|
|
||||||
|
#[path = "support/tmp.rs"]
|
||||||
|
mod tmp;
|
||||||
|
|
||||||
|
use brokerd::policy::{Label, SessionState};
|
||||||
|
use brokerd::state::StateStore;
|
||||||
|
use bxcore::{DataClass, SessionId};
|
||||||
|
use tmp::TempDir;
|
||||||
|
|
||||||
|
fn id(text: &str) -> SessionId {
|
||||||
|
SessionId::new(text).unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn store(home: &TempDir) -> StateStore {
|
||||||
|
StateStore::new(&home.path().join("broker/sessions"))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_new_subagent_copies_the_parents_untrusted_flag() {
|
||||||
|
let home = TempDir::new("state-sub");
|
||||||
|
let store = store(&home);
|
||||||
|
let parent = id("mm-thread");
|
||||||
|
store
|
||||||
|
.raise(
|
||||||
|
&parent,
|
||||||
|
SessionState::default(),
|
||||||
|
Label {
|
||||||
|
class: DataClass::Private,
|
||||||
|
untrusted: true,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
let child = store.read(&id("sub-mm-thread-3")).unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
child,
|
||||||
|
SessionState {
|
||||||
|
taint: DataClass::Private,
|
||||||
|
untrusted: true,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
let path = home.path().join("broker/sessions/sub-mm-thread-3.json");
|
||||||
|
assert!(path.exists());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_nested_subagent_copies_through_a_missing_middle_child() {
|
||||||
|
let home = TempDir::new("state-sub-nest");
|
||||||
|
let store = store(&home);
|
||||||
|
store
|
||||||
|
.raise(
|
||||||
|
&id("mm-thread"),
|
||||||
|
SessionState::default(),
|
||||||
|
Label {
|
||||||
|
class: DataClass::Secret,
|
||||||
|
untrusted: true,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
let leaf = store.read(&id("sub-sub-mm-thread-1-2")).unwrap();
|
||||||
|
assert_eq!(leaf.taint, DataClass::Secret);
|
||||||
|
assert!(leaf.untrusted);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_plain_session_still_creates_nothing_on_read() {
|
||||||
|
let home = TempDir::new("state-plain");
|
||||||
|
let store = store(&home);
|
||||||
|
let _ = store.read(&id("chat-1")).unwrap();
|
||||||
|
assert!(!home.path().join("broker").exists());
|
||||||
|
}
|
||||||
@@ -6,6 +6,7 @@ reviewer adds findings under "Reviews" once per milestone.
|
|||||||
|
|
||||||
| Task | Date | Status | Gate runs | First gate | Deviations | Notes | Model |
|
| Task | Date | Status | Gate runs | First gate | Deviations | Notes | Model |
|
||||||
|---|---|---|---|---|---|---|---|
|
|---|---|---|---|---|---|---|---|
|
||||||
|
| M6a/05-brokerd-state-inherit | 2026-10-03 | done | 1 | pass | none | Copied the given test `crates/brokerd/tests/state_subagent.rs` from `docs/plans/M6a/files/`. Step 2 (`cargo test -p brokerd --test state_subagent`) failed as expected: `a_new_subagent_copies_the_parents_untrusted_flag` (child was default private/trusted) and `a_nested_subagent_copies_through_a_missing_middle_child` (leaf was Private not Secret) failed, `a_plain_session_still_creates_nothing_on_read` passed. Applied I1 in state.rs `read`: replaced the `NotFound` early return with `return inherit_or_default(self, session)`. Applied I2 after `impl StateStore` (after `persist`'s and the `impl`'s closing braces, before `/// One line on disk`): added `inherit_or_default` which calls `parent_of_sub`, returns default when `None`, else `store.read(&parent)?` then `store.raise(session, SessionState::default(), Label{class: inherited.taint, untrusted: inherited.untrusted})`, and `parent_of_sub` which strips `sub-`, `rsplit_once('-')`, requires non-empty ASCII-digit suffix and builds `SessionId::new(parent)`. Did not touch `raise` or `persist`. `cargo check -p brokerd` clean, no warning; `wc -l` prints 176 for state.rs, under 500. All suites pass: `state_subagent` 3, `state` 9. `make gate` printed `gate: ok` with 1590 tests (1587 before, plus 3). No new dependency, so `Cargo.lock` unchanged. | ornith-1.5-35b-a3b |
|
||||||
| M6a/04-loopd-scheduler-skip | 2026-10-03 | done | 1 | pass | none | Copied the given test `crates/loopd/tests/scheduler_subagent.rs` from `docs/plans/M6a/files/`. Step 2 (`cargo test -p loopd --test scheduler_subagent`) failed as expected: both tests failed, `work` not empty (a `Heartbeat` and a `Job { name: "mail", session: "job-mail-20261003t0700" }`) and `requests_to(CHAT)` not empty while the child was held. Applied H1 in channel.rs: added `holds_prefix` after `hold`'s closing `}` inside `impl Context`, locking `busy` like `hold` and returning whether any id starts with the prefix. Applied K2 in scheduler.rs `heartbeat`: after the `if !due { return; }` block and before `SessionId::new("heartbeat")`, `if ctx.holds_prefix("sub-") { return; }`. Applied K1 in scheduler.rs `tick`: immediately after `self.heartbeat(...)` and before `jobs_dir`, `if ctx.holds_prefix("sub-") { return work; }` so leftover-close on the first tick is already done. `cargo check -p loopd` clean, no warning; `wc -l` prints 307 channel.rs, 310 scheduler.rs, both under 500. All suites pass: `scheduler_subagent` 2, `scheduler_heartbeat` 6, `scheduler_cron` 6, `scheduler_heartbeat_failed` 1. `make gate` printed `gate: ok` with 1587 tests (1585 before, plus 2). No new dependency, so `Cargo.lock` unchanged. | ornith-1.5-35b-a3b |
|
| M6a/04-loopd-scheduler-skip | 2026-10-03 | done | 1 | pass | none | Copied the given test `crates/loopd/tests/scheduler_subagent.rs` from `docs/plans/M6a/files/`. Step 2 (`cargo test -p loopd --test scheduler_subagent`) failed as expected: both tests failed, `work` not empty (a `Heartbeat` and a `Job { name: "mail", session: "job-mail-20261003t0700" }`) and `requests_to(CHAT)` not empty while the child was held. Applied H1 in channel.rs: added `holds_prefix` after `hold`'s closing `}` inside `impl Context`, locking `busy` like `hold` and returning whether any id starts with the prefix. Applied K2 in scheduler.rs `heartbeat`: after the `if !due { return; }` block and before `SessionId::new("heartbeat")`, `if ctx.holds_prefix("sub-") { return; }`. Applied K1 in scheduler.rs `tick`: immediately after `self.heartbeat(...)` and before `jobs_dir`, `if ctx.holds_prefix("sub-") { return work; }` so leftover-close on the first tick is already done. `cargo check -p loopd` clean, no warning; `wc -l` prints 307 channel.rs, 310 scheduler.rs, both under 500. All suites pass: `scheduler_subagent` 2, `scheduler_heartbeat` 6, `scheduler_cron` 6, `scheduler_heartbeat_failed` 1. `make gate` printed `gate: ok` with 1587 tests (1585 before, plus 2). No new dependency, so `Cargo.lock` unchanged. | ornith-1.5-35b-a3b |
|
||||||
| M6a/03-loopd-subagent-spawn | 2026-10-03 | done | 1 | pass | none | Copied the given test `crates/loopd/tests/subagent_spawn.rs` from `docs/plans/M6a/files/`. Step 2 (`cargo build --tests --keep-going -p loopd`) failed as expected: E0560 `Runtime` has no field `ctx`. Added `pub ctx: Option<&'a crate::channel::Context>` to `Runtime` in turn.rs R1, filled it `Some(&*ctx)` in channel.rs C1 (live turn), `Some(ctx)` in the cron and heartbeat literals in scheduler.rs S1/S2 (both already held an immutable borrow of `ctx`), and `None` in the seven test literals: support/turn.rs, compact_turn.rs (the helper and the inline one), session_slot.rs, end_to_end.rs, turn_broker.rs, tool_summary_call.rs. Replaced the stub `run` in subagent.rs with the full spawn: build `sub-<parent>-<n>` from the already-consumed `call_id.0`, overflow returns `denial_message(InvalidArguments, Some(...))` naming subagent-id without holding, then `rt.ctx` `None` and `hold Busy` and block/baseline/create errors each return `failed`, a `Create` `Exists` falls back to `open`, the child runs one `run_turn` plus `finish_turn` on `slots.background`, memory is updated, `untrusted` is read before the hold drops, and the result is `{id} completed\n{summary}` or `{id} failed`, all `Public`. Added `RUNBOOK` and the use lines the body needs. Appended `## subagent-id` to docs/runbook.md via the task's `{ echo; cat ...; }` (grep -c prints 1). `cargo check -p loopd` clean, no warning; `wc -l` prints 185 subagent.rs, 335 turn.rs, 301 channel.rs, 304 scheduler.rs, all under 500. All suites pass: `subagent_spawn` 5, `subagent_dispatch` 3, `subagent_registry` 2, `session_slot` 4, `tool_summary_call` 6, `compact_turn`, `channel`. `make gate` printed `gate: ok` with 1585 tests (1580 before, plus 5). No new dependency, so `Cargo.lock` unchanged. | ornith-1.5-35b-a3b |
|
| M6a/03-loopd-subagent-spawn | 2026-10-03 | done | 1 | pass | none | Copied the given test `crates/loopd/tests/subagent_spawn.rs` from `docs/plans/M6a/files/`. Step 2 (`cargo build --tests --keep-going -p loopd`) failed as expected: E0560 `Runtime` has no field `ctx`. Added `pub ctx: Option<&'a crate::channel::Context>` to `Runtime` in turn.rs R1, filled it `Some(&*ctx)` in channel.rs C1 (live turn), `Some(ctx)` in the cron and heartbeat literals in scheduler.rs S1/S2 (both already held an immutable borrow of `ctx`), and `None` in the seven test literals: support/turn.rs, compact_turn.rs (the helper and the inline one), session_slot.rs, end_to_end.rs, turn_broker.rs, tool_summary_call.rs. Replaced the stub `run` in subagent.rs with the full spawn: build `sub-<parent>-<n>` from the already-consumed `call_id.0`, overflow returns `denial_message(InvalidArguments, Some(...))` naming subagent-id without holding, then `rt.ctx` `None` and `hold Busy` and block/baseline/create errors each return `failed`, a `Create` `Exists` falls back to `open`, the child runs one `run_turn` plus `finish_turn` on `slots.background`, memory is updated, `untrusted` is read before the hold drops, and the result is `{id} completed\n{summary}` or `{id} failed`, all `Public`. Added `RUNBOOK` and the use lines the body needs. Appended `## subagent-id` to docs/runbook.md via the task's `{ echo; cat ...; }` (grep -c prints 1). `cargo check -p loopd` clean, no warning; `wc -l` prints 185 subagent.rs, 335 turn.rs, 301 channel.rs, 304 scheduler.rs, all under 500. All suites pass: `subagent_spawn` 5, `subagent_dispatch` 3, `subagent_registry` 2, `session_slot` 4, `tool_summary_call` 6, `compact_turn`, `channel`. `make gate` printed `gate: ok` with 1585 tests (1580 before, plus 5). No new dependency, so `Cargo.lock` unchanged. | ornith-1.5-35b-a3b |
|
||||||
| M6a/02-loopd-subagent-dispatch | 2026-10-03 | done | 1 | pass | none | Copied the given test `crates/loopd/tests/subagent_dispatch.rs` from `docs/plans/M6a/files/`. Step 2 (`cargo build --tests --keep-going -p loopd`) failed as expected: E0599 no `Subagent` variant and E0425 no `child_id`. Added `Dispatch::Subagent { task }` in tools.rs D1, routed `subagent` in `dispatch` and `dispatch_call_tool` (D2/D3) via `crate::subagent::dispatch_args`, added the `Subagent` arm in `run_call` (turn.rs T1) calling the stub `run`, and appended `child_id`, `parse_task`, `dispatch_args` and the stub `run` to subagent.rs. `parse_task` walks every key (unknown field), requires `task`, demands a string task and a JSON object arguments, and maps errors to `denial_message(InvalidArguments, Some(&detail))`. `run` stays a compiling stub, no spawn, no `Runtime.ctx`. `cargo check -p loopd` clean, no warning; `wc -l` prints 76 for subagent.rs, 477 for tools.rs, 332 for turn.rs, all under 500. All suites pass: `subagent_dispatch` 3, `subagent_registry` 2, `tools` 11. `make gate` printed `gate: ok` with 1580 tests (1577 before, plus 3). No new dependency, so `Cargo.lock` unchanged. | ornith-1.5-35b-a3b |
|
| M6a/02-loopd-subagent-dispatch | 2026-10-03 | done | 1 | pass | none | Copied the given test `crates/loopd/tests/subagent_dispatch.rs` from `docs/plans/M6a/files/`. Step 2 (`cargo build --tests --keep-going -p loopd`) failed as expected: E0599 no `Subagent` variant and E0425 no `child_id`. Added `Dispatch::Subagent { task }` in tools.rs D1, routed `subagent` in `dispatch` and `dispatch_call_tool` (D2/D3) via `crate::subagent::dispatch_args`, added the `Subagent` arm in `run_call` (turn.rs T1) calling the stub `run`, and appended `child_id`, `parse_task`, `dispatch_args` and the stub `run` to subagent.rs. `parse_task` walks every key (unknown field), requires `task`, demands a string task and a JSON object arguments, and maps errors to `denial_message(InvalidArguments, Some(&detail))`. `run` stays a compiling stub, no spawn, no `Runtime.ctx`. `cargo check -p loopd` clean, no warning; `wc -l` prints 76 for subagent.rs, 477 for tools.rs, 332 for turn.rs, all under 500. All suites pass: `subagent_dispatch` 3, `subagent_registry` 2, `tools` 11. `make gate` printed `gate: ok` with 1580 tests (1577 before, plus 3). No new dependency, so `Cargo.lock` unchanged. | ornith-1.5-35b-a3b |
|
||||||
|
|||||||
Reference in New Issue
Block a user