fix(plugins): a revocation blocks carrying built-in trust only until the next review (K4)

builtin_predecessor returned None whenever any same-named built-in entry
lacked a receipt. A user who revoked Computer Use on one build and then
reviewed and enabled a later build therefore lost it on every following
upgrade, forever; so did anyone with a disable-only (never reviewed) entry.

Now the most recently reviewed same-named entry decides. A revoked entry
is dated by its last review_history receipt, so a revocation still blocks
carrying until the user reviews a build again, and ties go to the
revocation. Entries that were never reviewed granted nothing and are
ignored. The K4 test gains the re-review step (v4 trusted and enabled,
v5 carries).

Checks:
- cargo test -p codewhale-tui --lib plugins::builtin: 19 passed, 0 failed,
  1 ignored
- cargo test -p codewhale-tui --lib plugins:: -- --test-threads=1:
  212 passed, 0 failed, 1 ignored (parallel runs flake on a pre-existing
  process-env race with managed_policy_path_env_override_is_honored)
- rustfmt --check on registry.rs and builtin.rs: clean

Refs #6303

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
This commit is contained in:
CodeWhale BotandClaude Opus 5.5 committed 2026-09-22 18:40:22 -07:00
1 parent 53aee22467
commit a518b2f9b4
7 files changed
+324 -26

No files matched your search

