mirror of
https://github.com/codewhale-hq/Codewhale.git
synced 2026-11-11 01:51:34 -10:00
Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1e2106e63a | ||
|
|
d1ea4bcecc | ||
|
|
bb07a62382 |
No files matched your search
@@ -49,6 +49,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
- ACP: editors can start, list, load, prompt and cancel Codewhale sessions and approve or reject tool permissions through the Runtime; prompts with image or audio blocks and relative session directories are now refused instead of ignored, and edit and read tools report the right kind. Sessions an editor drives now run the plugin tools (TypeScript and DSH, when the extension host is on) and the MCP servers configured in Codewhale; their calls show as ACP tool calls and ask through the session's permission setting, and `initialize` reports the state under `agentCapabilities._meta.codewhale`. MCP servers an editor passes in `mcpServers` and configured shell hooks still do not run in ACP sessions. A real-editor journey is not yet proved.
|
||||
- Extension host: the `sessions` and `systemPrompt` services now come from a reviewed DSH adapter instead of inline shims, and `sessions.flush` refuses an empty checkpoint ticket. Session reads and events and the `agent/pre-step` hook are still not available to plugins. (CW-7959)
|
||||
- Extension host: DSH service adapters now have one ticketed `dsh/broker` request to the core, each ticket is bound to the plugin, the service and the session, and every service (workspace, llm, network, settings, catalog, terminal and remote) refuses with a typed `not_available` until its broker lands. Nothing new is offered to plugins yet. (CW-8012)
|
||||
- **Plugin host services and who approved:** a plugin can now be granted a Codewhale host service, and only by a review that shows it. The review names each service and the plugins it is shared with, and every review records who approved it: you, the agent (in Full Access, in Auto-Review, or from a shell command carrying the agent's mark), or the command line with `--yes`; who turned the plugin on is recorded too. Plugins share one process, so a service works only while every loaded plugin of the same kind holds the same grant. A plugin the agent approved is active only in its own project and its code does not run beside the plugins you reviewed until you review it. No service is offered yet, and a plugin reviewed before this release holds no grant until it is reviewed again. Once a plugin has been reviewed or turned on in this release, Codewhale 0.10.2 and earlier cannot read the plugin state file and turn every plugin off when run on the same home folder. (CW-8012)
|
||||
- Fleet member starts are now durable: replaying an operation key after a timeout or restart returns the original worker, and GET /v1/threads/{id}/members/operations/{key} reports the outcome. A member start now answers with `state` `accepted` (200) or `unknown` (202) instead of `started` or `queued`. Verified by local HTTP tests on Linux; a real Engine restart is simulated, not exercised. (CW-7964)
|
||||
- Plugin activation now says when a requested service belongs to the Codewhale core and is not exposed to plugins yet. No plugin, provider or installed-app run exercised it; checked by the extension-host tests only.
|
||||
- Codex App Server v2 contract record (paused, not part of 0.10.3): the selected Codex v2 contract is frozen as a generated manifest in the protocol crate, with typed Rust and TypeScript shapes checked against the pinned Codex schema. It changes no wire behavior and adds no dispatch, so nothing is reachable through it yet.
|
||||
|
||||
@@ -43,7 +43,8 @@
|
||||
//! Full Access still opens their card. Auto-Review and Never refuse them
|
||||
//! (`resolve_approval_request_disposition`); explicit denials always win.
|
||||
//!
|
||||
//! **Caps.** Per invocation: 50 `core/call`s in all (the ticket's uses), 4 at
|
||||
//! **Caps.** Per invocation: 50 core tool calls in all, counted together
|
||||
//! across `core/call` and the nested calls of broker arms, 4 at
|
||||
//! once (code mode's own cap), and one approval card at a time (the turn
|
||||
//! loop serves one request at a time). Per host: the channel's 256 in-flight
|
||||
//! host requests. A burst of invalid tickets ends the host (`ticket`).
|
||||
@@ -102,6 +103,7 @@ pub const MAX_CALLS_PER_INVOCATION: u32 = 50;
|
||||
/// A backstop only: an invocation ends (and revokes its ticket) long before
|
||||
/// this, and its own deadline pauses while a person decides.
|
||||
const TICKET_TTL: Duration = Duration::from_secs(24 * 60 * 60);
|
||||
const GRANT_PROBE: Duration = Duration::from_millis(50);
|
||||
/// Receipts attached to an extension tool's result metadata.
|
||||
pub const RECEIPTS_ATTACHED: usize = 50;
|
||||
|
||||
@@ -266,7 +268,7 @@ pub struct Invocation {
|
||||
gate: NestedCallGate,
|
||||
root_caller: Option<SessionCheckpointCaller>,
|
||||
broker: Option<BrokerScope>,
|
||||
broker_core_calls: AtomicU32,
|
||||
nested_calls: AtomicU32,
|
||||
tickets: Vec<Ticket>,
|
||||
caller_tools: Vec<String>,
|
||||
activations: Mutex<
|
||||
@@ -318,18 +320,13 @@ impl BrokerContext {
|
||||
NestedFailure::Unavailable("the broker call was withdrawn".to_string()),
|
||||
));
|
||||
}
|
||||
if self
|
||||
.invocation
|
||||
.broker_core_calls
|
||||
.fetch_add(1, Ordering::SeqCst)
|
||||
>= MAX_CALLS_PER_INVOCATION
|
||||
{
|
||||
if self.invocation.nested_calls.fetch_add(1, Ordering::SeqCst) >= MAX_CALLS_PER_INVOCATION {
|
||||
return Err(BrokerRefusal::nested(
|
||||
self.service,
|
||||
NestedFailure::Rejected {
|
||||
decision: NestedDecision::Refused,
|
||||
message: format!(
|
||||
"an invocation's brokers may make at most {MAX_CALLS_PER_INVOCATION} core tool calls"
|
||||
"a tool call may make at most {MAX_CALLS_PER_INVOCATION} core tool calls"
|
||||
),
|
||||
},
|
||||
));
|
||||
@@ -365,7 +362,7 @@ impl BrokerContext {
|
||||
gate,
|
||||
root_caller: None,
|
||||
broker: None,
|
||||
broker_core_calls: AtomicU32::new(0),
|
||||
nested_calls: AtomicU32::new(0),
|
||||
tickets: Vec::new(),
|
||||
caller_tools: Vec::new(),
|
||||
activations: Mutex::new(HashMap::new()),
|
||||
@@ -414,6 +411,7 @@ impl CoreCalls {
|
||||
gate,
|
||||
None,
|
||||
String::new(),
|
||||
&dsh_broker::test_grants().unwrap_or_default(),
|
||||
)
|
||||
}
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
@@ -428,6 +426,7 @@ impl CoreCalls {
|
||||
gate: &NestedCallGate,
|
||||
scope: Option<codewhale_config::EntryRef>,
|
||||
content_hash: String,
|
||||
broker_services: &[DshService],
|
||||
) -> Option<InvocationGuard> {
|
||||
let (caller, specs) = gate.extension()?;
|
||||
if caller != expected {
|
||||
@@ -517,7 +516,7 @@ impl CoreCalls {
|
||||
.flat_map(|session| {
|
||||
DshService::ALL
|
||||
.into_iter()
|
||||
.filter(|service| dsh_broker::granted(*service))
|
||||
.filter(|service| tier == HostTier::Plugin && broker_services.contains(service))
|
||||
.map(move |service| {
|
||||
(
|
||||
service,
|
||||
@@ -566,7 +565,7 @@ impl CoreCalls {
|
||||
gate: gate.clone(),
|
||||
root_caller,
|
||||
broker,
|
||||
broker_core_calls: AtomicU32::new(0),
|
||||
nested_calls: AtomicU32::new(0),
|
||||
tickets: std::iter::once(ticket.clone())
|
||||
.chain(checkpoint_ticket.iter().cloned())
|
||||
.chain(question_ticket.iter().cloned())
|
||||
@@ -703,10 +702,30 @@ impl CoreCalls {
|
||||
method: &str,
|
||||
cx: &HostRequestContext,
|
||||
) -> Result<Arc<Invocation>, RpcErrorWire> {
|
||||
let refuse = |code, message| RpcErrorWire {
|
||||
code,
|
||||
message,
|
||||
data: None,
|
||||
self.redeem_invocation_or_limit(shared, tier, host_generation, owner, ticket, method, cx)
|
||||
.map_err(|(_, error)| error)
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn redeem_invocation_or_limit(
|
||||
&self,
|
||||
shared: &ManagerShared,
|
||||
tier: HostTier,
|
||||
host_generation: u64,
|
||||
owner: &OwnerRef,
|
||||
ticket: &str,
|
||||
method: &str,
|
||||
cx: &HostRequestContext,
|
||||
) -> Result<Arc<Invocation>, (bool, RpcErrorWire)> {
|
||||
let refuse = |code, message| {
|
||||
(
|
||||
false,
|
||||
RpcErrorWire {
|
||||
code,
|
||||
message,
|
||||
data: None,
|
||||
},
|
||||
)
|
||||
};
|
||||
let redeemed = self.tickets.redeem(&Presented {
|
||||
ticket,
|
||||
@@ -723,10 +742,8 @@ impl CoreCalls {
|
||||
"too many invalid {method} tickets from one host process"
|
||||
));
|
||||
}
|
||||
return Err(refuse(
|
||||
error_code::REFUSED,
|
||||
refused.reason.describe().to_string(),
|
||||
));
|
||||
let (_, error) = refuse(error_code::REFUSED, refused.reason.describe().to_string());
|
||||
return Err((refused.reason == super::ticket::Refusal::Exhausted, error));
|
||||
}
|
||||
// The redemption proved the ticket is live and ours; the owner must
|
||||
// still be the current one of this tier.
|
||||
@@ -1257,7 +1274,7 @@ impl CoreCalls {
|
||||
let service = DshService::parse(¶ms.service)
|
||||
.ok_or_else(|| BrokerRefusal::UnknownService(params.service.clone()).wire())?;
|
||||
let invocation = self
|
||||
.redeem_invocation(
|
||||
.redeem_invocation_or_limit(
|
||||
shared,
|
||||
tier,
|
||||
host_generation,
|
||||
@@ -1266,14 +1283,24 @@ impl CoreCalls {
|
||||
dsh_broker::ticket_method(service),
|
||||
&cx,
|
||||
)
|
||||
.map_err(|error| dsh_broker::coded(error, dsh_broker::TICKET_REFUSED))?;
|
||||
.map_err(|(limit, mut error)| {
|
||||
if limit {
|
||||
error.message = format!(
|
||||
"this tool call's limit for the {} service is used up",
|
||||
service.name()
|
||||
);
|
||||
dsh_broker::coded(error, dsh_broker::LIMIT)
|
||||
} else {
|
||||
dsh_broker::coded(error, dsh_broker::TICKET_REFUSED)
|
||||
}
|
||||
})?;
|
||||
let (session_id, tool) = invocation
|
||||
.broker
|
||||
.as_ref()
|
||||
.map(|scope| (scope.session_id.clone(), Arc::clone(&scope.context)))
|
||||
.ok_or_else(|| refused("dsh/broker requires an invocation in a session".into()))?;
|
||||
if !dsh_broker::granted(service) {
|
||||
return Err(BrokerRefusal::NotAvailable(service).wire());
|
||||
if !shared.broker_granted(¶ms.owner, service).await {
|
||||
return Err(BrokerRefusal::NotGranted(service).wire());
|
||||
}
|
||||
let arm = dsh_broker::arm_in(table, service)
|
||||
.ok_or_else(|| BrokerRefusal::NotAvailable(service).wire())?;
|
||||
@@ -1292,10 +1319,19 @@ impl CoreCalls {
|
||||
cancel: withdraw.clone(),
|
||||
},
|
||||
};
|
||||
let withdrawn = async {
|
||||
loop {
|
||||
tokio::time::sleep(GRANT_PROBE).await;
|
||||
if !shared.broker_granted(¶ms.owner, service).await {
|
||||
break;
|
||||
}
|
||||
}
|
||||
};
|
||||
let outcome = tokio::select! {
|
||||
biased;
|
||||
() = cx.cancel.cancelled() => return Err(cancelled("dsh/broker was cancelled by its host")),
|
||||
() = withdraw.cancelled() => return Err(cancelled("dsh/broker invocation ended")),
|
||||
() = withdrawn => return Err(BrokerRefusal::NotGranted(service).wire()),
|
||||
outcome = arm(call) => outcome,
|
||||
};
|
||||
if shared
|
||||
@@ -1319,6 +1355,9 @@ impl CoreCalls {
|
||||
invocation.scope.as_ref(),
|
||||
)
|
||||
.map_err(refused)?;
|
||||
if !shared.broker_granted(¶ms.owner, service).await {
|
||||
return Err(BrokerRefusal::NotGranted(service).wire());
|
||||
}
|
||||
outcome.map_err(BrokerRefusal::wire)
|
||||
}
|
||||
|
||||
@@ -1347,6 +1386,12 @@ impl CoreCalls {
|
||||
METHOD,
|
||||
&cx,
|
||||
)?;
|
||||
if invocation.nested_calls.fetch_add(1, Ordering::SeqCst) >= MAX_CALLS_PER_INVOCATION {
|
||||
return Err(refuse(
|
||||
error_code::REFUSED,
|
||||
super::ticket::Refusal::Exhausted.describe().to_string(),
|
||||
));
|
||||
}
|
||||
// Withdrawn when the host cancels this request, its owner is revoked,
|
||||
// the host exits (`cx.cancel`), or the invocation ends.
|
||||
let withdraw = invocation.cancel.child_token();
|
||||
|
||||
@@ -2041,8 +2041,9 @@ async fn dsh_broker_services_are_granted_by_one_check_and_an_ungranted_service_i
|
||||
assert!(
|
||||
DshService::ALL
|
||||
.into_iter()
|
||||
.all(|service| !super::dsh_broker::granted(service))
|
||||
.all(|service| !super::dsh_broker::offered(service))
|
||||
);
|
||||
assert!(f.manager.shared.broker_services(&f.owner).is_empty());
|
||||
let none = f.begin(7, &f.owner);
|
||||
assert!(
|
||||
none.broker_tickets().is_none(),
|
||||
@@ -2132,14 +2133,17 @@ async fn dsh_broker_services_are_granted_by_one_check_and_an_ungranted_service_i
|
||||
.await;
|
||||
assert_eq!(
|
||||
dsh_refusal(&withdrawn),
|
||||
(error_code::NOT_AVAILABLE, "not_available".into()),
|
||||
(error_code::REFUSED, "not_granted".into()),
|
||||
"a grant withdrawn after minting refuses {} before the arm runs",
|
||||
service.name()
|
||||
);
|
||||
assert_eq!(
|
||||
withdrawn.unwrap_err().message,
|
||||
format!("the {} broker is not available yet", service.name()),
|
||||
"the refusal names no grant"
|
||||
format!(
|
||||
"the {} service is not granted to this plugin",
|
||||
service.name()
|
||||
),
|
||||
"the refusal names no other plugin and no reason"
|
||||
);
|
||||
drop(guard);
|
||||
}
|
||||
@@ -2493,6 +2497,15 @@ async fn an_invocations_brokers_share_one_cap_on_nested_core_calls() {
|
||||
f.server.asked.load(Ordering::SeqCst),
|
||||
MAX_CALLS_PER_INVOCATION as usize
|
||||
);
|
||||
let (direct, _) = f.serve(HostTier::Plugin, 7, &f.owner, guard.ticket()).await;
|
||||
assert!(
|
||||
refused(&direct),
|
||||
"core/call and the brokers share the one cap: {direct:?}"
|
||||
);
|
||||
assert_eq!(
|
||||
f.server.asked.load(Ordering::SeqCst),
|
||||
MAX_CALLS_PER_INVOCATION as usize
|
||||
);
|
||||
drop(guard);
|
||||
}
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ use serde::de::DeserializeOwned;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::protocol::{DshService, RpcErrorWire, error_code};
|
||||
use super::registry::OwnerRegistry;
|
||||
use super::{
|
||||
dsh_catalog, dsh_llm, dsh_network, dsh_remote, dsh_settings, dsh_terminal, dsh_workspace,
|
||||
};
|
||||
@@ -34,6 +35,7 @@ pub enum BrokerRefusal {
|
||||
message: String,
|
||||
},
|
||||
NotAvailable(DshService),
|
||||
NotGranted(DshService),
|
||||
Denied {
|
||||
service: DshService,
|
||||
message: String,
|
||||
@@ -52,6 +54,7 @@ impl BrokerRefusal {
|
||||
Self::UnknownMethod { .. } => "unknown_method",
|
||||
Self::InvalidInput { .. } => "invalid_input",
|
||||
Self::NotAvailable(_) => "not_available",
|
||||
Self::NotGranted(_) => "not_granted",
|
||||
Self::Denied { .. } => "denied",
|
||||
Self::Refused { .. } => "refused",
|
||||
}
|
||||
@@ -85,6 +88,14 @@ impl BrokerRefusal {
|
||||
format!("the {} broker is not available yet", service.name()),
|
||||
Value::String(service.name().into()),
|
||||
),
|
||||
Self::NotGranted(service) => (
|
||||
error_code::REFUSED,
|
||||
format!(
|
||||
"the {} service is not granted to this plugin",
|
||||
service.name()
|
||||
),
|
||||
Value::String(service.name().into()),
|
||||
),
|
||||
Self::Denied { service, message } => (
|
||||
error_code::DENIED,
|
||||
message,
|
||||
@@ -118,6 +129,7 @@ impl BrokerRefusal {
|
||||
}
|
||||
|
||||
pub const TICKET_REFUSED: &str = "ticket_refused";
|
||||
pub const LIMIT: &str = "limit";
|
||||
pub const CANCELLED: &str = "cancelled";
|
||||
|
||||
#[must_use]
|
||||
@@ -139,8 +151,6 @@ pub const TABLE: &[(DshService, Arm)] = &[
|
||||
(DshService::Remote, dsh_remote::serve as Arm),
|
||||
];
|
||||
|
||||
const GRANTED: &[DshService] = &[];
|
||||
|
||||
#[cfg(test)]
|
||||
thread_local! {
|
||||
static TEST_GRANTS: std::cell::RefCell<Option<Vec<DshService>>> =
|
||||
@@ -165,13 +175,36 @@ impl Drop for TestGrants {
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(super) fn test_grants() -> Option<Vec<DshService>> {
|
||||
TEST_GRANTS.with(|grants| grants.borrow().clone())
|
||||
}
|
||||
|
||||
#[cfg(not(test))]
|
||||
pub(super) fn test_grants() -> Option<Vec<DshService>> {
|
||||
None
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn granted(service: DshService) -> bool {
|
||||
#[cfg(test)]
|
||||
if let Some(granted) = TEST_GRANTS.with(|grants| grants.borrow().clone()) {
|
||||
return granted.contains(&service);
|
||||
pub fn offered(service: DshService) -> bool {
|
||||
use codewhale_plugins::host_services::HostService;
|
||||
HostService::parse(service.name()).is_some_and(codewhale_plugins::host_services::offered)
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn granted(registry: &OwnerRegistry, owner: &OwnerRef, service: DshService) -> bool {
|
||||
match test_grants() {
|
||||
Some(granted) => granted.contains(&service),
|
||||
None => offered(service) && registry.host_service_granted(owner, service),
|
||||
}
|
||||
GRANTED.contains(&service)
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn granted_services(registry: &OwnerRegistry, owner: &OwnerRef) -> Vec<DshService> {
|
||||
DshService::ALL
|
||||
.into_iter()
|
||||
.filter(|service| granted(registry, owner, *service))
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
|
||||
@@ -0,0 +1,879 @@
|
||||
use std::path::Path;
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::time::Duration;
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use tokio::sync::mpsc;
|
||||
|
||||
use super::core_call::InvocationGuard;
|
||||
use super::dsh_broker::{Arm, BrokerCall, BrokerFuture, BrokerMethods, BrokerRefusal, decode};
|
||||
use super::protocol::{DshBrokerParams, DshService, HostRequest, RpcErrorWire, error_code};
|
||||
use super::supervisor::HostRequestContext;
|
||||
use super::tier::HostTier;
|
||||
use super::{ExtensionHostManager, ExtensionHostOptions};
|
||||
use crate::tools::codemode::{ExtensionCaller, NestedCallGate};
|
||||
use crate::tools::spec::ToolContext;
|
||||
use codewhale_config::OwnerRef;
|
||||
use codewhale_plugins::PluginRegistry;
|
||||
use codewhale_plugins::activation::TestPolicyGuard;
|
||||
use codewhale_plugins::discovery::{DiscoveryConfig, discover_with_config};
|
||||
use codewhale_plugins::host_services::{
|
||||
AgentApproval, AgentPosture, HostService, PersonVia, PluginApprover, ReviewApproval, TestOffer,
|
||||
};
|
||||
|
||||
const HOST: u64 = 7;
|
||||
|
||||
struct Plugins {
|
||||
_temp: tempfile::TempDir,
|
||||
config: DiscoveryConfig,
|
||||
}
|
||||
|
||||
impl Plugins {
|
||||
fn new(plugins: &[(&str, &[&str])]) -> Self {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let root = temp.path();
|
||||
let config = DiscoveryConfig {
|
||||
workspace: root.join("project"),
|
||||
user_plugins_dir: root.join("user"),
|
||||
workspace_plugins_dir: root.join("workspace"),
|
||||
builtin_plugin_dirs: Vec::new(),
|
||||
state_path: root.join("state/plugin-state.json"),
|
||||
};
|
||||
std::fs::create_dir_all(&config.workspace).unwrap();
|
||||
for (name, asks) in plugins {
|
||||
let plugin = config.user_plugins_dir.join(name);
|
||||
std::fs::create_dir_all(plugin.join("native")).unwrap();
|
||||
std::fs::write(
|
||||
plugin.join("native/index.mjs"),
|
||||
"export function apply() {}\n",
|
||||
)
|
||||
.unwrap();
|
||||
let asked = if asks.is_empty() {
|
||||
String::new()
|
||||
} else {
|
||||
format!(
|
||||
"[capabilities]\nhost_services = [{}]\n",
|
||||
asks.iter()
|
||||
.map(|service| format!("{service:?}"))
|
||||
.collect::<Vec<_>>()
|
||||
.join(", ")
|
||||
)
|
||||
};
|
||||
std::fs::write(
|
||||
plugin.join("plugin.toml"),
|
||||
format!(
|
||||
"schema_version = 1\n[plugin]\nname = {name:?}\nversion = \"1.0.0\"\n[native]\npath = \"native/index.mjs\"\n{asked}"
|
||||
),
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
let plugins_fixture = Self {
|
||||
_temp: temp,
|
||||
config,
|
||||
};
|
||||
for (name, _) in plugins {
|
||||
plugins_fixture.review(name, true);
|
||||
}
|
||||
plugins_fixture
|
||||
}
|
||||
|
||||
fn registry(&self) -> PluginRegistry {
|
||||
discover_with_config(&self.config)
|
||||
}
|
||||
|
||||
fn review(&self, name: &str, showing_services: bool) {
|
||||
let mut registry = self.registry();
|
||||
let shown = registry
|
||||
.host_service_review(name)
|
||||
.filter(|_| showing_services)
|
||||
.map(|review| review.token);
|
||||
registry
|
||||
.trust_reviewed(
|
||||
name,
|
||||
ReviewApproval {
|
||||
approved_by: PluginApprover::person(PersonVia::Terminal),
|
||||
host_services: shown,
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
registry.enable(name).unwrap();
|
||||
}
|
||||
|
||||
fn review_by_agent(&self, name: &str) {
|
||||
let mut registry = self.registry();
|
||||
let approver = PluginApprover::Agent(AgentApproval {
|
||||
posture: AgentPosture::FullAccess,
|
||||
workspace: self.config.workspace.to_string_lossy().into_owned(),
|
||||
thread_id: Some("thread".into()),
|
||||
turn_id: Some("turn".into()),
|
||||
tool_call_id: Some("call".into()),
|
||||
});
|
||||
let shown = registry
|
||||
.host_service_review_for(name, &approver)
|
||||
.map(|review| review.token);
|
||||
registry
|
||||
.trust_reviewed(
|
||||
name,
|
||||
ReviewApproval {
|
||||
approved_by: approver.clone(),
|
||||
host_services: shown,
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
registry.enable_by(name, approver).unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
struct Host {
|
||||
manager: ExtensionHostManager,
|
||||
context: ToolContext,
|
||||
gate: NestedCallGate,
|
||||
}
|
||||
|
||||
fn host(workspace: &Path) -> Host {
|
||||
let manager = ExtensionHostManager::new(ExtensionHostOptions::default());
|
||||
let (tx_event, mut rx_event) = mpsc::channel(8);
|
||||
tokio::spawn(async move { while rx_event.recv().await.is_some() {} });
|
||||
let (gate, _requests) = NestedCallGate::new(None, tx_event, Duration::from_secs(60));
|
||||
let context = ToolContext::new(workspace)
|
||||
.with_session_objects(crate::rlm::session::SessionObjectSnapshot::new(
|
||||
"grant-session".into(),
|
||||
"fixture-model".into(),
|
||||
workspace.to_path_buf(),
|
||||
None,
|
||||
Arc::new(Vec::new()),
|
||||
))
|
||||
.with_origin_turn_id("grant-turn");
|
||||
Host {
|
||||
manager,
|
||||
context,
|
||||
gate: gate.for_extension(
|
||||
ExtensionCaller {
|
||||
origin: "extension:grant".to_string(),
|
||||
tool: "grant_tool".to_string(),
|
||||
scope: "ext:grant@hash".to_string(),
|
||||
},
|
||||
Vec::new(),
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
impl Host {
|
||||
fn load(&self, plugins: &Plugins, name: &str) -> OwnerRef {
|
||||
let authority = plugins.registry().authority_for(name).unwrap();
|
||||
let services = codewhale_plugins::registry::granted_host_services(&authority)
|
||||
.unwrap()
|
||||
.into_iter()
|
||||
.filter_map(|service| DshService::parse(service.name()))
|
||||
.collect();
|
||||
let mut registry = self.manager.shared.registry.lock().unwrap();
|
||||
let owner = registry
|
||||
.begin_owner(
|
||||
HostTier::Plugin,
|
||||
authority.plugin_id.as_str(),
|
||||
name,
|
||||
Some(authority.clone()),
|
||||
&authority.content_hash,
|
||||
)
|
||||
.unwrap();
|
||||
registry.set_host_services(&owner, services);
|
||||
registry.mark_active(&owner);
|
||||
owner
|
||||
}
|
||||
|
||||
fn mint(&self, tier: HostTier, owner: &OwnerRef) -> InvocationGuard {
|
||||
self.manager
|
||||
.shared
|
||||
.core_calls
|
||||
.begin_scoped(
|
||||
tier,
|
||||
HOST,
|
||||
owner,
|
||||
"call-1",
|
||||
self.gate.extension().unwrap().0,
|
||||
&self.context,
|
||||
&self.gate,
|
||||
None,
|
||||
String::new(),
|
||||
&self.manager.shared.broker_services(owner),
|
||||
)
|
||||
.expect("an invocation")
|
||||
}
|
||||
|
||||
fn services(&self, owner: &OwnerRef) -> Vec<&'static str> {
|
||||
self.mint(HostTier::Plugin, owner)
|
||||
.broker_tickets()
|
||||
.unwrap_or_default()
|
||||
.into_iter()
|
||||
.filter_map(|wire| DshService::parse(&wire.service).map(DshService::name))
|
||||
.collect()
|
||||
}
|
||||
|
||||
async fn call(
|
||||
&self,
|
||||
table: &[(DshService, Arm)],
|
||||
owner: &OwnerRef,
|
||||
ticket: &str,
|
||||
service: &str,
|
||||
) -> (Result<Value, RpcErrorWire>, Vec<String>) {
|
||||
let (cx, mut violations, _cancel) = HostRequestContext::for_test(1);
|
||||
let result = self
|
||||
.manager
|
||||
.shared
|
||||
.core_calls
|
||||
.serve_broker_in(
|
||||
table,
|
||||
&self.manager.shared,
|
||||
HostTier::Plugin,
|
||||
HOST,
|
||||
DshBrokerParams {
|
||||
owner: owner.clone(),
|
||||
ticket: ticket.to_string(),
|
||||
service: service.to_string(),
|
||||
method: "echo".to_string(),
|
||||
input: json!({ "text": "ok", "granted": ["llm"], "host_services": ["llm"] }),
|
||||
},
|
||||
cx,
|
||||
)
|
||||
.await;
|
||||
let mut seen = Vec::new();
|
||||
while let Ok(violation) = violations.try_recv() {
|
||||
seen.push(violation);
|
||||
}
|
||||
(result, seen)
|
||||
}
|
||||
}
|
||||
|
||||
fn ticket(guard: &InvocationGuard, service: &str) -> String {
|
||||
guard
|
||||
.broker_tickets()
|
||||
.unwrap_or_default()
|
||||
.into_iter()
|
||||
.find(|wire| wire.service == service)
|
||||
.map(|wire| wire.ticket)
|
||||
.unwrap_or_else(|| panic!("no {service} ticket"))
|
||||
}
|
||||
|
||||
fn refusal(result: &Result<Value, RpcErrorWire>) -> (i64, String, String) {
|
||||
let error = result.as_ref().expect_err("a refusal");
|
||||
(
|
||||
error.code,
|
||||
error.data.as_ref().unwrap()["dsh_refusal"]
|
||||
.as_str()
|
||||
.unwrap()
|
||||
.to_string(),
|
||||
error.message.clone(),
|
||||
)
|
||||
}
|
||||
|
||||
fn not_granted(service: &str) -> (i64, String, String) {
|
||||
(
|
||||
error_code::REFUSED,
|
||||
"not_granted".to_string(),
|
||||
format!("the {service} service is not granted to this plugin"),
|
||||
)
|
||||
}
|
||||
|
||||
#[derive(serde::Deserialize)]
|
||||
#[serde(tag = "method", content = "input")]
|
||||
enum Echo {
|
||||
#[serde(rename = "echo")]
|
||||
Echo { text: String },
|
||||
}
|
||||
|
||||
impl BrokerMethods for Echo {
|
||||
const NAMES: &'static [&'static str] = &["echo"];
|
||||
}
|
||||
|
||||
fn echo_arm(call: BrokerCall) -> BrokerFuture {
|
||||
Box::pin(async move {
|
||||
let Echo::Echo { text } = decode::<Echo>(&call)?;
|
||||
Ok(json!({ "plugin": call.owner.plugin_id, "service": call.service.name(), "text": text }))
|
||||
})
|
||||
}
|
||||
|
||||
fn echo() -> Vec<(DshService, Arm)> {
|
||||
DshService::ALL
|
||||
.into_iter()
|
||||
.map(|service| (service, echo_arm as Arm))
|
||||
.collect()
|
||||
}
|
||||
|
||||
static WAITING: AtomicBool = AtomicBool::new(false);
|
||||
static WITHDRAWN: AtomicBool = AtomicBool::new(false);
|
||||
|
||||
fn waiting_arm(call: BrokerCall) -> BrokerFuture {
|
||||
Box::pin(async move {
|
||||
let cancel = call.context.cancel().clone();
|
||||
tokio::spawn(async move {
|
||||
cancel.cancelled().await;
|
||||
WITHDRAWN.store(true, Ordering::SeqCst);
|
||||
});
|
||||
WAITING.store(true, Ordering::SeqCst);
|
||||
std::future::pending::<()>().await;
|
||||
Err(BrokerRefusal::NotAvailable(call.service))
|
||||
})
|
||||
}
|
||||
|
||||
async fn until(what: &str, done: impl Fn() -> bool) {
|
||||
for _ in 0..400 {
|
||||
if done() {
|
||||
return;
|
||||
}
|
||||
tokio::time::sleep(Duration::from_millis(10)).await;
|
||||
}
|
||||
panic!("timed out waiting for {what}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_engine_and_the_review_record_name_the_same_services() {
|
||||
assert_eq!(
|
||||
DshService::ALL.map(DshService::name),
|
||||
HostService::ALL.map(HostService::name)
|
||||
);
|
||||
for service in DshService::ALL {
|
||||
assert!(!super::dsh_broker::offered(service));
|
||||
}
|
||||
let _offer = TestOffer::only(&[HostService::Remote]);
|
||||
assert!(super::dsh_broker::offered(DshService::Remote));
|
||||
assert!(!super::dsh_broker::offered(DshService::Network));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_agent_approved_owner_never_loads_into_the_reviewed_host() {
|
||||
let _policy = TestPolicyGuard::extension_host(true);
|
||||
let _offer = TestOffer::only(&[HostService::Network]);
|
||||
let plugins = Plugins::new(&[("reviewed", &["network"]), ("made", &["network"])]);
|
||||
let (desired, errors) = super::desired_owners(&plugins.registry());
|
||||
assert_eq!(desired.len(), 2, "{errors:?}");
|
||||
|
||||
plugins.review_by_agent("made");
|
||||
let registry = plugins.registry();
|
||||
assert!(registry.get("made").unwrap().active());
|
||||
let (desired, errors) = super::desired_owners(®istry);
|
||||
let names: Vec<&str> = desired
|
||||
.values()
|
||||
.map(|owner| owner.plugin_name.as_str())
|
||||
.collect();
|
||||
assert_eq!(names, vec!["reviewed"]);
|
||||
assert_eq!(
|
||||
desired.values().next().unwrap().host_services,
|
||||
vec![DshService::Network]
|
||||
);
|
||||
assert_eq!(
|
||||
errors
|
||||
.iter()
|
||||
.filter(|error| error.contains("`made` was approved by the agent"))
|
||||
.count(),
|
||||
1,
|
||||
"{errors:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn no_broker_ticket_is_minted_for_a_plugin_whose_reviewed_record_does_not_name_the_service() {
|
||||
let _policy = TestPolicyGuard::extension_host(true);
|
||||
let offer = TestOffer::only(&[HostService::Network, HostService::Llm]);
|
||||
let granted = Plugins::new(&[("alpha", &["network"])]);
|
||||
let host_a = host(&granted.config.workspace);
|
||||
let alpha = host_a.load(&granted, "alpha");
|
||||
assert_eq!(host_a.services(&alpha), vec!["network"]);
|
||||
let guard = host_a.mint(HostTier::Plugin, &alpha);
|
||||
let network = ticket(&guard, "network");
|
||||
let (served, _) = host_a.call(&echo(), &alpha, &network, "network").await;
|
||||
assert_eq!(served.unwrap()["service"], "network");
|
||||
let (wrong, _) = host_a.call(&echo(), &alpha, &network, "llm").await;
|
||||
assert_eq!(refusal(&wrong).1, "ticket_refused");
|
||||
|
||||
let ungranted = Plugins::new(&[("beta", &[])]);
|
||||
let host_b = host(&ungranted.config.workspace);
|
||||
let beta = host_b.load(&ungranted, "beta");
|
||||
assert!(host_b.services(&beta).is_empty());
|
||||
let forged = OwnerRef {
|
||||
plugin_id: beta.plugin_id.clone(),
|
||||
..alpha.clone()
|
||||
};
|
||||
let (borrowed, _) = host_b.call(&echo(), &forged, &network, "network").await;
|
||||
assert_eq!(refusal(&borrowed).1, "ticket_refused");
|
||||
|
||||
let unshown = Plugins::new(&[("gamma", &["network"])]);
|
||||
unshown.review("gamma", false);
|
||||
let host_c = host(&unshown.config.workspace);
|
||||
let gamma = host_c.load(&unshown, "gamma");
|
||||
assert!(
|
||||
host_c.services(&gamma).is_empty(),
|
||||
"a review that did not show the service grants none"
|
||||
);
|
||||
|
||||
drop(offer);
|
||||
assert!(
|
||||
host_a.services(&alpha).is_empty(),
|
||||
"a build that offers no service mints no broker ticket, whatever is recorded"
|
||||
);
|
||||
let (unreachable, _) = host_a.call(&echo(), &alpha, &network, "network").await;
|
||||
assert_eq!(refusal(&unreachable), not_granted("network"));
|
||||
for service in DshService::ALL {
|
||||
let (stub, _) = host_a
|
||||
.call(super::dsh_broker::TABLE, &alpha, &network, service.name())
|
||||
.await;
|
||||
assert!(stub.is_err(), "{}", service.name());
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn no_broker_ticket_while_a_plugin_without_the_grant_shares_the_host() {
|
||||
let _policy = TestPolicyGuard::extension_host(true);
|
||||
let _offer = TestOffer::only(&[HostService::Network, HostService::Llm]);
|
||||
let plugins = Plugins::new(&[
|
||||
("alpha", &["network", "llm"]),
|
||||
("beta", &[]),
|
||||
("gamma", &["network"]),
|
||||
]);
|
||||
plugins.review("alpha", true);
|
||||
let host = host(&plugins.config.workspace);
|
||||
let alpha = host.load(&plugins, "alpha");
|
||||
assert_eq!(host.services(&alpha), vec!["llm", "network"]);
|
||||
let alone = host.mint(HostTier::Plugin, &alpha);
|
||||
let network = ticket(&alone, "network");
|
||||
let llm = ticket(&alone, "llm");
|
||||
|
||||
let gamma = host.load(&plugins, "gamma");
|
||||
assert_eq!(host.services(&alpha), vec!["network"]);
|
||||
assert_eq!(host.services(&gamma), vec!["network"]);
|
||||
let (shared, _) = host.call(&echo(), &alpha, &network, "network").await;
|
||||
assert_eq!(shared.unwrap()["plugin"], alpha.plugin_id);
|
||||
let (unshared, _) = host.call(&echo(), &alpha, &llm, "llm").await;
|
||||
assert_eq!(refusal(&unshared), not_granted("llm"));
|
||||
let report = host
|
||||
.manager
|
||||
.shared
|
||||
.registry
|
||||
.lock()
|
||||
.unwrap()
|
||||
.take_withheld_report()
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
report,
|
||||
"host service `llm` is off: the review of `gamma` does not include it"
|
||||
);
|
||||
assert!(
|
||||
host.manager
|
||||
.shared
|
||||
.registry
|
||||
.lock()
|
||||
.unwrap()
|
||||
.take_withheld_report()
|
||||
.is_none(),
|
||||
"reported once"
|
||||
);
|
||||
|
||||
let beta = {
|
||||
let authority = plugins.registry().authority_for("beta").unwrap();
|
||||
host.manager
|
||||
.shared
|
||||
.registry
|
||||
.lock()
|
||||
.unwrap()
|
||||
.begin_owner(
|
||||
HostTier::Plugin,
|
||||
authority.plugin_id.as_str(),
|
||||
"beta",
|
||||
Some(authority.clone()),
|
||||
&authority.content_hash,
|
||||
)
|
||||
.unwrap()
|
||||
};
|
||||
assert!(
|
||||
host.services(&alpha).is_empty(),
|
||||
"an activating plugin without the grant already withholds it"
|
||||
);
|
||||
assert!(host.services(&gamma).is_empty());
|
||||
assert!(host.services(&beta).is_empty());
|
||||
let (withheld, _) = host.call(&echo(), &alpha, &network, "network").await;
|
||||
let own = Plugins::new(&[("delta", &[])]);
|
||||
let other_host = self::host(&own.config.workspace);
|
||||
let delta = other_host.load(&own, "delta");
|
||||
let lacking = other_host.mint(HostTier::Plugin, &delta);
|
||||
assert!(lacking.broker_tickets().is_none());
|
||||
assert_eq!(
|
||||
refusal(&withheld),
|
||||
not_granted("network"),
|
||||
"the refusal is the same whoever lacks the grant, and names nobody"
|
||||
);
|
||||
assert!(!refusal(&withheld).2.contains("beta"));
|
||||
let report = host
|
||||
.manager
|
||||
.shared
|
||||
.registry
|
||||
.lock()
|
||||
.unwrap()
|
||||
.take_withheld_report()
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
report,
|
||||
"host service `llm` is off: the reviews of `beta`, `gamma` do not include it; host service `network` is off: the review of `beta` does not include it"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_plugin_that_left_the_host_withholds_every_service_until_the_host_exits() {
|
||||
let _policy = TestPolicyGuard::extension_host(true);
|
||||
let _offer = TestOffer::only(&[HostService::Network]);
|
||||
let plugins = Plugins::new(&[("alpha", &["network"]), ("gamma", &["network"])]);
|
||||
plugins.review("alpha", true);
|
||||
let host = host(&plugins.config.workspace);
|
||||
let alpha = host.load(&plugins, "alpha");
|
||||
let gamma = host.load(&plugins, "gamma");
|
||||
assert_eq!(host.services(&alpha), vec!["network"]);
|
||||
let guard = host.mint(HostTier::Plugin, &alpha);
|
||||
let network = ticket(&guard, "network");
|
||||
assert!(
|
||||
!host
|
||||
.manager
|
||||
.shared
|
||||
.registry
|
||||
.lock()
|
||||
.unwrap()
|
||||
.host_services_await_restart()
|
||||
);
|
||||
|
||||
{
|
||||
let mut registry = host.manager.shared.registry.lock().unwrap();
|
||||
assert_eq!(registry.revoke_owner(&gamma.plugin_id), Some(gamma.clone()));
|
||||
registry.forget_owner(&gamma.plugin_id);
|
||||
assert!(registry.host_services_await_restart());
|
||||
assert_eq!(
|
||||
registry.take_withheld_report().unwrap(),
|
||||
"host services are off until the plugin host restarts: a plugin that was turned off, changed or failed is still loaded in it"
|
||||
);
|
||||
}
|
||||
assert!(
|
||||
host.services(&alpha).is_empty(),
|
||||
"code that was loaded cannot be unloaded: nothing is given until the process is gone"
|
||||
);
|
||||
let (lingering, _) = host.call(&echo(), &alpha, &network, "network").await;
|
||||
assert_eq!(refusal(&lingering), not_granted("network"));
|
||||
|
||||
let again = host.load(&plugins, "gamma");
|
||||
assert!(host.services(&alpha).is_empty());
|
||||
assert!(host.services(&again).is_empty());
|
||||
|
||||
{
|
||||
let mut registry = host.manager.shared.registry.lock().unwrap();
|
||||
registry.host_exited(HostTier::Plugin, "planned restart");
|
||||
assert!(!registry.host_services_await_restart());
|
||||
}
|
||||
let alpha = host.load(&plugins, "alpha");
|
||||
assert_eq!(host.services(&alpha), vec!["network"]);
|
||||
|
||||
let failing = host.load(&plugins, "gamma");
|
||||
host.manager
|
||||
.shared
|
||||
.registry
|
||||
.lock()
|
||||
.unwrap()
|
||||
.mark_failed(&failing, super::registry::OwnerState::Failed("boom".into()));
|
||||
assert!(host.services(&alpha).is_empty());
|
||||
host.manager
|
||||
.shared
|
||||
.registry
|
||||
.lock()
|
||||
.unwrap()
|
||||
.host_process_started(HostTier::Plugin);
|
||||
assert_eq!(host.services(&alpha), vec!["network"]);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_grant_is_read_from_the_owner_record_at_mint_and_at_redeem_never_from_the_wire() {
|
||||
let _policy = TestPolicyGuard::extension_host(true);
|
||||
let _offer = TestOffer::only(&[HostService::Network, HostService::Llm]);
|
||||
let plugins = Plugins::new(&[("alpha", &["network"])]);
|
||||
let host = host(&plugins.config.workspace);
|
||||
let alpha = host.load(&plugins, "alpha");
|
||||
let guard = host.mint(HostTier::Plugin, &alpha);
|
||||
let network = ticket(&guard, "network");
|
||||
let (served, _) = host.call(&echo(), &alpha, &network, "network").await;
|
||||
assert!(served.is_ok(), "{served:?}");
|
||||
let (claimed, _) = host.call(&echo(), &alpha, &network, "llm").await;
|
||||
assert_eq!(
|
||||
refusal(&claimed).1,
|
||||
"ticket_refused",
|
||||
"input that claims a grant grants nothing"
|
||||
);
|
||||
|
||||
plugins.review("alpha", false);
|
||||
let (reviewed_again, _) = host.call(&echo(), &alpha, &network, "network").await;
|
||||
assert_eq!(
|
||||
refusal(&reviewed_again),
|
||||
not_granted("network"),
|
||||
"a new review that drops the service refuses the next call of the running tool"
|
||||
);
|
||||
assert_eq!(
|
||||
host.services(&alpha),
|
||||
vec!["network"],
|
||||
"the owner record is replaced by reconcile; until then redeem reads the review record"
|
||||
);
|
||||
|
||||
plugins.review("alpha", true);
|
||||
let (stale, _) = host.call(&echo(), &alpha, &network, "network").await;
|
||||
assert_eq!(
|
||||
refusal(&stale),
|
||||
not_granted("network"),
|
||||
"an owner from before the review holds nothing, whatever the new review grants"
|
||||
);
|
||||
host.manager
|
||||
.shared
|
||||
.registry
|
||||
.lock()
|
||||
.unwrap()
|
||||
.host_exited(HostTier::Plugin, "restart");
|
||||
let current = host.load(&plugins, "alpha");
|
||||
let guard = host.mint(HostTier::Plugin, ¤t);
|
||||
let (fresh, _) = host
|
||||
.call(&echo(), ¤t, &ticket(&guard, "network"), "network")
|
||||
.await;
|
||||
assert!(fresh.is_ok(), "{fresh:?}");
|
||||
|
||||
std::fs::write(&plugins.config.state_path, b"{ not json").unwrap();
|
||||
let (unreadable, _) = host
|
||||
.call(&echo(), ¤t, &ticket(&guard, "network"), "network")
|
||||
.await;
|
||||
assert_eq!(refusal(&unreadable), not_granted("network"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_disable_from_another_process_stops_broker_calls_of_a_running_tool() {
|
||||
let _policy = TestPolicyGuard::extension_host(true);
|
||||
let _offer = TestOffer::only(&[HostService::Network]);
|
||||
let plugins = Plugins::new(&[("alpha", &["network"]), ("gamma", &["network"])]);
|
||||
plugins.review("alpha", true);
|
||||
let host = host(&plugins.config.workspace);
|
||||
let alpha = host.load(&plugins, "alpha");
|
||||
let _gamma = host.load(&plugins, "gamma");
|
||||
let guard = host.mint(HostTier::Plugin, &alpha);
|
||||
let network = ticket(&guard, "network");
|
||||
let waiting: Vec<(DshService, Arm)> = vec![(DshService::Network, waiting_arm as Arm)];
|
||||
|
||||
let in_flight = host.call(&waiting, &alpha, &network, "network");
|
||||
let elsewhere = async {
|
||||
until("the arm to start", || WAITING.load(Ordering::SeqCst)).await;
|
||||
plugins.registry().disable("gamma").unwrap();
|
||||
};
|
||||
let ((stopped, violations), ()) = tokio::join!(in_flight, elsewhere);
|
||||
assert_eq!(
|
||||
refusal(&stopped),
|
||||
not_granted("network"),
|
||||
"a co-hosted plugin turned off elsewhere withdraws the call in flight"
|
||||
);
|
||||
assert!(violations.is_empty());
|
||||
until("the arm's cancellation", || {
|
||||
WITHDRAWN.load(Ordering::SeqCst)
|
||||
})
|
||||
.await;
|
||||
|
||||
let (next, _) = host.call(&echo(), &alpha, &network, "network").await;
|
||||
assert_eq!(refusal(&next), not_granted("network"));
|
||||
assert_eq!(
|
||||
host.services(&alpha),
|
||||
vec!["network"],
|
||||
"this process has not reconciled yet; the review record already refuses"
|
||||
);
|
||||
|
||||
plugins.registry().enable("gamma").unwrap();
|
||||
let (spent, _) = host.call(&echo(), &alpha, &network, "network").await;
|
||||
assert_eq!(
|
||||
refusal(&spent),
|
||||
not_granted("network"),
|
||||
"the loaded owner is from before the plugin was turned off"
|
||||
);
|
||||
plugins.registry().disable("alpha").unwrap();
|
||||
let (own, _) = host.call(&echo(), &alpha, &network, "network").await;
|
||||
assert_eq!(refusal(&own), not_granted("network"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_builtin_tier_owner_never_gets_a_broker_ticket() {
|
||||
let _policy = TestPolicyGuard::extension_host(true);
|
||||
let _offer = TestOffer::only(&HostService::ALL);
|
||||
let workspace = tempfile::tempdir().unwrap();
|
||||
let host = host(workspace.path());
|
||||
let builtin = {
|
||||
let mut registry = host.manager.shared.registry.lock().unwrap();
|
||||
let owner = registry
|
||||
.begin_owner(HostTier::Builtin, "host:mcp", "mcp", None, "digest")
|
||||
.unwrap();
|
||||
registry.set_host_services(&owner, DshService::ALL.to_vec());
|
||||
registry.mark_active(&owner);
|
||||
assert!(
|
||||
DshService::ALL
|
||||
.into_iter()
|
||||
.all(|service| !registry.host_service_granted(&owner, service))
|
||||
);
|
||||
owner
|
||||
};
|
||||
assert!(host.manager.shared.broker_services(&builtin).is_empty());
|
||||
assert!(
|
||||
host.mint(HostTier::Builtin, &builtin)
|
||||
.broker_tickets()
|
||||
.is_none()
|
||||
);
|
||||
let _everything = super::dsh_broker::TestGrants::only(&DshService::ALL);
|
||||
let forced = host
|
||||
.manager
|
||||
.shared
|
||||
.core_calls
|
||||
.begin(
|
||||
HostTier::Builtin,
|
||||
HOST,
|
||||
&builtin,
|
||||
"call-2",
|
||||
host.gate.extension().unwrap().0,
|
||||
&host.context,
|
||||
&host.gate,
|
||||
)
|
||||
.unwrap();
|
||||
assert!(
|
||||
forced.broker_tickets().is_none(),
|
||||
"no override mints a broker ticket for the built-in tier"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_broker_ticket_is_refused_by_core_call_checkpoint_question_and_subagent() {
|
||||
let _policy = TestPolicyGuard::extension_host(true);
|
||||
let _offer = TestOffer::only(&[HostService::Network]);
|
||||
let plugins = Plugins::new(&[("alpha", &["network"])]);
|
||||
let host = host(&plugins.config.workspace);
|
||||
let alpha = host.load(&plugins, "alpha");
|
||||
let guard = host.mint(HostTier::Plugin, &alpha);
|
||||
let network = ticket(&guard, "network");
|
||||
let shared = &host.manager.shared;
|
||||
let cx = |id| HostRequestContext::for_test(id).0;
|
||||
let refused = |result: Result<Value, RpcErrorWire>, what: &str| {
|
||||
let error = result.expect_err(what);
|
||||
assert_eq!(error.code, error_code::REFUSED, "{what}: {error:?}");
|
||||
assert_eq!(
|
||||
error.message, "the ticket is not valid for this request",
|
||||
"{what}"
|
||||
);
|
||||
};
|
||||
|
||||
refused(
|
||||
shared
|
||||
.core_calls
|
||||
.serve(
|
||||
shared,
|
||||
HostTier::Plugin,
|
||||
HOST,
|
||||
super::protocol::CoreCallParams {
|
||||
owner: alpha.clone(),
|
||||
ticket: network.clone(),
|
||||
name: "read_file".into(),
|
||||
input: json!({ "path": "x" }),
|
||||
},
|
||||
cx(1),
|
||||
)
|
||||
.await,
|
||||
"core/call",
|
||||
);
|
||||
refused(
|
||||
shared
|
||||
.core_calls
|
||||
.serve_checkpoint(
|
||||
shared,
|
||||
HostTier::Plugin,
|
||||
HOST,
|
||||
super::protocol::SessionCheckpointParams {
|
||||
owner: alpha.clone(),
|
||||
ticket: network.clone(),
|
||||
},
|
||||
cx(2),
|
||||
)
|
||||
.await,
|
||||
"session/checkpoint",
|
||||
);
|
||||
refused(
|
||||
shared
|
||||
.core_calls
|
||||
.serve_question(
|
||||
shared,
|
||||
HostTier::Plugin,
|
||||
HOST,
|
||||
super::protocol::UserQuestionParams {
|
||||
owner: alpha.clone(),
|
||||
ticket: network.clone(),
|
||||
questions: Vec::new(),
|
||||
},
|
||||
cx(3),
|
||||
)
|
||||
.await,
|
||||
"user-questions/ask",
|
||||
);
|
||||
refused(
|
||||
shared
|
||||
.core_calls
|
||||
.serve_subagent(
|
||||
shared,
|
||||
HostTier::Plugin,
|
||||
HOST,
|
||||
HostRequest::SubagentStart(super::protocol::SubagentStartParams {
|
||||
owner: alpha.clone(),
|
||||
ticket: network.clone(),
|
||||
child_id: "child".into(),
|
||||
prompt: "p".into(),
|
||||
model: None,
|
||||
persona: None,
|
||||
denied_tools: Vec::new(),
|
||||
delivery: "caller".into(),
|
||||
}),
|
||||
cx(4),
|
||||
)
|
||||
.await,
|
||||
"subagent/start",
|
||||
);
|
||||
let (still_good, _) = host.call(&echo(), &alpha, &network, "network").await;
|
||||
assert!(still_good.is_ok(), "{still_good:?}");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_spent_or_just_ended_broker_ticket_is_a_limit_not_a_forgery() {
|
||||
let _policy = TestPolicyGuard::extension_host(true);
|
||||
let _offer = TestOffer::only(&[HostService::Network]);
|
||||
let plugins = Plugins::new(&[("alpha", &["network"])]);
|
||||
let host = host(&plugins.config.workspace);
|
||||
let alpha = host.load(&plugins, "alpha");
|
||||
let guard = host.mint(HostTier::Plugin, &alpha);
|
||||
let network = ticket(&guard, "network");
|
||||
for _ in 0..super::core_call::MAX_CALLS_PER_INVOCATION {
|
||||
let (served, _) = host.call(&echo(), &alpha, &network, "network").await;
|
||||
assert!(served.is_ok(), "{served:?}");
|
||||
}
|
||||
let (spent, violations) = host.call(&echo(), &alpha, &network, "network").await;
|
||||
assert_eq!(
|
||||
refusal(&spent),
|
||||
(
|
||||
error_code::REFUSED,
|
||||
"limit".to_string(),
|
||||
"this tool call's limit for the network service is used up".to_string()
|
||||
)
|
||||
);
|
||||
assert!(violations.is_empty());
|
||||
|
||||
drop(guard);
|
||||
let mut ended = Vec::new();
|
||||
for _ in 0..(super::ticket::INVALID_BURST * 3) {
|
||||
let (late, violations) = host.call(&echo(), &alpha, &network, "network").await;
|
||||
assert_eq!(refusal(&late).1, "ticket_refused");
|
||||
ended.extend(violations);
|
||||
}
|
||||
assert!(
|
||||
ended.is_empty(),
|
||||
"frames already in the pipe when a tool call ends do not end the host: {ended:?}"
|
||||
);
|
||||
for _ in 0..super::ticket::INVALID_BURST {
|
||||
let (_, violations) = host.call(&echo(), &alpha, "cwt.guess", "network").await;
|
||||
ended.extend(violations);
|
||||
}
|
||||
assert_eq!(ended.len(), 1, "a guessed ticket still does: {ended:?}");
|
||||
}
|
||||
@@ -152,6 +152,8 @@ mod windows;
|
||||
|
||||
#[cfg(test)]
|
||||
mod core_call_tests;
|
||||
#[cfg(test)]
|
||||
mod dsh_grant_tests;
|
||||
#[cfg(any(test, feature = "test-support"))]
|
||||
pub mod test_support;
|
||||
#[cfg(test)]
|
||||
@@ -540,6 +542,7 @@ struct DesiredOwner {
|
||||
plugin_name: String,
|
||||
authority: PluginAuthority,
|
||||
entries: Vec<(PathBuf, String)>,
|
||||
host_services: Vec<protocol::DshService>,
|
||||
}
|
||||
|
||||
/// What [`ExtensionHostManager::activate_owner`] activates, on either tier:
|
||||
@@ -555,6 +558,7 @@ struct Activation {
|
||||
/// pinned source digest.
|
||||
content_hash: String,
|
||||
entries: Vec<(PathBuf, String)>,
|
||||
host_services: Vec<protocol::DshService>,
|
||||
}
|
||||
|
||||
/// One engine's view: its plugin snapshot and the owners (plugin id →
|
||||
@@ -1375,7 +1379,15 @@ fn restart_dirty_host_when_idle(
|
||||
return false;
|
||||
}
|
||||
let mut supervision = runtime.supervision.lock().expect("supervision lock");
|
||||
if !supervision.dirty_restart_pending || !host.terminate_if_idle(DIRTY_RESTART_REASON) {
|
||||
let unload = host.tier == HostTier::Plugin
|
||||
&& shared
|
||||
.registry
|
||||
.lock()
|
||||
.expect("registry lock")
|
||||
.host_services_await_restart();
|
||||
if !(supervision.dirty_restart_pending || unload)
|
||||
|| !host.terminate_if_idle(DIRTY_RESTART_REASON)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
supervision.planned_restart = Some(generation);
|
||||
@@ -2224,6 +2236,7 @@ impl ExtensionHostManager {
|
||||
authority: Some(want.authority),
|
||||
content_hash,
|
||||
entries: want.entries,
|
||||
host_services: want.host_services,
|
||||
},
|
||||
)
|
||||
.await;
|
||||
@@ -2251,6 +2264,14 @@ impl ExtensionHostManager {
|
||||
state.settled = state.selection.revision;
|
||||
}
|
||||
}
|
||||
let withheld = shared
|
||||
.registry
|
||||
.lock()
|
||||
.expect("registry lock")
|
||||
.take_withheld_report();
|
||||
if let Some(withheld) = withheld {
|
||||
shared.diagnostic(withheld);
|
||||
}
|
||||
match (plugins, builtin) {
|
||||
(Err(error), Err(other)) => {
|
||||
shared.diagnostic(other);
|
||||
@@ -2397,6 +2418,7 @@ impl ExtensionHostManager {
|
||||
authority: None,
|
||||
content_hash: module.source_sha256.to_string(),
|
||||
entries: vec![entry],
|
||||
host_services: Vec::new(),
|
||||
}),
|
||||
Err(reason) => {
|
||||
let mut registry = shared.registry.lock().expect("registry lock");
|
||||
@@ -2444,13 +2466,17 @@ impl ExtensionHostManager {
|
||||
{
|
||||
Ok(owner.owner.clone())
|
||||
} else {
|
||||
registry.begin_owner(
|
||||
want.tier,
|
||||
plugin_id,
|
||||
&want.name,
|
||||
want.authority.clone(),
|
||||
&want.content_hash,
|
||||
)
|
||||
registry
|
||||
.begin_owner(
|
||||
want.tier,
|
||||
plugin_id,
|
||||
&want.name,
|
||||
want.authority.clone(),
|
||||
&want.content_hash,
|
||||
)
|
||||
.inspect(|owner| {
|
||||
registry.set_host_services(owner, want.host_services.clone());
|
||||
})
|
||||
}
|
||||
};
|
||||
let owner = match begun {
|
||||
@@ -2771,6 +2797,11 @@ impl ExtensionHostManager {
|
||||
supervision.planned_restart = None;
|
||||
runtime.host_generation.fetch_add(1, Ordering::SeqCst) + 1
|
||||
};
|
||||
shared
|
||||
.registry
|
||||
.lock()
|
||||
.expect("registry lock")
|
||||
.host_process_started(tier);
|
||||
// At most two launches: under `auto`, before anything is pinned, a
|
||||
// Bun that cannot start is reported once and Node is resolved for the
|
||||
// rest of the session. An explicit `bun` or `node` never falls back.
|
||||
@@ -3231,11 +3262,41 @@ fn desired_owners(plugins: &PluginRegistry) -> (BTreeMap<String, DesiredOwner>,
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
let owner = desired.entry(plugin_id).or_insert_with(|| DesiredOwner {
|
||||
plugin_name: source.plugin_name.clone(),
|
||||
authority: source.authority.clone(),
|
||||
entries: Vec::new(),
|
||||
});
|
||||
if !desired.contains_key(&plugin_id) {
|
||||
let record =
|
||||
codewhale_plugins::registry::reviewed_record(&source.authority).ok();
|
||||
if record.as_ref().is_some_and(|record| {
|
||||
record.approved_by.as_ref().is_some_and(|approver| {
|
||||
approver.cohort()
|
||||
!= codewhale_plugins::host_services::PluginCohort::Reviewed
|
||||
})
|
||||
}) {
|
||||
if broken.insert(plugin_id) {
|
||||
errors.push(format!(
|
||||
"Plugin `{}` was approved by the agent, so its code does not run in the host of the plugins you reviewed; review it to run its code",
|
||||
source.plugin_name
|
||||
));
|
||||
}
|
||||
continue;
|
||||
}
|
||||
desired.insert(
|
||||
plugin_id.clone(),
|
||||
DesiredOwner {
|
||||
plugin_name: source.plugin_name.clone(),
|
||||
authority: source.authority.clone(),
|
||||
entries: Vec::new(),
|
||||
host_services: record
|
||||
.map(|record| record.host_services)
|
||||
.unwrap_or_default()
|
||||
.into_iter()
|
||||
.filter_map(|service| protocol::DshService::parse(service.name()))
|
||||
.collect(),
|
||||
},
|
||||
);
|
||||
}
|
||||
let Some(owner) = desired.get_mut(&plugin_id) else {
|
||||
continue;
|
||||
};
|
||||
// The manifest may name one file twice; it is one entry.
|
||||
if !owner.entries.iter().any(|(path, _)| *path == source.path) {
|
||||
owner
|
||||
@@ -3540,6 +3601,45 @@ impl ManagerShared {
|
||||
&& state.selection.includes(plugin_id, hash, scope)
|
||||
})
|
||||
}
|
||||
pub(super) fn broker_services(&self, owner: &OwnerRef) -> Vec<protocol::DshService> {
|
||||
dsh_broker::granted_services(&self.registry.lock().expect("registry lock"), owner)
|
||||
}
|
||||
|
||||
pub(super) async fn broker_granted(
|
||||
&self,
|
||||
owner: &OwnerRef,
|
||||
service: protocol::DshService,
|
||||
) -> bool {
|
||||
if let Some(granted) = dsh_broker::test_grants() {
|
||||
return granted.contains(&service);
|
||||
}
|
||||
let authorities = {
|
||||
let registry = self.registry.lock().expect("registry lock");
|
||||
if !dsh_broker::granted(®istry, owner, service) {
|
||||
return false;
|
||||
}
|
||||
registry.plugin_host_authorities()
|
||||
};
|
||||
let Some(wanted) = codewhale_plugins::host_services::HostService::parse(service.name())
|
||||
else {
|
||||
return false;
|
||||
};
|
||||
let policy = activation::extension_host_policy_enabled();
|
||||
#[cfg(any(test, feature = "test-support"))]
|
||||
let env_scope = crate::test_support::env_scope_ticket();
|
||||
tokio::task::spawn_blocking(move || {
|
||||
#[cfg(any(test, feature = "test-support"))]
|
||||
let _env_scope = crate::test_support::join_env_scope(env_scope);
|
||||
let _scope = activation::PolicyScope::propagate(policy);
|
||||
authorities.iter().all(|authority| {
|
||||
codewhale_plugins::registry::granted_host_services(authority)
|
||||
.is_ok_and(|granted| granted.contains(&wanted))
|
||||
})
|
||||
})
|
||||
.await
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
pub fn check_selection(
|
||||
&self,
|
||||
selection: Option<codewhale_plugins::composition_scope::SelectionRevision>,
|
||||
|
||||
@@ -16,7 +16,7 @@ use std::sync::Arc;
|
||||
|
||||
use serde_json::Value;
|
||||
|
||||
use super::protocol::{RegisterKind, RegisterParams};
|
||||
use super::protocol::{DshService, RegisterKind, RegisterParams};
|
||||
use super::tier::HostTier;
|
||||
use codewhale_config::PluginAuthority;
|
||||
use codewhale_config::{EntryRef, OwnerRef};
|
||||
@@ -78,10 +78,21 @@ pub struct OwnerEntry {
|
||||
/// [`OwnerRegistry::set_config_hash`]). A change of config is a different
|
||||
/// activation: reconcile revokes the owner and activates a new generation.
|
||||
pub config_hash: String,
|
||||
pub host_services: Vec<DshService>,
|
||||
pub state: OwnerState,
|
||||
pub scopes: HashMap<EntryRef, OwnerState>,
|
||||
}
|
||||
|
||||
impl OwnerEntry {
|
||||
fn live(&self) -> bool {
|
||||
matches!(self.state, OwnerState::Activating | OwnerState::Active)
|
||||
}
|
||||
|
||||
fn loaded_in_plugin_host(&self) -> bool {
|
||||
self.tier == HostTier::Plugin && self.live()
|
||||
}
|
||||
}
|
||||
|
||||
/// Most schema violations one refused call reports back to the model.
|
||||
const MAX_REPORTED_INPUT_ERRORS: usize = 5;
|
||||
/// Bound on the refusal text (violations quote the offending values).
|
||||
@@ -261,6 +272,8 @@ pub struct OwnerRegistry {
|
||||
/// process build different native surfaces, and a name that is native
|
||||
/// anywhere is refused everywhere.
|
||||
native_names: HashSet<String>,
|
||||
plugin_host_departed: bool,
|
||||
withheld_reported: String,
|
||||
}
|
||||
|
||||
fn mint_token() -> String {
|
||||
@@ -428,6 +441,7 @@ impl OwnerRegistry {
|
||||
authority,
|
||||
content_hash: content_hash.to_string(),
|
||||
config_hash: String::new(),
|
||||
host_services: Vec::new(),
|
||||
state: OwnerState::Activating,
|
||||
scopes: HashMap::new(),
|
||||
},
|
||||
@@ -444,6 +458,110 @@ impl OwnerRegistry {
|
||||
}
|
||||
}
|
||||
|
||||
pub fn set_host_services(&mut self, owner: &OwnerRef, services: Vec<DshService>) {
|
||||
if let Some(entry) = self.owners.get_mut(&owner.plugin_id)
|
||||
&& entry.owner == *owner
|
||||
&& entry.tier == HostTier::Plugin
|
||||
&& entry.authority.is_some()
|
||||
{
|
||||
entry.host_services = services;
|
||||
}
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn host_service_granted(&self, owner: &OwnerRef, service: DshService) -> bool {
|
||||
self.current(owner)
|
||||
.is_some_and(|entry| entry.tier == HostTier::Plugin && entry.authority.is_some())
|
||||
&& !self.plugin_host_departed
|
||||
&& self
|
||||
.owners
|
||||
.values()
|
||||
.filter(|entry| entry.loaded_in_plugin_host())
|
||||
.all(|entry| entry.host_services.contains(&service))
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn plugin_host_authorities(&self) -> Vec<PluginAuthority> {
|
||||
self.owners
|
||||
.values()
|
||||
.filter(|entry| entry.loaded_in_plugin_host())
|
||||
.filter_map(|entry| entry.authority.clone())
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn host_services_await_restart(&self) -> bool {
|
||||
self.plugin_host_departed
|
||||
&& self
|
||||
.owners
|
||||
.values()
|
||||
.any(|entry| entry.loaded_in_plugin_host() && !entry.host_services.is_empty())
|
||||
}
|
||||
|
||||
pub fn host_process_started(&mut self, tier: HostTier) {
|
||||
if tier == HostTier::Plugin {
|
||||
self.plugin_host_departed = false;
|
||||
}
|
||||
}
|
||||
|
||||
pub fn take_withheld_report(&mut self) -> Option<String> {
|
||||
let loaded: Vec<&OwnerEntry> = self
|
||||
.owners
|
||||
.values()
|
||||
.filter(|entry| entry.loaded_in_plugin_host())
|
||||
.collect();
|
||||
let mut held: Vec<DshService> = DshService::ALL
|
||||
.into_iter()
|
||||
.filter(|service| {
|
||||
loaded
|
||||
.iter()
|
||||
.any(|entry| entry.host_services.contains(service))
|
||||
})
|
||||
.collect();
|
||||
let report = if held.is_empty() {
|
||||
String::new()
|
||||
} else if self.plugin_host_departed {
|
||||
"host services are off until the plugin host restarts: a plugin that was turned off, changed or failed is still loaded in it".to_string()
|
||||
} else {
|
||||
held.retain(|service| {
|
||||
loaded
|
||||
.iter()
|
||||
.any(|entry| !entry.host_services.contains(service))
|
||||
});
|
||||
held.iter()
|
||||
.map(|service| {
|
||||
let mut without: Vec<&str> = loaded
|
||||
.iter()
|
||||
.filter(|entry| !entry.host_services.contains(service))
|
||||
.map(|entry| entry.plugin_name.as_str())
|
||||
.collect();
|
||||
without.sort_unstable();
|
||||
format!(
|
||||
"host service `{}` is off: the {} of {} {} not include it",
|
||||
service.name(),
|
||||
if without.len() == 1 {
|
||||
"review"
|
||||
} else {
|
||||
"reviews"
|
||||
},
|
||||
without
|
||||
.iter()
|
||||
.map(|name| format!("`{name}`"))
|
||||
.collect::<Vec<_>>()
|
||||
.join(", "),
|
||||
if without.len() == 1 { "does" } else { "do" },
|
||||
)
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.join("; ")
|
||||
};
|
||||
if report == self.withheld_reported {
|
||||
return None;
|
||||
}
|
||||
self.withheld_reported.clone_from(&report);
|
||||
(!report.is_empty()).then_some(report)
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn owner(&self, plugin_id: &str) -> Option<&OwnerEntry> {
|
||||
self.owners.get(plugin_id)
|
||||
@@ -482,6 +600,7 @@ impl OwnerRegistry {
|
||||
if matches {
|
||||
self.remove_registrations_of(&owner.plugin_id);
|
||||
if let Some(entry) = self.owners.get_mut(&owner.plugin_id) {
|
||||
self.plugin_host_departed |= entry.loaded_in_plugin_host();
|
||||
entry.state = state;
|
||||
}
|
||||
}
|
||||
@@ -1594,7 +1713,8 @@ impl OwnerRegistry {
|
||||
pub fn revoke_owner(&mut self, plugin_id: &str) -> Option<OwnerRef> {
|
||||
self.remove_registrations_of(plugin_id);
|
||||
let entry = self.owners.get_mut(plugin_id)?;
|
||||
let was_live = matches!(entry.state, OwnerState::Activating | OwnerState::Active);
|
||||
let was_live = entry.live();
|
||||
self.plugin_host_departed |= entry.loaded_in_plugin_host();
|
||||
entry.state = OwnerState::Revoked;
|
||||
was_live.then(|| entry.owner.clone())
|
||||
}
|
||||
@@ -1602,7 +1722,9 @@ impl OwnerRegistry {
|
||||
/// Forget an owner entirely (after revocation, when its plugin is gone).
|
||||
pub fn forget_owner(&mut self, plugin_id: &str) {
|
||||
self.remove_registrations_of(plugin_id);
|
||||
self.owners.remove(plugin_id);
|
||||
if let Some(entry) = self.owners.remove(plugin_id) {
|
||||
self.plugin_host_departed |= entry.loaded_in_plugin_host();
|
||||
}
|
||||
}
|
||||
|
||||
/// Forget owners that are not live (failed, faulted, revoked) so a new
|
||||
@@ -1655,6 +1777,7 @@ impl OwnerRegistry {
|
||||
/// owners are not touched: they live in another process.
|
||||
pub fn host_exited(&mut self, tier: HostTier, reason: &str) {
|
||||
self.clear_tier_registrations(tier);
|
||||
self.host_process_started(tier);
|
||||
let activating: Vec<_> = self
|
||||
.owners
|
||||
.values()
|
||||
@@ -1679,6 +1802,7 @@ impl OwnerRegistry {
|
||||
if entry.tier == tier
|
||||
&& matches!(entry.state, OwnerState::Activating | OwnerState::Active)
|
||||
{
|
||||
self.plugin_host_departed |= entry.tier == HostTier::Plugin;
|
||||
entry.state = OwnerState::Failed(reason.to_string());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2164,6 +2164,7 @@ fn attachment_changes_discard_the_complete_scan_before_owner_side_effects() {
|
||||
plugin_name: "plugin".into(),
|
||||
authority: fake_authority("plugin"),
|
||||
entries: Vec::new(),
|
||||
host_services: Vec::new(),
|
||||
},
|
||||
)]
|
||||
.into(),
|
||||
|
||||
@@ -130,6 +130,7 @@ pub enum Refusal {
|
||||
WrongOwner,
|
||||
WrongMethod,
|
||||
WrongTarget,
|
||||
Revoked,
|
||||
}
|
||||
|
||||
impl Refusal {
|
||||
@@ -158,12 +159,48 @@ pub struct Refused {
|
||||
/// [`INVALID_WINDOW`] are a protocol violation.
|
||||
pub const INVALID_BURST: usize = 8;
|
||||
pub const INVALID_WINDOW: Duration = Duration::from_secs(60);
|
||||
pub const REVOKED_GRACE: Duration = Duration::from_secs(10);
|
||||
const MAX_REVOKED: usize = 4096;
|
||||
|
||||
#[derive(Default)]
|
||||
struct State {
|
||||
rows: HashMap<String, Row>,
|
||||
/// Recent invalid presentations, by host process `(tier, generation)`.
|
||||
invalid: HashMap<(HostTier, u64), VecDeque<Instant>>,
|
||||
revoked: VecDeque<(String, Instant)>,
|
||||
}
|
||||
|
||||
impl State {
|
||||
fn note_revoked(&mut self, ids: impl IntoIterator<Item = String>, now: Instant) {
|
||||
self.revoked.extend(ids.into_iter().map(|id| (id, now)));
|
||||
while self.revoked.len() > MAX_REVOKED
|
||||
|| self
|
||||
.revoked
|
||||
.front()
|
||||
.is_some_and(|(_, at)| now.saturating_duration_since(*at) >= REVOKED_GRACE)
|
||||
{
|
||||
self.revoked.pop_front();
|
||||
}
|
||||
}
|
||||
|
||||
fn revoked_just_now(&self, ticket: &str, now: Instant) -> bool {
|
||||
self.revoked
|
||||
.iter()
|
||||
.any(|(id, at)| id == ticket && now.saturating_duration_since(*at) < REVOKED_GRACE)
|
||||
}
|
||||
|
||||
fn revoke_where(&mut self, gone: impl Fn(&Row) -> bool) {
|
||||
let ids: Vec<String> = self
|
||||
.rows
|
||||
.iter()
|
||||
.filter(|(_, row)| gone(row))
|
||||
.map(|(id, _)| id.clone())
|
||||
.collect();
|
||||
for id in &ids {
|
||||
self.rows.remove(id);
|
||||
}
|
||||
self.note_revoked(ids, Instant::now());
|
||||
}
|
||||
}
|
||||
|
||||
/// Every live ticket, behind one mutex.
|
||||
@@ -210,7 +247,9 @@ impl TicketTable {
|
||||
|
||||
fn redeem_at(&self, presented: &Presented<'_>, now: Instant) -> Result<Value, Refused> {
|
||||
let mut state = self.state.lock().expect("ticket lock");
|
||||
let revoked = state.revoked_just_now(presented.ticket, now);
|
||||
let verdict = match state.rows.get_mut(presented.ticket) {
|
||||
None if revoked => Err(Refusal::Revoked),
|
||||
None => Err(Refusal::Unknown),
|
||||
Some(row) => Self::check(row, presented, now).map(|target| {
|
||||
row.uses_left -= 1;
|
||||
@@ -225,8 +264,8 @@ impl TicketTable {
|
||||
Ok(target)
|
||||
}
|
||||
Err(reason) => {
|
||||
let violation =
|
||||
reason != Refusal::Exhausted && Self::note_invalid(&mut state, presented, now);
|
||||
let violation = !matches!(reason, Refusal::Exhausted | Refusal::Revoked)
|
||||
&& Self::note_invalid(&mut state, presented, now);
|
||||
Err(Refused { reason, violation })
|
||||
}
|
||||
}
|
||||
@@ -279,11 +318,10 @@ impl TicketTable {
|
||||
|
||||
/// Revoke one ticket (its invocation ended). Idempotent.
|
||||
pub fn revoke(&self, ticket: &Ticket) {
|
||||
self.state
|
||||
.lock()
|
||||
.expect("ticket lock")
|
||||
.rows
|
||||
.remove(ticket.expose());
|
||||
let mut state = self.state.lock().expect("ticket lock");
|
||||
if state.rows.remove(ticket.expose()).is_some() {
|
||||
state.note_revoked([ticket.expose().to_string()], Instant::now());
|
||||
}
|
||||
}
|
||||
|
||||
/// Revoke every ticket of `plugin_id` (its owner was revoked).
|
||||
@@ -291,8 +329,7 @@ impl TicketTable {
|
||||
self.state
|
||||
.lock()
|
||||
.expect("ticket lock")
|
||||
.rows
|
||||
.retain(|_, row| row.owner.plugin_id != plugin_id);
|
||||
.revoke_where(|row| row.owner.plugin_id == plugin_id);
|
||||
}
|
||||
|
||||
/// Revoke every ticket of one host process and forget its invalid-burst
|
||||
@@ -473,6 +510,56 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_ticket_revoked_a_moment_ago_does_not_end_the_host() {
|
||||
let table = TicketTable::default();
|
||||
let a = owner("a", "token-a");
|
||||
let ended = table.mint(grant(5));
|
||||
let withdrawn = table.mint(grant(5));
|
||||
table.revoke(&ended);
|
||||
table.revoke_owner("a");
|
||||
let now = Instant::now();
|
||||
for ticket in [&ended, &withdrawn] {
|
||||
for _ in 0..(INVALID_BURST * 3) {
|
||||
assert_eq!(
|
||||
table.redeem_at(&presented(ticket, &a), now),
|
||||
Err(Refused {
|
||||
reason: Refusal::Revoked,
|
||||
violation: false
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
assert_eq!(Refusal::Revoked.describe(), Refusal::Unknown.describe());
|
||||
let guess = Ticket("cwt.guess".to_string());
|
||||
let violations = (0..INVALID_BURST)
|
||||
.filter(|_| {
|
||||
table
|
||||
.redeem_at(&presented(&guess, &a), now)
|
||||
.unwrap_err()
|
||||
.violation
|
||||
})
|
||||
.count();
|
||||
assert_eq!(violations, 1, "a guess still counts");
|
||||
|
||||
let fresh = TicketTable::default();
|
||||
let stale = fresh.mint(grant(5));
|
||||
fresh.revoke(&stale);
|
||||
let later = Instant::now() + REVOKED_GRACE;
|
||||
let outcomes: Vec<Refused> = (0..INVALID_BURST)
|
||||
.map(|_| fresh.redeem_at(&presented(&stale, &a), later).unwrap_err())
|
||||
.collect();
|
||||
assert!(
|
||||
outcomes
|
||||
.iter()
|
||||
.all(|refused| refused.reason == Refusal::Unknown)
|
||||
);
|
||||
assert!(
|
||||
outcomes.last().unwrap().violation,
|
||||
"after the grace it is a forgery again"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tickets_are_revoked_with_their_invocation_owner_and_host_and_never_printed() {
|
||||
let table = TicketTable::default();
|
||||
@@ -492,12 +579,12 @@ mod tests {
|
||||
table.revoke(&by_call);
|
||||
assert_eq!(
|
||||
refusal_of(&table, &presented(&by_call, &a)),
|
||||
Refusal::Unknown
|
||||
Refusal::Revoked
|
||||
);
|
||||
table.revoke_owner("a");
|
||||
assert_eq!(
|
||||
refusal_of(&table, &presented(&by_owner, &a)),
|
||||
Refusal::Unknown
|
||||
Refusal::Revoked
|
||||
);
|
||||
assert_eq!(table.live(), 2, "only a's tickets went");
|
||||
table.revoke_host(HostTier::Plugin, 9);
|
||||
|
||||
@@ -362,6 +362,7 @@ impl ToolSpec for HostToolSpec {
|
||||
gate,
|
||||
registration.scope.clone(),
|
||||
registration.content_hash.clone(),
|
||||
&self.manager.broker_services(®istration.owner),
|
||||
)
|
||||
});
|
||||
let request = CoreRequest::ToolCall(ToolCallParams {
|
||||
|
||||
@@ -1125,6 +1125,7 @@ fn capabilities_are_default(capabilities: &PluginCapabilities) -> bool {
|
||||
capabilities.filesystem_roots.is_empty()
|
||||
&& capabilities.network_hosts.is_empty()
|
||||
&& !capabilities.lifecycle_mutation
|
||||
&& capabilities.host_services.is_empty()
|
||||
}
|
||||
|
||||
fn config_to_standard_server(
|
||||
|
||||
@@ -399,6 +399,10 @@ fn load_plugin(
|
||||
content_hash: validated.content_hash,
|
||||
capability_hash: validated.capability_hash,
|
||||
state_generation: 0,
|
||||
approved_by: None,
|
||||
enabled_by: None,
|
||||
host_services: Vec::new(),
|
||||
outside_reach: false,
|
||||
skill_snapshots,
|
||||
diagnostics,
|
||||
})
|
||||
|
||||
@@ -0,0 +1,828 @@
|
||||
use std::fs;
|
||||
use std::path::Path;
|
||||
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::activation::TestPolicyGuard;
|
||||
use super::discovery::{DiscoveryConfig, discover_with_config};
|
||||
use super::host_services::{
|
||||
AgentApproval, AgentPosture, HostService, PersonVia, PluginApprover, ReviewApproval, TestOffer,
|
||||
};
|
||||
use super::registry::{PluginRegistry, granted_host_services, verify_plugin_state_authority};
|
||||
|
||||
type Edit = fn(&mut Value);
|
||||
|
||||
fn config(root: &Path) -> DiscoveryConfig {
|
||||
DiscoveryConfig {
|
||||
workspace: root.join("project"),
|
||||
user_plugins_dir: root.join("user"),
|
||||
workspace_plugins_dir: root.join("workspace"),
|
||||
builtin_plugin_dirs: Vec::new(),
|
||||
state_path: root.join("state/plugin-state.json"),
|
||||
}
|
||||
}
|
||||
|
||||
fn write_plugin(config: &DiscoveryConfig, name: &str, asks: &[&str], body: &str) {
|
||||
let plugin = config.user_plugins_dir.join(name);
|
||||
fs::create_dir_all(plugin.join("native")).unwrap();
|
||||
fs::write(plugin.join("native/index.mjs"), body).unwrap();
|
||||
let asked = if asks.is_empty() {
|
||||
String::new()
|
||||
} else {
|
||||
format!(
|
||||
"[capabilities]\nhost_services = [{}]\n",
|
||||
asks.iter()
|
||||
.map(|service| format!("{service:?}"))
|
||||
.collect::<Vec<_>>()
|
||||
.join(", ")
|
||||
)
|
||||
};
|
||||
fs::write(
|
||||
plugin.join("plugin.toml"),
|
||||
format!(
|
||||
"schema_version = 1\n[plugin]\nname = {name:?}\nversion = \"1.0.0\"\n[native]\npath = \"native/index.mjs\"\n{asked}"
|
||||
),
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
fn person() -> PluginApprover {
|
||||
PluginApprover::person(PersonVia::Terminal)
|
||||
}
|
||||
|
||||
fn agent(config: &DiscoveryConfig, posture: AgentPosture) -> PluginApprover {
|
||||
PluginApprover::Agent(AgentApproval {
|
||||
posture,
|
||||
workspace: config.workspace.to_string_lossy().into_owned(),
|
||||
thread_id: Some("thread-1".into()),
|
||||
turn_id: Some("turn-1".into()),
|
||||
tool_call_id: Some("call-1".into()),
|
||||
})
|
||||
}
|
||||
|
||||
fn approve(registry: &mut PluginRegistry, name: &str, approved_by: PluginApprover) {
|
||||
let shown = registry
|
||||
.host_service_review_for(name, &approved_by)
|
||||
.map(|review| review.token);
|
||||
registry
|
||||
.trust_reviewed(
|
||||
name,
|
||||
ReviewApproval {
|
||||
approved_by,
|
||||
host_services: shown,
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
registry.enable(name).unwrap();
|
||||
}
|
||||
|
||||
fn granted(config: &DiscoveryConfig, name: &str) -> Result<Vec<HostService>, String> {
|
||||
let registry = discover_with_config(config);
|
||||
let authority = registry
|
||||
.authority_for(name)
|
||||
.ok_or_else(|| "no authority".to_string())?;
|
||||
granted_host_services(&authority)
|
||||
}
|
||||
|
||||
fn edit_receipt(config: &DiscoveryConfig, name: &str, edit: impl FnOnce(&mut Value)) {
|
||||
let mut state: Value =
|
||||
serde_json::from_str(&fs::read_to_string(&config.state_path).unwrap()).unwrap();
|
||||
let entry = state["plugins"]
|
||||
.as_object_mut()
|
||||
.unwrap()
|
||||
.iter_mut()
|
||||
.find(|(id, _)| id.ends_with(&format!("/{name}")))
|
||||
.map(|(_, entry)| entry)
|
||||
.unwrap();
|
||||
edit(&mut entry["trust"]);
|
||||
fs::write(
|
||||
&config.state_path,
|
||||
serde_json::to_string_pretty(&state).unwrap(),
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_reviewed_grant_is_recorded_with_its_approver_and_read_back_for_the_reviewed_content() {
|
||||
let _policy = TestPolicyGuard::extension_host(true);
|
||||
let _offer = TestOffer::only(&[HostService::Network]);
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let config = config(tmp.path());
|
||||
write_plugin(
|
||||
&config,
|
||||
"alpha",
|
||||
&["network", "llm"],
|
||||
"export function apply() {}\n",
|
||||
);
|
||||
|
||||
let mut registry = discover_with_config(&config);
|
||||
let review = registry.host_service_review("alpha").unwrap();
|
||||
assert_eq!(
|
||||
review
|
||||
.lines
|
||||
.iter()
|
||||
.map(|line| (line.service, line.offered))
|
||||
.collect::<Vec<_>>(),
|
||||
vec![(HostService::Llm, false), (HostService::Network, true)]
|
||||
);
|
||||
assert!(review.text()[1].contains("does not offer it"));
|
||||
assert!(review.text()[2].ends_with("No other reviewed plugin holds it."));
|
||||
let by_agent = agent(&config, AgentPosture::FullAccess);
|
||||
approve(&mut registry, "alpha", by_agent.clone());
|
||||
|
||||
let plugin = registry.get("alpha").unwrap();
|
||||
assert_eq!(plugin.host_services, vec![HostService::Network]);
|
||||
assert_eq!(plugin.approved_by, Some(by_agent.clone()));
|
||||
assert_eq!(plugin.enabled_by, Some(person()));
|
||||
assert!(plugin.active());
|
||||
assert_eq!(
|
||||
granted(&config, "alpha").unwrap(),
|
||||
vec![HostService::Network]
|
||||
);
|
||||
let state: Value =
|
||||
serde_json::from_str(&fs::read_to_string(&config.state_path).unwrap()).unwrap();
|
||||
let receipt = &state["plugins"]
|
||||
.as_object()
|
||||
.unwrap()
|
||||
.values()
|
||||
.next()
|
||||
.unwrap()["trust"];
|
||||
assert_eq!(receipt["version"], 2);
|
||||
assert_eq!(
|
||||
receipt["approved_by"],
|
||||
json!({
|
||||
"kind": "agent",
|
||||
"posture": "full_access",
|
||||
"workspace": config.workspace.to_string_lossy(),
|
||||
"thread_id": "thread-1",
|
||||
"turn_id": "turn-1",
|
||||
"tool_call_id": "call-1",
|
||||
})
|
||||
);
|
||||
assert_eq!(receipt["host_services"], json!(["network"]));
|
||||
assert_eq!(
|
||||
receipt["reviewed_capabilities"]["host_services"],
|
||||
json!(["llm", "network"])
|
||||
);
|
||||
|
||||
write_plugin(
|
||||
&config,
|
||||
"alpha",
|
||||
&["network", "llm"],
|
||||
"export function apply() { globalThis.x = 1 }\n",
|
||||
);
|
||||
let changed = discover_with_config(&config);
|
||||
assert!(!changed.get("alpha").unwrap().trusted());
|
||||
assert!(changed.get("alpha").unwrap().host_services.is_empty());
|
||||
assert_eq!(changed.get("alpha").unwrap().approved_by, None);
|
||||
assert!(granted(&config, "alpha").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn trust_without_the_shown_review_grants_nothing_and_a_stale_token_refuses() {
|
||||
let _policy = TestPolicyGuard::extension_host(true);
|
||||
let _offer = TestOffer::only(&[HostService::Network]);
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let config = config(tmp.path());
|
||||
write_plugin(
|
||||
&config,
|
||||
"alpha",
|
||||
&["network"],
|
||||
"export function apply() {}\n",
|
||||
);
|
||||
write_plugin(
|
||||
&config,
|
||||
"beta",
|
||||
&["network"],
|
||||
"export function apply() {}\n",
|
||||
);
|
||||
write_plugin(&config, "plain", &[], "export function apply() {}\n");
|
||||
|
||||
let mut registry = discover_with_config(&config);
|
||||
registry.trust("alpha").unwrap();
|
||||
registry.enable("alpha").unwrap();
|
||||
assert!(registry.get("alpha").unwrap().trusted());
|
||||
assert_eq!(registry.get("alpha").unwrap().approved_by, Some(person()));
|
||||
assert!(granted(&config, "alpha").unwrap().is_empty());
|
||||
let plain = registry.host_service_review("plain").unwrap();
|
||||
assert!(plain.lines.is_empty() && plain.text().is_empty());
|
||||
assert!(
|
||||
registry
|
||||
.trust_reviewed(
|
||||
"plain",
|
||||
ReviewApproval {
|
||||
approved_by: person(),
|
||||
host_services: Some("0".repeat(64)),
|
||||
},
|
||||
)
|
||||
.unwrap_err()
|
||||
.contains("changed since it was shown")
|
||||
);
|
||||
|
||||
let sole = registry.host_service_review("beta").unwrap();
|
||||
assert!(sole.lines[0].shared_with.is_empty());
|
||||
assert_eq!(sole.lines[0].withheld_by[0].name, "alpha");
|
||||
assert!(
|
||||
sole.text()[1]
|
||||
.ends_with("Off for now: `alpha` is turned on and its review does not include it.")
|
||||
);
|
||||
approve(&mut registry, "alpha", person());
|
||||
let before = fs::read(&config.state_path).unwrap();
|
||||
let refused = registry
|
||||
.trust_reviewed(
|
||||
"beta",
|
||||
ReviewApproval {
|
||||
approved_by: person(),
|
||||
host_services: Some(sole.token.clone()),
|
||||
},
|
||||
)
|
||||
.unwrap_err();
|
||||
assert!(refused.contains("changed since it was shown"), "{refused}");
|
||||
assert_eq!(fs::read(&config.state_path).unwrap(), before);
|
||||
assert!(!registry.get("beta").unwrap().trusted());
|
||||
|
||||
let shared = registry.host_service_review("beta").unwrap();
|
||||
assert_ne!(shared.token, sole.token);
|
||||
assert_eq!(shared.lines[0].shared_with[0].name, "alpha");
|
||||
assert!(shared.lines[0].withheld_by.is_empty());
|
||||
assert!(shared.text()[1].contains("Shared with `alpha`: each can use it as this plugin"));
|
||||
approve(&mut registry, "beta", person());
|
||||
assert_eq!(
|
||||
granted(&config, "beta").unwrap(),
|
||||
vec![HostService::Network]
|
||||
);
|
||||
assert_eq!(
|
||||
granted(&config, "alpha").unwrap(),
|
||||
vec![HostService::Network]
|
||||
);
|
||||
let again = registry.host_service_review("alpha").unwrap();
|
||||
assert_eq!(again.lines[0].shared_with[0].name, "beta");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_older_altered_or_unreadable_record_grants_nothing() {
|
||||
let _policy = TestPolicyGuard::extension_host(true);
|
||||
let _offer = TestOffer::only(&[HostService::Network, HostService::Llm]);
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let config = config(tmp.path());
|
||||
write_plugin(
|
||||
&config,
|
||||
"alpha",
|
||||
&["network"],
|
||||
"export function apply() {}\n",
|
||||
);
|
||||
let mut registry = discover_with_config(&config);
|
||||
approve(&mut registry, "alpha", person());
|
||||
let authority = registry.authority_for("alpha").unwrap();
|
||||
assert_eq!(
|
||||
granted_host_services(&authority).unwrap(),
|
||||
vec![HostService::Network]
|
||||
);
|
||||
let sound = fs::read(&config.state_path).unwrap();
|
||||
|
||||
edit_receipt(&config, "alpha", |receipt| {
|
||||
receipt.as_object_mut().unwrap().remove("version");
|
||||
receipt.as_object_mut().unwrap().remove("approved_by");
|
||||
});
|
||||
assert!(verify_plugin_state_authority(&authority).is_ok());
|
||||
assert!(granted_host_services(&authority).unwrap().is_empty());
|
||||
let older = discover_with_config(&config);
|
||||
assert!(older.get("alpha").unwrap().trusted());
|
||||
assert_eq!(older.get("alpha").unwrap().approved_by, None);
|
||||
assert!(older.get("alpha").unwrap().host_services.is_empty());
|
||||
|
||||
let edits: [(&str, Edit); 3] = [
|
||||
("a later format", |receipt| receipt["version"] = json!(3)),
|
||||
("a grant beyond what was asked", |receipt| {
|
||||
receipt["host_services"] = json!(["network", "llm"]);
|
||||
}),
|
||||
("an edited reviewed inventory", |receipt| {
|
||||
receipt["host_services"] = json!(["network", "llm"]);
|
||||
receipt["reviewed_capabilities"]["host_services"] = json!(["llm", "network"]);
|
||||
}),
|
||||
];
|
||||
for (what, edit) in edits {
|
||||
fs::write(&config.state_path, &sound).unwrap();
|
||||
edit_receipt(&config, "alpha", edit);
|
||||
assert!(
|
||||
granted_host_services(&authority).unwrap().is_empty(),
|
||||
"{what}"
|
||||
);
|
||||
assert!(
|
||||
discover_with_config(&config)
|
||||
.get("alpha")
|
||||
.unwrap()
|
||||
.host_services
|
||||
.is_empty(),
|
||||
"{what}"
|
||||
);
|
||||
}
|
||||
|
||||
fs::write(&config.state_path, &sound).unwrap();
|
||||
edit_receipt(&config, "alpha", |receipt| {
|
||||
receipt["capability_hash"] = json!("0".repeat(64));
|
||||
});
|
||||
assert!(granted_host_services(&authority).is_err());
|
||||
|
||||
fs::write(&config.state_path, &sound).unwrap();
|
||||
edit_receipt(&config, "alpha", |receipt| {
|
||||
receipt["host_services"] = json!(["everything"]);
|
||||
});
|
||||
assert!(granted_host_services(&authority).is_err());
|
||||
assert!(
|
||||
!discover_with_config(&config)
|
||||
.get("alpha")
|
||||
.unwrap()
|
||||
.trusted()
|
||||
);
|
||||
|
||||
fs::write(&config.state_path, b"{ not json").unwrap();
|
||||
assert!(granted_host_services(&authority).is_err());
|
||||
fs::remove_file(&config.state_path).unwrap();
|
||||
assert!(granted_host_services(&authority).is_err());
|
||||
|
||||
fs::write(&config.state_path, &sound).unwrap();
|
||||
assert_eq!(
|
||||
granted_host_services(&authority).unwrap(),
|
||||
vec![HostService::Network]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn turning_off_revoking_or_reviewing_again_withdraws_the_grant_from_the_old_authority() {
|
||||
let _policy = TestPolicyGuard::extension_host(true);
|
||||
let _offer = TestOffer::only(&[HostService::Network]);
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let config = config(tmp.path());
|
||||
write_plugin(
|
||||
&config,
|
||||
"alpha",
|
||||
&["network"],
|
||||
"export function apply() {}\n",
|
||||
);
|
||||
let mut registry = discover_with_config(&config);
|
||||
approve(&mut registry, "alpha", person());
|
||||
let held = registry.authority_for("alpha").unwrap();
|
||||
assert_eq!(
|
||||
granted_host_services(&held).unwrap(),
|
||||
vec![HostService::Network]
|
||||
);
|
||||
|
||||
discover_with_config(&config).disable("alpha").unwrap();
|
||||
assert!(granted_host_services(&held).is_err());
|
||||
let mut registry = discover_with_config(&config);
|
||||
registry.enable("alpha").unwrap();
|
||||
assert!(
|
||||
granted_host_services(&held).is_err(),
|
||||
"an authority from before the plugin was turned off is spent"
|
||||
);
|
||||
let current = registry.authority_for("alpha").unwrap();
|
||||
assert_eq!(
|
||||
granted_host_services(¤t).unwrap(),
|
||||
vec![HostService::Network]
|
||||
);
|
||||
|
||||
discover_with_config(&config).revoke_trust("alpha").unwrap();
|
||||
assert!(granted_host_services(¤t).is_err());
|
||||
|
||||
let mut registry = discover_with_config(&config);
|
||||
registry.trust("alpha").unwrap();
|
||||
registry.enable("alpha").unwrap();
|
||||
assert!(granted_host_services(¤t).is_err());
|
||||
assert!(
|
||||
granted_host_services(®istry.authority_for("alpha").unwrap())
|
||||
.unwrap()
|
||||
.is_empty(),
|
||||
"a review that did not show the services grants none"
|
||||
);
|
||||
|
||||
write_plugin(&config, "alpha", &[], "export function apply() {}\n");
|
||||
let narrowed = discover_with_config(&config);
|
||||
assert_eq!(
|
||||
narrowed.get("alpha").unwrap().trust_status.as_str(),
|
||||
"capabilities-changed"
|
||||
);
|
||||
assert!(narrowed.authority_for("alpha").is_none() || granted(&config, "alpha").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_importer_asks_only_for_services_its_own_server_modules_inject() {
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use super::install::dsh::{DshClientClosure, asked_host_services};
|
||||
|
||||
let table = [
|
||||
("llm", HostService::Llm),
|
||||
("credentials", HostService::Network),
|
||||
("remote", HostService::Remote),
|
||||
("terminal", HostService::Terminal),
|
||||
];
|
||||
let file = |text: &str| text.as_bytes().to_vec();
|
||||
let files: BTreeMap<String, Vec<u8>> = [
|
||||
(
|
||||
"source/lib/index.js",
|
||||
file("export const inject = ['tools', 'llm']"),
|
||||
),
|
||||
(
|
||||
"source/lib/client.js",
|
||||
file("export const inject = [\"slots\", \"remote\"]"),
|
||||
),
|
||||
("source/README.md", file("inject = ['terminal']")),
|
||||
(
|
||||
"source/node_modules/@scope/row/index.mjs",
|
||||
file("export const inject = ['credentials']"),
|
||||
),
|
||||
(
|
||||
"source/node_modules/@scope/row/node_modules/deep/index.js",
|
||||
file("export const inject = ['terminal']"),
|
||||
),
|
||||
(
|
||||
"source/node_modules/other/index.js",
|
||||
file("export const inject = ['terminal']"),
|
||||
),
|
||||
(
|
||||
"native/index.mjs",
|
||||
file("export const inject = ['terminal']"),
|
||||
),
|
||||
]
|
||||
.into_iter()
|
||||
.map(|(path, bytes)| (path.to_string(), bytes))
|
||||
.collect();
|
||||
let client = DshClientClosure {
|
||||
package: "demo".into(),
|
||||
root: String::new(),
|
||||
entry: "source/lib/client.js".into(),
|
||||
platform: None,
|
||||
inject: vec!["remote".into()],
|
||||
host_provided: Vec::new(),
|
||||
files: [("source/lib/client.js".to_string(), String::new())].into(),
|
||||
bytes: 0,
|
||||
};
|
||||
let rows = vec!["demo".to_string(), "@scope/row".to_string()];
|
||||
assert_eq!(
|
||||
asked_host_services(&files, &rows, std::slice::from_ref(&client), &table),
|
||||
vec![HostService::Llm, HostService::Network]
|
||||
);
|
||||
assert_eq!(
|
||||
asked_host_services(&files, &rows, &[], &table),
|
||||
vec![HostService::Llm, HostService::Network, HostService::Remote]
|
||||
);
|
||||
assert!(asked_host_services(&files, &rows, &[], &[]).is_empty());
|
||||
assert!(
|
||||
asked_host_services(
|
||||
&files,
|
||||
&rows,
|
||||
&[],
|
||||
super::host_services::HOST_SERVICE_INJECTS
|
||||
)
|
||||
.is_empty()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_agent_is_an_approver_only_with_the_token_of_its_own_review_and_in_its_own_project() {
|
||||
let _policy = TestPolicyGuard::extension_host(true);
|
||||
let _offer = TestOffer::only(&[HostService::Network]);
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let config = config(tmp.path());
|
||||
write_plugin(
|
||||
&config,
|
||||
"alpha",
|
||||
&["network"],
|
||||
"export function apply() {}\n",
|
||||
);
|
||||
write_plugin(
|
||||
&config,
|
||||
"beta",
|
||||
&["network"],
|
||||
"export function apply() {}\n",
|
||||
);
|
||||
let mut registry = discover_with_config(&config);
|
||||
approve(&mut registry, "alpha", person());
|
||||
let by_agent = agent(&config, AgentPosture::FullAccess);
|
||||
let agents_review = registry.host_service_review_for("beta", &by_agent).unwrap();
|
||||
let persons_review = registry.host_service_review("beta").unwrap();
|
||||
assert!(agents_review.lines[0].shared_with.is_empty());
|
||||
assert_eq!(persons_review.lines[0].shared_with[0].name, "alpha");
|
||||
assert_ne!(agents_review.token, persons_review.token);
|
||||
|
||||
let before = fs::read(&config.state_path).unwrap();
|
||||
let PluginApprover::Agent(fields) = by_agent.clone() else {
|
||||
unreachable!()
|
||||
};
|
||||
let refusals = [
|
||||
(by_agent.clone(), None, "must carry the token"),
|
||||
(
|
||||
PluginApprover::Agent(AgentApproval {
|
||||
tool_call_id: None,
|
||||
..fields.clone()
|
||||
}),
|
||||
Some(agents_review.token.clone()),
|
||||
"must name the thread, turn and tool call",
|
||||
),
|
||||
(
|
||||
PluginApprover::Agent(AgentApproval {
|
||||
workspace: tmp.path().join("elsewhere").to_string_lossy().into_owned(),
|
||||
..fields.clone()
|
||||
}),
|
||||
Some(agents_review.token.clone()),
|
||||
"limited to its own project",
|
||||
),
|
||||
(
|
||||
by_agent.clone(),
|
||||
Some(persons_review.token.clone()),
|
||||
"changed since it was shown",
|
||||
),
|
||||
];
|
||||
for (approved_by, host_services, says) in refusals {
|
||||
let refused = registry
|
||||
.trust_reviewed(
|
||||
"beta",
|
||||
ReviewApproval {
|
||||
approved_by,
|
||||
host_services,
|
||||
},
|
||||
)
|
||||
.unwrap_err();
|
||||
assert!(refused.contains(says), "{refused}");
|
||||
assert_eq!(fs::read(&config.state_path).unwrap(), before);
|
||||
}
|
||||
|
||||
approve(&mut registry, "beta", by_agent.clone());
|
||||
assert_eq!(registry.get("beta").unwrap().approved_by, Some(by_agent));
|
||||
assert_eq!(
|
||||
granted(&config, "beta").unwrap(),
|
||||
vec![HostService::Network]
|
||||
);
|
||||
let alphas = registry.host_service_review("alpha").unwrap();
|
||||
assert!(
|
||||
alphas.lines[0].shared_with.is_empty(),
|
||||
"a reviewed plugin shares nothing with an agent-approved one"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_agent_approved_record_is_active_only_in_the_project_it_names() {
|
||||
let _policy = TestPolicyGuard::extension_host(true);
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let config = config(tmp.path());
|
||||
write_plugin(&config, "alpha", &[], "export function apply() {}\n");
|
||||
write_plugin(&config, "beta", &[], "export function apply() {}\n");
|
||||
let mut registry = discover_with_config(&config);
|
||||
approve(
|
||||
&mut registry,
|
||||
"alpha",
|
||||
agent(&config, AgentPosture::AutoReview),
|
||||
);
|
||||
approve(&mut registry, "beta", person());
|
||||
assert!(registry.get("alpha").unwrap().active());
|
||||
|
||||
let elsewhere = DiscoveryConfig {
|
||||
workspace: tmp.path().join("other-project"),
|
||||
..config.clone()
|
||||
};
|
||||
let other = discover_with_config(&elsewhere);
|
||||
let alpha = other.get("alpha").unwrap();
|
||||
assert!(alpha.trusted() && alpha.enabled && alpha.outside_reach);
|
||||
assert!(!alpha.active());
|
||||
assert!(other.get("beta").unwrap().active());
|
||||
|
||||
let again = discover_with_config(&config);
|
||||
assert!(!again.get("alpha").unwrap().outside_reach);
|
||||
assert!(again.get("alpha").unwrap().active());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_command_line_trust_is_recorded_as_such_and_the_agents_mark_makes_it_the_agents() {
|
||||
let _policy = TestPolicyGuard::extension_host(true);
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let config = config(tmp.path());
|
||||
write_plugin(&config, "alpha", &[], "export function apply() {}\n");
|
||||
write_plugin(&config, "beta", &[], "export function apply() {}\n");
|
||||
let mut registry = discover_with_config(&config);
|
||||
approve(&mut registry, "alpha", PluginApprover::command_line(None));
|
||||
let marked = PluginApprover::command_line(Some(&config.workspace));
|
||||
assert!(
|
||||
registry
|
||||
.trust_reviewed(
|
||||
"beta",
|
||||
ReviewApproval {
|
||||
approved_by: marked.clone(),
|
||||
host_services: None,
|
||||
},
|
||||
)
|
||||
.unwrap_err()
|
||||
.contains("must carry the token")
|
||||
);
|
||||
approve(&mut registry, "beta", marked.clone());
|
||||
|
||||
let state: Value =
|
||||
serde_json::from_str(&fs::read_to_string(&config.state_path).unwrap()).unwrap();
|
||||
let record = |name: &str| {
|
||||
state["plugins"]
|
||||
.as_object()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.find(|(id, _)| id.ends_with(&format!("/{name}")))
|
||||
.map(|(_, entry)| entry.clone())
|
||||
.unwrap()
|
||||
};
|
||||
assert_eq!(
|
||||
record("alpha")["trust"]["approved_by"],
|
||||
json!({"kind": "command_line"})
|
||||
);
|
||||
assert_eq!(
|
||||
record("beta")["trust"]["approved_by"],
|
||||
json!({"kind": "agent", "posture": "command_line", "workspace": config.workspace.to_string_lossy()})
|
||||
);
|
||||
assert_eq!(
|
||||
record("beta")["enabled_by"],
|
||||
json!({"kind": "person", "via": "terminal"})
|
||||
);
|
||||
|
||||
let mut registry = discover_with_config(&config);
|
||||
registry.enable_by("alpha", marked.clone()).unwrap();
|
||||
assert_eq!(registry.get("alpha").unwrap().enabled_by, Some(marked));
|
||||
registry.disable("alpha").unwrap();
|
||||
assert_eq!(registry.get("alpha").unwrap().enabled_by, None);
|
||||
registry.enable("alpha").unwrap();
|
||||
registry.trust("alpha").unwrap();
|
||||
let alpha = registry.get("alpha").unwrap();
|
||||
assert!(!alpha.enabled && alpha.enabled_by.is_none());
|
||||
|
||||
let elsewhere = DiscoveryConfig {
|
||||
workspace: tmp.path().join("other-project"),
|
||||
..config.clone()
|
||||
};
|
||||
assert!(
|
||||
!discover_with_config(&elsewhere)
|
||||
.get("beta")
|
||||
.unwrap()
|
||||
.active()
|
||||
);
|
||||
}
|
||||
|
||||
mod v0_10_2 {
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use serde::Deserialize;
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct PluginStateFile {
|
||||
pub schema_version: u32,
|
||||
#[serde(default)]
|
||||
pub plugins: BTreeMap<String, PersistedPluginState>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct PersistedPluginState {
|
||||
#[serde(default)]
|
||||
pub generation: u64,
|
||||
#[serde(default)]
|
||||
pub enabled: bool,
|
||||
#[serde(default)]
|
||||
pub trust: Option<TrustReceipt>,
|
||||
#[serde(default)]
|
||||
pub review_history: Vec<TrustReceipt>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct TrustReceipt {
|
||||
pub content_hash: String,
|
||||
pub capability_hash: String,
|
||||
pub reviewed_capabilities: PluginInventory,
|
||||
pub reviewed_at: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct PluginInventory {
|
||||
pub skills: usize,
|
||||
pub mcp_servers: usize,
|
||||
#[serde(default)]
|
||||
pub stdio_mcp_servers: usize,
|
||||
#[serde(default)]
|
||||
pub remote_mcp_servers: usize,
|
||||
pub commands: usize,
|
||||
pub agents: usize,
|
||||
pub hooks: usize,
|
||||
pub lsp: usize,
|
||||
pub native: usize,
|
||||
#[serde(default)]
|
||||
pub providers: usize,
|
||||
pub filesystem_roots: Vec<String>,
|
||||
pub network_hosts: Vec<String>,
|
||||
pub lifecycle_mutation: bool,
|
||||
}
|
||||
|
||||
impl PluginStateFile {
|
||||
pub fn reviews(&self) -> Vec<String> {
|
||||
self.plugins
|
||||
.values()
|
||||
.flat_map(|entry| {
|
||||
entry
|
||||
.trust
|
||||
.iter()
|
||||
.chain(&entry.review_history)
|
||||
.map(move |receipt| {
|
||||
format!(
|
||||
"{}:{}:{}:{}:{}",
|
||||
entry.generation,
|
||||
entry.enabled,
|
||||
receipt.content_hash,
|
||||
receipt.capability_hash,
|
||||
receipt.reviewed_at
|
||||
) + &receipt.reviewed_capabilities.counted()
|
||||
})
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
|
||||
impl PluginInventory {
|
||||
fn counted(&self) -> String {
|
||||
format!(
|
||||
":{}:{}:{}:{}:{}:{}:{}:{}:{}:{}:{}:{}:{}",
|
||||
self.skills,
|
||||
self.mcp_servers,
|
||||
self.stdio_mcp_servers,
|
||||
self.remote_mcp_servers,
|
||||
self.commands,
|
||||
self.agents,
|
||||
self.hooks,
|
||||
self.lsp,
|
||||
self.native,
|
||||
self.providers,
|
||||
self.filesystem_roots.len(),
|
||||
self.network_hosts.len(),
|
||||
self.lifecycle_mutation
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn v0_10_2_reads_this_state_file_until_a_plugin_is_reviewed_or_turned_on_here() {
|
||||
let _policy = TestPolicyGuard::extension_host(true);
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let config = config(tmp.path());
|
||||
write_plugin(&config, "alpha", &[], "export function apply() {}\n");
|
||||
write_plugin(&config, "beta", &[], "export function apply() {}\n");
|
||||
let older = |config: &DiscoveryConfig| {
|
||||
serde_json::from_str::<v0_10_2::PluginStateFile>(
|
||||
&fs::read_to_string(&config.state_path).unwrap(),
|
||||
)
|
||||
};
|
||||
|
||||
let mut registry = discover_with_config(&config);
|
||||
approve(&mut registry, "alpha", person());
|
||||
edit_receipt(&config, "alpha", |receipt| {
|
||||
receipt.as_object_mut().unwrap().remove("version");
|
||||
receipt.as_object_mut().unwrap().remove("approved_by");
|
||||
});
|
||||
let mut state: Value =
|
||||
serde_json::from_str(&fs::read_to_string(&config.state_path).unwrap()).unwrap();
|
||||
for entry in state["plugins"].as_object_mut().unwrap().values_mut() {
|
||||
entry.as_object_mut().unwrap().remove("enabled_by");
|
||||
for receipt in entry["review_history"].as_array_mut().unwrap() {
|
||||
receipt.as_object_mut().unwrap().remove("version");
|
||||
receipt.as_object_mut().unwrap().remove("approved_by");
|
||||
}
|
||||
}
|
||||
fs::write(
|
||||
&config.state_path,
|
||||
serde_json::to_string_pretty(&state).unwrap(),
|
||||
)
|
||||
.unwrap();
|
||||
let parsed = older(&config).expect("a state file of v0.10.2's shape");
|
||||
assert_eq!(parsed.schema_version, 1);
|
||||
let content = ®istry.get("alpha").unwrap().content_hash;
|
||||
assert!(
|
||||
parsed
|
||||
.reviews()
|
||||
.iter()
|
||||
.any(|review| review.contains(content.as_str())),
|
||||
"{:?}",
|
||||
parsed.reviews()
|
||||
);
|
||||
|
||||
let mut registry = discover_with_config(&config);
|
||||
assert!(registry.get("alpha").unwrap().active());
|
||||
registry.disable("beta").unwrap();
|
||||
registry.disable("alpha").unwrap();
|
||||
let parsed = older(&config).expect("writes that record no review or enablement stay readable");
|
||||
assert!(parsed.plugins.values().all(|entry| !entry.enabled));
|
||||
|
||||
registry.enable("alpha").unwrap();
|
||||
let refused = older(&config).unwrap_err().to_string();
|
||||
assert!(refused.contains("unknown field `enabled_by`"), "{refused}");
|
||||
|
||||
registry.disable("alpha").unwrap();
|
||||
assert!(older(&config).is_ok());
|
||||
registry.trust("beta").unwrap();
|
||||
let refused = older(&config).unwrap_err().to_string();
|
||||
assert!(refused.contains("unknown field `version`"), "{refused}");
|
||||
}
|
||||
@@ -0,0 +1,646 @@
|
||||
use std::collections::BTreeSet;
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use sha2::{Digest, Sha256};
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum HostService {
|
||||
Workspace,
|
||||
Llm,
|
||||
Network,
|
||||
Settings,
|
||||
Catalog,
|
||||
Terminal,
|
||||
Remote,
|
||||
}
|
||||
|
||||
impl HostService {
|
||||
pub const ALL: [Self; 7] = [
|
||||
Self::Workspace,
|
||||
Self::Llm,
|
||||
Self::Network,
|
||||
Self::Settings,
|
||||
Self::Catalog,
|
||||
Self::Terminal,
|
||||
Self::Remote,
|
||||
];
|
||||
|
||||
#[must_use]
|
||||
pub fn name(self) -> &'static str {
|
||||
match self {
|
||||
Self::Workspace => "workspace",
|
||||
Self::Llm => "llm",
|
||||
Self::Network => "network",
|
||||
Self::Settings => "settings",
|
||||
Self::Catalog => "catalog",
|
||||
Self::Terminal => "terminal",
|
||||
Self::Remote => "remote",
|
||||
}
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn parse(name: &str) -> Option<Self> {
|
||||
Self::ALL.into_iter().find(|service| service.name() == name)
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn label(self) -> &'static str {
|
||||
match self {
|
||||
Self::Workspace => "Workspace files and commands",
|
||||
Self::Llm => "Your model provider",
|
||||
Self::Network => "Network requests",
|
||||
Self::Settings => "Codewhale settings",
|
||||
Self::Catalog => "Plugins and provider routes",
|
||||
Self::Terminal => "Terminal sessions",
|
||||
Self::Remote => "Remote pairing",
|
||||
}
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn plain(self) -> &'static str {
|
||||
match self {
|
||||
Self::Workspace => {
|
||||
"read and change files and run commands in the session's workspace, under Codewhale's approval rules"
|
||||
}
|
||||
Self::Llm => "send requests to the model provider you approved, on your account",
|
||||
Self::Network => {
|
||||
"send requests to the hosts in its review, with credentials it stores in Codewhale"
|
||||
}
|
||||
Self::Settings => "read and change the Codewhale settings in its review",
|
||||
Self::Catalog => {
|
||||
"list your provider routes, read your balance and plan plugin installs"
|
||||
}
|
||||
Self::Terminal => "start and drive terminal sessions in the session's workspace",
|
||||
Self::Remote => "pair another device with the session",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub const HOST_SERVICE_INJECTS: &[(&str, HostService)] = &[];
|
||||
|
||||
#[cfg(any(test, feature = "test-support"))]
|
||||
thread_local! {
|
||||
static TEST_OFFER: std::cell::RefCell<Option<Vec<HostService>>> =
|
||||
const { std::cell::RefCell::new(None) };
|
||||
}
|
||||
|
||||
#[cfg(any(test, feature = "test-support"))]
|
||||
pub struct TestOffer(Option<Vec<HostService>>);
|
||||
|
||||
#[cfg(any(test, feature = "test-support"))]
|
||||
impl TestOffer {
|
||||
pub fn only(services: &[HostService]) -> Self {
|
||||
Self(TEST_OFFER.with(|offer| offer.replace(Some(services.to_vec()))))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(any(test, feature = "test-support"))]
|
||||
impl Drop for TestOffer {
|
||||
fn drop(&mut self) {
|
||||
let previous = self.0.take();
|
||||
TEST_OFFER.with(|offer| *offer.borrow_mut() = previous);
|
||||
}
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn offered(service: HostService) -> bool {
|
||||
#[cfg(any(test, feature = "test-support"))]
|
||||
if let Some(offer) = TEST_OFFER.with(|offer| offer.borrow().clone()) {
|
||||
return offer.contains(&service);
|
||||
}
|
||||
HOST_SERVICE_INJECTS
|
||||
.iter()
|
||||
.any(|(_, candidate)| *candidate == service)
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn declared_injects(source: &str) -> BTreeSet<String> {
|
||||
static DECLARATION: std::sync::OnceLock<regex::Regex> = std::sync::OnceLock::new();
|
||||
static NAME: std::sync::OnceLock<regex::Regex> = std::sync::OnceLock::new();
|
||||
let declaration = DECLARATION.get_or_init(|| {
|
||||
regex::Regex::new(r"\binject\b\s*[:=]\s*(\[[^\]]*\]|\{[^;]*?\}\s*(?:[;,\n)]|$))")
|
||||
.expect("inject declaration pattern")
|
||||
});
|
||||
let name = NAME.get_or_init(|| {
|
||||
regex::Regex::new(
|
||||
r#""([A-Za-z_$][\w$]{0,63})"|'([A-Za-z_$][\w$]{0,63})'|([A-Za-z_$][\w$]{0,63})\s*:"#,
|
||||
)
|
||||
.expect("inject name pattern")
|
||||
});
|
||||
let mut names = BTreeSet::new();
|
||||
for found in declaration.captures_iter(source) {
|
||||
let body = found.get(1).map_or("", |body| body.as_str());
|
||||
for capture in name.captures_iter(body) {
|
||||
if let Some(matched) = capture.get(1).or(capture.get(2)).or(capture.get(3)) {
|
||||
names.insert(matched.as_str().to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
names
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn services_for_injects<'a>(
|
||||
injects: impl IntoIterator<Item = &'a str>,
|
||||
table: &[(&str, HostService)],
|
||||
) -> Vec<HostService> {
|
||||
let services: BTreeSet<HostService> = injects
|
||||
.into_iter()
|
||||
.flat_map(|inject| {
|
||||
table
|
||||
.iter()
|
||||
.filter(move |(name, _)| *name == inject)
|
||||
.map(|(_, service)| *service)
|
||||
})
|
||||
.collect();
|
||||
services.into_iter().collect()
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum PersonVia {
|
||||
Terminal,
|
||||
App,
|
||||
ApprovalCard,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum AgentPosture {
|
||||
FullAccess,
|
||||
AutoReview,
|
||||
CommandLine,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct AgentApproval {
|
||||
pub posture: AgentPosture,
|
||||
pub workspace: String,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub thread_id: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub turn_id: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub tool_call_id: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(tag = "kind", rename_all = "snake_case")]
|
||||
pub enum PluginApprover {
|
||||
Person { via: PersonVia },
|
||||
Agent(AgentApproval),
|
||||
CommandLine,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
|
||||
#[serde(tag = "kind", rename_all = "snake_case")]
|
||||
pub enum PluginCohort {
|
||||
Reviewed,
|
||||
Agent { workspace: String },
|
||||
}
|
||||
|
||||
impl PluginApprover {
|
||||
#[must_use]
|
||||
pub fn person(via: PersonVia) -> Self {
|
||||
Self::Person { via }
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn command_line(agent_workspace: Option<&std::path::Path>) -> Self {
|
||||
match agent_workspace {
|
||||
Some(workspace) => Self::Agent(AgentApproval {
|
||||
posture: AgentPosture::CommandLine,
|
||||
workspace: workspace.to_string_lossy().into_owned(),
|
||||
thread_id: None,
|
||||
turn_id: None,
|
||||
tool_call_id: None,
|
||||
}),
|
||||
None => Self::CommandLine,
|
||||
}
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn kind(&self) -> &'static str {
|
||||
match self {
|
||||
Self::Person { .. } => "person",
|
||||
Self::Agent(_) => "agent",
|
||||
Self::CommandLine => "command_line",
|
||||
}
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn cohort(&self) -> PluginCohort {
|
||||
match self {
|
||||
Self::Agent(agent) => PluginCohort::Agent {
|
||||
workspace: agent.workspace.clone(),
|
||||
},
|
||||
Self::Person { .. } | Self::CommandLine => PluginCohort::Reviewed,
|
||||
}
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn agent_workspace(&self) -> Option<&str> {
|
||||
match self {
|
||||
Self::Agent(agent) => Some(agent.workspace.as_str()),
|
||||
Self::Person { .. } | Self::CommandLine => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn cohort_of(approver: Option<&PluginApprover>) -> PluginCohort {
|
||||
approver.map_or(PluginCohort::Reviewed, PluginApprover::cohort)
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn approver_label(approver: Option<&PluginApprover>) -> &'static str {
|
||||
match approver {
|
||||
Some(PluginApprover::Person { .. }) => "approved by you",
|
||||
Some(PluginApprover::Agent(agent)) => match agent.posture {
|
||||
AgentPosture::FullAccess => "approved by the agent in Full Access",
|
||||
AgentPosture::AutoReview => "approved by the agent in Auto-Review",
|
||||
AgentPosture::CommandLine => "approved by the agent from a shell command",
|
||||
},
|
||||
Some(PluginApprover::CommandLine) => {
|
||||
"trusted from the command line with --yes; no review was shown there"
|
||||
}
|
||||
None => "approver not recorded (reviewed before 0.10.3)",
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct ReviewApproval {
|
||||
pub approved_by: PluginApprover,
|
||||
pub host_services: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
|
||||
pub struct ReviewPeer {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
|
||||
pub struct HostServiceLine {
|
||||
pub service: HostService,
|
||||
pub offered: bool,
|
||||
pub shared_with: Vec<ReviewPeer>,
|
||||
pub withheld_by: Vec<ReviewPeer>,
|
||||
}
|
||||
|
||||
pub const REVIEW_LEAD: &str = "This plugin asks to use services of the Codewhale host. Plugins share one process: a service works only while every plugin that is turned on holds it, and each of those plugins can use it while another's tool runs.";
|
||||
|
||||
fn quoted(peers: &[ReviewPeer]) -> String {
|
||||
peers
|
||||
.iter()
|
||||
.map(|peer| format!("`{}`", peer.name))
|
||||
.collect::<Vec<_>>()
|
||||
.join(", ")
|
||||
}
|
||||
|
||||
impl HostServiceLine {
|
||||
#[must_use]
|
||||
pub fn sentence(&self) -> String {
|
||||
let label = self.service.label();
|
||||
if !self.offered {
|
||||
return format!(
|
||||
"{label}: this version of Codewhale does not offer it to plugins. Approving grants nothing for it."
|
||||
);
|
||||
}
|
||||
let mut sentence = format!("{label}: {}.", self.service.plain());
|
||||
if self.shared_with.is_empty() {
|
||||
sentence.push_str(" No other reviewed plugin holds it.");
|
||||
} else {
|
||||
sentence.push_str(&format!(
|
||||
" Shared with {}: each can use it as this plugin while this plugin's tool runs, and this plugin can use it as them.",
|
||||
quoted(&self.shared_with)
|
||||
));
|
||||
}
|
||||
if !self.withheld_by.is_empty() {
|
||||
sentence.push_str(&format!(
|
||||
" Off for now: {} {} turned on and {} review does not include it.",
|
||||
quoted(&self.withheld_by),
|
||||
if self.withheld_by.len() == 1 {
|
||||
"is"
|
||||
} else {
|
||||
"are"
|
||||
},
|
||||
if self.withheld_by.len() == 1 {
|
||||
"its"
|
||||
} else {
|
||||
"their"
|
||||
},
|
||||
));
|
||||
}
|
||||
sentence
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
|
||||
pub struct HostServiceReview {
|
||||
pub lines: Vec<HostServiceLine>,
|
||||
pub token: String,
|
||||
}
|
||||
|
||||
impl HostServiceReview {
|
||||
#[must_use]
|
||||
pub fn new(
|
||||
plugin_id: &str,
|
||||
content_hash: &str,
|
||||
capability_hash: &str,
|
||||
cohort: &PluginCohort,
|
||||
lines: Vec<HostServiceLine>,
|
||||
) -> Self {
|
||||
let mut hasher = Sha256::new();
|
||||
let mut field = |label: &str, value: &str| {
|
||||
hasher.update(label.as_bytes());
|
||||
hasher.update(b"\0");
|
||||
hasher.update((value.len() as u64).to_le_bytes());
|
||||
hasher.update(value.as_bytes());
|
||||
};
|
||||
field("codewhale-host-service-review-v1", plugin_id);
|
||||
field("content", content_hash);
|
||||
field("capability", capability_hash);
|
||||
match cohort {
|
||||
PluginCohort::Reviewed => field("cohort", "reviewed"),
|
||||
PluginCohort::Agent { workspace } => field("cohort-agent", workspace),
|
||||
}
|
||||
for line in &lines {
|
||||
field("service", line.service.name());
|
||||
field("offered", if line.offered { "yes" } else { "no" });
|
||||
for peer in &line.shared_with {
|
||||
field("shared", &peer.id);
|
||||
}
|
||||
for peer in &line.withheld_by {
|
||||
field("withheld", &peer.id);
|
||||
}
|
||||
}
|
||||
let token = crate::manifest::hex_digest(hasher.finalize());
|
||||
Self { lines, token }
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn grants(&self) -> Vec<HostService> {
|
||||
self.lines
|
||||
.iter()
|
||||
.filter(|line| line.offered)
|
||||
.map(|line| line.service)
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn text(&self) -> Vec<String> {
|
||||
if self.lines.is_empty() {
|
||||
return Vec::new();
|
||||
}
|
||||
std::iter::once(REVIEW_LEAD.to_string())
|
||||
.chain(self.lines.iter().map(HostServiceLine::sentence))
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn peer(name: &str) -> ReviewPeer {
|
||||
ReviewPeer {
|
||||
id: format!("user/0/{name}"),
|
||||
name: name.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
fn line(shared: &[&str], withheld: &[&str], offered: bool) -> HostServiceLine {
|
||||
HostServiceLine {
|
||||
service: HostService::Network,
|
||||
offered,
|
||||
shared_with: shared.iter().map(|name| peer(name)).collect(),
|
||||
withheld_by: withheld.iter().map(|name| peer(name)).collect(),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_host_service_is_offered_and_the_table_names_only_advertised_injects() {
|
||||
assert!(HOST_SERVICE_INJECTS.is_empty());
|
||||
assert!(
|
||||
HostService::ALL
|
||||
.into_iter()
|
||||
.all(|service| !offered(service))
|
||||
);
|
||||
let table: serde_json::Value =
|
||||
serde_json::from_slice(crate::activation::DSH_COMPATIBILITY).unwrap();
|
||||
let advertised: BTreeSet<&str> = table["provided_services"]
|
||||
.as_object()
|
||||
.unwrap()
|
||||
.values()
|
||||
.flat_map(|group| group.as_array().unwrap())
|
||||
.map(|name| name.as_str().unwrap())
|
||||
.collect();
|
||||
for (inject, _) in HOST_SERVICE_INJECTS {
|
||||
assert!(advertised.contains(inject), "{inject} is not advertised");
|
||||
}
|
||||
for service in HostService::ALL {
|
||||
assert_eq!(HostService::parse(service.name()), Some(service));
|
||||
assert_eq!(
|
||||
serde_json::to_value(service).unwrap(),
|
||||
serde_json::Value::String(service.name().into())
|
||||
);
|
||||
}
|
||||
let _offer = TestOffer::only(&[HostService::Llm]);
|
||||
assert!(offered(HostService::Llm));
|
||||
assert!(!offered(HostService::Network));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_review_says_whether_a_service_is_held_alone_shared_withheld_or_not_offered() {
|
||||
assert_eq!(
|
||||
line(&[], &[], true).sentence(),
|
||||
"Network requests: send requests to the hosts in its review, with credentials it stores in Codewhale. No other reviewed plugin holds it."
|
||||
);
|
||||
assert_eq!(
|
||||
line(&["alpha", "beta"], &[], true).sentence(),
|
||||
"Network requests: send requests to the hosts in its review, with credentials it stores in Codewhale. Shared with `alpha`, `beta`: each can use it as this plugin while this plugin's tool runs, and this plugin can use it as them."
|
||||
);
|
||||
assert_eq!(
|
||||
line(&[], &["gamma"], true).sentence(),
|
||||
"Network requests: send requests to the hosts in its review, with credentials it stores in Codewhale. No other reviewed plugin holds it. Off for now: `gamma` is turned on and its review does not include it."
|
||||
);
|
||||
assert!(
|
||||
line(&["alpha"], &["gamma", "delta"], true)
|
||||
.sentence()
|
||||
.ends_with("Off for now: `gamma`, `delta` are turned on and their review does not include it.")
|
||||
);
|
||||
assert_eq!(
|
||||
line(&["alpha"], &["gamma"], false).sentence(),
|
||||
"Network requests: this version of Codewhale does not offer it to plugins. Approving grants nothing for it."
|
||||
);
|
||||
let review = HostServiceReview::new(
|
||||
"id",
|
||||
"c",
|
||||
"k",
|
||||
&PluginCohort::Reviewed,
|
||||
vec![line(&["alpha"], &[], true)],
|
||||
);
|
||||
assert_eq!(review.text()[0], REVIEW_LEAD);
|
||||
assert_eq!(review.text().len(), 2);
|
||||
assert_eq!(review.grants(), vec![HostService::Network]);
|
||||
assert!(
|
||||
HostServiceReview::new(
|
||||
"id",
|
||||
"c",
|
||||
"k",
|
||||
&PluginCohort::Reviewed,
|
||||
vec![line(&[], &[], false)]
|
||||
)
|
||||
.grants()
|
||||
.is_empty()
|
||||
);
|
||||
let token =
|
||||
|lines| HostServiceReview::new("id", "c", "k", &PluginCohort::Reviewed, lines).token;
|
||||
let base = token(vec![line(&["alpha"], &[], true)]);
|
||||
assert_eq!(base.len(), 64);
|
||||
assert_ne!(base, token(vec![line(&["beta"], &[], true)]));
|
||||
assert_ne!(base, token(vec![line(&[], &["alpha"], true)]));
|
||||
assert_ne!(base, token(vec![line(&["alpha"], &[], false)]));
|
||||
assert_ne!(
|
||||
base,
|
||||
HostServiceReview::new(
|
||||
"other",
|
||||
"c",
|
||||
"k",
|
||||
&PluginCohort::Reviewed,
|
||||
vec![line(&["alpha"], &[], true)]
|
||||
)
|
||||
.token
|
||||
);
|
||||
assert_ne!(
|
||||
base,
|
||||
HostServiceReview::new(
|
||||
"id",
|
||||
"c2",
|
||||
"k",
|
||||
&PluginCohort::Reviewed,
|
||||
vec![line(&["alpha"], &[], true)]
|
||||
)
|
||||
.token
|
||||
);
|
||||
assert_ne!(
|
||||
base,
|
||||
HostServiceReview::new(
|
||||
"id",
|
||||
"c",
|
||||
"k",
|
||||
&PluginCohort::Agent {
|
||||
workspace: "/w".into()
|
||||
},
|
||||
vec![line(&["alpha"], &[], true)]
|
||||
)
|
||||
.token
|
||||
);
|
||||
assert!(
|
||||
HostServiceReview::new("id", "c", "k", &PluginCohort::Reviewed, Vec::new())
|
||||
.text()
|
||||
.is_empty()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_record_says_who_approved_in_one_shape_and_the_label_says_it_in_words() {
|
||||
let agent = PluginApprover::Agent(AgentApproval {
|
||||
posture: AgentPosture::AutoReview,
|
||||
workspace: "/w".into(),
|
||||
thread_id: Some("t".into()),
|
||||
turn_id: Some("u".into()),
|
||||
tool_call_id: Some("c".into()),
|
||||
});
|
||||
let cases = [
|
||||
(
|
||||
PluginApprover::person(PersonVia::ApprovalCard),
|
||||
serde_json::json!({"kind": "person", "via": "approval_card"}),
|
||||
"approved by you",
|
||||
),
|
||||
(
|
||||
agent.clone(),
|
||||
serde_json::json!({"kind": "agent", "posture": "auto_review", "workspace": "/w", "thread_id": "t", "turn_id": "u", "tool_call_id": "c"}),
|
||||
"approved by the agent in Auto-Review",
|
||||
),
|
||||
(
|
||||
PluginApprover::command_line(None),
|
||||
serde_json::json!({"kind": "command_line"}),
|
||||
"trusted from the command line with --yes; no review was shown there",
|
||||
),
|
||||
(
|
||||
PluginApprover::command_line(Some(std::path::Path::new("/w"))),
|
||||
serde_json::json!({"kind": "agent", "posture": "command_line", "workspace": "/w"}),
|
||||
"approved by the agent from a shell command",
|
||||
),
|
||||
];
|
||||
for (approver, json, label) in cases {
|
||||
assert_eq!(serde_json::to_value(&approver).unwrap(), json);
|
||||
assert_eq!(
|
||||
serde_json::from_value::<PluginApprover>(json).unwrap(),
|
||||
approver
|
||||
);
|
||||
assert_eq!(approver_label(Some(&approver)), label);
|
||||
}
|
||||
assert_eq!(
|
||||
approver_label(None),
|
||||
"approver not recorded (reviewed before 0.10.3)"
|
||||
);
|
||||
assert_eq!(cohort_of(None), PluginCohort::Reviewed);
|
||||
assert_eq!(
|
||||
PluginApprover::command_line(None).cohort(),
|
||||
PluginCohort::Reviewed
|
||||
);
|
||||
assert_eq!(
|
||||
agent.cohort(),
|
||||
PluginCohort::Agent {
|
||||
workspace: "/w".into()
|
||||
}
|
||||
);
|
||||
assert!(serde_json::from_value::<PluginApprover>(serde_json::json!("person")).is_err());
|
||||
assert!(
|
||||
serde_json::from_value::<PluginApprover>(serde_json::json!({"kind": "agent"})).is_err()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn inject_declarations_map_to_host_services_through_one_table() {
|
||||
let table = [
|
||||
("llm", HostService::Llm),
|
||||
("credentials", HostService::Network),
|
||||
("pluginManager", HostService::Catalog),
|
||||
("deepseekAccount", HostService::Catalog),
|
||||
];
|
||||
let array =
|
||||
"export const inject = ['tools', 'llm', \"subagents\"]\nconst other = ['credentials']";
|
||||
assert_eq!(
|
||||
declared_injects(array),
|
||||
["llm", "subagents", "tools"].map(String::from).into()
|
||||
);
|
||||
assert_eq!(
|
||||
services_for_injects(declared_injects(array).iter().map(String::as_str), &table),
|
||||
vec![HostService::Llm]
|
||||
);
|
||||
let property =
|
||||
"var plugin={name:'w',inject:['webServer','credentials','connection'],apply(){}}";
|
||||
assert_eq!(
|
||||
services_for_injects(
|
||||
declared_injects(property).iter().map(String::as_str),
|
||||
&table
|
||||
),
|
||||
vec![HostService::Network]
|
||||
);
|
||||
let object = "class P { static inject = { required: ['pluginManager'], optional: [\"llm\"] }; }\nPlugin.inject = { deepseekAccount: { required: false } };";
|
||||
assert_eq!(
|
||||
services_for_injects(declared_injects(object).iter().map(String::as_str), &table),
|
||||
vec![HostService::Llm, HostService::Catalog]
|
||||
);
|
||||
assert!(declared_injects("const injected = ['llm']; reinject('llm')").is_empty());
|
||||
assert!(
|
||||
services_for_injects(declared_injects(array).iter().map(String::as_str), &[])
|
||||
.is_empty()
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -2599,6 +2599,48 @@ fn host_hook_bridge(name: &str) -> bool {
|
||||
crate::activation::dsh_row_bridge(name)
|
||||
}
|
||||
|
||||
pub(crate) fn asked_host_services(
|
||||
files: &BTreeMap<String, Vec<u8>>,
|
||||
rows: &[String],
|
||||
client_closures: &[DshClientClosure],
|
||||
table: &[(&str, crate::host_services::HostService)],
|
||||
) -> Vec<crate::host_services::HostService> {
|
||||
if table.is_empty() {
|
||||
return Vec::new();
|
||||
}
|
||||
let mut injects = std::collections::BTreeSet::new();
|
||||
for (path, bytes) in files {
|
||||
let Some(relative) = path.strip_prefix("source/") else {
|
||||
continue;
|
||||
};
|
||||
let module = relative
|
||||
.rsplit_once('.')
|
||||
.is_some_and(|(_, extension)| CLIENT_JS.contains(&extension));
|
||||
let own = match relative.split_once("node_modules/") {
|
||||
None => true,
|
||||
Some(("", dependency)) => rows.iter().any(|row| {
|
||||
dependency
|
||||
.strip_prefix(row.as_str())
|
||||
.and_then(|rest| rest.strip_prefix('/'))
|
||||
.is_some_and(|rest| !rest.split('/').any(|part| part == "node_modules"))
|
||||
}),
|
||||
Some(_) => false,
|
||||
};
|
||||
if !module
|
||||
|| !own
|
||||
|| client_closures
|
||||
.iter()
|
||||
.any(|closure| closure.files.contains_key(path))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
if let Ok(text) = std::str::from_utf8(bytes) {
|
||||
injects.extend(crate::host_services::declared_injects(text));
|
||||
}
|
||||
}
|
||||
crate::host_services::services_for_injects(injects.iter().map(String::as_str), table)
|
||||
}
|
||||
|
||||
/// Closed relative modules and exact contained ESM package exports. Missing
|
||||
/// foreign rows remain manual ports for a portable-only import; a live Native
|
||||
/// graph refuses unresolved rows and never uses an ambient Node parent-walk.
|
||||
@@ -3061,8 +3103,33 @@ pub fn convert_package(package: &Path, output: &Path) -> Result<DshConversion> {
|
||||
},
|
||||
);
|
||||
}
|
||||
let mut capabilities = serde_json::Map::new();
|
||||
if !hosts.is_empty() {
|
||||
extension.insert("capabilities".into(), json!({"network_hosts": hosts}));
|
||||
capabilities.insert("network_hosts".into(), json!(hosts));
|
||||
}
|
||||
let host_services = if requires_native {
|
||||
asked_host_services(
|
||||
&output_files.files,
|
||||
&native_rows,
|
||||
&client_closures,
|
||||
crate::host_services::HOST_SERVICE_INJECTS,
|
||||
)
|
||||
} else {
|
||||
Vec::new()
|
||||
};
|
||||
if !host_services.is_empty() {
|
||||
capabilities.insert("host_services".into(), json!(host_services));
|
||||
notes.push(format!(
|
||||
"asks for host services: {}; each is granted only by a review that shows it",
|
||||
host_services
|
||||
.iter()
|
||||
.map(|service| service.name())
|
||||
.collect::<Vec<_>>()
|
||||
.join(", ")
|
||||
));
|
||||
}
|
||||
if !capabilities.is_empty() {
|
||||
extension.insert("capabilities".into(), Json::Object(capabilities));
|
||||
}
|
||||
if requires_node || loaded.platforms.is_some() {
|
||||
let mut when = serde_json::Map::new();
|
||||
|
||||
@@ -6,6 +6,7 @@ pub mod composition_scope;
|
||||
pub mod context;
|
||||
pub mod discovery;
|
||||
pub mod export;
|
||||
pub mod host_services;
|
||||
pub mod install;
|
||||
pub mod managed_policy;
|
||||
pub mod manifest;
|
||||
@@ -19,6 +20,8 @@ pub mod registry;
|
||||
pub mod runtime;
|
||||
pub mod types;
|
||||
|
||||
#[cfg(test)]
|
||||
mod host_service_tests;
|
||||
#[cfg(any(test, feature = "test-support"))]
|
||||
pub mod test_fixture;
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -12,6 +12,7 @@ use super::activation::CAPABILITY_HASH_DOMAIN_V2;
|
||||
use super::activation::{
|
||||
CAPABILITY_HASH_DOMAIN_V1, PluginActivationCapability, PluginActivationPolicy,
|
||||
};
|
||||
use super::host_services::HostService;
|
||||
use codewhale_mcp::config::{McpServerConfig, is_relative_stdio_path_arg};
|
||||
use codewhale_util::path_identity::metadata_is_link_or_reparse;
|
||||
#[cfg(windows)]
|
||||
@@ -146,6 +147,8 @@ pub struct PluginCapabilities {
|
||||
/// the bundle's supported declarative adapters from activating.
|
||||
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
|
||||
pub lifecycle_mutation: bool,
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub host_services: Vec<HostService>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)]
|
||||
@@ -191,6 +194,8 @@ pub struct PluginInventory {
|
||||
pub filesystem_roots: Vec<String>,
|
||||
pub network_hosts: Vec<String>,
|
||||
pub lifecycle_mutation: bool,
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub host_services: Vec<HostService>,
|
||||
}
|
||||
|
||||
/// Host compatibility of a reviewed bundle. This is independent of trust,
|
||||
@@ -324,7 +329,7 @@ impl PluginInventory {
|
||||
|
||||
#[must_use]
|
||||
pub fn summary(&self) -> String {
|
||||
format!(
|
||||
let mut summary = format!(
|
||||
"skills={} mcp={} (stdio={} remote={}) commands={} agents={} hooks={} lsp={} native={} providers={}",
|
||||
self.skills,
|
||||
self.mcp_servers,
|
||||
@@ -336,7 +341,12 @@ impl PluginInventory {
|
||||
self.lsp,
|
||||
self.native,
|
||||
self.providers
|
||||
)
|
||||
);
|
||||
if !self.host_services.is_empty() {
|
||||
summary.push_str(" host_services=");
|
||||
summary.push_str(&host_service_names(&self.host_services).join(","));
|
||||
}
|
||||
summary
|
||||
}
|
||||
}
|
||||
|
||||
@@ -614,6 +624,12 @@ impl PluginManifest {
|
||||
validate_unique_texts("keyword", &self.plugin.keywords, 128)?;
|
||||
validate_unique_texts("filesystem root", &self.capabilities.filesystem_roots, 512)?;
|
||||
validate_unique_texts("network host", &self.capabilities.network_hosts, 253)?;
|
||||
let mut asked = BTreeSet::new();
|
||||
for service in &self.capabilities.host_services {
|
||||
if !asked.insert(*service) {
|
||||
return Err(format!("duplicate host service value `{}`", service.name()));
|
||||
}
|
||||
}
|
||||
let declared_network_hosts = self
|
||||
.capabilities
|
||||
.network_hosts
|
||||
@@ -946,6 +962,9 @@ impl PluginManifest {
|
||||
let mut filesystem_roots = self.capabilities.filesystem_roots.clone();
|
||||
filesystem_roots.sort();
|
||||
filesystem_roots.dedup();
|
||||
let mut host_services = self.capabilities.host_services.clone();
|
||||
host_services.sort();
|
||||
host_services.dedup();
|
||||
|
||||
Ok(PluginInventory {
|
||||
skills: components.skills.len(),
|
||||
@@ -961,6 +980,7 @@ impl PluginManifest {
|
||||
filesystem_roots,
|
||||
network_hosts,
|
||||
lifecycle_mutation: self.capabilities.lifecycle_mutation,
|
||||
host_services,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1601,10 +1621,14 @@ fn ensure_windows_path_still_opened(path: &Path, opened: &fs::File) -> Result<()
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn hash_inventory(inventory: &PluginInventory) -> String {
|
||||
pub(super) fn hash_inventory(inventory: &PluginInventory) -> String {
|
||||
hash_inventory_with_policy(inventory, PluginActivationPolicy::current())
|
||||
}
|
||||
|
||||
fn host_service_names(services: &[HostService]) -> Vec<&'static str> {
|
||||
services.iter().map(|service| service.name()).collect()
|
||||
}
|
||||
|
||||
fn hash_inventory_counts(inventory: &PluginInventory) -> BTreeMap<&'static str, String> {
|
||||
let mut normalized = BTreeMap::new();
|
||||
normalized.insert("skills", inventory.skills.to_string());
|
||||
@@ -1620,6 +1644,12 @@ fn hash_inventory_counts(inventory: &PluginInventory) -> BTreeMap<&'static str,
|
||||
normalized.insert("filesystem", inventory.filesystem_roots.join("\n"));
|
||||
normalized.insert("network", inventory.network_hosts.join("\n"));
|
||||
normalized.insert("lifecycle", inventory.lifecycle_mutation.to_string());
|
||||
if !inventory.host_services.is_empty() {
|
||||
normalized.insert(
|
||||
"host-services",
|
||||
host_service_names(&inventory.host_services).join("\n"),
|
||||
);
|
||||
}
|
||||
normalized
|
||||
}
|
||||
|
||||
@@ -2174,6 +2204,82 @@ mod tests {
|
||||
assert!(validated.inventory.can_activate_supported_components());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn asked_host_services_move_the_capability_hash_and_none_keeps_it() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let plain = PluginManifest::validate_from_path(&write_manifest(tmp.path(), "")).unwrap();
|
||||
assert!(plain.inventory.host_services.is_empty());
|
||||
let mut before = Sha256::new();
|
||||
PluginActivationPolicy::current().write_hash_material(&mut before);
|
||||
let inventory = &plain.inventory;
|
||||
for (key, value) in [
|
||||
("agents", inventory.agents.to_string()),
|
||||
("commands", inventory.commands.to_string()),
|
||||
("filesystem", inventory.filesystem_roots.join("\n")),
|
||||
("hooks", inventory.hooks.to_string()),
|
||||
("lifecycle", inventory.lifecycle_mutation.to_string()),
|
||||
("lsp", inventory.lsp.to_string()),
|
||||
("mcp", inventory.mcp_servers.to_string()),
|
||||
("mcp-remote", inventory.remote_mcp_servers.to_string()),
|
||||
("mcp-stdio", inventory.stdio_mcp_servers.to_string()),
|
||||
("native", inventory.native.to_string()),
|
||||
("network", inventory.network_hosts.join("\n")),
|
||||
("providers", inventory.providers.to_string()),
|
||||
("skills", inventory.skills.to_string()),
|
||||
] {
|
||||
before.update(key.as_bytes());
|
||||
before.update(b"\0");
|
||||
before.update(value.as_bytes());
|
||||
before.update(b"\0");
|
||||
}
|
||||
assert_eq!(
|
||||
plain.capability_hash,
|
||||
hex_digest(before.finalize()),
|
||||
"a bundle that asks for nothing hashes exactly as it did before"
|
||||
);
|
||||
assert!(!plain.inventory.summary().contains("host_services"));
|
||||
assert!(
|
||||
!serde_json::to_string(&plain.inventory)
|
||||
.unwrap()
|
||||
.contains("host_services")
|
||||
);
|
||||
|
||||
let asked = |list: &str| {
|
||||
PluginManifest::validate_from_path(&write_manifest(
|
||||
tmp.path(),
|
||||
&format!("[capabilities]\nhost_services = [{list}]\n"),
|
||||
))
|
||||
};
|
||||
let network = asked("\"network\"").unwrap();
|
||||
assert_eq!(network.inventory.host_services, vec![HostService::Network]);
|
||||
assert_ne!(network.capability_hash, plain.capability_hash);
|
||||
assert!(
|
||||
network
|
||||
.inventory
|
||||
.summary()
|
||||
.ends_with(" host_services=network")
|
||||
);
|
||||
let both = asked("\"network\", \"llm\"").unwrap();
|
||||
assert_eq!(
|
||||
both.inventory.host_services,
|
||||
vec![HostService::Llm, HostService::Network]
|
||||
);
|
||||
assert_ne!(both.capability_hash, network.capability_hash);
|
||||
assert_eq!(
|
||||
both.capability_hash,
|
||||
asked("\"llm\", \"network\"").unwrap().capability_hash
|
||||
);
|
||||
let reread: PluginInventory =
|
||||
serde_json::from_str(&serde_json::to_string(&both.inventory).unwrap()).unwrap();
|
||||
assert_eq!(reread, both.inventory);
|
||||
assert!(asked("\"everything\"").is_err());
|
||||
assert!(
|
||||
asked("\"llm\", \"llm\"")
|
||||
.unwrap_err()
|
||||
.contains("duplicate host service")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn activation_policy_change_changes_the_capability_hash() {
|
||||
let inventory = PluginInventory {
|
||||
|
||||
@@ -437,6 +437,7 @@ fn count_declared_components(
|
||||
filesystem_roots: Vec::new(),
|
||||
network_hosts: Vec::new(),
|
||||
lifecycle_mutation: false,
|
||||
host_services: Vec::new(),
|
||||
}),
|
||||
diags,
|
||||
)
|
||||
|
||||
+269
-18
@@ -7,6 +7,10 @@ use serde::{Deserialize, Serialize};
|
||||
use sha2::{Digest, Sha256};
|
||||
|
||||
use super::activation::{PluginActivationCapability, PluginActivationPolicy};
|
||||
use super::host_services::{
|
||||
AgentPosture, HostService, HostServiceLine, HostServiceReview, PersonVia, PluginApprover,
|
||||
PluginCohort, ReviewApproval, ReviewPeer, cohort_of,
|
||||
};
|
||||
use super::managed_policy::{
|
||||
ManagedPluginPolicy, ManagedPolicyOutcome, load_managed_policy, resolve_managed_policy_path,
|
||||
};
|
||||
@@ -21,6 +25,7 @@ pub mod gc;
|
||||
|
||||
const STATE_SCHEMA_VERSION: u32 = 1;
|
||||
const MAX_REVIEW_HISTORY: usize = 32;
|
||||
const RECEIPT_VERSION: u32 = 2;
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
@@ -46,6 +51,8 @@ struct PersistedPluginState {
|
||||
generation: u64,
|
||||
#[serde(default)]
|
||||
enabled: bool,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
enabled_by: Option<PluginApprover>,
|
||||
#[serde(default)]
|
||||
trust: Option<TrustReceipt>,
|
||||
#[serde(default)]
|
||||
@@ -59,6 +66,134 @@ struct TrustReceipt {
|
||||
capability_hash: String,
|
||||
reviewed_capabilities: PluginInventory,
|
||||
reviewed_at: String,
|
||||
#[serde(default, skip_serializing_if = "receipt_version_is_unset")]
|
||||
version: u32,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
approved_by: Option<PluginApprover>,
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
host_services: Vec<HostService>,
|
||||
}
|
||||
|
||||
fn receipt_version_is_unset(version: &u32) -> bool {
|
||||
*version == 0
|
||||
}
|
||||
|
||||
impl TrustReceipt {
|
||||
fn granted(&self) -> Vec<HostService> {
|
||||
if self.version != RECEIPT_VERSION
|
||||
|| self.host_services.is_empty()
|
||||
|| !self
|
||||
.host_services
|
||||
.iter()
|
||||
.all(|service| self.reviewed_capabilities.host_services.contains(service))
|
||||
|| super::manifest::hash_inventory(&self.reviewed_capabilities) != self.capability_hash
|
||||
{
|
||||
return Vec::new();
|
||||
}
|
||||
let mut granted = self.host_services.clone();
|
||||
granted.sort();
|
||||
granted.dedup();
|
||||
granted
|
||||
}
|
||||
}
|
||||
|
||||
fn peer_name(id: &PluginId, names: &BTreeMap<PluginId, String>) -> String {
|
||||
names.get(id).cloned().unwrap_or_else(|| {
|
||||
id.as_str()
|
||||
.rsplit('/')
|
||||
.next()
|
||||
.unwrap_or_default()
|
||||
.to_string()
|
||||
})
|
||||
}
|
||||
|
||||
fn host_service_review_in(
|
||||
state: &PluginStateFile,
|
||||
plugin: &LoadedPlugin,
|
||||
names: &BTreeMap<PluginId, String>,
|
||||
cohort: &PluginCohort,
|
||||
) -> HostServiceReview {
|
||||
let peers = |holds: bool, service: HostService| -> Vec<ReviewPeer> {
|
||||
state
|
||||
.plugins
|
||||
.iter()
|
||||
.filter(|(id, _)| **id != plugin.id)
|
||||
.filter(|(_, entry)| {
|
||||
entry.trust.as_ref().is_some_and(|receipt| {
|
||||
if cohort_of(receipt.approved_by.as_ref()) != *cohort {
|
||||
return false;
|
||||
}
|
||||
if holds {
|
||||
receipt.granted().contains(&service)
|
||||
} else {
|
||||
entry.enabled
|
||||
&& receipt.reviewed_capabilities.native > 0
|
||||
&& !receipt.granted().contains(&service)
|
||||
}
|
||||
})
|
||||
})
|
||||
.map(|(id, _)| ReviewPeer {
|
||||
id: id.as_str().to_string(),
|
||||
name: peer_name(id, names),
|
||||
})
|
||||
.collect()
|
||||
};
|
||||
let lines = plugin
|
||||
.inventory
|
||||
.host_services
|
||||
.iter()
|
||||
.map(|service| HostServiceLine {
|
||||
service: *service,
|
||||
offered: super::host_services::offered(*service),
|
||||
shared_with: peers(true, *service),
|
||||
withheld_by: peers(false, *service),
|
||||
})
|
||||
.collect();
|
||||
HostServiceReview::new(
|
||||
plugin.id.as_str(),
|
||||
&plugin.content_hash,
|
||||
&plugin.capability_hash,
|
||||
cohort,
|
||||
lines,
|
||||
)
|
||||
}
|
||||
|
||||
fn check_approver(approver: &PluginApprover, workspace: &Path, shown: bool) -> Result<(), String> {
|
||||
let PluginApprover::Agent(agent) = approver else {
|
||||
return Ok(());
|
||||
};
|
||||
if !shown {
|
||||
return Err(
|
||||
"An approval by the agent must carry the token of the review composed for the call"
|
||||
.to_string(),
|
||||
);
|
||||
}
|
||||
let identified = |value: &Option<String>| value.as_deref().is_some_and(|id| !id.is_empty());
|
||||
if matches!(
|
||||
agent.posture,
|
||||
AgentPosture::FullAccess | AgentPosture::AutoReview
|
||||
) && !(identified(&agent.thread_id)
|
||||
&& identified(&agent.turn_id)
|
||||
&& identified(&agent.tool_call_id))
|
||||
{
|
||||
return Err(
|
||||
"An approval by the agent must name the thread, turn and tool call that made it"
|
||||
.to_string(),
|
||||
);
|
||||
}
|
||||
if agent.workspace.is_empty() || Path::new(&agent.workspace) != workspace {
|
||||
return Err(
|
||||
"An approval by the agent is limited to its own project, and this is not that project"
|
||||
.to_string(),
|
||||
);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn outside_reach(approver: Option<&PluginApprover>, workspace: &Path) -> bool {
|
||||
approver
|
||||
.and_then(PluginApprover::agent_workspace)
|
||||
.is_some_and(|recorded| recorded.is_empty() || Path::new(recorded) != workspace)
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default)]
|
||||
@@ -277,9 +412,21 @@ impl PluginRegistry {
|
||||
Some(_) => PluginTrustStatus::Trusted,
|
||||
None => PluginTrustStatus::NeverReviewed,
|
||||
};
|
||||
let receipt = persisted
|
||||
.and_then(|state| state.trust.as_ref())
|
||||
.filter(|_| plugin.trust_status == PluginTrustStatus::Trusted);
|
||||
plugin.approved_by = receipt.and_then(|receipt| receipt.approved_by.clone());
|
||||
plugin.enabled_by = persisted
|
||||
.filter(|state| state.enabled)
|
||||
.and_then(|state| state.enabled_by.clone());
|
||||
plugin.host_services = receipt.map(TrustReceipt::granted).unwrap_or_default();
|
||||
plugin.outside_reach = outside_reach(plugin.approved_by.as_ref(), &self.workspace);
|
||||
if self.state_error.is_some() {
|
||||
plugin.enabled = false;
|
||||
plugin.trust_status = PluginTrustStatus::NeverReviewed;
|
||||
plugin.approved_by = None;
|
||||
plugin.enabled_by = None;
|
||||
plugin.host_services = Vec::new();
|
||||
}
|
||||
// The managed policy is enforced here — inside the single choke
|
||||
// point that turns persisted state into live enablement — so a
|
||||
@@ -727,24 +874,87 @@ impl PluginRegistry {
|
||||
}
|
||||
}
|
||||
|
||||
fn peer_names(&self) -> BTreeMap<PluginId, String> {
|
||||
self.plugins
|
||||
.iter()
|
||||
.map(|(id, plugin)| (id.clone(), plugin.name().to_string()))
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn host_service_review(&self, selector: &str) -> Option<HostServiceReview> {
|
||||
self.host_service_review_for(selector, &PluginApprover::person(PersonVia::Terminal))
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn host_service_review_for(
|
||||
&self,
|
||||
selector: &str,
|
||||
approver: &PluginApprover,
|
||||
) -> Option<HostServiceReview> {
|
||||
if self.state_error.is_some() {
|
||||
return None;
|
||||
}
|
||||
Some(host_service_review_in(
|
||||
&self.state,
|
||||
self.get(selector)?,
|
||||
&self.peer_names(),
|
||||
&approver.cohort(),
|
||||
))
|
||||
}
|
||||
|
||||
pub fn trust(&mut self, selector: &str) -> Result<(), String> {
|
||||
self.trust_reviewed(
|
||||
selector,
|
||||
ReviewApproval {
|
||||
approved_by: PluginApprover::person(PersonVia::Terminal),
|
||||
host_services: None,
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
pub fn trust_reviewed(
|
||||
&mut self,
|
||||
selector: &str,
|
||||
approval: ReviewApproval,
|
||||
) -> Result<(), String> {
|
||||
let plugin = self
|
||||
.get(selector)
|
||||
.ok_or_else(|| format!("Plugin bundle `{selector}` was not found"))?;
|
||||
let plugin = plugin.clone();
|
||||
let id = plugin.id.clone();
|
||||
check_approver(
|
||||
&approval.approved_by,
|
||||
&self.workspace,
|
||||
approval.host_services.is_some(),
|
||||
)?;
|
||||
let state_path = self
|
||||
.state_path
|
||||
.as_deref()
|
||||
.ok_or_else(|| "Plugin registry has no persistence store".to_string())?;
|
||||
stage_bundle(state_path, &plugin, StageIntent::Review)?;
|
||||
let receipt = TrustReceipt {
|
||||
let names = self.peer_names();
|
||||
let cohort = approval.approved_by.cohort();
|
||||
let mut receipt = TrustReceipt {
|
||||
content_hash: plugin.content_hash.clone(),
|
||||
capability_hash: plugin.capability_hash.clone(),
|
||||
reviewed_capabilities: plugin.inventory.clone(),
|
||||
reviewed_at: chrono::Utc::now().to_rfc3339(),
|
||||
version: RECEIPT_VERSION,
|
||||
approved_by: Some(approval.approved_by),
|
||||
host_services: Vec::new(),
|
||||
};
|
||||
self.commit_state_change(|state| {
|
||||
if let Some(shown) = approval.host_services.as_deref() {
|
||||
let review = host_service_review_in(state, &plugin, &names, &cohort);
|
||||
if review.token != shown {
|
||||
return Err(format!(
|
||||
"The host service review for plugin bundle `{}` changed since it was shown; read it again before approving",
|
||||
plugin.name()
|
||||
));
|
||||
}
|
||||
receipt.host_services = review.grants();
|
||||
}
|
||||
let entry = state.plugins.entry(id).or_default();
|
||||
entry.generation = entry
|
||||
.generation
|
||||
@@ -754,6 +964,7 @@ impl PluginRegistry {
|
||||
// kept the enablement bit across revocation or content drift,
|
||||
// re-review must never reactivate the bundle implicitly.
|
||||
entry.enabled = false;
|
||||
entry.enabled_by = None;
|
||||
entry.trust = Some(receipt.clone());
|
||||
entry.review_history.push(receipt);
|
||||
if entry.review_history.len() > MAX_REVIEW_HISTORY {
|
||||
@@ -781,6 +992,10 @@ impl PluginRegistry {
|
||||
}
|
||||
|
||||
pub fn enable(&mut self, selector: &str) -> Result<(), String> {
|
||||
self.enable_by(selector, PluginApprover::person(PersonVia::Terminal))
|
||||
}
|
||||
|
||||
pub fn enable_by(&mut self, selector: &str, enabled_by: PluginApprover) -> Result<(), String> {
|
||||
let plugin = self
|
||||
.get(selector)
|
||||
.ok_or_else(|| format!("Plugin bundle `{selector}` was not found"))?
|
||||
@@ -874,6 +1089,7 @@ impl PluginRegistry {
|
||||
.checked_add(1)
|
||||
.ok_or_else(|| "Plugin authority generation is exhausted".to_string())?;
|
||||
entry.enabled = true;
|
||||
entry.enabled_by = Some(enabled_by);
|
||||
Ok(())
|
||||
})
|
||||
}
|
||||
@@ -890,6 +1106,7 @@ impl PluginRegistry {
|
||||
.checked_add(1)
|
||||
.ok_or_else(|| "Plugin authority generation is exhausted".to_string())?;
|
||||
entry.enabled = false;
|
||||
entry.enabled_by = None;
|
||||
Ok(())
|
||||
})
|
||||
}
|
||||
@@ -955,11 +1172,14 @@ impl PluginRegistry {
|
||||
let id = plugin.id.clone();
|
||||
let name = plugin.name().to_string();
|
||||
let applicable = plugin.applicable;
|
||||
let carried = TrustReceipt {
|
||||
let mut carried = TrustReceipt {
|
||||
content_hash: plugin.content_hash.clone(),
|
||||
capability_hash: plugin.capability_hash.clone(),
|
||||
reviewed_capabilities: plugin.inventory.clone(),
|
||||
reviewed_at: chrono::Utc::now().to_rfc3339(),
|
||||
version: 0,
|
||||
approved_by: None,
|
||||
host_services: Vec::new(),
|
||||
};
|
||||
self.commit_state_change(|state| {
|
||||
let Some(predecessor) = builtin_predecessor(state, &id, &name).cloned() else {
|
||||
@@ -968,9 +1188,14 @@ impl PluginRegistry {
|
||||
let Some(prior) = predecessor.trust else {
|
||||
return Ok(());
|
||||
};
|
||||
carried.approved_by.clone_from(&prior.approved_by);
|
||||
if carried.approved_by.is_some() {
|
||||
carried.version = RECEIPT_VERSION;
|
||||
}
|
||||
let mut entry = PersistedPluginState {
|
||||
generation: 1,
|
||||
enabled: false,
|
||||
enabled_by: None,
|
||||
trust: None,
|
||||
review_history: predecessor.review_history,
|
||||
};
|
||||
@@ -981,6 +1206,9 @@ impl PluginRegistry {
|
||||
return Ok(());
|
||||
}
|
||||
entry.enabled = predecessor.enabled && applicable;
|
||||
if entry.enabled {
|
||||
entry.enabled_by = predecessor.enabled_by;
|
||||
}
|
||||
entry.trust = Some(carried.clone());
|
||||
entry.review_history.push(carried);
|
||||
if entry.review_history.len() > MAX_REVIEW_HISTORY {
|
||||
@@ -2994,6 +3222,30 @@ pub fn capture_plugin_state_authority(
|
||||
authority: &PluginAuthority,
|
||||
expected: Option<&fs::File>,
|
||||
) -> Result<fs::File, String> {
|
||||
read_active_receipt(authority, expected).map(|(state_file, _)| state_file)
|
||||
}
|
||||
|
||||
pub fn granted_host_services(authority: &PluginAuthority) -> Result<Vec<HostService>, String> {
|
||||
reviewed_record(authority).map(|record| record.host_services)
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct ReviewedRecord {
|
||||
pub approved_by: Option<PluginApprover>,
|
||||
pub host_services: Vec<HostService>,
|
||||
}
|
||||
|
||||
pub fn reviewed_record(authority: &PluginAuthority) -> Result<ReviewedRecord, String> {
|
||||
read_active_receipt(authority, None).map(|(_, receipt)| ReviewedRecord {
|
||||
host_services: receipt.granted(),
|
||||
approved_by: receipt.approved_by,
|
||||
})
|
||||
}
|
||||
|
||||
fn read_active_receipt(
|
||||
authority: &PluginAuthority,
|
||||
expected: Option<&fs::File>,
|
||||
) -> Result<(fs::File, TrustReceipt), String> {
|
||||
let policy_path = authority
|
||||
.managed_policy_source
|
||||
.as_deref()
|
||||
@@ -3055,24 +3307,23 @@ pub fn capture_plugin_state_authority(
|
||||
.map_err(|_| {
|
||||
"Plugin authority state is invalid; the bundle is disabled fail-closed".to_string()
|
||||
})?;
|
||||
let active = state
|
||||
let receipt = state
|
||||
.plugins
|
||||
.get(&authority.plugin_id)
|
||||
.is_some_and(|entry| {
|
||||
entry.generation == authority.state_generation
|
||||
&& entry.enabled
|
||||
&& entry.trust.as_ref().is_some_and(|receipt| {
|
||||
receipt.content_hash == authority.content_hash
|
||||
&& receipt.capability_hash == authority.capability_hash
|
||||
})
|
||||
});
|
||||
if !active {
|
||||
return Err(format!(
|
||||
"Plugin bundle `{}` is disabled, revoked, or no longer matches its review receipt",
|
||||
authority.plugin_name
|
||||
));
|
||||
}
|
||||
Ok(state_file)
|
||||
.filter(|entry| entry.generation == authority.state_generation && entry.enabled)
|
||||
.and_then(|entry| entry.trust.as_ref())
|
||||
.filter(|receipt| {
|
||||
receipt.content_hash == authority.content_hash
|
||||
&& receipt.capability_hash == authority.capability_hash
|
||||
})
|
||||
.cloned()
|
||||
.ok_or_else(|| {
|
||||
format!(
|
||||
"Plugin bundle `{}` is disabled, revoked, or no longer matches its review receipt",
|
||||
authority.plugin_name
|
||||
)
|
||||
})?;
|
||||
Ok((state_file, receipt))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -157,6 +157,9 @@ fn receipt(capability: &str, age: Duration) -> TrustReceipt {
|
||||
capability_hash: capability.into(),
|
||||
reviewed_capabilities: PluginInventory::default(),
|
||||
reviewed_at: rfc3339_ago(age),
|
||||
version: 0,
|
||||
approved_by: None,
|
||||
host_services: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -166,6 +169,7 @@ fn trusted(capability: &str, enabled: bool, age: Duration) -> PersistedPluginSta
|
||||
PersistedPluginState {
|
||||
generation: 1,
|
||||
enabled,
|
||||
enabled_by: None,
|
||||
trust: Some(receipt.clone()),
|
||||
review_history: vec![receipt],
|
||||
}
|
||||
@@ -176,6 +180,7 @@ fn inert(age: Duration) -> PersistedPluginState {
|
||||
PersistedPluginState {
|
||||
generation: 4,
|
||||
enabled: false,
|
||||
enabled_by: None,
|
||||
trust: None,
|
||||
review_history: vec![receipt("cap", age)],
|
||||
}
|
||||
|
||||
@@ -154,6 +154,10 @@ pub struct LoadedPlugin {
|
||||
pub content_hash: String,
|
||||
pub capability_hash: String,
|
||||
pub state_generation: u64,
|
||||
pub approved_by: Option<super::host_services::PluginApprover>,
|
||||
pub enabled_by: Option<super::host_services::PluginApprover>,
|
||||
pub host_services: Vec<super::host_services::HostService>,
|
||||
pub outside_reach: bool,
|
||||
pub skill_snapshots: Vec<PluginSkillSnapshot>,
|
||||
pub diagnostics: Vec<PluginDiagnostic>,
|
||||
}
|
||||
@@ -187,6 +191,7 @@ impl LoadedPlugin {
|
||||
pub fn active(&self) -> bool {
|
||||
self.enabled
|
||||
&& self.trusted()
|
||||
&& !self.outside_reach
|
||||
&& self.staged_root.is_some()
|
||||
&& self.applicable
|
||||
&& self.inventory.can_activate_supported_components()
|
||||
|
||||
@@ -49,6 +49,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
- ACP: editors can start, list, load, prompt and cancel Codewhale sessions and approve or reject tool permissions through the Runtime; prompts with image or audio blocks and relative session directories are now refused instead of ignored, and edit and read tools report the right kind. Sessions an editor drives now run the plugin tools (TypeScript and DSH, when the extension host is on) and the MCP servers configured in Codewhale; their calls show as ACP tool calls and ask through the session's permission setting, and `initialize` reports the state under `agentCapabilities._meta.codewhale`. MCP servers an editor passes in `mcpServers` and configured shell hooks still do not run in ACP sessions. A real-editor journey is not yet proved.
|
||||
- Extension host: the `sessions` and `systemPrompt` services now come from a reviewed DSH adapter instead of inline shims, and `sessions.flush` refuses an empty checkpoint ticket. Session reads and events and the `agent/pre-step` hook are still not available to plugins. (CW-7959)
|
||||
- Extension host: DSH service adapters now have one ticketed `dsh/broker` request to the core, each ticket is bound to the plugin, the service and the session, and every service (workspace, llm, network, settings, catalog, terminal and remote) refuses with a typed `not_available` until its broker lands. Nothing new is offered to plugins yet. (CW-8012)
|
||||
- **Plugin host services and who approved:** a plugin can now be granted a Codewhale host service, and only by a review that shows it. The review names each service and the plugins it is shared with, and every review records who approved it: you, the agent (in Full Access, in Auto-Review, or from a shell command carrying the agent's mark), or the command line with `--yes`; who turned the plugin on is recorded too. Plugins share one process, so a service works only while every loaded plugin of the same kind holds the same grant. A plugin the agent approved is active only in its own project and its code does not run beside the plugins you reviewed until you review it. No service is offered yet, and a plugin reviewed before this release holds no grant until it is reviewed again. Once a plugin has been reviewed or turned on in this release, Codewhale 0.10.2 and earlier cannot read the plugin state file and turn every plugin off when run on the same home folder. (CW-8012)
|
||||
- Fleet member starts are now durable: replaying an operation key after a timeout or restart returns the original worker, and GET /v1/threads/{id}/members/operations/{key} reports the outcome. A member start now answers with `state` `accepted` (200) or `unknown` (202) instead of `started` or `queued`. Verified by local HTTP tests on Linux; a real Engine restart is simulated, not exercised. (CW-7964)
|
||||
- Plugin activation now says when a requested service belongs to the Codewhale core and is not exposed to plugins yet. No plugin, provider or installed-app run exercised it; checked by the extension-host tests only.
|
||||
- Codex App Server v2 contract record (paused, not part of 0.10.3): the selected Codex v2 contract is frozen as a generated manifest in the protocol crate, with typed Rust and TypeScript shapes checked against the pinned Codex schema. It changes no wire behavior and adds no dispatch, so nothing is reachable through it yet.
|
||||
|
||||
@@ -143,6 +143,22 @@ WebRTC traffic despite content rules, CSP, an opaque sandboxed frame and a
|
||||
restrictive Permissions-Policy; that historical result does not qualify the
|
||||
current Electron/Chromium host.
|
||||
|
||||
## Host services and grants
|
||||
|
||||
DSH services that need the core to act for a plugin (`llm`, `credentials`,
|
||||
`settings`, `terminal`, `remote`, the plugin catalog, workspace files) are
|
||||
brokered: the Rust core serves them, and only for a plugin whose review granted
|
||||
them. See [Host services](EXTENSIONS.md#host-services).
|
||||
|
||||
| Behavior | Status |
|
||||
| --- | --- |
|
||||
| A service is granted only by a review that showed it, recorded with who approved | Verified by focused plugin and Engine tests on Linux |
|
||||
| A review recorded before grants existed, or an altered or unreadable record | Grants nothing; verified by focused tests |
|
||||
| Plugins share one process: a service is given only while every loaded plugin holds the same grant | Verified by focused Engine tests |
|
||||
| Turned off, revoked or reviewed again | Grant withdrawn at once, including a call in flight; verified by focused Engine tests |
|
||||
| Services offered by this build | None. Each is switched on separately, after its own review |
|
||||
| A real plugin using a granted service end to end | Not verified |
|
||||
|
||||
## Containment
|
||||
|
||||
| Behavior | Status |
|
||||
|
||||
+79
-1
@@ -611,6 +611,80 @@ tool's text (`content`) and, when it was JSON, the parsed value
|
||||
tool asked the core to run is recorded (bounded) in your tool result's
|
||||
`core_calls` metadata, with each call's decision and outcome.
|
||||
|
||||
## Host services
|
||||
|
||||
A host service is something the core does for an extension beyond running a
|
||||
core tool: workspace files and commands, the model provider, network requests,
|
||||
settings, the plugin catalog, terminal sessions and remote pairing. Each is
|
||||
served by the core, never by the host process, and each is off until a review
|
||||
grants it. This version of Codewhale offers none of them yet: the mechanism is
|
||||
in place, and each service is switched on separately.
|
||||
|
||||
**Asking.** Name the services in the manifest. A name outside this list fails
|
||||
validation.
|
||||
|
||||
```toml
|
||||
[capabilities]
|
||||
host_services = ["network"]
|
||||
```
|
||||
|
||||
(In a `plugin.json` bundle: `extensions["net.codewhale"].capabilities.host_services`.)
|
||||
For an imported DeepSeek Harness package the importer writes this list from the
|
||||
package's own `inject` declarations. What a bundle asks for is part of its
|
||||
capability hash, so changing the list needs a new review.
|
||||
|
||||
**Granting.** Only a review grants a service, and only when the review showed
|
||||
it: the review lists each service in plain words, the reviewed plugins that
|
||||
already hold it, and any plugin that is turned on without it. Approval is bound
|
||||
to exactly that list. If it changed between showing and approving, the approval
|
||||
is refused and nothing is recorded. A review that did not show the services
|
||||
(`/plugin trust` today) records trust and grants none. A review recorded before
|
||||
this feature holds no grant.
|
||||
|
||||
**Who approved.** Every review records its approver, and every place that shows
|
||||
a plugin's trust shows it:
|
||||
|
||||
| Approver | How it happens | Reach |
|
||||
| --- | --- | --- |
|
||||
| You | the Plugins page, `/plugin trust`, or an approval card | every project |
|
||||
| The agent, in Full Access or Auto-Review | the agent's plugin tool, bound to the token of the review composed for that call and to the thread, turn and tool call that made it | only the project named in the record |
|
||||
| The agent, from a shell command | `codewhale plugins trust … --yes` run by a process that carries the agent's mark | only the project named in the record |
|
||||
| The command line | `codewhale plugins trust … --yes` without the mark: no review was shown there | every project |
|
||||
|
||||
The agent's mark is a label on the agent's shell commands, not a lock: in Full
|
||||
Access the agent can remove it, and the record then says "the command line".
|
||||
A plugin the agent approved is active only in the project its record names,
|
||||
and its code does not run in the host process of the plugins you reviewed:
|
||||
until it has a host of its own, its native code is refused with a diagnostic
|
||||
that asks you to review it. Your own review replaces the agent's record. Who
|
||||
turned a plugin on is recorded the same way, separately from who approved it.
|
||||
|
||||
**One process, one rule.** Extensions share one host process and cannot be
|
||||
isolated from each other inside it. A service is therefore given to a tool call
|
||||
only while every extension loaded in that process holds the same grant. The
|
||||
rule, and the list of plugins a review says a service is shared with, cover
|
||||
only plugins of the same kind: the plugins you reviewed are counted together,
|
||||
and a plugin the agent approved is never counted with them. If one
|
||||
does not, the service is off for all of them, and the host diagnostics name the
|
||||
extension without it. Code that was loaded cannot be unloaded, so after an
|
||||
extension is turned off, changed or fails, every service stays off until the
|
||||
host process has restarted, which Codewhale does by itself once the host is
|
||||
idle.
|
||||
|
||||
**What a refused call receives.** `REFUSED` with `data.dsh_refusal`:
|
||||
`not_granted` (the same answer whether your own review lacks the service or
|
||||
another extension's does), `limit` (this tool call used up its calls to that
|
||||
service), `ticket_refused`, `cancelled`, or the service's own typed refusal.
|
||||
Turning an extension off, revoking it or reviewing it again withdraws its grant
|
||||
at once: a call already running is dropped and its answer withheld.
|
||||
|
||||
Known limits: a grant is all of what the review showed or none of it; there is
|
||||
no per-service choice inside one review. The review record is protected by its
|
||||
private directory, not by a signature: another program running as you, outside
|
||||
the sandbox, can write it, as it can write trust itself. A change made by
|
||||
another Codewhale process reaches a running call within about 50 ms; a side
|
||||
effect a service completes inside that window is not undone.
|
||||
|
||||
## Lifecycle, diagnostics and restarts
|
||||
|
||||
Use `/plugin show <name>` for that plugin's owner state, live tool names and
|
||||
@@ -679,7 +753,11 @@ resident size at each 3-second heartbeat and kills it past the cap. Under Node
|
||||
the JavaScript heap is also limited to 256 MiB. The host has a
|
||||
32 MiB frame limit, 256 in-flight request
|
||||
limit (plus a reserved heartbeat), 128 tools per owner and 1024 per host, and
|
||||
64 commands per owner and 256 per host. Tool
|
||||
64 commands per owner and 256 per host. One tool call may make 50 core tool
|
||||
calls in all, counting those a host service makes for it, and 50 calls to each
|
||||
host service. Eight invalid tickets in a minute end the host process for every
|
||||
extension in it; a ticket whose tool call ended in the last 10 seconds is
|
||||
refused without counting. Tool
|
||||
descriptions are at most 4 KiB and schemas 64 KiB. Tool calls have a 120-second
|
||||
deadline and commands a 30-second one. The feature stays Experimental and off by default; local fixture
|
||||
tests do not establish sandbox parity, provider behavior or release readiness.
|
||||
@@ -240,6 +240,13 @@ The accept/reject behavior is deliberately loud, never silent:
|
||||
- `capabilities.network_hosts` is not a future surface: it is enforced today,
|
||||
and must exactly match the normalized host set of the bundle's remote MCP
|
||||
endpoints (so it cannot be declared without them, or omitted with them).
|
||||
- `capabilities.host_services` names the host services a bundle's native code
|
||||
asks to use (`workspace`, `llm`, `network`, `settings`, `catalog`,
|
||||
`terminal`, `remote`). It is hashed into the capability receipt only when it
|
||||
is not empty, so bundles that ask for nothing keep their review. Asking
|
||||
grants nothing: only a review that showed the services does, and this
|
||||
version offers none yet. See
|
||||
[Host services](EXTENSIONS.md#host-services).
|
||||
|
||||
A successful environment or health check is never treated as trust.
|
||||
|
||||
|
||||
@@ -208,6 +208,11 @@ lifecycle_mutation = true
|
||||
- `capabilities.network_hosts` 不属于未来才会生效的组件面:它今天就已强制执行,
|
||||
且必须与插件包远程 MCP 端点的归一化主机集合完全一致
|
||||
(有远程 MCP 端点就必须声明它,没有远程端点就不能声明)。
|
||||
- `capabilities.host_services` 列出插件包的原生代码请求使用的宿主服务
|
||||
(`workspace`、`llm`、`network`、`settings`、`catalog`、`terminal`、`remote`)。
|
||||
只有在该列表非空时才计入能力回执的哈希,因此不请求任何服务的插件包无需重新审查。
|
||||
声明本身不授予任何权限:只有展示过这些服务的审查才会授予,而当前版本尚未开放任何服务。
|
||||
参见[宿主服务](../EXTENSIONS.md#host-services)。
|
||||
|
||||
环境检查或健康检查通过,永远不等于信任。
|
||||
|
||||
|
||||
@@ -313,8 +313,8 @@
|
||||
},
|
||||
{
|
||||
"path": "docs/PLUGIN_BUNDLES.md",
|
||||
"sha256": "bc178f6e0c60792f135476ea2e301796125089c5cae164f69af906212a56b9ce",
|
||||
"content": "# Plugin bundles\n\n> 阅读简体中文版:[zh_hans/PLUGIN_BUNDLES.md](zh_hans/PLUGIN_BUNDLES.md)。\n\nCodewhale supports a deliberately small plugin-bundle boundary. The boundary\nwas drawn in v0.9.1 and is extended deliberately in v0.9.10: a bundle may\ncontribute declarative Skills, MCP configuration, Commands, Agent profiles,\nand Hooks through Codewhale's existing engines. Unsupported declarations stay\ninventoried instead of disabling a mixed bundle. Discovery alone never\nexecutes, enables, trusts, downloads, updates, or installs anything.\n\nThis document owns the bundle formats, discovery,\nvalidation, and the trust/enable/runtime contract. [PLUGINS.md](PLUGINS.md)\nowns how bits get onto and off disk — the `/plugin install`, `update`,\n`uninstall`, and `suggest` on-ramp added in v0.9.4 (#5182). Compatible Claude Code bundles use the same native adapters; their supported subset is\n[CLAUDE_PLUGIN_COMPAT.md](CLAUDE_PLUGIN_COMPAT.md).\nFor a runnable native example and explicit OpenCode/DSH data conversion, see\n[Write your first plugin](PLUGIN_AUTHORING.md).\n\n## Discovery and precedence\n\nCodewhale scans only its own roots, looking in each `<name>/` directory for a\nmanifest named `plugin.json` (the native Agent Plugins v1.0.0 format, since\nv0.9.4), `kimi.plugin.json` (the compatible Kimi Skills/MCP subset, since\nv0.9.8), `plugin.toml` (the legacy Codewhale format, still fully readable), or\n`.claude-plugin/plugin.json` (the compatible Claude subset, since v0.9.13):\n\n- User: `~/.codewhale/plugins/<name>/`\n- Workspace: `<workspace>/.codewhale/plugins/<name>/`\n\nA bundle that publishes multiple formats is read through `plugin.json` first,\nthen `kimi.plugin.json`, then the legacy `plugin.toml`, then\n`.claude-plugin/plugin.json`.\nComputer Use installs from the first-party catalog as a normal user plugin.\nIt updates independently of the Engine and requires review and enablement.\nThe native Mac helper is installed separately for permission setup and the\ncomputer-control indicator. See the [Computer Use guide](https://github.com/codewhale-hq/codewhale-cu-plugin).\n\nUser plugins take precedence over workspace plugins. Symbolic-link roots,\nmanifests, component paths, and nested component files fail closed.\nHistorical built-in snapshots and receipts remain untouched for older binaries;\nthe current Engine neither creates nor discovers a built-in Computer Use copy.\nInstall and review the catalog plugin once when upgrading from that layout.\n\nNew user and workspace bundles are always untrusted and disabled. Discovery is\nread-only and does not inspect any other application's extension or credential\ndirectories: ambient roots such as `.claude/plugins` or `.cursor/plugins` are\nnever scanned.\n\nPre-v0.9.1 `overrides.json` enablement was intentionally not imported as\ntrust; every bundle activates only through the content-hash and\n`codewhale-plugin-capabilities-v3` activation-policy review below.\n\n## Manifest\n\nAll supported encodings parse into the same internal manifest, so validation, hashing,\nreview, and runtime behavior are identical downstream. On-disk auto-migration\nbetween them is deliberately not performed; `/plugin export <name>\n<target-dir>` publishes a loaded bundle as a spec-valid Agent Plugins v1.0.0\ndirectory without modifying the installed one.\n\n### `plugin.json` (Agent Plugins v1.0.0)\n\nThe standard's manifest root is closed: `$schema`, `name`, and the optional\nwell-known fields (`version`, `description`, `author`, `homepage`,\n`repository`, `license`, `keywords`, `extensions`). An unknown root key is a\nparse error. Client-specific data lives under `extensions`, keyed by\nreverse-domain namespace: unknown vendor namespaces are ignored, never\nrejected — that is what lets a bundle authored for another client load here —\nwhile unknown keys inside Codewhale's own `extensions[\"net.codewhale\"]`\nnamespace are rejected rather than silently dropped.\n\nNames follow the standard's rule: 1–64 lowercase ASCII letters, digits, or\ninternal single `-`/`.`, starting and ending alphanumeric, never `--` or `..`.\nA `skills/` directory in the bundle root is picked up automatically; other\ncomponent locations, `capabilities`, `when`, and `display_name` ride in\n`extensions[\"net.codewhale\"]`.\n\nMCP servers cannot live in `plugin.json` (the root is closed); they live in a\nsibling `mcp.json` under `mcpServers`, with `stdio`, `streamable-http`, or\n`sse` transports (`type` may be omitted and is inferred from `command` vs\n`url`). Codewhale-only server options — timeouts, tool filters, env-backed\ncredentials, enablement — ride per-server under `extensions[\"net.codewhale\"]`.\nThe `env` names `PLUGIN_ROOT` and `PLUGIN_DATA` are reserved by the standard\nfor the host runtime and are rejected in plugin definitions.\n\n### `plugin.toml` (legacy Codewhale format)\n\n```toml\nschema_version = 1\n\n[plugin]\nname = \"example\"\nversion = \"0.1.0\Line truncated
|
||||
"sha256": "5901d95a9a294590d19cf736140986967470eea02782776f250c1bb929608b65",
|
||||
"content": "# Plugin bundles\n\n> 阅读简体中文版:[zh_hans/PLUGIN_BUNDLES.md](zh_hans/PLUGIN_BUNDLES.md)。\n\nCodewhale supports a deliberately small plugin-bundle boundary. The boundary\nwas drawn in v0.9.1 and is extended deliberately in v0.9.10: a bundle may\ncontribute declarative Skills, MCP configuration, Commands, Agent profiles,\nand Hooks through Codewhale's existing engines. Unsupported declarations stay\ninventoried instead of disabling a mixed bundle. Discovery alone never\nexecutes, enables, trusts, downloads, updates, or installs anything.\n\nThis document owns the bundle formats, discovery,\nvalidation, and the trust/enable/runtime contract. [PLUGINS.md](PLUGINS.md)\nowns how bits get onto and off disk — the `/plugin install`, `update`,\n`uninstall`, and `suggest` on-ramp added in v0.9.4 (#5182). Compatible Claude Code bundles use the same native adapters; their supported subset is\n[CLAUDE_PLUGIN_COMPAT.md](CLAUDE_PLUGIN_COMPAT.md).\nFor a runnable native example and explicit OpenCode/DSH data conversion, see\n[Write your first plugin](PLUGIN_AUTHORING.md).\n\n## Discovery and precedence\n\nCodewhale scans only its own roots, looking in each `<name>/` directory for a\nmanifest named `plugin.json` (the native Agent Plugins v1.0.0 format, since\nv0.9.4), `kimi.plugin.json` (the compatible Kimi Skills/MCP subset, since\nv0.9.8), `plugin.toml` (the legacy Codewhale format, still fully readable), or\n`.claude-plugin/plugin.json` (the compatible Claude subset, since v0.9.13):\n\n- User: `~/.codewhale/plugins/<name>/`\n- Workspace: `<workspace>/.codewhale/plugins/<name>/`\n\nA bundle that publishes multiple formats is read through `plugin.json` first,\nthen `kimi.plugin.json`, then the legacy `plugin.toml`, then\n`.claude-plugin/plugin.json`.\nComputer Use installs from the first-party catalog as a normal user plugin.\nIt updates independently of the Engine and requires review and enablement.\nThe native Mac helper is installed separately for permission setup and the\ncomputer-control indicator. See the [Computer Use guide](https://github.com/codewhale-hq/codewhale-cu-plugin).\n\nUser plugins take precedence over workspace plugins. Symbolic-link roots,\nmanifests, component paths, and nested component files fail closed.\nHistorical built-in snapshots and receipts remain untouched for older binaries;\nthe current Engine neither creates nor discovers a built-in Computer Use copy.\nInstall and review the catalog plugin once when upgrading from that layout.\n\nNew user and workspace bundles are always untrusted and disabled. Discovery is\nread-only and does not inspect any other application's extension or credential\ndirectories: ambient roots such as `.claude/plugins` or `.cursor/plugins` are\nnever scanned.\n\nPre-v0.9.1 `overrides.json` enablement was intentionally not imported as\ntrust; every bundle activates only through the content-hash and\n`codewhale-plugin-capabilities-v3` activation-policy review below.\n\n## Manifest\n\nAll supported encodings parse into the same internal manifest, so validation, hashing,\nreview, and runtime behavior are identical downstream. On-disk auto-migration\nbetween them is deliberately not performed; `/plugin export <name>\n<target-dir>` publishes a loaded bundle as a spec-valid Agent Plugins v1.0.0\ndirectory without modifying the installed one.\n\n### `plugin.json` (Agent Plugins v1.0.0)\n\nThe standard's manifest root is closed: `$schema`, `name`, and the optional\nwell-known fields (`version`, `description`, `author`, `homepage`,\n`repository`, `license`, `keywords`, `extensions`). An unknown root key is a\nparse error. Client-specific data lives under `extensions`, keyed by\nreverse-domain namespace: unknown vendor namespaces are ignored, never\nrejected — that is what lets a bundle authored for another client load here —\nwhile unknown keys inside Codewhale's own `extensions[\"net.codewhale\"]`\nnamespace are rejected rather than silently dropped.\n\nNames follow the standard's rule: 1–64 lowercase ASCII letters, digits, or\ninternal single `-`/`.`, starting and ending alphanumeric, never `--` or `..`.\nA `skills/` directory in the bundle root is picked up automatically; other\ncomponent locations, `capabilities`, `when`, and `display_name` ride in\n`extensions[\"net.codewhale\"]`.\n\nMCP servers cannot live in `plugin.json` (the root is closed); they live in a\nsibling `mcp.json` under `mcpServers`, with `stdio`, `streamable-http`, or\n`sse` transports (`type` may be omitted and is inferred from `command` vs\n`url`). Codewhale-only server options — timeouts, tool filters, env-backed\ncredentials, enablement — ride per-server under `extensions[\"net.codewhale\"]`.\nThe `env` names `PLUGIN_ROOT` and `PLUGIN_DATA` are reserved by the standard\nfor the host runtime and are rejected in plugin definitions.\n\n### `plugin.toml` (legacy Codewhale format)\n\n```toml\nschema_version = 1\n\n[plugin]\nname = \"example\"\nversion = \"0.1.0\Line truncated
|
||||
},
|
||||
{
|
||||
"path": "docs/PLUGIN_MARKETPLACE.md",
|
||||
@@ -323,8 +323,8 @@
|
||||
},
|
||||
{
|
||||
"path": "docs/EXTENSIONS.md",
|
||||
"sha256": "fee756fa0715db7c6c3e83c5fbd124b9b0faa0cb11dded91845d4c913839bc5b",
|
||||
"content": "# Writing an extension tool or command\n\nThe experimental TypeScript extension host runs reviewed plugin code in a\nshared Node process (or, as an opt-in, Bun). Rust still owns sessions, tool admission, approval and\nexecution. Extensions currently contribute tools and slash commands; they\ncannot provide an approval service, run a second agent loop or replace\nbuilt-in tools or commands.\n\n## Quick start\n\nStart with [hello-extension](examples/plugins/hello-extension/hello.mts) and its\n[plugin.json](examples/plugins/hello-extension/plugin.json). The example is\nexecuted unchanged by the host tests. It returns a greeting and does not read\nfiles or use the network.\n\n1. Explicitly enable `[features] extension_host = true` in your configuration,\n or start Codewhale with `codewhale --enable extension_host` (the flag goes\n before any subcommand).\n2. Install the example's directory using `/plugin install <local-directory>`.\n3. Run `/plugin validate hello-extension`, then `/plugin show hello-extension`.\n4. Run `/plugin enable hello-extension` to open its content/capability review.\n Read the review and personally run its exact `/plugin trust <token>` command,\n then enable the plugin. Trust alone does not execute the code.\n5. Ask Codewhale to use `hello_greet`. The tool follows the normal approval gate.\n6. Run `/hello-greet Codewhale`. The command runs when you type it, and shows\n its answer in the transcript with its origin, `extension:hello-extension`.\n\nAn authoring agent should stop after installation and validation and present\nthe review to the person. Do not trust or enable a bundle automatically.\n\n## Locations, installation and trust\n\nUser bundles live under `~/.codewhale/plugins/`; workspace bundles live under\n`<workspace>/.codewhale/plugins/`. New bundles use Agent Plugins v1.0.0\n`plugin.json`. Declare `extensions[\"net.codewhale\"].native.path` as one regular\n`.mjs`, `.js` or `.mts` file inside the bundle. Directories and other extensions\nfail validation when the host is enabled. With the flag off, native entries\nremain inventory-only.\n\nA plugin may split its code across several entries with `native.paths` (up to\n64, alone or beside `path`). They are activated in the order declared, as the\nfibers of one plugin: they share one owner, so one disable, review change or\ncrash tears all of them down together. Each entry is a separate module with its\nown `apply`, and tool and command names must be unique across them. If an entry\nfails to activate (throws, requires a service the host does not provide, or has\na registration refused), only that entry is withdrawn: entries that already\nactivated stay registered, later entries still activate, and the plugin fails\nonly when no entry activates. Listing the same file twice is one entry.\n\n## Runtime\n\n`[extension_host] runtime` selects what runs the host. Node is the default.\nBun is an opt-in: it is qualified on macOS only, and becomes the default only\nafter an explicit, recorded cutover.\n\n```toml\n[extension_host]\nruntime = \"node\" # default: Node only\n# runtime = \"bun\" # Bun only; fails rather than falling back to Node\n# runtime = \"auto\" # Bun >= 1.4.0 if one is found and starts, otherwise Node\n# bun = \"~/.bun/bin/bun\" # the only Bun tried when set\n# node = \"/opt/homebrew/bin/node\" # the only Node tried when set\n# mcp_backend = \"rust\" # temporary opt-out; default is \"host\"\n```\n\nThe default `mcp_backend = \"host\"` uses the pinned MCP SDK independently of\n`[features] extension_host`, which controls optional Native extensions. Existing\nstdio and HTTP servers use modern discovery when supported; explicitly configured\nlegacy SSE servers use their legacy handshake. Rust retains process and network\nauthority, credential resolution, catalog admission, tool approval and cancellation.\nThe SDK receives opaque session selectors and exact operation grants. Modern\nrequests carry Rust-approved protocol metadata and headers without a session id.\n\nA typed discovery refusal can admit a legacy handshake. Only an explicit HTTP\ntransport refusal during that handshake can admit legacy SSE with a fresh exact\nRust grant. Legacy HTTP retains its GET preflight and bounded OAuth refresh;\nmodern HTTP does not inherit those session or resend behaviors. Incomplete result\nflows are reported without automatically repeating the operation. Credentials,\nconfigured URLs, OAuth browser/login operations and Computer Use decision keys\nstay in Rust.\n\nBoth backends share recorded semantic MCP goldens and retain separate transport\ngoldens, including discovery, authentication and session evidence. The Host default\nrequires those fixtures and the actual broker acceptance checks, plus the platform\nisolation and measured gates in Ops CURRENT_DECISIONS §26. An explicit\n`mcp_backend = \"rust\"` remains available for one release after the qualified default\nflip, before the native protocoLine truncated
|
||||
"sha256": "43df456f3dc8dd67b905a719a3d5b9d9f18620c0031bbbaa99f4456cc33276f8",
|
||||
"content": "# Writing an extension tool or command\n\nThe experimental TypeScript extension host runs reviewed plugin code in a\nshared Node process (or, as an opt-in, Bun). Rust still owns sessions, tool admission, approval and\nexecution. Extensions currently contribute tools and slash commands; they\ncannot provide an approval service, run a second agent loop or replace\nbuilt-in tools or commands.\n\n## Quick start\n\nStart with [hello-extension](examples/plugins/hello-extension/hello.mts) and its\n[plugin.json](examples/plugins/hello-extension/plugin.json). The example is\nexecuted unchanged by the host tests. It returns a greeting and does not read\nfiles or use the network.\n\n1. Explicitly enable `[features] extension_host = true` in your configuration,\n or start Codewhale with `codewhale --enable extension_host` (the flag goes\n before any subcommand).\n2. Install the example's directory using `/plugin install <local-directory>`.\n3. Run `/plugin validate hello-extension`, then `/plugin show hello-extension`.\n4. Run `/plugin enable hello-extension` to open its content/capability review.\n Read the review and personally run its exact `/plugin trust <token>` command,\n then enable the plugin. Trust alone does not execute the code.\n5. Ask Codewhale to use `hello_greet`. The tool follows the normal approval gate.\n6. Run `/hello-greet Codewhale`. The command runs when you type it, and shows\n its answer in the transcript with its origin, `extension:hello-extension`.\n\nAn authoring agent should stop after installation and validation and present\nthe review to the person. Do not trust or enable a bundle automatically.\n\n## Locations, installation and trust\n\nUser bundles live under `~/.codewhale/plugins/`; workspace bundles live under\n`<workspace>/.codewhale/plugins/`. New bundles use Agent Plugins v1.0.0\n`plugin.json`. Declare `extensions[\"net.codewhale\"].native.path` as one regular\n`.mjs`, `.js` or `.mts` file inside the bundle. Directories and other extensions\nfail validation when the host is enabled. With the flag off, native entries\nremain inventory-only.\n\nA plugin may split its code across several entries with `native.paths` (up to\n64, alone or beside `path`). They are activated in the order declared, as the\nfibers of one plugin: they share one owner, so one disable, review change or\ncrash tears all of them down together. Each entry is a separate module with its\nown `apply`, and tool and command names must be unique across them. If an entry\nfails to activate (throws, requires a service the host does not provide, or has\na registration refused), only that entry is withdrawn: entries that already\nactivated stay registered, later entries still activate, and the plugin fails\nonly when no entry activates. Listing the same file twice is one entry.\n\n## Runtime\n\n`[extension_host] runtime` selects what runs the host. Node is the default.\nBun is an opt-in: it is qualified on macOS only, and becomes the default only\nafter an explicit, recorded cutover.\n\n```toml\n[extension_host]\nruntime = \"node\" # default: Node only\n# runtime = \"bun\" # Bun only; fails rather than falling back to Node\n# runtime = \"auto\" # Bun >= 1.4.0 if one is found and starts, otherwise Node\n# bun = \"~/.bun/bin/bun\" # the only Bun tried when set\n# node = \"/opt/homebrew/bin/node\" # the only Node tried when set\n# mcp_backend = \"rust\" # temporary opt-out; default is \"host\"\n```\n\nThe default `mcp_backend = \"host\"` uses the pinned MCP SDK independently of\n`[features] extension_host`, which controls optional Native extensions. Existing\nstdio and HTTP servers use modern discovery when supported; explicitly configured\nlegacy SSE servers use their legacy handshake. Rust retains process and network\nauthority, credential resolution, catalog admission, tool approval and cancellation.\nThe SDK receives opaque session selectors and exact operation grants. Modern\nrequests carry Rust-approved protocol metadata and headers without a session id.\n\nA typed discovery refusal can admit a legacy handshake. Only an explicit HTTP\ntransport refusal during that handshake can admit legacy SSE with a fresh exact\nRust grant. Legacy HTTP retains its GET preflight and bounded OAuth refresh;\nmodern HTTP does not inherit those session or resend behaviors. Incomplete result\nflows are reported without automatically repeating the operation. Credentials,\nconfigured URLs, OAuth browser/login operations and Computer Use decision keys\nstay in Rust.\n\nBoth backends share recorded semantic MCP goldens and retain separate transport\ngoldens, including discovery, authentication and session evidence. The Host default\nrequires those fixtures and the actual broker acceptance checks, plus the platform\nisolation and measured gates in Ops CURRENT_DECISIONS §26. An explicit\n`mcp_backend = \"rust\"` remains available for one release after the qualified default\nflip, before the native protocoLine truncated
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
Reference in new issue
Block a user