+16 -2
View File
@@ -675,7 +675,7 @@ mod tests {
const MANIFEST: &[u8] = br#"{"$schema":"https://agent-plugins.org/schemas/plugin.json","name":"fixture","version":"1.0.0"}"#;
const SKILL: &[u8] = b"---\nname: extra\ndescription: An added skill.\n---\nBody.\n";
let builds: [&[(&str, &[u8])]; 4] = [
let builds: [&[(&str, &[u8])]; 5] = [
&[("plugin.json", MANIFEST), ("body.txt", b"v1")],
&[("plugin.json", MANIFEST), ("body.txt", b"v2")],
&[
@@ -688,6 +688,11 @@ mod tests {
("body.txt", b"v4"),
("skills/extra/SKILL.md", SKILL),
],
&[
("plugin.json", MANIFEST),
("body.txt", b"v5"),
("skills/extra/SKILL.md", SKILL),
],
];
let temp = tempfile::tempdir().unwrap();
let cache = temp.path().join("cache");
@@ -747,10 +752,19 @@ mod tests {
// A revocation anywhere in the line blocks carrying.
let mut v3 = v3;
v3.revoke_trust("fixture").unwrap();
let v4 = registry_for(builds[3]);
let mut v4 = registry_for(builds[3]);
let plugin = v4.get("fixture").unwrap();
assert_eq!(plugin.trust_status, PluginTrustStatus::NeverReviewed);
assert!(!plugin.enabled);
// A revocation blocks only until the next review: once the user
// reviews and enables a later build, upgrades carry that review again.
v4.trust("fixture").unwrap();
v4.enable("fixture").unwrap();
let v5 = registry_for(builds[4]);
let plugin = v5.get("fixture").unwrap();
assert_eq!(plugin.trust_status, PluginTrustStatus::Trusted);
assert!(plugin.active());
}
#[test]
+18 -9
View File
@@ -563,8 +563,8 @@ impl PluginRegistry {
/// user reviews the changes.
///
/// Fail-closed: nothing is carried when the new id already has state,
/// when any same-named predecessor has no receipt (it was revoked, or
/// never reviewed), or when the state file is invalid. Older ids are left
/// when the most recently reviewed same-named predecessor has since been
/// revoked, or when the state file is invalid. Older ids are left
/// untouched, so a still-running older binary keeps its own authority.
/// Only the built-in scope is ever carried; user and workspace bundles
/// still require review of the exact bytes on disk.
@@ -1227,9 +1227,13 @@ pub(crate) fn harden_plugin_state_file(_path: &Path) -> Result<(), String> {
Ok(())
}
/// The newest persisted review of another built-in with this name, when one
/// may be carried to `id`: `id` has no state yet, and every same-named
/// built-in entry still holds a receipt (a revocation anywhere blocks it).
/// The newest persisted review of another built-in with this name, when it
/// may be carried to `id`: `id` has no state yet, and the same-named built-in
/// entry with the most recent review still holds its receipt. A revoked entry
/// is dated by its last review, so revoking blocks carrying until the user
/// reviews a build again; ties go to the revocation. Entries that were never
/// reviewed (for example, disabled before any review) granted nothing and are
/// ignored.
fn builtin_predecessor<'a>(
state: &'a PluginStateFile,
id: &PluginId,
@@ -1245,14 +1249,19 @@ fn builtin_predecessor<'a>(
if parts.next() != Some(builtin) || parts.nth(1) != Some(name) {
continue;
}
let receipt = entry.trust.as_ref()?;
let reviewed = chrono::DateTime::parse_from_rfc3339(&receipt.reviewed_at)
let Some(last_review) = entry.trust.as_ref().or(entry.review_history.last()) else {
continue;
};
let reviewed = chrono::DateTime::parse_from_rfc3339(&last_review.reviewed_at)
.map_or(i64::MIN, |at| at.timestamp_micros());
if newest.is_none_or(|(_, at)| reviewed > at) {
let revoked = entry.trust.is_none();
if newest.is_none_or(|(_, at)| reviewed > at || (reviewed == at && revoked)) {
newest = Some((entry, reviewed));
}
}
newest.map(|(entry, _)| entry)
newest
.map(|(entry, _)| entry)
.filter(|entry| entry.trust.is_some())
}
fn runtime_stage_path(state_path: &Path, id: &PluginId, content_hash: &str) -> PathBuf {
+138 -10
View File
@@ -86,8 +86,8 @@ use crate::task_manager::{
NewTaskRequest, SharedTaskManager, TaskManager, TaskManagerConfig, TaskRecord, TaskSummary,
};
use crate::tools::subagent::{
AgentWorkerRecord, SharedSubAgentManager, load_persisted_agent_worker_records,
new_shared_subagent_manager_with_timeout,
AgentWorkerRecord, AgentWorkerStatus, SharedSubAgentManager, SubAgentStatus,
load_persisted_agent_worker_records, new_shared_subagent_manager_with_timeout,
};
#[cfg(test)]
pub(super) use codewhale_models::{ContentBlock, Message};
@@ -1205,6 +1205,7 @@ pub fn build_router(state: RuntimeApiState) -> Router {
.route("/v1/workspace/instructions", get(workspace_instructions))
.route("/v1/agent-runs", get(list_agent_runs))
.route("/v1/agent-runs/{run_id}", get(get_agent_run))
.route("/v1/agent-runs/{run_id}/cancel", post(cancel_agent_run))
.route("/v1/fleet/profiles", get(list_fleet_profiles))
.route(
"/v1/fleet/runs",
@@ -2075,18 +2076,145 @@ async fn get_agent_run(
})?;
let run = runs
.into_iter()
.find(|record| {
let effective_run_id = if record.spec.run_id.is_empty() {
record.spec.worker_id.as_str()
} else {
record.spec.run_id.as_str()
};
effective_run_id == run_id || record.spec.worker_id == run_id
})
.find(|record| agent_run_matches(record, &run_id))
.ok_or_else(|| ApiError::not_found(format!("agent run '{run_id}' not found")))?;
Ok(Json(run))
}
/// A run is addressed by its run id, or by its worker id for records that
/// predate run ids.
fn agent_run_matches(record: &AgentWorkerRecord, run_id: &str) -> bool {
let effective_run_id = if record.spec.run_id.is_empty() {
record.spec.worker_id.as_str()
} else {
record.spec.run_id.as_str()
};
effective_run_id == run_id || record.spec.worker_id == run_id
}
/// How long a stop request waits for the owning engine to record the
/// terminal receipt before answering `202 Accepted` with the live record.
const AGENT_RUN_CANCEL_SETTLE: Duration = Duration::from_secs(3);
/// `POST /v1/agent-runs/{run_id}/cancel`: stop a delegated agent run and
/// answer with its receipt (addendum F2).
///
/// The stop goes through the same session-scoped path as the TUI's `X` and
/// the `agent/cancel` tool, so descendants stop with it and a write-scoped
/// child's work is inventoried rather than dropped. The answer is:
/// - `200` with the terminal record once the run is stopped (or was already
/// finished — stopping is idempotent);
/// - `202` with the current record when the owning engine accepted the stop
/// but has not recorded the terminal receipt yet;
/// - `404` for an unknown run;
/// - `409` when the run belongs to a session this runtime does not host, so
/// nothing here can reach it.
async fn cancel_agent_run(
State(state): State<RuntimeApiState>,
Path(run_id): Path<String>,
) -> Result<(StatusCode, Json<AgentWorkerRecord>), ApiError> {
// Runs this runtime is executing itself (Fleet-launched children) stop
// in place. Only a child running in this process qualifies: records the
// manager loaded from disk belong to whichever process wrote them.
let owned = {
let manager = state.sub_agent_manager.read().await;
manager
.list_worker_records()
.into_iter()
.find(|record| agent_run_matches(record, &run_id))
.filter(|record| {
manager
.get_result(&record.spec.worker_id)
.is_ok_and(|agent| agent.status == SubAgentStatus::Running)
})
};
if let Some(record) = owned {
let agent_id = record.spec.worker_id.clone();
let cancelled = {
let mut manager = state.sub_agent_manager.write().await;
if record.owner_session_id.is_empty() {
manager.cancel_agent(&agent_id)
} else {
manager.cancel_agent_for_session(&record.owner_session_id, &agent_id)
}
}
.map_err(|err| {
ApiError::conflict(format!("agent run '{run_id}' could not be stopped: {err}"))
})?;
crate::tools::subagent::preserve_cancelled_work(&state.sub_agent_manager, cancelled).await;
let manager = state.sub_agent_manager.read().await;
let record = manager
.list_worker_records()
.into_iter()
.find(|record| record.spec.worker_id == agent_id)
.unwrap_or(record);
let status = if record.status.is_terminal() {
StatusCode::OK
} else {
StatusCode::ACCEPTED
};
return Ok((status, Json(record)));
}
let find_persisted = |workspace: &FsPath| -> Result<Option<AgentWorkerRecord>, ApiError> {
load_persisted_agent_worker_records(workspace)
.map(|runs| {
runs.into_iter()
.find(|record| agent_run_matches(record, &run_id))
})
.map_err(|err| {
ApiError::internal(format!("Failed to load persisted agent run records: {err}"))
})
};
let record = find_persisted(&state.workspace)?
.ok_or_else(|| ApiError::not_found(format!("agent run '{run_id}' not found")))?;
// A runtime thread's session id is its thread id: its live engine owns
// the child and stops it through the session-scoped cancel path. The
// on-disk projection cannot tell a live child from an orphan (loading it
// marks every in-flight record interrupted), so a hosted thread is always
// asked, and only its own write settles the answer.
let engine = if record.owner_session_id.is_empty() {
None
} else {
state
.runtime_threads
.loaded_engine(&record.owner_session_id)
.await
};
let Some(engine) = engine else {
if record.status.is_terminal() {
return Ok((StatusCode::OK, Json(record)));
}
return Err(ApiError::conflict(format!(
"agent run '{run_id}' belongs to a session this runtime is not hosting; stop it from that session"
)));
};
engine
.send(crate::core::ops::Op::CancelSubAgent {
agent_id: record.spec.worker_id.clone(),
})
.await
.map_err(|err| ApiError::internal(format!("Failed to reach the run's engine: {err}")))?;
let settled = |current: &AgentWorkerRecord| {
current.status.is_terminal()
&& (current.status != AgentWorkerStatus::Interrupted
|| current.latest_message != record.latest_message)
};
let deadline = tokio::time::Instant::now() + AGENT_RUN_CANCEL_SETTLE;
loop {
let current = find_persisted(&state.workspace)?.unwrap_or_else(|| record.clone());
if settled(&current) {
return Ok((StatusCode::OK, Json(current)));
}
if tokio::time::Instant::now() >= deadline {
return Ok((StatusCode::ACCEPTED, Json(current)));
}
tokio::time::sleep(Duration::from_millis(100)).await;
}
}
async fn list_fleet_profiles(
State(state): State<RuntimeApiState>,
) -> Result<Json<Value>, ApiError> {
+120
View File
@@ -2850,6 +2850,126 @@ async fn agent_runs_runtime_api_exposes_persisted_worker_receipts() -> Result<()
Ok(())
}
#[tokio::test]
async fn agent_run_cancel_stops_a_live_child_and_returns_its_receipt() -> Result<()> {
let root = std::env::temp_dir().join(format!("codewhale-agent-run-cancel-{}", Uuid::new_v4()));
let workspace = root.join("workspace");
fs::create_dir_all(&workspace)?;
let manager = crate::tools::subagent::new_shared_subagent_manager(workspace.clone(), 2);
let agent_id = {
let mut guard = manager.write().await;
let id = guard.insert_test_running_agent("stoppable", &workspace);
guard.assign_test_session_owner(&id, "session-stop");
id
};
let Some((addr, _runtime_threads, handle)) =
spawn_test_server_with_root_token_mobile_workspace_and_subagents(
root.clone(),
root.join("sessions"),
None,
false,
workspace,
Some(manager.clone()),
None,
)
.await?
else {
return Ok(());
};
let client = crate::tls::reqwest_client();
let response = client
.post(format!("http://{addr}/v1/agent-runs/{agent_id}/cancel"))
.send()
.await?;
assert_eq!(response.status(), StatusCode::OK);
let receipt: serde_json::Value = response.json().await?;
assert_eq!(receipt["spec"]["worker_id"], agent_id.as_str());
assert_eq!(receipt["status"], "cancelled");
assert_eq!(
manager.read().await.get_result(&agent_id)?.status,
crate::tools::subagent::SubAgentStatus::Cancelled
);
// Stopping a stopped run is a no-op that answers with the same receipt.
let again = client
.post(format!("http://{addr}/v1/agent-runs/{agent_id}/cancel"))
.send()
.await?;
assert_eq!(again.status(), StatusCode::OK);
let again: serde_json::Value = again.json().await?;
assert_eq!(again["status"], "cancelled");
let missing = client
.post(format!("http://{addr}/v1/agent-runs/missing/cancel"))
.send()
.await?
.status();
assert_eq!(missing, StatusCode::NOT_FOUND);
handle.abort();
Ok(())
}
#[tokio::test]
async fn agent_run_cancel_refuses_a_run_owned_by_a_session_it_does_not_host() -> Result<()> {
let root = std::env::temp_dir().join(format!(
"codewhale-agent-run-cancel-foreign-{}",
Uuid::new_v4()
));
let workspace = root.join("workspace");
fs::create_dir_all(workspace.join(".codewhale/state"))?;
// A child parked on a question in another terminal session: in flight on
// disk, but no engine in this runtime owns it.
let mut record = {
let manager = crate::tools::subagent::new_shared_subagent_manager(workspace.clone(), 1);
let mut guard = manager.write().await;
let id = guard.insert_test_running_agent("elsewhere", &workspace);
guard.assign_test_session_owner(&id, "terminal-session");
guard
.list_worker_records()
.into_iter()
.find(|record| record.spec.worker_id == id)
.expect("seeded record")
};
record.status = crate::tools::subagent::AgentWorkerStatus::WaitingForUser;
fs::write(
workspace.join(".codewhale/state/subagents.v1.json"),
serde_json::to_vec_pretty(&json!({
"schema_version": 1,
"agents": [],
"workers": [record],
}))?,
)?;
let Some((addr, _runtime_threads, handle)) =
spawn_test_server_with_root_token_mobile_workspace(
root.clone(),
root.join("sessions"),
None,
false,
workspace,
)
.await?
else {
return Ok(());
};
let client = crate::tls::reqwest_client();
let response = client
.post(format!(
"http://{addr}/v1/agent-runs/agent_elsewhere/cancel"
))
.send()
.await?;
assert_eq!(response.status(), StatusCode::CONFLICT);
let body = response.text().await?;
assert!(body.contains("not hosting"), "{body}");
handle.abort();
Ok(())
}
#[tokio::test]
async fn stream_requires_prompt() -> Result<()> {
let Some((addr, _runtime_threads, handle)) = spawn_test_server().await? else {
+12
View File
@@ -11215,6 +11215,18 @@ impl RuntimeThreadManager {
}
}
/// The thread's engine only when it is already live in this process.
/// Control routes that act on in-flight work (stopping an agent run) use
/// this: loading a cold engine cannot reach work that is not running here.
pub async fn loaded_engine(&self, thread_id: &str) -> Option<EngineHandle> {
self.active
.lock()
.await
.engines
.get(thread_id)
.map(|state| state.engine.clone())
}
/// Get the engine handle for a thread, loading it if necessary.
/// Public wrapper around the private `ensure_engine_loaded`.
pub async fn get_engine(&self, thread_id: &str) -> Result<EngineHandle> {
+3 -2
View File
@@ -38,8 +38,9 @@ hash is unchanged: a bundle you trusted and enabled stays trusted and enabled
on the new build. When the capabilities changed, it shows
`capabilities-changed` and stays off until you review it again with
`/plugin show computer-use` and `/plugin trust computer-use`. If you revoked
trust on any earlier build, nothing carries and the new build waits for a
fresh review. User and workspace plugins never carry trust: changed bytes
trust after your most recent review, nothing carries and the new build waits
for a fresh review; once you review a build again, later upgrades carry that
review. User and workspace plugins never carry trust: changed bytes
always need review.
## Chromewhale
+17 -3
View File
@@ -1999,6 +1999,7 @@ a read-only inspection surface:
|---|---|
| List persisted agent runs | `GET /v1/agent-runs` |
| Inspect one run | `GET /v1/agent-runs/{run_id}` |
| Stop one run | `POST /v1/agent-runs/{run_id}/cancel` |
The response is the same worker-record shape surfaced by `agent` receipts:
`spec.run_id`, `actor_kind`, lifecycle `status`, bounded `events`,
@@ -2006,9 +2007,22 @@ The response is the same worker-record shape surfaced by `agent` receipts:
falls back to the worker id for older records, and `{run_id}` may be either the
run id or the worker id.
These endpoints do not start, cancel, or steer sub-agents. The API surface
exists so app/editor/headless clients can inspect the same handoff receipts that
the TUI and parent model see.
These endpoints do not start or steer sub-agents. The API surface exists so
app/editor/headless clients can inspect the same handoff receipts that the TUI
and parent model see, and stop a run they are showing.
`POST /v1/agent-runs/{run_id}/cancel` takes no body. It stops the run through
the same session-scoped path as the TUI's stop and the `agent/cancel` tool:
descendants stop with it, and a write-scoped child's changed files are named in
its result rather than dropped. It answers with the worker record:
- `200` when the record is terminal (stopping an already-finished run is a
no-op that returns its receipt);
- `202` when the owning engine accepted the stop but has not recorded the
terminal receipt within a few seconds; poll `GET /v1/agent-runs/{run_id}`;
- `404` for an unknown run;
- `409` when the run belongs to a session this runtime is not hosting (for
example a separate terminal session); stop it from that session.
## Session lifecycle (native UI supervision)