refactor(split): S19 codewhale-plugins crate (CW-6129) [skip ci]

Plugin discovery, review, install, marketplace parsing and the provider
registry move from crates/tui/src/plugins into a new codewhale-plugins
crate (git mv, mod.rs -> lib.rs). The MCP config types (McpConfig,
McpTimeouts, McpServerConfig, McpServerOAuthConfig) move to
codewhale_mcp::config, with reviewed-plugin provenance stored as an
opaque McpProvenance; the skills parse core and recommendations move to
codewhale_skill_install::{skill_parse, recommend}. About 120 tui files
are rewired with no re-export shims. The node composition reviewer stays
in tui behind a OnceLock hook that fails closed ("composition reviewer
is unavailable") if it is not installed. first-party-marketplace.json
and dsh-compatibility.json move to crates/plugins/assets. Tests that
need the extension host stay in tui (plugins_dsh_tests,
plugins_skill_tests, plugins_provider_tests).

Integration onto main: route_preferences.rs conflicted because
0296261823 moved the route-writer block above the test module; the
patch's copy at the old location was dropped and the moved block
repointed at codewhale_plugins (identical otherwise). The
blocking-calls budget entry moved to crates/plugins/src/builtin.rs with
upstream's tightened count. DeclarativePluginFixture gains a Default
impl: as a pub item under --all-features it tripped
clippy::new_without_default.

Verified on Linux x86_64 Boat VM vm21, tree ea13bd15d4a9 on 0d0b2f46af:
- cargo fmt --all -- --check: exit 0
- cargo check --workspace --all-targets --locked: exit 0, 0 warnings
- CI Lint clippy (--workspace --all-targets --all-features -D warnings,
  CI's three -A allows): exit 0
- cargo test --lib: codewhale-plugins 201 passed, 1 ignored;
  codewhale-skill-install 47; codewhale-mcp 2; 0 failed
- codewhale-tui lib filtered to plugins skills:: mcp:: extension_host::
  runtime_threads runtime_api provider_lake provider_picker: 1780 passed,
  3 failed, 2 ignored. Failures: runtime_api::tests::mcp_management_
  selected_connection_and_retry_preserve_siblings and mcp_tools_endpoint_
  is_passive_until_connect_requested (known Linux baseline), and
  extension_host::mcp::tests::host_restart_mints_fresh_owner_and_never_
  revives_old_grant (bwrap handshake timeout under parallel load; passes
  3/3 alone with --exact on the same binary)
- check-blocking-calls-budget.py: only the pre-existing overages in
  cli/src/update/uninstall.rs and sandbox/src/{bwrap,lib}.rs
Not run: full tui lib suite, tui integration binaries, macOS/Windows
builds, hosted CI.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
CodeWhale BotandClaude Opus 5.5 committed 2026-10-28 04:14:10 +00:00
1 parent 7d3d46c0d7
commit 786a0e4b33
209 files changed
+7595 -7344

No files matched your search

+1 -1
View File
@@ -23,7 +23,7 @@ jobs:
run: |
python3 - <<'PY'
import json, os, re
snapshot = json.load(open('crates/tui/assets/first-party-marketplace.json'))
snapshot = json.load(open('crates/plugins/assets/first-party-marketplace.json'))
revision = snapshot['revision']
assert re.fullmatch(r'[0-9a-f]{40}', revision), 'Invalid marketplace revision'
if os.environ['EVENT_NAME'] in ('schedule', 'workflow_dispatch'):
+1
View File
@@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- **Sandboxed commands cannot plant git hooks or edit git config:** in workspace-write mode, `.git/hooks` and `.git/config` (and a linked worktree's shared `hooks` and `config`) are read-only to sandboxed commands, so a command cannot leave a hook or `core.hooksPath` that later runs outside the sandbox. The workspace's `.codewhale/` is protected even before it exists (on Linux it is created first, because bubblewrap can only protect existing paths). `git commit` and ordinary file writes are unaffected; `git config` writes and hook edits need an approved unsandboxed command.
- **Interrupted session hint stays visible (#7095):** when a plain `codewhale` launch finds an in-flight session snapshot, the "Run `codewhale --continue` to resume it" hint now appears in the TUI's status line instead of being printed to the terminal just before the TUI covered it.
- **`codewhale-plugins` crate:** plugin discovery, review, install and marketplace parsing move out of `codewhale-tui` into a new crate; MCP server config types move to `codewhale-mcp`, and the skill parse core and recommendations to `codewhale-skill-install`. Review and trust behavior is unchanged. (S19, CW-6129)
- **Crate split S24a (internal):** the workshop byte-budget slot moves to `codewhale-runtime`, `AutoRouteDecisionEvidence` to `codewhale-models`, and cost-status session persistence to a tui `usage_persistence` module, so billing no longer reaches the session manager or model routing. No behavior change. (CW-6129)
- **Nested and deletable threads (Runtime API):** a fork (including a backtrack fork) records `parent_thread_id`, `POST /v1/threads` accepts one naming an existing thread, and `GET /v1/threads/summary` returns it so clients can nest child conversations. `DELETE /v1/threads/{id}` removes an idle thread with its turns, items, events and goal, and its saved session unless another thread shares it or a terminal holds it open; a running thread answers 409.
- **Hunk actions in Review (Runtime API):** `POST /v1/git/stage`, `/v1/git/unstage` and `/v1/git/discard` take a `patch` holding hunks of the one path in `paths` and apply only those hunks through `git apply`, under the same `expect` preconditions as whole-path writes. A patch that no longer applies fails with git's message and writes nothing; renames, mode changes and binary patches are refused. The tasks tool can also `suggest` a task, shown as a Start/Hide card, without starting anything.
Generated
+41
View File
@@ -1108,6 +1108,7 @@ dependencies = [
name = "codewhale-mcp"
version = "0.10.2"
dependencies = [
"serde",
"serde_json",
"tracing",
]
@@ -1195,6 +1196,44 @@ dependencies = [
"dirs 7.0.0",
]
[[package]]
name = "codewhale-plugins"
version = "0.10.2"
dependencies = [
"anyhow",
"base64 0.23.1",
"chrono",
"codewhale-config",
"codewhale-mcp",
"codewhale-net",
"codewhale-runtime",
"codewhale-sanitize",
"codewhale-secrets",
"codewhale-skill-install",
"codewhale-util",
"fd-lock",
"flate2",
"libc",
"regex",
"reqwest",
"semver",
"serde",
"serde_json",
"sha2 0.11.1",
"tar",
"tempfile",
"thiserror 2.0.21",
"tokio",
"toml 1.1.4+spec-1.1.0",
"toml_edit 0.25.15+spec-1.1.0",
"tracing",
"uuid",
"webbrowser",
"windows",
"windows-sys 0.61.2",
"yaml-rust2",
]
[[package]]
name = "codewhale-portable-config"
version = "0.10.2"
@@ -1392,6 +1431,7 @@ dependencies = [
"codewhale-util",
"flate2",
"futures-util",
"regex",
"reqwest",
"serde",
"serde_json",
@@ -1507,6 +1547,7 @@ dependencies = [
"codewhale-notify",
"codewhale-palette",
"codewhale-paths",
"codewhale-plugins",
"codewhale-protocol",
"codewhale-ratatui",
"codewhale-release",
+1
View File
@@ -21,6 +21,7 @@ members = [
"crates/notify",
"crates/palette",
"crates/paths",
"crates/plugins",
"crates/protocol",
"crates/release",
"crates/runtime",
+1
View File
@@ -11,5 +11,6 @@ description = "Bounded MCP guidance normalization for Codewhale"
workspace = true
[dependencies]
serde.workspace = true
serde_json.workspace = true
tracing.workspace = true
+238
View File
@@ -0,0 +1,238 @@
use std::collections::HashMap;
use std::path::PathBuf;
use std::sync::Arc;
use serde::{Deserialize, Serialize};
#[derive(Clone)]
pub struct McpProvenance(Arc<dyn std::any::Any + Send + Sync>);
impl McpProvenance {
pub fn new<T: std::any::Any + Send + Sync>(value: T) -> Self {
Self(Arc::new(value))
}
#[must_use]
pub fn downcast_ref<T: std::any::Any>(&self) -> Option<&T> {
self.0.downcast_ref::<T>()
}
}
impl std::fmt::Debug for McpProvenance {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str("McpProvenance(..)")
}
}
pub fn is_relative_stdio_path_arg(value: &str) -> bool {
if value.is_empty() || value.starts_with('-') || value.contains("://") || value.starts_with('~')
{
return false;
}
let looks_like_path = value.contains('/') || value.contains('\\');
if !looks_like_path {
return false;
}
let bytes = value.as_bytes();
let windows_absolute = value.starts_with("\\\\")
|| (bytes.len() >= 3 && bytes[1] == b':' && (bytes[2] == b'\\' || bytes[2] == b'/'));
!std::path::Path::new(value).is_absolute() && !windows_absolute
}
/// Full MCP configuration from mcp.json
#[derive(Debug, Clone, Default, Deserialize, Serialize)]
pub struct McpConfig {
#[serde(default)]
pub timeouts: McpTimeouts,
#[serde(default, alias = "mcpServers")]
pub servers: HashMap<String, McpServerConfig>,
}
/// Global timeout configuration
#[derive(Debug, Clone, Copy, Deserialize, Serialize)]
#[allow(clippy::struct_field_names)]
pub struct McpTimeouts {
#[serde(default = "default_connect_timeout")]
pub connect_timeout: u64,
#[serde(default = "default_execute_timeout")]
pub execute_timeout: u64,
#[serde(default = "default_read_timeout")]
pub read_timeout: u64,
}
/// Covers spawn, `initialize` and the first `tools/list` together. A cold
/// `uvx`/`npx` start resolves and downloads the package inside this window,
/// which routinely takes longer than 10 s; 30 s matches Codex, opencode, OMP
/// and Claude Code's `MCP_TIMEOUT` default.
pub fn default_connect_timeout() -> u64 {
30
}
// 30 minutes: an MCP tool call legitimately runs minutes — builds, test
// suites, scrapes, remote jobs. The old 60s default returned "timed out" to
// the model for healthy-but-slow tools, which then retried and compounded
// the cost. Per-server and global `execute_timeout` overrides still win.
// Scope note: `prompts/get` also routes through `effective_execute_timeout`,
// so it inherits this default; its server-side template work is normally
// fast, but the override knob is the intended way to keep it tight.
pub fn default_execute_timeout() -> u64 {
1800
}
pub fn default_read_timeout() -> u64 {
120
}
impl Default for McpTimeouts {
fn default() -> Self {
Self {
connect_timeout: default_connect_timeout(),
execute_timeout: default_execute_timeout(),
read_timeout: default_read_timeout(),
}
}
}
/// Configuration for a single MCP server
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct McpServerConfig {
pub command: Option<String>,
#[serde(default)]
pub args: Vec<String>,
#[serde(default)]
pub env: HashMap<String, String>,
#[serde(default)]
#[serde(skip_serializing_if = "Option::is_none")]
pub cwd: Option<PathBuf>,
pub url: Option<String>,
/// Explicit operator authority for private DNS names at this exact origin.
/// Ignored for model-added runtime servers.
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
pub allow_private_network: bool,
/// Optional explicit HTTP transport override.
///
/// By default URL-based MCP servers use Streamable HTTP first and fall
/// back to legacy SSE only when the server rejects Streamable HTTP with
/// a known incompatible status. Set this to `"sse"` for legacy SSE
/// endpoints that must start with a long-lived GET endpoint discovery
/// stream and cannot accept an initial POST to the configured URL.
#[serde(default)]
#[serde(skip_serializing_if = "Option::is_none")]
pub transport: Option<String>,
#[serde(default)]
pub connect_timeout: Option<u64>,
#[serde(default)]
pub execute_timeout: Option<u64>,
#[serde(default)]
pub read_timeout: Option<u64>,
#[serde(default)]
pub disabled: bool,
#[serde(default = "default_enabled")]
pub enabled: bool,
#[serde(default)]
pub required: bool,
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
pub lazy: bool,
#[serde(default)]
pub enabled_tools: Vec<String>,
#[serde(default)]
pub disabled_tools: Vec<String>,
/// Extra HTTP headers sent with every request to this MCP server.
/// Only the HTTP transports (streamable HTTP today; SSE in a
/// follow-up) honor this — `command`-based stdio servers ignore it.
///
/// Mirrors the `headers` field that Claude Code, Codex, and
/// OpenCode already accept in their MCP config formats. Use it to
/// authenticate against gateways that require a Bearer token or
/// API key, e.g.:
///
/// ```jsonc
/// "huggingface": {
/// "url": "https://huggingface.co/api/mcp",
/// "headers": { "Authorization": "Bearer ${HF_TOKEN}" }
/// }
/// ```
///
/// Header keys and values are passed through as-is — we do not
/// substitute environment variables in v0.8.31. If you store a
/// real token here, the value lives in plain text in
/// `~/.deepseek/mcp.json`; treat that file with the same care
/// as any other secret-bearing config.
#[serde(default)]
#[serde(skip_serializing_if = "HashMap::is_empty")]
pub headers: HashMap<String, String>,
/// HTTP headers whose values are read from environment variables at request
/// time. This keeps common bearer/API-token integrations out of mcp.json.
#[serde(default, alias = "env_http_headers")]
#[serde(skip_serializing_if = "HashMap::is_empty")]
pub env_headers: HashMap<String, String>,
/// Environment variable containing a bearer token. When present and set,
/// CodeWhale sends `Authorization: Bearer <value>` for URL-based servers.
#[serde(default)]
#[serde(skip_serializing_if = "Option::is_none")]
pub bearer_token_env_var: Option<String>,
/// OAuth scopes requested during `codewhale mcp login`.
#[serde(default)]
#[serde(skip_serializing_if = "Vec::is_empty")]
pub scopes: Vec<String>,
/// OAuth client override for MCP servers that require a pre-registered
/// public client instead of dynamic registration.
#[serde(default)]
#[serde(skip_serializing_if = "Option::is_none")]
pub oauth: Option<McpServerOAuthConfig>,
/// Optional exact RFC 8707 resource pin, checked against SDK discovery.
#[serde(default)]
#[serde(skip_serializing_if = "Option::is_none")]
pub oauth_resource: Option<String>,
/// In-memory provenance for MCP servers contributed by a reviewed plugin
/// bundle. This is never deserialized from or serialized into user config:
/// only the trusted plugin merge adapter may attach it.
#[serde(skip)]
pub reviewed_plugin: Option<McpProvenance>,
/// Only the runtime registration boundary can attach this provenance.
#[serde(skip)]
pub runtime_added: bool,
}
#[derive(Debug, Clone, Default, Deserialize, Serialize, PartialEq, Eq)]
pub struct McpServerOAuthConfig {
#[serde(default)]
#[serde(skip_serializing_if = "Option::is_none")]
pub client_id: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub issuer: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub client_metadata_url: Option<String>,
}
fn default_enabled() -> bool {
true
}
impl McpServerConfig {
pub fn effective_connect_timeout(&self, global: &McpTimeouts) -> u64 {
self.connect_timeout.unwrap_or(global.connect_timeout)
}
pub fn effective_execute_timeout(&self, global: &McpTimeouts) -> u64 {
self.execute_timeout.unwrap_or(global.execute_timeout)
}
pub fn effective_read_timeout(&self, global: &McpTimeouts) -> u64 {
self.read_timeout.unwrap_or(global.read_timeout)
}
pub fn is_enabled(&self) -> bool {
self.enabled && !self.disabled
}
pub fn is_tool_enabled(&self, tool_name: &str) -> bool {
let allowed = if self.enabled_tools.is_empty() {
true
} else {
self.enabled_tools.iter().any(|t| t == tool_name)
};
if !allowed {
return false;
}
!self.disabled_tools.iter().any(|t| t == tool_name)
}
}
+2
View File
@@ -4,6 +4,8 @@
//! Client/session authority stays in the existing TUI MCP pool; native server
//! mode uses its shared Rust tool registry through `serve --mcp`.
pub mod config;
use serde_json::Value;
/// Upper bound, in bytes, on the `instructions` an MCP server may supply at
+58
View File
@@ -0,0 +1,58 @@
[package]
name = "codewhale-plugins"
version.workspace = true
edition.workspace = true
rust-version.workspace = true
license.workspace = true
repository.workspace = true
description = "Plugin manifests, discovery, review, install and marketplace for Codewhale"
[lints]
workspace = true
[features]
test-support = ["codewhale-runtime/test-support"]
[dependencies]
anyhow.workspace = true
base64 = "0.23.1"
chrono.workspace = true
codewhale-config = { path = "../config", version = "0.10.2" }
codewhale-mcp = { path = "../mcp", version = "0.10.2" }
codewhale-net = { path = "../net", version = "0.10.2" }
codewhale-runtime = { path = "../runtime", version = "0.10.2" }
codewhale-sanitize = { path = "../sanitize", version = "0.10.2" }
codewhale-secrets = { path = "../secrets", version = "0.10.2" }
codewhale-skill-install = { path = "../skill-install", version = "0.10.2" }
codewhale-util = { path = "../util", version = "0.10.2" }
fd-lock = "4.0.4"
flate2 = "1.1"
regex = "1.11"
reqwest = { workspace = true, features = ["blocking", "stream", "form", "http2"] }
semver.workspace = true
serde.workspace = true
serde_json = { workspace = true, features = ["preserve_order", "raw_value"] }
sha2.workspace = true
tar = "0.4"
tempfile.workspace = true
thiserror.workspace = true
tokio.workspace = true
toml.workspace = true
toml_edit.workspace = true
tracing.workspace = true
uuid.workspace = true
webbrowser = "1.0"
yaml-rust2 = { workspace = true }
[target.'cfg(unix)'.dependencies]
libc = "0.2"
[target.'cfg(windows)'.dependencies]
windows-sys = { version = "0.61.2", features = ["Wdk_Storage_FileSystem", "Win32_System_IO", "Win32_Foundation", "Win32_Security", "Win32_Security_Authorization", "Win32_Security_Isolation", "Win32_Storage_FileSystem", "Win32_System_LibraryLoader", "Win32_System_Pipes", "Win32_System_Threading", "Win32_System_SystemServices", "Win32_System_WindowsProgramming"] }
windows = { version = "0.62", features = ["Win32_Foundation", "Win32_Security", "Win32_Security_Authorization", "Win32_Storage_FileSystem", "Win32_System_Console", "Win32_System_Diagnostics_ToolHelp", "Win32_System_Environment", "Win32_System_JobObjects", "Win32_System_Registry", "Win32_System_Threading", "Win32_UI_WindowsAndMessaging"] }
[dev-dependencies]
codewhale-config = { path = "../config", version = "0.10.2", features = ["schema", "test-support"] }
codewhale-runtime = { path = "../runtime", version = "0.10.2", features = ["test-support", "test-thread-scoped-state"] }
codewhale-skill-install = { path = "../skill-install", version = "0.10.2", features = ["test-support"] }
codewhale-util = { path = "../util", version = "0.10.2", features = ["test-support"] }
@@ -28,10 +28,9 @@ pub const ACTIVATION_POLICY_VERSION: u32 = 5;
/// either direction re-reviews every plugin. That is intended.
pub const EXTENSION_HOST_POLICY_VERSION: u32 = 6;
pub(crate) const DSH_COMPATIBILITY: &[u8] =
include_bytes!("../../extension-host/dist/dsh-compatibility.json");
pub const DSH_COMPATIBILITY: &[u8] = include_bytes!("../assets/dsh-compatibility.json");
pub(crate) fn dsh_row_bridge(name: &str) -> bool {
pub fn dsh_row_bridge(name: &str) -> bool {
static TABLE: std::sync::OnceLock<Option<serde_json::Value>> = std::sync::OnceLock::new();
TABLE
.get_or_init(|| serde_json::from_slice(DSH_COMPATIBILITY).ok())
@@ -46,7 +45,7 @@ pub(crate) fn dsh_row_bridge(name: &str) -> bool {
/// mid-process; unset means the shipping v5 policy, which also covers tests.
static EXTENSION_HOST_POLICY: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
#[cfg(test)]
#[cfg(any(test, feature = "test-support"))]
thread_local! {
/// Test-only per-thread override so one test can exercise v6 without
/// changing the policy every other (parallel) test observes.
@@ -61,7 +60,7 @@ pub fn install_extension_host_policy(extension_host_enabled: bool) {
/// Whether this process activates `Native` (extension host) components.
#[must_use]
pub fn extension_host_policy_enabled() -> bool {
#[cfg(test)]
#[cfg(any(test, feature = "test-support"))]
if let Some(value) = TEST_POLICY_OVERRIDE.with(std::cell::Cell::get) {
return value;
}
@@ -71,21 +70,21 @@ pub fn extension_host_policy_enabled() -> bool {
/// Carries the calling thread's policy into a `spawn_blocking` closure. In
/// production the policy is process-wide, so this is a no-op; under test it
/// re-applies the per-thread override on the blocking thread.
pub(crate) struct PolicyScope {
#[cfg(test)]
pub struct PolicyScope {
#[cfg(any(test, feature = "test-support"))]
_guard: TestPolicyGuard,
}
impl PolicyScope {
#[must_use]
pub(crate) fn propagate(extension_host_enabled: bool) -> Self {
#[cfg(test)]
pub fn propagate(extension_host_enabled: bool) -> Self {
#[cfg(any(test, feature = "test-support"))]
{
Self {
_guard: TestPolicyGuard::extension_host(extension_host_enabled),
}
}
#[cfg(not(test))]
#[cfg(not(any(test, feature = "test-support")))]
{
let _ = extension_host_enabled;
Self {}
@@ -94,20 +93,20 @@ impl PolicyScope {
}
/// Test guard selecting the v6 (or v5) policy on the current thread only.
#[cfg(test)]
pub(crate) struct TestPolicyGuard {
#[cfg(any(test, feature = "test-support"))]
pub struct TestPolicyGuard {
previous: Option<bool>,
}
#[cfg(test)]
#[cfg(any(test, feature = "test-support"))]
impl TestPolicyGuard {
pub(crate) fn extension_host(enabled: bool) -> Self {
pub fn extension_host(enabled: bool) -> Self {
let previous = TEST_POLICY_OVERRIDE.with(|cell| cell.replace(Some(enabled)));
Self { previous }
}
}
#[cfg(test)]
#[cfg(any(test, feature = "test-support"))]
impl Drop for TestPolicyGuard {
fn drop(&mut self) {
TEST_POLICY_OVERRIDE.with(|cell| cell.set(self.previous));
@@ -38,7 +38,7 @@ use super::manifest::{
CURRENT_SCHEMA_VERSION, MAX_PLUGIN_NAME_CHARS, PluginCapabilities, PluginManifest, PluginMeta,
PluginPathSpec, PluginWhen,
};
use crate::mcp::{McpServerConfig, McpServerOAuthConfig};
use codewhale_mcp::config::{McpServerConfig, McpServerOAuthConfig};
/// Manifest file names, in discovery preference order. `plugin.json` is the
/// native Agent Plugins format; `plugin.toml` is the legacy Codewhale format
@@ -148,7 +148,7 @@ pub fn resolve_manifest_path(root: &Path) -> Option<PathBuf> {
}
/// The bundle root is outside Claude's manifest-only metadata directory.
pub(crate) fn plugin_root_for_manifest(path: &Path) -> Option<&Path> {
pub fn plugin_root_for_manifest(path: &Path) -> Option<&Path> {
let parent = path.parent()?;
if path.file_name()?.to_str()? == PLUGIN_JSON_NAME
&& parent
@@ -163,7 +163,7 @@ pub(crate) fn plugin_root_for_manifest(path: &Path) -> Option<&Path> {
/// Claude's skills/commands/agents/MCP subset uses the existing declarative
/// adapters. Unsupported components fail validation; no hooks are silently lost.
pub(crate) fn parse_claude_plugin_json(
pub fn parse_claude_plugin_json(
text: &str,
root: &Path,
mcp_bytes: Option<&[u8]>,
@@ -1,36 +1,36 @@
use std::io;
use std::path::{Path, PathBuf};
pub(crate) const STAMP_NAME: &str = ".stamp";
pub const STAMP_NAME: &str = ".stamp";
/// A historical bundle identity used by snapshot garbage collection.
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct EmbeddedBundle {
pub struct EmbeddedBundle {
pub name: &'static str,
pub digest: String,
}
impl EmbeddedBundle {
/// Directory name of this build's snapshot under the snapshots root.
pub(crate) fn snapshot_dir_name(&self) -> String {
pub fn snapshot_dir_name(&self) -> String {
format!("{}-{}", self.name, self.digest)
}
}
/// The bundles this build would materialize. Garbage collection compares
/// on-disk snapshots against this list to decide which belong to other builds.
pub(crate) fn embedded_bundles() -> Vec<EmbeddedBundle> {
pub fn embedded_bundles() -> Vec<EmbeddedBundle> {
Vec::new()
}
/// `<home>/builtin-plugins/snapshots`, the only place snapshots are published.
pub(crate) fn snapshots_dir(home: &Path) -> PathBuf {
pub fn snapshots_dir(home: &Path) -> PathBuf {
home.join("builtin-plugins").join("snapshots")
}
/// Split `<bundle>-<64 hex>` into the embedded bundle name and its digest.
/// Anything else in the snapshots root is not ours to classify.
pub(crate) fn parse_snapshot_dir_name(
pub fn parse_snapshot_dir_name(
dir_name: &str,
bundles: &[EmbeddedBundle],
) -> Option<(&'static str, String)> {
File renamed without changes.
+18
View File
@@ -0,0 +1,18 @@
use std::sync::OnceLock;
use serde_json::Value;
pub type CompositionReviewFn = fn(&Value) -> Result<Value, String>;
static REVIEWER: OnceLock<CompositionReviewFn> = OnceLock::new();
pub fn install_composition_reviewer(reviewer: CompositionReviewFn) {
let _ = REVIEWER.set(reviewer);
}
pub fn review(input: &Value) -> Result<Value, String> {
let reviewer = REVIEWER
.get()
.ok_or_else(|| "composition reviewer is unavailable".to_string())?;
reviewer(input)
}
+49
View File
@@ -0,0 +1,49 @@
//! Caller snapshots in AttachmentState. No second registry, store or owner token.
use codewhale_config::EntryRef;
use serde::{Deserialize, Serialize};
use std::collections::BTreeMap;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct SelectionRevision {
pub attachment_id: u64,
pub revision: u64,
}
/// A core-selected entry from the owner's existing reviewed Native inventory.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct NativePresetRef {
pub plugin_id: String,
pub content_hash: String,
pub entry: EntryRef,
}
#[derive(Debug, Clone, Default)]
pub struct CompositionSelection {
pub revision: Option<SelectionRevision>,
pub desired: BTreeMap<String, String>,
pub entries: Vec<NativePresetRef>,
}
impl CompositionSelection {
pub fn includes(&self, plugin_id: &str, content_hash: &str, scope: Option<&EntryRef>) -> bool {
self.desired
.get(plugin_id)
.is_some_and(|hash| hash == content_hash)
&& scope.is_none_or(|scope| {
self.entries.iter().any(|entry| {
entry.plugin_id == plugin_id
&& entry.content_hash == content_hash
&& entry.entry == *scope
})
})
}
}
/// Separate scopes may spell a name alike; core names remain fenced unconditionally.
pub fn name_key(owner: &str, name: &str, scope: Option<&EntryRef>) -> String {
match scope {
None => name.to_string(),
Some(scope) => format!("{owner}\0{}\0{}\0{name}", scope.path, scope.sha256),
}
}
@@ -21,9 +21,9 @@ impl HostEnvironment {
}
}
#[cfg(test)]
#[cfg(any(test, feature = "test-support"))]
#[must_use]
pub(crate) fn from_entries(entries: impl IntoIterator<Item = (OsString, OsString)>) -> Self {
pub fn from_entries(entries: impl IntoIterator<Item = (OsString, OsString)>) -> Self {
Self {
entries: entries.into_iter().collect::<Vec<_>>().into(),
}
@@ -96,9 +96,9 @@ impl PluginDiscoveryContext {
})
}
#[cfg(test)]
#[cfg(any(test, feature = "test-support"))]
#[must_use]
pub(crate) fn from_config_and_environment(
pub fn from_config_and_environment(
config: &DiscoveryConfig,
host_environment: HostEnvironment,
) -> Arc<Self> {
@@ -134,7 +134,7 @@ impl PluginDiscoveryContext {
// Only after carry-forward: retiring superseded built-in records is
// safe once this build holds its own. Unit tests call the collector
// directly so that no test ever mutates a home implicitly.
#[cfg(not(test))]
#[cfg(not(any(test, feature = "test-support")))]
registry.collect_garbage_at_startup();
Arc::new(registry)
}
@@ -186,15 +186,17 @@ mod tests {
#[test]
fn discovery_roots_are_frozen_before_later_environment_changes() {
let _lock = crate::test_support::lock_test_env();
let _lock = codewhale_runtime::test_support::lock_test_env();
let temp = tempfile::tempdir().unwrap();
let first_home = temp.path().join("first-home");
let second_home = temp.path().join("second-home");
let workspace = temp.path().join("workspace");
fs::create_dir_all(&workspace).unwrap();
let _first = crate::test_support::EnvVarGuard::set("CODEWHALE_HOME", &first_home);
let _first =
codewhale_runtime::test_support::EnvVarGuard::set("CODEWHALE_HOME", &first_home);
let context = PluginDiscoveryContext::capture_pre_dotenv();
let _second = crate::test_support::EnvVarGuard::set("CODEWHALE_HOME", &second_home);
let _second =
codewhale_runtime::test_support::EnvVarGuard::set("CODEWHALE_HOME", &second_home);
for (home, name) in [(&first_home, "before"), (&second_home, "after")] {
let bundle = home.join("plugins").join(name);
@@ -228,8 +230,8 @@ mod tests {
)
.unwrap();
let registry = crate::plugins::PluginRegistry::empty(&workspace)
.rediscover_for_workspace(&next_workspace);
let registry =
crate::PluginRegistry::empty(&workspace).rediscover_for_workspace(&next_workspace);
assert!(registry.is_empty());
assert_eq!(registry.workspace(), next_workspace);
}
@@ -15,6 +15,9 @@ use super::types::{
use codewhale_config::PluginId;
use codewhale_util::path_identity::metadata_is_link_or_reparse;
pub const MAX_SKILLS_PER_OWNER: usize = 128;
pub const MAX_BYTES_PER_OWNER: usize = 4 * 1024 * 1024;
#[derive(Debug, Clone)]
pub struct DiscoveryConfig {
pub workspace: PathBuf,
@@ -86,7 +89,7 @@ pub fn default_workspace_plugins_dir(workspace: &Path) -> PathBuf {
workspace.join(".codewhale").join("plugins")
}
#[cfg(test)]
#[cfg(any(test, feature = "test-support"))]
#[must_use]
pub fn discover_with_config(config: &DiscoveryConfig) -> PluginRegistry {
let context = super::context::PluginDiscoveryContext::from_config_and_environment(
@@ -97,7 +100,7 @@ pub fn discover_with_config(config: &DiscoveryConfig) -> PluginRegistry {
}
#[must_use]
pub(crate) fn discover_with_context(
pub fn discover_with_context(
config: &DiscoveryConfig,
context: std::sync::Arc<super::context::PluginDiscoveryContext>,
) -> PluginRegistry {
@@ -433,7 +436,8 @@ fn parse_skill_snapshots_for_roots(
let components: Vec<_> = relative.components().collect();
path.file_name().is_some_and(|name| name == "SKILL.md")
&& components.len() >= 2
&& components.len() - 2 <= crate::skills::SkillRegistry::MAX_DISCOVERY_DEPTH
&& components.len() - 2
<= codewhale_skill_install::skill_parse::MAX_DISCOVERY_DEPTH
&& components[..components.len() - 1].iter().all(|component| {
!component
.as_os_str()
@@ -457,15 +461,14 @@ fn parse_skill_snapshots_for_roots(
// Even an invalid parent Skill claims its package, so nested
// examples cannot become independent instructions.
claimed.insert(directory.to_path_buf());
if bounded_native && parsed_files >= crate::extension_host::skills::MAX_SKILLS_PER_OWNER
{
if bounded_native && parsed_files >= crate::discovery::MAX_SKILLS_PER_OWNER {
return Err("skill root exceeds the bounded candidate count".to_string());
}
parsed_files += 1;
let path = validated.canonical_root.join(relative);
let bytes = read_skill_bytes(&validated.canonical_root, &path)?;
parsed_bytes = parsed_bytes.saturating_add(bytes.len());
if bounded_native && parsed_bytes > crate::extension_host::skills::MAX_BYTES_PER_OWNER {
if bounded_native && parsed_bytes > crate::discovery::MAX_BYTES_PER_OWNER {
return Err("skill root exceeds the bounded instruction byte limit".to_string());
}
let actual_hash = hash_skill_bytes(&bytes);
@@ -475,7 +478,7 @@ fn parse_skill_snapshots_for_roots(
let content = std::str::from_utf8(&bytes)
.map_err(|_| "plugin Skill must be valid UTF-8".to_string())?;
let (skill, warnings) =
match crate::skills::SkillRegistry::parse_verified_content(&path, content) {
match codewhale_skill_install::skill_parse::parse_verified_content(&path, content) {
Ok(parsed) => parsed,
Err(reason) => {
diagnostics.push(PluginDiagnostic::warning(
@@ -549,7 +552,7 @@ fn hash_skill_bytes(bytes: &[u8]) -> String {
.collect()
}
pub(crate) fn load_staged_skill_snapshots(
pub fn load_staged_skill_snapshots(
staged_root: &Path,
expected_content_hash: &str,
expected_capability_hash: &str,
@@ -562,7 +565,7 @@ pub(crate) fn load_staged_skill_snapshots(
)
}
pub(crate) fn load_staged_skill_root_snapshots(
pub fn load_staged_skill_root_snapshots(
authority: &codewhale_config::PluginAuthority,
relative: &Path,
) -> Result<Vec<PluginSkillSnapshot>, String> {
@@ -830,11 +833,11 @@ mod tests {
#[test]
fn discovery_ignores_ambient_compatibility_roots() {
let _lock = crate::test_support::lock_test_env();
let _lock = codewhale_runtime::test_support::lock_test_env();
let tmp = tempfile::tempdir().unwrap();
let home = tmp.path().join("home");
let workspace = tmp.path().join("workspace");
let _home = crate::test_support::EnvVarGuard::set("CODEWHALE_HOME", &home);
let _home = codewhale_runtime::test_support::EnvVarGuard::set("CODEWHALE_HOME", &home);
write_plugin(
&workspace.join(".claude/plugins"),
"ambient",
@@ -846,7 +849,7 @@ mod tests {
"cursor-plugin",
);
let discovery = crate::plugins::PluginDiscoveryContext::capture_pre_dotenv();
let discovery = crate::PluginDiscoveryContext::capture_pre_dotenv();
let registry = discovery.registry_for_workspace(&workspace);
assert!(registry.get("ambient-plugin").is_none());
assert!(registry.get("cursor-plugin").is_none());
@@ -76,7 +76,7 @@ fn reserved_backup_containers_are_hidden_but_human_bundle_paths_remain_visible()
fs::write(
dotted.join("plugin.json"),
serde_json::json!({
"$schema": crate::plugins::agent_plugin::PLUGIN_SCHEMA_URL,
"$schema": crate::agent_plugin::PLUGIN_SCHEMA_URL,
"name": "demo.bak", "version": "1.0.0"
})
.to_string(),
File renamed without changes.
@@ -15,9 +15,9 @@ use regex::Regex;
use serde::Serialize;
use serde_json::{Value as Json, json};
mod presets;
pub mod presets;
pub(crate) const CONVERTER_VERSION: &str = "0.10.1";
pub const CONVERTER_VERSION: &str = "0.10.1";
const MAX_FILES: usize = 4096;
const MAX_BYTES: usize = 64 * 1024 * 1024;
const MAX_DOCUMENT: usize = 1024 * 1024;
@@ -38,12 +38,12 @@ fn require(condition: bool, message: &str) -> Result<()> {
/// A refusal names at most this many rows and counts the rest.
const MAX_REFUSAL_ROWS: usize = 5;
/// Longest package-authored fragment quoted in a refusal, in characters.
const MAX_REFUSAL_TEXT: usize = 120;
pub const MAX_REFUSAL_TEXT: usize = 120;
/// Package-authored text for a one-line refusal: bounded, with control and
/// bidirectional-format characters shown as escapes. A refusal is plain text
/// (error line, Runtime API), so this is not the review's Markdown escape.
fn refusal_text(text: &str) -> String {
pub fn refusal_text(text: &str) -> String {
let mut shown = String::new();
for ch in text.chars().take(MAX_REFUSAL_TEXT) {
match ch {
@@ -58,7 +58,7 @@ fn refusal_text(text: &str) -> String {
}
/// The first rows of a refusal, then how many more were left out.
fn refusal_list(mut rows: impl Iterator<Item = String>) -> String {
pub fn refusal_list(mut rows: impl Iterator<Item = String>) -> String {
let mut shown: Vec<String> = rows.by_ref().take(MAX_REFUSAL_ROWS).collect();
let more = rows.count();
if more > 0 {
@@ -78,7 +78,7 @@ fn pattern(source: &str) -> Regex {
/// Parsed configuration data. `Js` is an unevaluated `!!js` scalar; it is
/// never executed. Maps keep source order.
#[derive(Debug, Clone, PartialEq)]
enum Value {
pub enum Value {
Null,
Bool(bool),
Int(i64),
@@ -90,14 +90,15 @@ enum Value {
}
impl Value {
fn get(&self, key: &str) -> Option<&Value> {
pub fn get(&self, key: &str) -> Option<&Value> {
match self {
Value::Map(entries) => entries.iter().find(|(k, _)| k == key).map(|(_, v)| v),
_ => None,
}
}
fn get_mut(&mut self, key: &str) -> Option<&mut Value> {
#[cfg(any(test, feature = "test-support"))]
pub fn get_mut(&mut self, key: &str) -> Option<&mut Value> {
match self {
Value::Map(entries) => entries.iter_mut().find(|(k, _)| k == key).map(|(_, v)| v),
_ => None,
@@ -183,7 +184,7 @@ const PARSE_REFUSAL: &str =
/// scalars (when allowed), no duplicate or non-string keys, at most one
/// document and 32 levels. Parser errors are not echoed: they can contain
/// source lines and credentials.
fn parse_yaml(text: &str, allow_js: bool) -> Result<Value> {
pub fn parse_yaml(text: &str, allow_js: bool) -> Result<Value> {
use yaml_rust2::parser::{Event, Parser};
use yaml_rust2::scanner::TScalarStyle;
@@ -334,7 +335,7 @@ fn parse_yaml(text: &str, allow_js: bool) -> Result<Value> {
/// Strict JSON (no duplicate keys, finite numbers) through the same closed
/// model as the YAML layers.
fn parse_json(text: &str) -> Result<Value> {
pub fn parse_json(text: &str) -> Result<Value> {
serde_json::from_str::<serde::de::IgnoredAny>(text)
.map_err(|_| anyhow::Error::msg(PARSE_REFUSAL))?;
parse_yaml(text, false)
@@ -346,12 +347,12 @@ fn parse_json(text: &str) -> Result<Value> {
/// A package directory, canonicalized once; every path below it is walked
/// component by component so a link anywhere inside the package refuses.
struct Package {
pub struct Package {
root: PathBuf,
}
impl Package {
fn open(path: &Path) -> Result<Self> {
pub fn open(path: &Path) -> Result<Self> {
let root = path.canonicalize().map_err(|_| {
anyhow::Error::msg(
"Select a DSH bundle package directory (a directory containing package.json).",
@@ -468,67 +469,67 @@ fn plain_relative(text: &str) -> bool {
/// One structured per-row or per-patch outcome.
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub(crate) struct DshOutcome {
pub(crate) row: Option<String>,
pub(crate) package: Option<String>,
pub(crate) kind: String,
pub(crate) outcome: String,
pub(crate) reason: String,
pub struct DshOutcome {
pub row: Option<String>,
pub package: Option<String>,
pub kind: String,
pub outcome: String,
pub reason: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub(crate) layer: Option<String>,
pub layer: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub(crate) patch: Option<usize>,
pub patch: Option<usize>,
}
impl DshOutcome {
/// A skipped row or operation is a manual port the reviewer must see.
pub(crate) fn needs_manual_port(&self) -> bool {
pub fn needs_manual_port(&self) -> bool {
self.outcome == "skipped"
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub(crate) struct DshLayer {
pub(crate) path: String,
pub(crate) sha256: String,
pub(crate) bytes: usize,
pub struct DshLayer {
pub path: String,
pub sha256: String,
pub bytes: usize,
}
/// What a conversion produced, for preview and install receipts.
#[derive(Debug, Clone, Serialize)]
pub(crate) struct DshConversion {
pub(crate) plugin_name: String,
pub(crate) source_package: Option<String>,
pub(crate) source_version: Option<String>,
pub(crate) manifest_sha256: String,
pub(crate) layers: Vec<DshLayer>,
pub(crate) outcomes: Vec<DshOutcome>,
pub(crate) diagnostics: Vec<String>,
pub(crate) skills: Vec<String>,
pub(crate) remote_servers: Vec<String>,
pub(crate) local_servers: Vec<String>,
pub(crate) network_hosts: Vec<String>,
pub(crate) requires_node: bool,
pub struct DshConversion {
pub plugin_name: String,
pub source_package: Option<String>,
pub source_version: Option<String>,
pub manifest_sha256: String,
pub layers: Vec<DshLayer>,
pub outcomes: Vec<DshOutcome>,
pub diagnostics: Vec<String>,
pub skills: Vec<String>,
pub remote_servers: Vec<String>,
pub local_servers: Vec<String>,
pub network_hosts: Vec<String>,
pub requires_node: bool,
/// Ordinary reviewed Native entry; remains experimental and disabled at install.
pub(crate) requires_native: bool,
pub(crate) native_rows: Vec<String>,
pub requires_native: bool,
pub native_rows: Vec<String>,
}
// ─────────────────────────────────────────────────────────────────────────────
// Bundle loading and patch evaluation
// ─────────────────────────────────────────────────────────────────────────────
struct LoadedBundle {
composition: Json,
manifest: Value,
manifest_sha256: String,
entries: Vec<Value>,
notes: Vec<String>,
layers: Vec<DshLayer>,
outcomes: Vec<DshOutcome>,
pub struct LoadedBundle {
pub composition: Json,
pub manifest: Value,
pub manifest_sha256: String,
pub entries: Vec<Value>,
pub notes: Vec<String>,
pub layers: Vec<DshLayer>,
pub outcomes: Vec<DshOutcome>,
}
fn load_bundle(package: &Package) -> Result<LoadedBundle> {
pub fn load_bundle(package: &Package) -> Result<LoadedBundle> {
let manifest_bytes = read_file(&package.contained(Path::new("package.json"))?, MAX_DOCUMENT)
.map_err(|_| {
anyhow::Error::msg("Not a DSH bundle package: package.json is missing or unreadable.")
@@ -604,8 +605,7 @@ fn load_bundle(package: &Package) -> Result<LoadedBundle> {
});
}
let composition = json!({"version":1,"layers":raw_layers,"modules":[],"files":{}});
let reviewed = crate::extension_host::composition_review::review(&composition)
.map_err(anyhow::Error::msg)?;
let reviewed = crate::composition_review::review(&composition).map_err(anyhow::Error::msg)?;
let entries = reviewed
.get("entries")
.and_then(Json::as_array)
@@ -712,8 +712,8 @@ fn reviewed_value(value: &Json) -> Result<Value> {
/// Rows are addressed by index path: `[i]` is a top-level entry, `[i, j]` the
/// `j`th child in entry `i`'s group `config`.
#[cfg(test)]
fn row_at<'a>(entries: &'a mut [Value], path: &[usize]) -> &'a mut Value {
#[cfg(any(test, feature = "test-support"))]
pub fn row_at<'a>(entries: &'a mut [Value], path: &[usize]) -> &'a mut Value {
let (first, rest) = path.split_first().expect("non-empty row path");
let mut row = &mut entries[*first];
for index in rest {
@@ -728,8 +728,8 @@ fn row_at<'a>(entries: &'a mut [Value], path: &[usize]) -> &'a mut Value {
/// `applyEntryPatches` parity over an empty entry list: `insert` appends rows
/// (or appends into a group entry's config); keyed patches replace fields on
/// an earlier inserted row. Skipped patches are recorded with their layer.
#[cfg(test)]
fn evaluate_patches(
#[cfg(any(test, feature = "test-support"))]
pub fn evaluate_patches(
patches: Vec<Value>,
notes: &mut Vec<String>,
locations: &[(String, usize)],
@@ -894,7 +894,7 @@ fn js_literal(text: &str, label: &str) -> Result<String> {
}
/// Lower only the `!!js` idioms that need no ambient state.
fn lower_js(text: &str, label: &str) -> Result<String> {
pub fn lower_js(text: &str, label: &str) -> Result<String> {
let text = text.trim();
if text == "process.execPath" {
return Ok("node".to_string());
@@ -940,7 +940,7 @@ fn server_options(config: &Value) -> Result<serde_json::Map<String, Json>> {
/// Parse a literal HTTP(S) endpoint the way the native reviewer will see it,
/// returning the host as it enters the capability set.
fn endpoint_host(url: &str) -> Result<String> {
pub fn endpoint_host(url: &str) -> Result<String> {
require(
!url.chars()
.any(|c| c.is_whitespace() || c == '\\' || c == '{' || c == '}'),
@@ -1100,14 +1100,14 @@ fn stdio_server(config: &Value, name: &str, root: &Path) -> Result<Json> {
// ─────────────────────────────────────────────────────────────────────────────
#[derive(Default)]
struct Components {
servers: Vec<(String, Json)>,
hosts: Vec<String>,
pub struct Components {
pub servers: Vec<(String, Json)>,
pub hosts: Vec<String>,
/// Local server name → packaged source root inside the package.
roots: Vec<(String, PathBuf)>,
skill_dirs: Vec<PathBuf>,
notes: Vec<String>,
outcomes: Vec<DshOutcome>,
pub roots: Vec<(String, PathBuf)>,
pub skill_dirs: Vec<PathBuf>,
pub notes: Vec<String>,
pub outcomes: Vec<DshOutcome>,
}
impl Components {
@@ -1236,7 +1236,7 @@ impl Components {
Ok(())
}
fn walk(
pub fn walk(
&mut self,
package: &Package,
rows: &[Value],
@@ -1456,7 +1456,7 @@ impl OutputFiles {
}
/// Walk a contained directory without following links, bounded.
fn walk_files(root: &Path, mut visit: impl FnMut(&Path, bool) -> Result<()>) -> Result<()> {
pub fn walk_files(root: &Path, mut visit: impl FnMut(&Path, bool) -> Result<()>) -> Result<()> {
let mut pending = vec![root.to_path_buf()];
let mut visited = 0usize;
while let Some(directory) = pending.pop() {
@@ -1636,7 +1636,7 @@ fn stdio_files(root: &Path, name: &str, output: &mut OutputFiles) -> Result<()>
/// A native plugin name from the package name: its last path segment,
/// lowercased, with other characters replaced by single hyphens.
fn host_hook_bridge(name: &str) -> bool {
crate::plugins::activation::dsh_row_bridge(name)
crate::activation::dsh_row_bridge(name)
}
/// Closed relative modules and exact contained ESM package exports. Missing
@@ -1685,10 +1685,7 @@ fn native_composition_files(
continue;
}
match presets::contained_module(package, name) {
Ok(path)
if crate::plugins::runtime::native_entry_problem(&path, path.is_file())
.is_none() =>
{
Ok(path) if crate::runtime::native_entry_problem(&path, path.is_file()).is_none() => {
paths.insert(name.clone(), path);
}
_ => unresolved.push(name),
@@ -1767,7 +1764,7 @@ fn native_composition_files(
Ok(names)
}
pub(crate) fn derived_plugin_name(package_name: &str) -> Option<String> {
pub fn derived_plugin_name(package_name: &str) -> Option<String> {
let segment = package_name
.rsplit('/')
.next()
@@ -1802,7 +1799,7 @@ fn valid_plugin_name(name: &str) -> bool {
/// Convert the DSH bundle package at `package` into a fresh native bundle at
/// `output`. `output` must not exist; nothing is written unless every
/// component validated, and a failed write removes what it created.
pub(crate) fn convert_package(package: &Path, output: &Path) -> Result<DshConversion> {
pub fn convert_package(package: &Path, output: &Path) -> Result<DshConversion> {
static CONVERSION: std::sync::Mutex<()> = std::sync::Mutex::new(());
let _conversion = CONVERSION
.lock()
@@ -2190,9 +2187,7 @@ fn write_output(output: &Path, files: &OutputFiles) -> Result<()> {
/// Convert into a private scratch directory and return it with the receipt.
/// The directory is removed when the returned guard drops.
pub(crate) fn convert_to_scratch(
package: &Path,
) -> Result<(tempfile::TempDir, PathBuf, DshConversion)> {
pub fn convert_to_scratch(package: &Path) -> Result<(tempfile::TempDir, PathBuf, DshConversion)> {
let scratch = tempfile::Builder::new()
.prefix("codewhale-dsh-import-")
.tempdir()?;
@@ -2203,7 +2198,7 @@ pub(crate) fn convert_to_scratch(
/// Whether `path` looks like a DSH bundle package (for routing a plain local
/// install to the importer instead of the native bundle reader).
pub(crate) fn is_dsh_package(path: &Path) -> bool {
pub fn is_dsh_package(path: &Path) -> bool {
let Ok(package) = Package::open(path) else {
return false;
};
@@ -2221,129 +2216,3 @@ pub(crate) fn is_dsh_package(path: &Path) -> bool {
.is_some()
})
}
#[cfg(test)]
mod shell_hook_import_tests {
use super::*;
#[test]
fn raw_mixed_preset_import_keeps_mcp_and_skills_selected_without_global_duplicates() {
let _home = crate::test_support::SealedHome::new();
let temp = tempfile::tempdir().unwrap();
let source = temp.path().join("source");
fs::create_dir_all(source.join("presets/a")).unwrap();
fs::create_dir_all(source.join("skills/check")).unwrap();
fs::write(source.join("package.json"), r#"{"name":"@demo/mixed-preset","version":"1.0.0","dsh":{"bundle":{"patch":"./cordis.patch.json"}}}"#).unwrap();
fs::write(source.join("cordis.patch.json"), json!([{"insert":[{"id":"roster","name":"@deepseek-ai/dsh-agent-presets","config":{"default":"a","roots":[{"path":"presets","trust":"user"}],"includeShippedRoot":false,"includeUserRoot":false}}]}]).to_string()).unwrap();
fs::write(
source.join("presets/a/preset.yml"),
"name: Mixed\ndescription: Five selected component categories\n",
)
.unwrap();
fs::write(source.join("presets/a/agent.cordis.yml"), "- name: '@deepseek-ai/dsh-mcp-client'\n config: {serverName: scoped, transport: stdio, command: node, args: [peer.mjs]}\n- name: '@deepseek-ai/dsh-skill-filesystem'\n config: {includeDefaultRoots: false, watch: false, customSkillDirs: [skills]}\n- name: ../../authored.mjs\n").unwrap();
fs::write(source.join("authored.mjs"), "export const inject=['tools','commands'];export function apply(ctx){ctx.tools.register({name:'echo',description:'mixed',parameters:{type:'object'},execute:()=>''});ctx.commands.register({name:'echo-mod',description:'mixed',handler:()=>''});ctx.on('tools/pre-execute',()=>undefined)}").unwrap();
fs::write(
source.join("peer.mjs"),
"throw new Error('import must not execute this MCP process')",
)
.unwrap();
fs::write(
source.join("skills/check/SKILL.md"),
"---\nname: check\ndescription: mixed skill\n---\nInspect the selected source.\n",
)
.unwrap();
let output = temp.path().join("out");
let converted = convert_package(&source, &output).unwrap();
assert!(converted.requires_native);
assert!(
!converted.requires_node,
"Native composition uses the selected host runtime"
);
assert!(
!output.join("mcp.json").exists(),
"no global duplicate MCP adapter"
);
assert!(
!output.join("skills").exists(),
"no global duplicate Skill adapter"
);
assert!(output.join("source/skills/check/SKILL.md").is_file());
assert!(output.join("source/peer.mjs").is_file());
assert!(output.join("native/presets/a.mjs").is_file());
let manifest: Json =
serde_json::from_slice(&fs::read(output.join("plugin.json")).unwrap()).unwrap();
assert_eq!(
manifest["extensions"]["net.codewhale"]["native"]["paths"],
json!(["native/presets/a.mjs"])
);
assert!(manifest.get("skills").is_none());
let admitted =
crate::plugins::manifest::PluginManifest::from_path(&output.join("plugin.json"))
.unwrap();
assert!(
admitted.when.is_none(),
"no fabricated Node binary condition"
);
assert!(
admitted.check_when(),
"Native-only applicability is runtime-neutral"
);
}
#[test]
fn native_shell_bridge_import_seals_assets_without_executing_commands() {
let _home = crate::test_support::SealedHome::new();
let temp = tempfile::tempdir().unwrap();
let bundle = temp.path().join("bundle");
fs::create_dir(&bundle).unwrap();
fs::write(bundle.join("package.json"), r#"{"name":"@demo/hook-bundle","version":"1.0.0","dsh":{"bundle":{"patch":"./cordis.patch.yml"}}}"#).unwrap();
fs::write(bundle.join("cordis.patch.yml"), "- insert:\n - id: hooks\n name: '@deepseek-ai/dsh-hooks-claude-code'\n config: {configPath: hooks.json}\n").unwrap();
let sentinel = temp.path().join("must-not-execute");
let config = json!({"hooks":{"PreToolUse":[{"hooks":[{"type":"command","command":format!("touch {}",sentinel.display())}]}]}}).to_string();
fs::write(bundle.join("hooks.json"), &config).unwrap();
let output = temp.path().join("output");
let converted = convert_package(&bundle, &output).unwrap();
assert!(converted.requires_native);
assert!(
!converted.requires_node,
"Native composition uses the selected host runtime"
);
assert_eq!(
converted.native_rows,
["@deepseek-ai/dsh-hooks-claude-code"]
);
let spec: Json =
serde_json::from_slice(&fs::read(output.join("native/composition.json")).unwrap())
.unwrap();
assert_eq!(spec["modules"], json!([]));
assert_eq!(spec["files"]["hooks.json"], sha256_hex(config.as_bytes()));
assert_eq!(
fs::read(output.join("source/hooks.json")).unwrap(),
config.as_bytes()
);
let manifest: Json =
serde_json::from_slice(&fs::read(output.join("plugin.json")).unwrap()).unwrap();
assert_eq!(
manifest["extensions"]["net.codewhale"]["native"]["path"],
"native/index.mjs"
);
let admitted =
crate::plugins::manifest::PluginManifest::from_path(&output.join("plugin.json"))
.unwrap();
assert!(
admitted.when.is_none(),
"no fabricated Node binary condition"
);
assert!(
admitted.check_when(),
"Native-only applicability is runtime-neutral"
);
assert!(
!sentinel.exists(),
"import and preview must never run a shell command"
);
}
}
#[cfg(test)]
#[path = "dsh_tests.rs"]
mod tests;
@@ -2,11 +2,11 @@
use super::*;
pub(super) const AGENT_PRESETS: &str = "@deepseek-ai/dsh-agent-presets";
pub(crate) const MARKER: &str = "// codewhale-native-preset-v1 ";
pub const MARKER: &str = "// codewhale-native-preset-v1 ";
/// Exact contained package lookup. No upward node_modules walk, exports glob,
/// install script, require fallback or package-selected resolver is executed.
pub(super) fn contained_module(package: &Package, name: &str) -> Result<PathBuf> {
pub fn contained_module(package: &Package, name: &str) -> Result<PathBuf> {
if name.starts_with("./") {
return package.contained(Path::new(name));
}
@@ -104,7 +104,7 @@ pub(super) fn prepare(
.push(json!({"path":relative,"sha256":sha256_hex(source.as_bytes()),"source":source}));
Ok(())
})?;
let review = crate::extension_host::composition_review::review(&json!({"kind":"agent-presets","composition":document,"documents":documents,"directories":directories})).map_err(anyhow::Error::msg)?;
let review = crate::composition_review::review(&json!({"kind":"agent-presets","composition":document,"documents":documents,"directories":directories})).map_err(anyhow::Error::msg)?;
let catalog = review
.get("catalog")
.filter(|c| c.get("version") == Some(&json!(1)))
@@ -66,14 +66,14 @@ pub struct PluginContentMismatch {
pub actual: String,
}
use crate::plugins::manifest::PluginManifest;
use crate::manifest::PluginManifest;
use codewhale_net::network_policy::NetworkPolicy;
use codewhale_skill_install::install::{
self as skill_install, FetchOutcome, INSTALLED_FROM_MARKER, InstallSource, InstalledFromMarker,
fetch_tarball, sha256_hex, source_spec_string,
};
pub(crate) mod dsh;
pub mod dsh;
mod place;
mod stage;
mod tarball;
@@ -217,8 +217,7 @@ impl PluginInstallSource {
bail!("local install path must not be empty");
}
let path = PathBuf::from(trimmed);
if crate::plugins::agent_plugin::resolve_manifest_path(&path).is_none()
&& dsh::is_dsh_package(&path)
if crate::agent_plugin::resolve_manifest_path(&path).is_none() && dsh::is_dsh_package(&path)
{
return Ok(Self::Dsh(path));
}
@@ -477,11 +476,11 @@ struct ConvertedDsh {
/// Parse and convert off the async runtime: conversion reads and copies the
/// whole package synchronously.
async fn convert_dsh_off_runtime(package: PathBuf) -> Result<ConvertedDsh> {
#[cfg(test)]
let env_scope = crate::test_support::env_scope_ticket();
#[cfg(any(test, feature = "test-support"))]
let env_scope = codewhale_runtime::test_support::env_scope_ticket();
tokio::task::spawn_blocking(move || {
#[cfg(test)]
let _env_scope = crate::test_support::join_env_scope(env_scope);
#[cfg(any(test, feature = "test-support"))]
let _env_scope = codewhale_runtime::test_support::join_env_scope(env_scope);
let canonical = package
.canonicalize()
.with_context(|| format!("failed to resolve {}", package.display()))?;
@@ -530,9 +529,9 @@ fn install_converted_dsh(
/// Review a DSH package without installing it: the conversion receipt and the
/// content hash that an exact install of the same package will stage.
pub(crate) fn preview_dsh(package: &Path) -> Result<(dsh::DshConversion, String)> {
pub fn preview_dsh(package: &Path) -> Result<(dsh::DshConversion, String)> {
let (_scratch, bundle, conversion) = dsh::convert_to_scratch(package)?;
let manifest = crate::plugins::agent_plugin::resolve_manifest_path(&bundle)
let manifest = crate::agent_plugin::resolve_manifest_path(&bundle)
.context("converted DSH bundle has no manifest")?;
let validated = PluginManifest::validate_from_path(&manifest)
.map_err(|error| anyhow::anyhow!("converted DSH bundle failed validation: {error}"))?;
@@ -620,11 +619,11 @@ pub async fn update(
let path = path.clone();
let user_plugins_dir = user_plugins_dir.to_path_buf();
let name = name.to_string();
#[cfg(test)]
let env_scope = crate::test_support::env_scope_ticket();
#[cfg(any(test, feature = "test-support"))]
let env_scope = codewhale_runtime::test_support::env_scope_ticket();
return tokio::task::spawn_blocking(move || {
#[cfg(test)]
let _env_scope = crate::test_support::join_env_scope(env_scope);
#[cfg(any(test, feature = "test-support"))]
let _env_scope = codewhale_runtime::test_support::join_env_scope(env_scope);
let staged = stage_local_copy(&path, &user_plugins_dir, max_size)?;
let staged_path = staged.staged_path.clone();
let result = (|| {
@@ -675,7 +674,7 @@ pub async fn update(
// new content hash invalidates the trust receipt at next discovery.
let converted = convert_dsh_off_runtime(package.clone()).await?;
let content_hash = {
let manifest = crate::plugins::agent_plugin::resolve_manifest_path(&converted.bundle)
let manifest = crate::agent_plugin::resolve_manifest_path(&converted.bundle)
.context("converted DSH bundle has no manifest")?;
PluginManifest::validate_from_path(&manifest)
.map_err(|error| {
@@ -57,13 +57,13 @@ pub(super) fn finalize_install(
let reserved = builder
.tempdir_in(user_plugins_dir)
.context("failed to reserve a private plugin backup container")?;
crate::plugins::registry::ensure_private_plugin_state_directory(reserved.path())
crate::registry::ensure_private_plugin_state_directory(reserved.path())
.map_err(anyhow::Error::msg)?;
// Persist before moving any user data: TempDir cleanup must never
// delete the incumbent during an error return or unwind.
let container = reserved.keep();
let backup = container.join("bundle");
if let Err(error) = crate::plugins::builtin::publish_snapshot(&final_path, &backup) {
if let Err(error) = crate::builtin::publish_snapshot(&final_path, &backup) {
if let Err(cleanup) = fs::remove_dir(&container) {
tracing::warn!(path = %container.display(), %cleanup, "empty plugin backup container could not be removed");
}
@@ -76,8 +76,7 @@ pub(super) fn finalize_install(
}
backup_container = Some(container);
}
if let Err(error) = crate::plugins::builtin::publish_snapshot(&staged.staged_path, &final_path)
{
if let Err(error) = crate::builtin::publish_snapshot(&staged.staged_path, &final_path) {
if let Some(container) = backup_container.as_deref() {
return Err(restore_backup(container, &final_path, error.into()));
}
@@ -112,23 +111,22 @@ pub(super) fn finalize_install(
));
}
let installed_content_hash =
match crate::plugins::agent_plugin::resolve_manifest_path(&final_path)
.ok_or_else(|| anyhow::anyhow!("installed plugin has no supported manifest"))
.and_then(|manifest_path| {
crate::plugins::manifest::PluginManifest::validate_from_path(&manifest_path)
.map(|validated| validated.content_hash)
.map_err(anyhow::Error::msg)
}) {
Ok(hash) => hash,
Err(error) => {
return Err(retained_publication_error(
error.context("installed plugin failed post-copy validation"),
&final_path,
backup_container.as_deref(),
));
}
};
let installed_content_hash = match crate::agent_plugin::resolve_manifest_path(&final_path)
.ok_or_else(|| anyhow::anyhow!("installed plugin has no supported manifest"))
.and_then(|manifest_path| {
crate::manifest::PluginManifest::validate_from_path(&manifest_path)
.map(|validated| validated.content_hash)
.map_err(anyhow::Error::msg)
}) {
Ok(hash) => hash,
Err(error) => {
return Err(retained_publication_error(
error.context("installed plugin failed post-copy validation"),
&final_path,
backup_container.as_deref(),
));
}
};
if let Some(container) = backup_container
&& let Err(error) = fs::remove_dir_all(&container)
{
@@ -154,7 +152,7 @@ pub(super) fn restore_backup(
error: anyhow::Error,
) -> anyhow::Error {
let backup = container.join("bundle");
match crate::plugins::builtin::publish_snapshot(&backup, final_path) {
match crate::builtin::publish_snapshot(&backup, final_path) {
Ok(()) => {
if let Err(cleanup) = fs::remove_dir(container) {
tracing::warn!(path = %container.display(), %cleanup, "restored plugin but empty recovery container could not be removed");
@@ -11,7 +11,7 @@ use std::path::{Path, PathBuf};
use anyhow::{Context, Result, bail};
use crate::plugins::manifest::PluginManifest;
use crate::manifest::PluginManifest;
use codewhale_skill_install::install::{INSTALLED_FROM_MARKER, validate_skill_name_segment};
use super::PluginInstallError;
@@ -68,7 +68,7 @@ fn ensure_plugins_dir(user_plugins_dir: &Path) -> Result<()> {
/// Validate the staged tree and return the manifest name + content hash.
pub(super) fn validate_staged(staged_path: &Path) -> Result<(String, String)> {
let manifest_path = crate::plugins::agent_plugin::resolve_manifest_path(staged_path)
let manifest_path = crate::agent_plugin::resolve_manifest_path(staged_path)
.ok_or_else(|| {
anyhow::anyhow!("staged bundle has no plugin.json, .claude-plugin/plugin.json, kimi.plugin.json, or plugin.toml")
})?;
@@ -91,7 +91,7 @@ pub(super) fn stage_local_copy(
) -> Result<StagedPlugin> {
// Validate the source first; this also rejects symlinked roots/manifests.
let manifest_path =
crate::plugins::agent_plugin::resolve_manifest_path(source).ok_or_else(|| {
crate::agent_plugin::resolve_manifest_path(source).ok_or_else(|| {
anyhow::anyhow!(
"source is not a valid plugin bundle: no plugin.json, .claude-plugin/plugin.json, kimi.plugin.json, or plugin.toml"
)
@@ -93,10 +93,9 @@ fn scan_tarball_for_bundle(
}
if header.entry_type().is_file()
&& path.file_name().is_some_and(|name| {
name == std::ffi::OsStr::new(crate::plugins::agent_plugin::PLUGIN_JSON_NAME)
|| name
== std::ffi::OsStr::new(crate::plugins::agent_plugin::KIMI_PLUGIN_JSON_NAME)
|| name == std::ffi::OsStr::new(crate::plugins::agent_plugin::PLUGIN_TOML_NAME)
name == std::ffi::OsStr::new(crate::agent_plugin::PLUGIN_JSON_NAME)
|| name == std::ffi::OsStr::new(crate::agent_plugin::KIMI_PLUGIN_JSON_NAME)
|| name == std::ffi::OsStr::new(crate::agent_plugin::PLUGIN_TOML_NAME)
})
{
manifest_paths.push(path_str);
@@ -109,7 +108,7 @@ fn scan_tarball_for_bundle(
let roots: BTreeSet<String> = manifest_paths
.iter()
.map(|manifest| {
crate::plugins::agent_plugin::plugin_root_for_manifest(Path::new(manifest))
crate::agent_plugin::plugin_root_for_manifest(Path::new(manifest))
.map(|root| root.to_string_lossy().into_owned())
.unwrap_or_default()
})
@@ -321,15 +321,13 @@ async fn exact_local_install_rejects_changed_bytes_before_placement() {
let tmp = tempfile::tempdir().unwrap();
let plugins = tmp.path().join("plugins");
let source = write_bundle(tmp.path(), "src/exact-demo", "exact-demo");
let expected =
crate::plugins::manifest::PluginManifest::validate_from_path(&source.join("plugin.toml"))
.unwrap()
.content_hash;
let expected = crate::manifest::PluginManifest::validate_from_path(&source.join("plugin.toml"))
.unwrap()
.content_hash;
fs::write(source.join("README.md"), "changed after review\n").unwrap();
let actual =
crate::plugins::manifest::PluginManifest::validate_from_path(&source.join("plugin.toml"))
.unwrap()
.content_hash;
let actual = crate::manifest::PluginManifest::validate_from_path(&source.join("plugin.toml"))
.unwrap()
.content_hash;
let error = install_with_expected_content_hash(
PluginInstallSource::parse(source.to_str().unwrap()).unwrap(),
@@ -411,7 +409,7 @@ async fn install_imports_official_kimi_datasource_shape() {
};
assert_eq!(installed.name, "kimi-datasource");
assert!(installed.path.join("kimi.plugin.json").exists());
let validated = crate::plugins::manifest::PluginManifest::validate_from_path(
let validated = crate::manifest::PluginManifest::validate_from_path(
&installed.path.join("kimi.plugin.json"),
)
.unwrap();
@@ -486,7 +484,7 @@ async fn install_imports_managed_kimi_cu_shape_with_platform_and_tool_filters()
let PluginInstallOutcome::Installed(installed) = outcome else {
panic!("expected managed Kimi CU plugin install to succeed");
};
let validated = crate::plugins::manifest::PluginManifest::validate_from_path(
let validated = crate::manifest::PluginManifest::validate_from_path(
&installed.path.join("kimi.plugin.json"),
)
.unwrap();
@@ -564,7 +562,7 @@ async fn install_imports_official_kimi_webbridge_shape() {
let PluginInstallOutcome::Installed(installed) = outcome else {
panic!("expected Kimi plugin install to succeed");
};
let validated = crate::plugins::manifest::PluginManifest::validate_from_path(
let validated = crate::manifest::PluginManifest::validate_from_path(
&installed.path.join("kimi.plugin.json"),
)
.unwrap();
@@ -1307,7 +1305,7 @@ async fn install_claude_bundle_keeps_root_components_and_review_hash() {
panic!("expected install")
};
let path = installed.path.join(".claude-plugin/plugin.json");
let validated = crate::plugins::manifest::PluginManifest::validate_from_path(&path).unwrap();
let validated = crate::manifest::PluginManifest::validate_from_path(&path).unwrap();
assert_eq!(
validated.canonical_root,
installed.path.canonicalize().unwrap()
@@ -1326,7 +1324,7 @@ async fn install_claude_bundle_keeps_root_components_and_review_hash() {
r#"{"linear":{"type":"http","url":"https://changed.invalid/mcp"}}"#,
)
.unwrap();
let changed = crate::plugins::manifest::PluginManifest::validate_from_path(&path).unwrap();
let changed = crate::manifest::PluginManifest::validate_from_path(&path).unwrap();
assert_ne!(validated.content_hash, changed.content_hash);
assert_ne!(validated.capability_hash, changed.capability_hash);
}
@@ -1355,7 +1353,7 @@ fn claude_archive_stages_the_bundle_outside_metadata_directory() {
);
assert!(staged.staged_path.join(".mcp.json").is_file());
assert!(staged.staged_path.join("skills/review/SKILL.md").is_file());
let validated = crate::plugins::manifest::PluginManifest::validate_from_path(
let validated = crate::manifest::PluginManifest::validate_from_path(
&staged.staged_path.join(".claude-plugin/plugin.json"),
)
.unwrap();
@@ -1430,7 +1428,7 @@ fn claude_metadata_and_mcp_links_cannot_escape_bundle_validation() {
fs::write(outside.join("plugin.json"), r#"{"name":"demo"}"#).unwrap();
symlink(&outside, root.join(".claude-plugin")).unwrap();
assert!(
crate::plugins::manifest::PluginManifest::validate_from_path(
crate::manifest::PluginManifest::validate_from_path(
&root.join(".claude-plugin/plugin.json")
)
.is_err()
@@ -1445,7 +1443,7 @@ fn claude_metadata_and_mcp_links_cannot_escape_bundle_validation() {
fs::write(outside.join("mcp.json"), "{}").unwrap();
symlink(outside.join("mcp.json"), root.join(".mcp.json")).unwrap();
assert!(
crate::plugins::manifest::PluginManifest::validate_from_path(
crate::manifest::PluginManifest::validate_from_path(
&root.join(".claude-plugin/plugin.json")
)
.is_err()
@@ -1,6 +1,6 @@
use super::*;
use crate::plugins::install::place::{finalize_install, restore_backup};
use crate::plugins::install::stage::stage_local_copy;
use crate::install::place::{finalize_install, restore_backup};
use crate::install::stage::stage_local_copy;
fn incumbent(plugins: &Path) -> PathBuf {
let target = write_bundle(plugins, "demo", "demo");
@@ -23,15 +23,14 @@ fn update_preserves_a_legitimate_dot_bak_plugin() {
let other = plugins.join("demo.bak");
fs::create_dir(&other).unwrap();
let manifest = serde_json::json!({
"$schema": crate::plugins::agent_plugin::PLUGIN_SCHEMA_URL,
"$schema": crate::agent_plugin::PLUGIN_SCHEMA_URL,
"name": "demo.bak", "version": "1.0.0"
});
fs::write(other.join("plugin.json"), manifest.to_string()).unwrap();
fs::write(other.join("canary.txt"), b"unrelated installed plugin").unwrap();
let before =
crate::plugins::manifest::PluginManifest::validate_from_path(&other.join("plugin.json"))
.unwrap()
.content_hash;
let before = crate::manifest::PluginManifest::validate_from_path(&other.join("plugin.json"))
.unwrap()
.content_hash;
let staged = prepared_update(tmp.path(), &plugins);
let outcome =
finalize_install(staged, "path:/controlled/source", None, "", &plugins, true).unwrap();
@@ -41,10 +40,9 @@ fn update_preserves_a_legitimate_dot_bak_plugin() {
fs::read(other.join("canary.txt")).unwrap(),
b"unrelated installed plugin"
);
let after =
crate::plugins::manifest::PluginManifest::validate_from_path(&other.join("plugin.json"))
.unwrap()
.content_hash;
let after = crate::manifest::PluginManifest::validate_from_path(&other.join("plugin.json"))
.unwrap()
.content_hash;
assert_eq!(
before, after,
"unrelated valid bundle must be byte-identical"
@@ -1,8 +1,8 @@
#![allow(dead_code)]
pub mod activation;
pub mod agent_plugin;
pub mod builtin;
pub mod composition_review;
pub mod composition_scope;
pub mod context;
pub mod discovery;
pub mod export;
@@ -12,15 +12,15 @@ pub mod manifest;
pub mod marketplace;
pub mod matcher;
pub mod mutation;
pub(crate) mod native_presets;
pub mod native_presets;
pub mod providers;
pub mod recommend;
pub mod registry;
pub mod runtime;
pub mod types;
#[cfg(test)]
pub(crate) mod test_fixture;
#[cfg(any(test, feature = "test-support"))]
pub mod test_fixture;
#[cfg(test)]
mod tests;
@@ -77,7 +77,7 @@ impl ManagedPluginPolicy {
/// Outcome of loading the managed policy document. There is no "ignore and
/// allow" outcome: anything other than absent-or-valid fails closed.
pub(crate) enum ManagedPolicyOutcome {
pub enum ManagedPolicyOutcome {
Absent,
Loaded(ManagedPluginPolicy),
Invalid(String),
@@ -86,7 +86,7 @@ pub(crate) enum ManagedPolicyOutcome {
/// Resolve the single policy source: the env override when set to a non-empty
/// value, otherwise `managed-policy.json` next to the plugin state file,
/// frozen as an absolute path while trusted discovery inputs are captured.
pub(crate) fn resolve_managed_policy_path(
pub fn resolve_managed_policy_path(
state_path: &Path,
host_environment: Option<&HostEnvironment>,
) -> Result<PathBuf, String> {
@@ -111,7 +111,7 @@ pub(crate) fn resolve_managed_policy_path(
/// Load the policy document. A missing file is [`ManagedPolicyOutcome::Absent`]
/// (today's behaviour); any other failure is [`ManagedPolicyOutcome::Invalid`].
pub(crate) fn load_managed_policy(path: &Path) -> ManagedPolicyOutcome {
pub fn load_managed_policy(path: &Path) -> ManagedPolicyOutcome {
let raw = match fs::read_to_string(path) {
Ok(raw) => raw,
Err(error) if error.kind() == ErrorKind::NotFound => {
@@ -7,18 +7,18 @@ use semver::Version;
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
#[cfg(test)]
#[cfg(any(test, feature = "test-support"))]
use super::activation::CAPABILITY_HASH_DOMAIN_V2;
use super::activation::{
CAPABILITY_HASH_DOMAIN_V1, PluginActivationCapability, PluginActivationPolicy,
};
use crate::mcp::{McpServerConfig, is_relative_stdio_path_arg};
use codewhale_mcp::config::{McpServerConfig, is_relative_stdio_path_arg};
use codewhale_util::path_identity::metadata_is_link_or_reparse;
#[cfg(windows)]
use codewhale_util::path_identity::windows_file_identity;
pub const CURRENT_SCHEMA_VERSION: u32 = 1;
pub(crate) const MAX_PLUGIN_NAME_CHARS: usize = 64;
pub const MAX_PLUGIN_NAME_CHARS: usize = 64;
const MAX_COMPONENT_PATHS: usize = 64;
const MAX_MANIFEST_BYTES: u64 = 1024 * 1024;
const MAX_HASHED_FILES: usize = 4_096;
@@ -350,11 +350,11 @@ pub struct ValidatedManifest {
/// Digest of the exact bytes read for every regular bundle file, keyed by
/// its lossless relative OS path. Runtime adapters use this to bind parsed
/// representations to the same bytes that produced `content_hash`.
pub(crate) file_hashes: BTreeMap<PathBuf, String>,
pub file_hashes: BTreeMap<PathBuf, String>,
/// Plain SHA256 for declared regular Native entry files, captured from
/// the same anchored, bounded read as the domain-separated bundle hashes.
/// EntryRef binds these digests; the bundle file inventory keeps its domain.
pub(crate) native_entry_hashes: BTreeMap<PathBuf, String>,
pub native_entry_hashes: BTreeMap<PathBuf, String>,
pub capability_hash: String,
pub applicable: bool,
pub warnings: Vec<String>,
@@ -683,7 +683,7 @@ impl PluginManifest {
})
}
pub(crate) fn validate_mcp_servers(&self, root: &Path) -> Result<(), String> {
pub fn validate_mcp_servers(&self, root: &Path) -> Result<(), String> {
let Some(servers) = &self.mcp_servers else {
return Ok(());
};
@@ -1643,7 +1643,7 @@ fn hash_inventory_with_policy(
/// Pre-policy capability digest. Tests use this to prove that a v1 trust
/// receipt cannot match the current capability hash.
pub(crate) fn capability_hash_v1(inventory: &PluginInventory) -> String {
pub fn capability_hash_v1(inventory: &PluginInventory) -> String {
let mut hasher = Sha256::new();
hasher.update(CAPABILITY_HASH_DOMAIN_V1);
for (key, value) in hash_inventory_counts(inventory) {
@@ -1681,8 +1681,8 @@ pub fn validate_icon(value: &str) -> Result<(), String> {
Ok(())
}
#[cfg(test)]
pub(crate) fn capability_hash_v2(inventory: &PluginInventory) -> String {
#[cfg(any(test, feature = "test-support"))]
pub fn capability_hash_v2(inventory: &PluginInventory) -> String {
let mut hasher = Sha256::new();
hasher.update(CAPABILITY_HASH_DOMAIN_V2);
hasher.update(b"policy-version\0");
@@ -1718,8 +1718,8 @@ pub(crate) fn capability_hash_v2(inventory: &PluginInventory) -> String {
hex_digest(hasher.finalize())
}
#[cfg(test)]
pub(crate) fn capability_hash_with_policy(
#[cfg(any(test, feature = "test-support"))]
pub fn capability_hash_with_policy(
inventory: &PluginInventory,
policy: PluginActivationPolicy,
) -> String {
@@ -2380,8 +2380,7 @@ mod icon_tests {
#[test]
fn artwork_is_inline_bounded_png_only() {
let catalog: serde_json::Value =
serde_json::from_str(include_str!("../../assets/first-party-marketplace.json"))
.unwrap();
serde_json::from_str(include_str!("../assets/first-party-marketplace.json")).unwrap();
let icon = catalog["catalog"]["plugins"]
.as_array()
.unwrap()
@@ -102,7 +102,7 @@ pub enum CatalogInstallResolution<'a> {
},
/// A matching name is occupied; catalog metadata does not prove identity.
AlreadyPresent {
plugin: &'a crate::plugins::types::LoadedPlugin,
plugin: &'a crate::types::LoadedPlugin,
reason: String,
},
Unsupported {
@@ -119,7 +119,7 @@ pub enum CatalogInstallResolution<'a> {
pub fn resolve_candidate_install<'a>(
entry: &StoredMarketplaceCatalog,
candidate: &MarketplaceCandidate,
registry: &'a crate::plugins::PluginRegistry,
registry: &'a crate::PluginRegistry,
) -> CatalogInstallResolution<'a> {
if candidate.has_errors() {
return CatalogInstallResolution::HasErrors {
@@ -127,7 +127,7 @@ pub fn resolve_candidate_install<'a>(
};
}
if let Some(plugin) = registry.get(&candidate.name) {
if plugin.scope == crate::plugins::types::PluginScope::Builtin {
if plugin.scope == crate::types::PluginScope::Builtin {
// A shipped bundle (Computer Use): the published "install"
// command must say so, not read as a failure (B5). The catalog
// entry still never replaces it.
@@ -216,8 +216,8 @@ fn resolve_spec(
// as remote is a local directory there — so an archive `url` of `/etc`
// or `../..` would bypass the containment check above. Only a spec the
// installer's own parser reads as remote is installable from a catalog.
match crate::plugins::install::PluginInstallSource::parse(spec) {
Ok(crate::plugins::install::PluginInstallSource::Remote(_)) => Ok(spec.to_string()),
match crate::install::PluginInstallSource::parse(spec) {
Ok(crate::install::PluginInstallSource::Remote(_)) => Ok(spec.to_string()),
_ => Err(format!(
"Catalog source `{spec}` is not a remote (github: or http(s)://) source; a catalog may name a local directory only as a path inside the catalog"
)),
@@ -246,7 +246,7 @@ fn canonical_document(path: &Path) -> Result<PathBuf, String> {
fn read_bounded(path: &Path) -> Result<String, String> {
// Validate the opened handle, not just the path checked before opening.
let file = crate::plugins::registry::open_existing_regular_file(path, false)?
let file = crate::registry::open_existing_regular_file(path, false)?
.ok_or_else(|| format!("Cannot read catalog at {}: file is missing", path.display()))?;
let mut text = String::new();
let mut limited = file.take(MAX_CATALOG_BYTES + 1);
@@ -265,7 +265,7 @@ fn read_bounded(path: &Path) -> Result<String, String> {
}
fn render_diagnostics_inline(diagnostics: &[super::types::MarketplaceDiagnostic]) -> String {
use crate::plugins::types::PluginDiagnosticLevel;
use crate::types::PluginDiagnosticLevel;
diagnostics
.iter()
.map(|d| {
@@ -23,8 +23,8 @@
use serde_json::Value;
use crate::plugins::agent_plugin::{is_standard_plugin_name, slugify_plugin_name};
use crate::plugins::manifest::PluginInventory;
use crate::agent_plugin::{is_standard_plugin_name, slugify_plugin_name};
use crate::manifest::PluginInventory;
use super::super::types::{
CatalogProvenance, CatalogTier, MarketplaceCandidate, MarketplaceCandidateId,
@@ -18,7 +18,7 @@
use serde_json::Value;
use crate::plugins::install::PluginInstallSource;
use crate::install::PluginInstallSource;
use super::super::types::{
CatalogProvenance, CatalogTier, MarketplaceCandidate, MarketplaceCandidateId,
@@ -174,8 +174,8 @@ fn parse_codewhale_entry(
// The name is the candidate's identity (registry lookup, `/plugin show`,
// install), so it is held to the plugin-name rules the other formats
// normalize to, rather than stored verbatim with controls or unbounded.
if crate::plugins::manifest::validate_plugin_name(name).is_err()
&& !crate::plugins::agent_plugin::is_standard_plugin_name(name)
if crate::manifest::validate_plugin_name(name).is_err()
&& !crate::agent_plugin::is_standard_plugin_name(name)
{
diagnostics.push(MarketplaceDiagnostic::error(
"INVALID_NAME",
@@ -265,7 +265,7 @@ fn parse_codewhale_entry(
}
let mut icon = labels.pop().flatten();
if let Some(value) = &icon
&& let Err(reason) = crate::plugins::manifest::validate_icon(value)
&& let Err(reason) = crate::manifest::validate_icon(value)
{
entry_diags.push(MarketplaceDiagnostic::error(
"INVALID_ICON",
@@ -358,7 +358,7 @@ fn parse_codewhale_entry(
publisher: None,
source_url: None,
},
when: platforms.map(|os| crate::plugins::manifest::PluginWhen {
when: platforms.map(|os| crate::manifest::PluginWhen {
os: Some(os),
binaries: None,
}),
@@ -32,7 +32,7 @@
use serde_json::Value;
use crate::plugins::agent_plugin::{is_standard_plugin_name, slugify_plugin_name};
use crate::agent_plugin::{is_standard_plugin_name, slugify_plugin_name};
use super::super::types::{
CatalogProvenance, CatalogTier, MarketplaceCandidate, MarketplaceCandidateId,
@@ -19,7 +19,7 @@
use serde_json::Value;
use crate::plugins::agent_plugin::{is_standard_plugin_name, slugify_plugin_name};
use crate::agent_plugin::{is_standard_plugin_name, slugify_plugin_name};
use super::super::types::{
CatalogProvenance, CatalogTier, MarketplaceCandidate, MarketplaceCandidateId,
@@ -12,7 +12,7 @@ pub mod kimi;
use serde_json::Value;
use crate::plugins::manifest::{PluginCompatibility, PluginInventory};
use crate::manifest::{PluginCompatibility, PluginInventory};
use super::types::{
MarketplaceCatalog, MarketplaceCatalogId, MarketplaceDiagnostic, MarketplaceFormat,
@@ -17,7 +17,7 @@ use std::path::{Path, PathBuf};
use serde::{Deserialize, Serialize};
use super::types::{MarketplaceCatalog, MarketplaceCatalogId};
use crate::plugins::registry::{
use crate::registry::{
ensure_private_plugin_state_directory, harden_plugin_state_file, open_existing_regular_file,
open_state_lock, path_entry_exists, save_state_with_hardener, state_lock_path,
validate_existing_plugin_state_parent,
@@ -297,7 +297,7 @@ fn first_party_catalog() -> Result<StoredMarketplaceCatalog, String> {
catalog: serde_json::Value,
}
let snapshot: Snapshot =
serde_json::from_str(include_str!("../../../assets/first-party-marketplace.json"))
serde_json::from_str(include_str!("../../assets/first-party-marketplace.json"))
.map_err(|error| format!("invalid bundled marketplace: {error}"))?;
let source = format!("{}/tree/{}", snapshot.repository, snapshot.revision);
let mut catalog = super::parsers::parse_catalog(super::parsers::MarketplaceDocument {
@@ -337,7 +337,7 @@ mod tests {
assert_eq!(catalog.catalog.error_count(), 0);
assert_eq!(catalog.catalog.warning_count(), 0);
assert!(!catalog.catalog.provenance.grants_trust());
let registry = crate::plugins::PluginRegistry::empty(root.path());
let registry = crate::PluginRegistry::empty(root.path());
for candidate in &catalog.catalog.candidates {
assert!(candidate.install_plan.is_supported());
assert!(!candidate.provenance.grants_trust());
@@ -141,7 +141,7 @@ fn parse_auto(id: &str, body: &str) -> super::types::MarketplaceCatalog {
#[test]
fn native_catalog_keeps_identity_without_fetching_artwork_or_granting_trust() {
let snapshot: Value =
serde_json::from_str(include_str!("../../../assets/first-party-marketplace.json")).unwrap();
serde_json::from_str(include_str!("../../assets/first-party-marketplace.json")).unwrap();
let icon = snapshot["catalog"]["plugins"]
.as_array()
.unwrap()
@@ -352,7 +352,7 @@ fn claude_component_declarations_drive_compatibility() {
]
}"#;
let catalog = parse("claude", MarketplaceFormat::Claude, body);
use crate::plugins::manifest::PluginCompatibility as C;
use crate::manifest::PluginCompatibility as C;
assert_eq!(
catalog
.candidate_by_name("skills-only")
@@ -601,7 +601,7 @@ fn claude_relative_install_source_is_rooted_outside_catalog_metadata() {
std::fs::write(&path, r#"{"name":"official","owner":{"name":"Example"},"plugins":[{"name":"linear","source":"./external_plugins/linear"}]}"#).unwrap();
let loaded = load_catalog_document("official", tmp.path(), path.to_str().unwrap()).unwrap();
let candidate = loaded.entry.catalog.candidate_by_name("linear").unwrap();
let registry = crate::plugins::PluginRegistry::empty(tmp.path());
let registry = crate::PluginRegistry::empty(tmp.path());
let CatalogInstallResolution::Supported { spec, .. } =
resolve_candidate_install(&loaded.entry, candidate, &registry)
else {
@@ -673,7 +673,7 @@ fn catalog_archive_sources_must_be_remote() {
.unwrap();
let loaded = load_catalog_document("n", tmp.path(), path.to_str().unwrap()).unwrap();
assert_eq!(loaded.entry.catalog.format, MarketplaceFormat::Claude);
let registry = crate::plugins::PluginRegistry::empty(tmp.path());
let registry = crate::PluginRegistry::empty(tmp.path());
for name in ["absolute", "parent", "prefixed"] {
let candidate = loaded.entry.catalog.candidate_by_name(name).unwrap();
match resolve_candidate_install(&loaded.entry, candidate, &registry) {
@@ -710,7 +710,7 @@ fn catalog_local_sources_cannot_leave_the_catalog_directory() {
.unwrap();
let loaded = load_catalog_document("n", tmp.path(), path.to_str().unwrap()).unwrap();
assert_eq!(loaded.entry.catalog.format, MarketplaceFormat::Codex);
let registry = crate::plugins::PluginRegistry::empty(tmp.path());
let registry = crate::PluginRegistry::empty(tmp.path());
for name in ["absolute", "parent"] {
let candidate = loaded.entry.catalog.candidate_by_name(name).unwrap();
match resolve_candidate_install(&loaded.entry, candidate, &registry) {
@@ -81,7 +81,7 @@ fn effective_keywords(candidate: &KeywordCandidate<'_>) -> Vec<String> {
/// list as `STOPLIST` and rejects a manifest keyword on it, so a catalog
/// author finds out at review time instead of the term silently never
/// matching here. Change both together; kept sorted so the two diff cleanly.
pub(crate) const GENERIC_TERM_STOPLIST: &[&str] = &[
pub const GENERIC_TERM_STOPLIST: &[&str] = &[
"accessibility",
"agent",
"agents",
@@ -124,7 +124,7 @@ fn is_matchable_term(term: &str) -> bool {
&& !GENERIC_TERM_STOPLIST.contains(&term)
}
pub(crate) fn normalize_domain(domain: &str) -> Option<String> {
pub fn normalize_domain(domain: &str) -> Option<String> {
let trimmed = domain.trim();
let after_scheme = match trimmed.find("://") {
Some(i) => &trimmed[i + 3..],
@@ -101,9 +101,9 @@ pub async fn install_from_cli(
mut registry: PluginRegistry,
) -> Result<()> {
let runtime = tokio::runtime::Handle::current();
let policy = crate::plugins::activation::extension_host_policy_enabled();
let policy = crate::activation::extension_host_policy_enabled();
let receipt = tokio::task::spawn_blocking(move || {
let _scope = crate::plugins::activation::PolicyScope::propagate(policy);
let _scope = crate::activation::PolicyScope::propagate(policy);
let network = config
.network
.map(codewhale_net::network_policy::NetworkPolicy::from)
@@ -1,7 +1,7 @@
//! Pure roster facts tied to existing Native entry receipts. No new profile,
//! session, filesystem root or writable catalog authority.
use super::{PluginRegistry, activation::PluginActivationCapability};
use crate::extension_host::composition_scope::NativePresetRef;
use crate::composition_scope::NativePresetRef;
use codewhale_config::EntryRef;
use serde::Deserialize;
use std::collections::{BTreeMap, BTreeSet};
@@ -13,7 +13,7 @@ const MAX_ENTRY: u64 = 16 * 1024;
#[derive(Debug, Clone, Deserialize)]
#[serde(deny_unknown_fields)]
pub(crate) struct NativePresetMetadata {
pub struct NativePresetMetadata {
pub id: String,
pub trust: String,
pub name: Option<String>,
@@ -23,7 +23,7 @@ pub(crate) struct NativePresetMetadata {
pub broken: Option<String>,
}
pub(crate) fn metadata_from_bytes(bytes: &[u8]) -> Option<NativePresetMetadata> {
pub fn metadata_from_bytes(bytes: &[u8]) -> Option<NativePresetMetadata> {
let source = std::str::from_utf8(bytes).ok()?;
let first = source.lines().next()?.strip_prefix(MARKER)?;
let data: NativePresetMetadata = serde_json::from_str(first).ok()?;
@@ -48,7 +48,7 @@ pub(crate) fn metadata_from_bytes(bytes: &[u8]) -> Option<NativePresetMetadata>
Some(data)
}
pub(crate) fn metadata(
pub fn metadata(
plugins: &PluginRegistry,
preset: &NativePresetRef,
) -> Option<NativePresetMetadata> {
@@ -111,7 +111,7 @@ fn reviewed_entry(
/// New roster choices come from a whole admitted bundle validation, not a
/// fresh hash of an unmounted mutable file mistaken for a reviewed receipt.
pub(crate) fn admitted_entries(
pub fn admitted_entries(
plugins: &PluginRegistry,
) -> Vec<(
NativePresetRef,
@@ -154,9 +154,7 @@ pub(crate) fn admitted_entries(
/// One caller snapshot: each raw catalog chooses one default; ordinary Native
/// plugins keep their existing complete entry set. The manager may own a union
/// across callers but no caller receives a union of that catalog's presets.
pub(crate) fn default_selection(
plugins: &PluginRegistry,
) -> (Vec<NativePresetRef>, BTreeSet<String>) {
pub fn default_selection(plugins: &PluginRegistry) -> (Vec<NativePresetRef>, BTreeSet<String>) {
let (sources, _) =
super::runtime::active_component_sources(plugins, PluginActivationCapability::Native);
let admitted = admitted_entries(plugins);
@@ -40,14 +40,14 @@ pub fn load_config_structural(
static STARTUP_REGISTRY: OnceLock<Arc<PluginRegistry>> = OnceLock::new();
#[cfg(test)]
#[cfg(any(test, feature = "test-support"))]
thread_local! {
static TEST_REGISTRY: std::cell::RefCell<Option<Arc<PluginRegistry>>> = const { std::cell::RefCell::new(None) };
pub static TEST_REGISTRY: std::cell::RefCell<Option<Arc<PluginRegistry>>> = const { std::cell::RefCell::new(None) };
}
/// Restore the calling test's previous registry even if its operation panics.
#[cfg(test)]
fn with_test_registry<T>(registry: Arc<PluginRegistry>, operation: impl FnOnce() -> T) -> T {
#[cfg(any(test, feature = "test-support"))]
pub fn with_test_registry<T>(registry: Arc<PluginRegistry>, operation: impl FnOnce() -> T) -> T {
struct Restore(Option<Arc<PluginRegistry>>);
impl Drop for Restore {
fn drop(&mut self) {
@@ -185,7 +185,7 @@ pub fn install_startup_registry(registry: Arc<PluginRegistry>) {
}
pub fn apply_startup_providers(config: &mut Config) -> anyhow::Result<()> {
#[cfg(test)]
#[cfg(any(test, feature = "test-support"))]
if let Some(registry) = TEST_REGISTRY.with(|slot| slot.borrow().clone()) {
return apply_providers(config, &registry);
}
@@ -197,16 +197,16 @@ pub fn apply_startup_providers(config: &mut Config) -> anyhow::Result<()> {
/// Core-owned standard public-client PKCE login. Blocking sockets and secure
/// storage stay on the dedicated worker; plugins never receive token material.
pub(crate) async fn plugin_oauth_login(
pub async fn plugin_oauth_login(
provider: String,
base_url: String,
descriptor: codewhale_runtime::oauth::PluginOAuthConfig,
authority: codewhale_config::PluginAuthority,
) -> Result<()> {
let policy = crate::plugins::activation::extension_host_policy_enabled();
let policy = crate::activation::extension_host_policy_enabled();
tokio::task::spawn_blocking(move || {
let _scope = crate::plugins::activation::PolicyScope::propagate(policy);
crate::plugins::providers::verify_provider_binding(
let _scope = crate::activation::PolicyScope::propagate(policy);
crate::providers::verify_provider_binding(
&authority,
&provider,
&base_url,
@@ -235,7 +235,7 @@ pub(crate) async fn plugin_oauth_login(
&state,
Some(&descriptor.issuer),
)?;
crate::plugins::providers::verify_provider_binding(
crate::providers::verify_provider_binding(
&authority,
&provider,
&base_url,
@@ -254,7 +254,7 @@ pub(crate) async fn plugin_oauth_login(
form.push(("resource", resource));
}
let token = codewhale_runtime::oauth::plugin_token_response(&descriptor, &form, None)?;
crate::plugins::providers::verify_provider_binding(
crate::providers::verify_provider_binding(
&authority,
&provider,
&base_url,
@@ -269,7 +269,7 @@ pub(crate) async fn plugin_oauth_login(
.context("Plugin OAuth login worker failed")?
}
pub(crate) fn plugin_auth_entry(config: &Config, provider: &str) -> anyhow::Result<ProviderConfig> {
pub fn plugin_auth_entry(config: &Config, provider: &str) -> anyhow::Result<ProviderConfig> {
let mut entry = config
.providers
.as_ref()
@@ -299,7 +299,7 @@ pub(crate) fn plugin_auth_entry(config: &Config, provider: &str) -> anyhow::Resu
/// a still-valid receipt. Call this on a blocking worker at the use boundary.
/// `None` skips public-header comparison for login/logout; inference must pass
/// `Some` with its effective configured headers, including an empty map.
pub(crate) fn verify_provider_binding(
pub fn verify_provider_binding(
authority: &codewhale_config::PluginAuthority,
name: &str,
base_url: &str,
@@ -372,7 +372,7 @@ fn model_preference(entry: &ProviderConfig) -> Option<&str> {
}
}
pub(crate) fn is_account_catalog_scope(provider: &str, fingerprint: &str) -> bool {
pub fn is_account_catalog_scope(provider: &str, fingerprint: &str) -> bool {
STARTUP_REGISTRY.get().is_some_and(|registry| {
registry.active_plugins().into_iter().any(|plugin| {
plugin.manifest.providers.iter().any(|(name, declaration)| {
@@ -455,7 +455,6 @@ pub fn apply_providers(config: &mut Config, registry: &PluginRegistry) -> anyhow
#[cfg(test)]
mod tests {
use super::*;
use crate::plugins::discovery::{DiscoveryConfig, discover_with_config};
fn declaration() -> PluginProviderDeclaration {
serde_json::from_value(serde_json::json!({
@@ -521,217 +520,4 @@ mod tests {
headers.insert("X-Test".into(), "injected\r\nAuthorization: secret".into());
assert!(validate_declarations(&declarations).is_err());
}
#[test]
fn reviewed_provider_registration_preserves_collisions_and_revokes_existing_routes() {
let temp = tempfile::tempdir().unwrap();
let workspace = temp.path().join("workspace");
let plugin = temp.path().join("plugins/provider-demo");
std::fs::create_dir_all(&workspace).unwrap();
std::fs::create_dir_all(&plugin).unwrap();
let mut reviewed_declaration = declaration();
reviewed_declaration
.http_headers
.insert("X-Route-Group".into(), "reviewed-group".into());
let manifest = serde_json::json!({
"$schema": super::super::agent_plugin::PLUGIN_SCHEMA_URL,
"name": "provider-demo", "version": "1.0.0",
"extensions": {"net.codewhale": {"providers": {"gateway": reviewed_declaration}}}
});
std::fs::write(
plugin.join("plugin.json"),
serde_json::to_vec(&manifest).unwrap(),
)
.unwrap();
let discovery = DiscoveryConfig {
workspace: workspace.clone(),
user_plugins_dir: temp.path().join("plugins"),
workspace_plugins_dir: workspace.join(".codewhale/plugins"),
builtin_plugin_dirs: vec![],
state_path: temp.path().join("state/plugins.json"),
};
let mut registry = discover_with_config(&discovery);
let mut config = Config::default();
apply_providers(&mut config, &registry).unwrap();
assert!(
config
.providers
.as_ref()
.is_none_or(|providers| !providers.custom.contains_key("gateway"))
);
registry.trust("provider-demo").unwrap();
registry.enable("provider-demo").unwrap();
let registry = discover_with_config(&discovery);
apply_providers(&mut config, &registry).unwrap();
let entry = config
.providers
.as_ref()
.unwrap()
.custom
.get("gateway")
.unwrap();
assert_eq!(entry.auth_mode.as_deref(), Some("oauth"));
let authority = entry.plugin_authority.clone().unwrap();
let oauth = entry.oauth.as_ref().unwrap();
let headers = entry.http_headers.as_ref().unwrap();
verify_provider_binding(
&authority,
"gateway",
"https://gateway.example/api/",
oauth,
Some(headers),
)
.unwrap();
assert!(
verify_provider_binding(
&authority,
"gateway",
"https://other.example/api",
oauth,
Some(headers)
)
.is_err()
);
let mut altered_oauth = oauth.clone();
altered_oauth.scopes.push("admin:write".into());
assert!(
verify_provider_binding(
&authority,
"gateway",
"https://gateway.example/api",
&altered_oauth,
Some(headers)
)
.is_err()
);
let mut altered_headers = headers.clone();
altered_headers.insert("X-Route-Group".into(), "unreviewed-group".into());
assert!(
verify_provider_binding(
&authority,
"gateway",
"https://gateway.example/api",
oauth,
Some(&altered_headers)
)
.is_err()
);
assert!(
verify_provider_binding(
&authority,
"gateway",
"https://gateway.example/api",
oauth,
Some(&std::collections::HashMap::new())
)
.is_err()
);
assert!(
verify_provider_binding(
&authority,
"other-provider",
"https://gateway.example/api",
oauth,
Some(headers)
)
.is_err()
);
assert!(
config
.custom_models
.as_ref()
.unwrap()
.iter()
.any(|model| model.provider == "gateway" && model.id == "tiny-model")
);
// Persist only the user's selection; the reviewed route stays in the
// plugin and is reconstructed after parsing the next startup document.
let selection_path = temp.path().join("selection.toml");
let saved = with_test_registry(Arc::new(registry.clone()), || {
// First use starts from an empty document, with no provider table.
let identity = config.resolve_provider_pin_identity("gateway").unwrap();
let mut doc = toml_edit::DocumentMut::new();
crate::route_preferences::set_provider_model_document(
&mut doc,
&identity,
"selected-model",
)
.unwrap();
let model_only = doc.to_string();
assert!(!model_only.contains("base_url") && !model_only.contains("oauth"));
let mut parsed = codewhale_runtime::config::parse_config_base(&model_only).unwrap();
apply_startup_providers(&mut parsed).unwrap();
assert_eq!(
parsed.providers.as_ref().unwrap().custom["gateway"]
.model
.as_deref(),
Some("selected-model")
);
crate::route_preferences::persist_provider_selection(
Some(&selection_path),
&identity,
Some("selected-model"),
)
.unwrap();
std::fs::read_to_string(&selection_path).unwrap()
});
// The scoped registry is gone; reconstruct using the explicit fixture.
assert!(TEST_REGISTRY.with(|slot| slot.borrow().is_none()));
let mut restored = codewhale_runtime::config::parse_config_base(&saved).unwrap();
apply_providers(&mut restored, &registry).unwrap();
assert_eq!(restored.provider.as_deref(), Some("gateway"));
let restored_entry = restored
.providers
.as_ref()
.unwrap()
.custom
.get("gateway")
.unwrap();
assert_eq!(restored_entry.model.as_deref(), Some("selected-model"));
assert_eq!(
restored_entry.base_url.as_deref(),
Some("https://gateway.example/api")
);
assert_eq!(restored_entry.auth_mode.as_deref(), Some("oauth"));
assert_eq!(restored_entry.plugin_authority, Some(authority.clone()));
assert!(!saved.contains("base_url") && !saved.contains("oauth"));
for override_field in [
"vendor = 'other'",
"api_key = 'other'",
"base_url = 'https://other.example'",
"context_window = 1",
"model_context_windows = { other = 1 }",
"mode = 'other'",
"wire = 'responses'",
"auth_mode = 'none'",
"oauth_credential_generation = 'other'",
"insecure_skip_tls_verify = false",
"allow_insecure_http = false",
"http_headers = {}",
"path_suffix = '/other'",
"reasoning_stream_style = 'other'",
"max_concurrency = 1",
"kind = 'openai-compatible'",
"api_key_env = 'OTHER_KEY'",
] {
let mut collision = codewhale_runtime::config::parse_config_base(&format!(
"[providers.gateway]\nmodel = 'tiny-model'\n{override_field}\n"
))
.unwrap();
assert!(
apply_providers(&mut collision, &registry).is_err(),
"accepted non-model route override {override_field}"
);
}
let snapshot = config.providers.as_ref().unwrap().custom.len();
assert!(apply_providers(&mut config, &registry).is_err());
assert_eq!(config.providers.as_ref().unwrap().custom.len(), snapshot);
let mut fresh = discover_with_config(&discovery);
fresh.disable("provider-demo").unwrap();
assert!(
verify_plugin_component_authority(&authority, PluginActivationCapability::Providers)
.is_err()
);
}
}
@@ -11,8 +11,8 @@
use std::collections::{BTreeMap, BTreeSet};
use crate::skills::recommend::recommend_remote_skills;
use codewhale_skill_install::install::{RegistryDocument, RegistryEntry};
use codewhale_skill_install::recommend::recommend_remote_skills;
use super::marketplace::types::MarketplaceCandidate;
use super::registry::PluginRegistry;
@@ -125,7 +125,7 @@ impl PluginKeywordMatch {
pub fn load_marketplace_candidates(
state_path: Option<&std::path::Path>,
) -> Vec<MarketplaceCandidate> {
let Some(store) = crate::plugins::marketplace::store::MarketplaceStore::open(state_path) else {
let Some(store) = crate::marketplace::store::MarketplaceStore::open(state_path) else {
return Vec::new();
};
let Ok(state) = store.load() else {
@@ -206,7 +206,7 @@ fn idle_and_catalog_keyword_matches_for_os(
// are installable, but this pool feeds the composer toast, so a skill
// must not be dressed as one. This replaces #6274's name suppression, which existed only
// because the catalog mixed the two kinds.
if candidate.kind != crate::plugins::marketplace::types::MarketplaceEntryKind::Plugin {
if candidate.kind != crate::marketplace::types::MarketplaceEntryKind::Plugin {
continue;
}
if installed_names.contains(&candidate.name.to_ascii_lowercase())
@@ -233,9 +233,9 @@ fn idle_and_catalog_keyword_matches_for_os(
domains.push(homepage.clone());
}
let next_step = match &candidate.install_plan {
crate::plugins::marketplace::types::MarketplaceInstallPlan::Supported {
spec, ..
} if candidate.catalog_id.as_str().is_empty() => {
crate::marketplace::types::MarketplaceInstallPlan::Supported { spec, .. }
if candidate.catalog_id.as_str().is_empty() =>
{
PluginNextStep::SourceInstall { spec: spec.clone() }
}
_ => PluginNextStep::MarketplaceInstall {
@@ -276,13 +276,13 @@ pub fn match_plugin_for_draft_among(
) -> Option<PluginKeywordMatch> {
let keyword_candidates = candidates
.iter()
.map(|candidate| crate::plugins::matcher::KeywordCandidate {
.map(|candidate| crate::matcher::KeywordCandidate {
name: candidate.name.as_str(),
domains: &candidate.domains,
keywords: &candidate.keywords,
})
.collect::<Vec<_>>();
let (idx, term) = crate::plugins::matcher::match_plugin_keyword(draft, &keyword_candidates)?;
let (idx, term) = crate::matcher::match_plugin_keyword(draft, &keyword_candidates)?;
let mut matched = candidates.get(idx)?.clone();
matched.matched_term = Some(term);
Some(matched)
@@ -327,7 +327,7 @@ pub fn recommend_plugins_for_task(
if candidate.has_errors() {
continue;
}
if candidate.kind != crate::plugins::marketplace::types::MarketplaceEntryKind::Plugin {
if candidate.kind != crate::marketplace::types::MarketplaceEntryKind::Plugin {
continue;
}
if installed_names.contains(&candidate.name.to_ascii_lowercase()) {
@@ -475,11 +475,11 @@ fn recommend_from_entries(
#[cfg(test)]
mod tests {
use super::*;
use crate::plugins::marketplace::types::{
use crate::marketplace::types::{
CatalogProvenance, CatalogTier, MarketplaceCandidate, MarketplaceCandidateId,
MarketplaceCatalogId, MarketplaceEntryKind, MarketplaceInstallPlan, MarketplaceSourceSpec,
};
use crate::test_support::{EnvVarGuard, lock_test_env};
use codewhale_runtime::test_support::{EnvVarGuard, lock_test_env};
use std::fs;
use tempfile::TempDir;
@@ -554,8 +554,8 @@ mod tests {
"Hosted Postgres and auth",
&["supabase", "postgres"],
);
let registry = crate::plugins::PluginDiscoveryContext::capture_pre_dotenv()
.registry_for_workspace(root.path());
let registry =
crate::PluginDiscoveryContext::capture_pre_dotenv().registry_for_workspace(root.path());
let recs = recommend_plugins_for_task(
"add supabase auth to this app",
@@ -575,8 +575,8 @@ mod tests {
let _lock = lock_test_env();
let root = TempDir::new().unwrap();
let _home = EnvVarGuard::set("CODEWHALE_HOME", root.path().join("home"));
let registry = crate::plugins::PluginDiscoveryContext::capture_pre_dotenv()
.registry_for_workspace(root.path());
let registry =
crate::PluginDiscoveryContext::capture_pre_dotenv().registry_for_workspace(root.path());
let catalog = [marketplace_candidate("official", "supabase", &["supabase"])];
let recs = recommend_plugins_for_task(
@@ -605,7 +605,7 @@ mod tests {
let root = TempDir::new().unwrap();
let _home = EnvVarGuard::set("CODEWHALE_HOME", root.path().join("home"));
write_keyword_bundle(root.path(), "supabase", "Hosted Postgres", &["supabase"]);
let mut registry = crate::plugins::PluginDiscoveryContext::capture_pre_dotenv()
let mut registry = crate::PluginDiscoveryContext::capture_pre_dotenv()
.registry_for_workspace(root.path())
.as_ref()
.clone();
@@ -629,7 +629,7 @@ mod tests {
#[test]
fn builtin_plugins_are_never_suggested() {
let root = TempDir::new().unwrap();
let config = crate::plugins::discovery::DiscoveryConfig {
let config = crate::discovery::DiscoveryConfig {
workspace: root.path().join("project"),
user_plugins_dir: root.path().join("user"),
workspace_plugins_dir: root.path().join("workspace"),
@@ -643,9 +643,9 @@ mod tests {
"schema_version = 1\n[plugin]\nname = \"computer-use\"\nversion = \"1.0.0\"\nkeywords = [\"accessibility\", \"screenshot\", \"desktop control\"]\n",
)
.unwrap();
let registry = crate::plugins::discovery::discover_with_config(&config);
let registry = crate::discovery::discover_with_config(&config);
let plugin = registry.get("computer-use").expect("builtin discovered");
assert_eq!(plugin.scope, crate::plugins::types::PluginScope::Builtin);
assert_eq!(plugin.scope, crate::types::PluginScope::Builtin);
assert!(!plugin.active(), "fixture must be idle to prove the skip");
let catalog = [marketplace_candidate(
@@ -682,8 +682,8 @@ mod tests {
"Codewhale in your own Chrome",
&["chrome", "browser", "extension", "side-panel", "web"],
);
let registry = crate::plugins::PluginDiscoveryContext::capture_pre_dotenv()
.registry_for_workspace(root.path());
let registry =
crate::PluginDiscoveryContext::capture_pre_dotenv().registry_for_workspace(root.path());
let catalog = [marketplace_candidate(
"official",
"screen-tools",
@@ -716,9 +716,9 @@ mod tests {
let _lock = lock_test_env();
let root = TempDir::new().unwrap();
let _home = EnvVarGuard::set("CODEWHALE_HOME", root.path().join("home"));
let registry = crate::plugins::PluginRegistry::empty(root.path());
let registry = crate::PluginRegistry::empty(root.path());
let mut mac_only = marketplace_candidate("official", "mac-control", &["mac control"]);
mac_only.when = Some(crate::plugins::manifest::PluginWhen {
mac_only.when = Some(crate::manifest::PluginWhen {
os: Some(vec!["macos".to_string()]),
binaries: None,
});
@@ -746,8 +746,8 @@ mod tests {
),
)
.unwrap();
let registry = crate::plugins::PluginDiscoveryContext::capture_pre_dotenv()
.registry_for_workspace(root.path());
let registry =
crate::PluginDiscoveryContext::capture_pre_dotenv().registry_for_workspace(root.path());
let plugin = registry.get("elsewhere").expect("bundle discovered");
assert!(!plugin.applicable);
assert_eq!(
@@ -763,7 +763,7 @@ mod tests {
let _lock = lock_test_env();
let root = TempDir::new().unwrap();
let _home = EnvVarGuard::set("CODEWHALE_HOME", root.path().join("home"));
let registry = crate::plugins::PluginRegistry::empty(root.path());
let registry = crate::PluginRegistry::empty(root.path());
let mut skill = marketplace_candidate("cw2", "test", &["test"]);
skill.kind = MarketplaceEntryKind::Skill;
let slice = std::slice::from_ref(&skill);
@@ -792,7 +792,7 @@ mod tests {
let _lock = lock_test_env();
let root = TempDir::new().unwrap();
let _home = EnvVarGuard::set("CODEWHALE_HOME", root.path().join("home"));
let registry = crate::plugins::PluginRegistry::empty(root.path());
let registry = crate::PluginRegistry::empty(root.path());
let mut candidate = marketplace_candidate("anthropic", "receipts", &["expense"]);
candidate.display_name = None;
candidate.categories = vec!["productivity".to_string(), "development".to_string()];
@@ -829,7 +829,7 @@ mod tests {
let root = TempDir::new().unwrap();
let _home = EnvVarGuard::set("CODEWHALE_HOME", root.path().join("home"));
write_keyword_bundle(root.path(), "supabase", "Hosted Postgres", &["supabase"]);
let mut registry = crate::plugins::PluginDiscoveryContext::capture_pre_dotenv()
let mut registry = crate::PluginDiscoveryContext::capture_pre_dotenv()
.registry_for_workspace(root.path())
.as_ref()
.clone();
@@ -17,7 +17,7 @@ use codewhale_util::path_identity::metadata_is_link_or_reparse;
#[cfg(windows)]
use codewhale_util::path_identity::windows_file_identity;
pub(crate) mod gc;
pub mod gc;
const STATE_SCHEMA_VERSION: u32 = 1;
const MAX_REVIEW_HISTORY: usize = 32;
@@ -76,8 +76,8 @@ pub struct PluginRegistry {
discovery_context: Option<std::sync::Arc<super::context::PluginDiscoveryContext>>,
catalog_stamp: super::discovery::PluginCatalogStamp,
/// Ephemeral caller binding, never persisted in plugin state.
caller_selection: Option<crate::extension_host::composition_scope::SelectionRevision>,
selected_native_entries: Vec<crate::extension_host::composition_scope::NativePresetRef>,
caller_selection: Option<crate::composition_scope::SelectionRevision>,
selected_native_entries: Vec<crate::composition_scope::NativePresetRef>,
/// Catalog-only Native owners with no upstream default. Ephemeral caller
/// selection data; an empty entry list must not broaden these owners.
unselected_native_catalogs: BTreeSet<String>,
@@ -89,28 +89,21 @@ impl PluginRegistry {
Self::default()
}
pub(crate) fn caller_selection(
&self,
) -> Option<crate::extension_host::composition_scope::SelectionRevision> {
pub fn caller_selection(&self) -> Option<crate::composition_scope::SelectionRevision> {
self.caller_selection
}
pub(crate) fn bind_caller(
&self,
revision: crate::extension_host::composition_scope::SelectionRevision,
) -> Self {
pub fn bind_caller(&self, revision: crate::composition_scope::SelectionRevision) -> Self {
let mut view = self.clone();
view.caller_selection = Some(revision);
view
}
pub(crate) fn selected_native_entries(
&self,
) -> &[crate::extension_host::composition_scope::NativePresetRef] {
pub fn selected_native_entries(&self) -> &[crate::composition_scope::NativePresetRef] {
&self.selected_native_entries
}
/// Keep a narrowed selector across rediscovery even when its receipt is now
/// invalid. Desired-owner admission then withdraws it; failure must never
/// widen this caller back to every Native entry.
pub(crate) fn retain_native_selection_from(&self, previous: &Self) -> Self {
pub fn retain_native_selection_from(&self, previous: &Self) -> Self {
let mut view = self.clone();
if !previous.selected_native_entries.is_empty()
|| !previous.unselected_native_catalogs.is_empty()
@@ -121,11 +114,11 @@ impl PluginRegistry {
view
}
pub(crate) fn native_catalog_requires_selection(&self, plugin_id: &str) -> bool {
pub fn native_catalog_requires_selection(&self, plugin_id: &str) -> bool {
self.unselected_native_catalogs.contains(plugin_id)
}
pub(crate) fn native_entry_selected(&self, plugin_id: &str, path: &str, sha256: &str) -> bool {
pub fn native_entry_selected(&self, plugin_id: &str, path: &str, sha256: &str) -> bool {
if self.unselected_native_catalogs.contains(plugin_id) {
return false;
}
@@ -143,9 +136,9 @@ impl PluginRegistry {
}
/// Select one reviewed Native inventory entry. No additional Agent capability.
pub(crate) fn with_native_preset(
pub fn with_native_preset(
&self,
selected: crate::extension_host::composition_scope::NativePresetRef,
selected: crate::composition_scope::NativePresetRef,
) -> Result<Self, String> {
let (sources, _) =
super::runtime::active_component_sources(self, PluginActivationCapability::Native);
@@ -191,7 +184,7 @@ impl PluginRegistry {
}
}
pub(crate) fn from_discovery(
pub fn from_discovery(
plugins: Vec<LoadedPlugin>,
mut diagnostics: Vec<PluginDiagnostic>,
state_path: PathBuf,
@@ -375,8 +368,8 @@ impl PluginRegistry {
.map(|context| context.host_environment())
}
#[cfg(test)]
pub(crate) fn replace_skill_snapshots_for_test(
#[cfg(any(test, feature = "test-support"))]
pub fn replace_skill_snapshots_for_test(
&mut self,
selector: &str,
snapshots: Vec<super::types::PluginSkillSnapshot>,
@@ -666,7 +659,7 @@ impl PluginRegistry {
/// untouched, so a still-running older binary keeps its own authority.
/// Only the built-in scope is ever carried; user and workspace bundles
/// still require review of the exact bytes on disk.
pub(crate) fn carry_forward_builtin_trust(&mut self) {
pub fn carry_forward_builtin_trust(&mut self) {
if self.state_error.is_some() || self.state_path.is_none() {
return;
}
@@ -871,7 +864,7 @@ fn save_state(path: &Path, state: &PluginStateFile) -> Result<(), String> {
/// Publish a hardened JSON document atomically. Generic over the schema so
/// sibling Codewhale-owned stores (e.g. the marketplace catalog store) share
/// the exact durability and no-follow path this registry was audited for.
pub(crate) fn save_state_with_hardener<T: serde::Serialize>(
pub fn save_state_with_hardener<T: serde::Serialize>(
path: &Path,
state: &T,
harden_temporary: impl FnOnce(&Path) -> Result<(), String>,
@@ -1040,7 +1033,7 @@ fn persist_plugin_state(temporary: tempfile::NamedTempFile, path: &Path) -> Resu
.map_err(|error| format!("failed to atomically persist {}: {error}", path.display()))
}
pub(crate) fn state_lock_path(path: &Path) -> PathBuf {
pub fn state_lock_path(path: &Path) -> PathBuf {
let mut name = path
.file_name()
.map(|name| name.to_os_string())
@@ -1050,7 +1043,7 @@ pub(crate) fn state_lock_path(path: &Path) -> PathBuf {
}
#[cfg(not(windows))]
pub(crate) fn open_state_lock(path: &Path, create: bool) -> Result<fs::File, String> {
pub fn open_state_lock(path: &Path, create: bool) -> Result<fs::File, String> {
let mut options = OpenOptions::new();
options
.read(true)
@@ -1078,7 +1071,7 @@ pub(crate) fn open_state_lock(path: &Path, create: bool) -> Result<fs::File, Str
}
#[cfg(windows)]
pub(crate) fn open_state_lock(path: &Path, create: bool) -> Result<fs::File, String> {
pub fn open_state_lock(path: &Path, create: bool) -> Result<fs::File, String> {
use std::os::windows::fs::OpenOptionsExt as _;
const LOCK_ACCESS_WITH_OWNER: u32 = 0x001e_019f;
@@ -1146,7 +1139,7 @@ fn open_windows_state_lock(
.open(path)
}
pub(crate) fn path_entry_exists(path: &Path) -> Result<bool, String> {
pub fn path_entry_exists(path: &Path) -> Result<bool, String> {
match fs::symlink_metadata(path) {
Ok(_) => Ok(true),
Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(false),
@@ -1157,10 +1150,7 @@ pub(crate) fn path_entry_exists(path: &Path) -> Result<bool, String> {
/// Open an existing state file without following its final link/reparse point.
/// `None` is returned only for a genuinely absent entry; an existing unsafe
/// object always fails closed.
pub(crate) fn open_existing_regular_file(
path: &Path,
write: bool,
) -> Result<Option<fs::File>, String> {
pub fn open_existing_regular_file(path: &Path, write: bool) -> Result<Option<fs::File>, String> {
if !path_entry_exists(path)? {
return Ok(None);
}
@@ -1231,7 +1221,7 @@ fn validate_opened_regular_file(path: &Path, file: &fs::File) -> Result<(), Stri
Ok(())
}
pub(crate) fn validate_existing_plugin_state_parent(path: &Path) -> Result<(), String> {
pub fn validate_existing_plugin_state_parent(path: &Path) -> Result<(), String> {
let Some(parent) = path
.parent()
.filter(|parent| !parent.as_os_str().is_empty())
@@ -1294,7 +1284,7 @@ fn validate_plugin_state_directory_for_read(_path: &Path) -> Result<(), String>
}
#[cfg(unix)]
pub(crate) fn ensure_private_plugin_state_directory(path: &Path) -> Result<(), String> {
pub fn ensure_private_plugin_state_directory(path: &Path) -> Result<(), String> {
use std::os::unix::fs::DirBuilderExt as _;
if !path_entry_exists(path)? {
@@ -1308,20 +1298,20 @@ pub(crate) fn ensure_private_plugin_state_directory(path: &Path) -> Result<(), S
}
#[cfg(windows)]
pub(crate) fn ensure_private_plugin_state_directory(path: &Path) -> Result<(), String> {
pub fn ensure_private_plugin_state_directory(path: &Path) -> Result<(), String> {
fs::create_dir_all(path)
.map_err(|error| format!("failed to create plugin state directory: {error}"))?;
set_windows_owner_only_acl(path)
}
#[cfg(all(not(unix), not(windows)))]
pub(crate) fn ensure_private_plugin_state_directory(path: &Path) -> Result<(), String> {
pub fn ensure_private_plugin_state_directory(path: &Path) -> Result<(), String> {
fs::create_dir_all(path)
.map_err(|error| format!("failed to create plugin state directory: {error}"))
}
#[cfg(windows)]
pub(crate) fn harden_plugin_state_file(path: &Path) -> Result<(), String> {
pub fn harden_plugin_state_file(path: &Path) -> Result<(), String> {
set_windows_owner_only_acl(path)
}
@@ -1337,7 +1327,7 @@ fn harden_opened_plugin_state_file(
}
#[cfg(unix)]
pub(crate) fn harden_plugin_state_file(path: &Path) -> Result<(), String> {
pub fn harden_plugin_state_file(path: &Path) -> Result<(), String> {
use std::os::unix::fs::PermissionsExt as _;
fs::set_permissions(path, fs::Permissions::from_mode(0o600)).map_err(|error| {
format!(
@@ -1348,7 +1338,7 @@ pub(crate) fn harden_plugin_state_file(path: &Path) -> Result<(), String> {
}
#[cfg(all(not(unix), not(windows)))]
pub(crate) fn harden_plugin_state_file(_path: &Path) -> Result<(), String> {
pub fn harden_plugin_state_file(_path: &Path) -> Result<(), String> {
Ok(())
}
@@ -47,26 +47,26 @@ use super::{
load_state_unlocked, metadata_is_link_or_reparse, open_state_lock, path_entry_exists,
runtime_stage_key, save_state, save_state_with_hardener, state_lock_path,
};
use crate::plugins::builtin::{
use crate::builtin::{
EmbeddedBundle, STAMP_NAME, embedded_bundles, parse_snapshot_dir_name, snapshots_dir,
};
use crate::plugins::discovery::{plugin_id, plugin_root_hash};
use crate::plugins::types::PluginScope;
use crate::discovery::{plugin_id, plugin_root_hash};
use crate::types::PluginScope;
use codewhale_config::PluginId;
/// How long a snapshot or stage must sit untouched before it may be removed.
/// Every start of a build re-stamps its snapshot, so this is "not started by
/// any binary for a day", which keeps parallel checkouts' builds safe.
pub(crate) const DEFAULT_GRACE: Duration = Duration::from_secs(24 * 60 * 60);
pub const DEFAULT_GRACE: Duration = Duration::from_secs(24 * 60 * 60);
/// How long an inert (disabled, never trusted) record must be idle to be pruned.
pub(crate) const DEFAULT_INERT_STALE: Duration = Duration::from_secs(14 * 24 * 60 * 60);
pub const DEFAULT_INERT_STALE: Duration = Duration::from_secs(14 * 24 * 60 * 60);
const BACKUP_NAME: &str = "state.json.pre-gc";
const LEFTOVER_PREFIXES: [&str; 2] = [".staging-", ".retired-"];
/// Which findings a run may act on.
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
pub(crate) enum GcTier {
pub enum GcTier {
/// Superseded builds, inert records, leftovers: nothing that holds authority.
Safe,
/// Also records that still hold trust or enablement for a vanished bundle.
@@ -74,7 +74,7 @@ pub(crate) enum GcTier {
}
#[derive(Debug, Clone)]
pub(crate) struct GcOptions {
pub struct GcOptions {
pub grace: Duration,
pub inert_stale: Duration,
pub now: SystemTime,
@@ -91,7 +91,7 @@ impl Default for GcOptions {
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
pub(crate) enum GcKind {
pub enum GcKind {
Record,
Snapshot,
RuntimeStage,
@@ -99,7 +99,7 @@ pub(crate) enum GcKind {
}
impl GcKind {
pub(crate) fn label(self) -> &'static str {
pub fn label(self) -> &'static str {
match self {
Self::Record => "record",
Self::Snapshot => "snapshot",
@@ -110,7 +110,7 @@ impl GcKind {
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct GcItem {
pub struct GcItem {
pub kind: GcKind,
/// Record id, or directory path.
pub target: String,
@@ -121,7 +121,7 @@ pub(crate) struct GcItem {
}
#[derive(Debug, Clone, Default)]
pub(crate) struct GcReport {
pub struct GcReport {
pub items: Vec<GcItem>,
/// Things deliberately left alone, with the reason.
pub kept: Vec<String>,
@@ -136,11 +136,11 @@ pub(crate) struct GcReport {
}
impl GcReport {
pub(crate) fn is_clean(&self) -> bool {
pub fn is_clean(&self) -> bool {
self.items.is_empty()
}
pub(crate) fn reclaimable_bytes(&self) -> u64 {
pub fn reclaimable_bytes(&self) -> u64 {
self.items.iter().map(|item| item.bytes).sum()
}
}
@@ -762,7 +762,7 @@ fn report_header(state: &PluginStateFile, obs: &Observed, plan: &Plan) -> GcRepo
/// Read-only: what `apply(.., GcTier::Explicit)` would do, with the startup
/// subset distinguished from the `--fix`-only remainder.
pub(crate) fn dry_run(
pub fn dry_run(
state_path: &Path,
live: &BTreeSet<String>,
opts: &GcOptions,
@@ -808,7 +808,7 @@ fn dry_run_with(
}
/// Apply every finding at or below `tier`.
pub(crate) fn apply(
pub fn apply(
state_path: &Path,
live: &BTreeSet<String>,
opts: &GcOptions,
@@ -943,7 +943,7 @@ impl PluginRegistry {
/// The automatic startup pass: the [`GcTier::Safe`] subset, at most once
/// per process and state file, after built-in trust has been carried to
/// this build. Never fatal: a failure is logged and startup continues.
pub(crate) fn collect_garbage_at_startup(&self) {
pub fn collect_garbage_at_startup(&self) {
static DONE: std::sync::Mutex<BTreeSet<PathBuf>> = std::sync::Mutex::new(BTreeSet::new());
let Some(state_path) = self.state_path() else {
return;
@@ -986,7 +986,7 @@ impl PluginRegistry {
/// report; startup applies [`GcTier::Safe`] and `--fix` applies
/// [`GcTier::Explicit`]. Path identity is resolved inside the walk, off the
/// caller's thread.
pub(crate) fn run(
pub fn run(
state_path: PathBuf,
live: BTreeSet<String>,
opts: GcOptions,
@@ -1112,7 +1112,7 @@ static POOL: std::sync::LazyLock<tokio::runtime::Runtime> = std::sync::LazyLock:
});
/// Human-readable size for reports.
pub(crate) fn format_bytes(bytes: u64) -> String {
pub fn format_bytes(bytes: u64) -> String {
const KIB: f64 = 1024.0;
let value = bytes as f64;
if value >= KIB * KIB {
@@ -10,8 +10,8 @@ use std::time::{Duration, SystemTime};
use tempfile::TempDir;
use super::*;
use crate::plugins::manifest::PluginInventory;
use crate::plugins::registry::TrustReceipt;
use crate::manifest::PluginInventory;
use crate::registry::TrustReceipt;
const DAY: Duration = Duration::from_secs(24 * 60 * 60);
const CURRENT: char = 'c';
@@ -669,9 +669,9 @@ fn a_state_path_outside_the_standard_layout_gets_record_cleanup_only() {
mod through_the_registry {
use super::*;
use crate::plugins::context::{HostEnvironment, PluginDiscoveryContext};
use crate::plugins::discovery::DiscoveryConfig;
use crate::plugins::types::PluginTrustStatus;
use crate::context::{HostEnvironment, PluginDiscoveryContext};
use crate::discovery::DiscoveryConfig;
use crate::types::PluginTrustStatus;
use sha2::{Digest, Sha256};
const MANIFEST: &[u8] = br#"{"$schema":"https://agent-plugins.org/schemas/plugin.json","name":"fixture","version":"1.0.0"}"#;
@@ -687,7 +687,7 @@ mod through_the_registry {
hasher.update((contents.len() as u64).to_le_bytes());
hasher.update(contents);
}
crate::plugins::manifest::hex_digest(hasher.finalize())
crate::manifest::hex_digest(hasher.finalize())
}
fn write_bundle(root: &Path, name: &str, files: &[(&str, &[u8])]) -> std::io::Result<PathBuf> {
@@ -701,7 +701,7 @@ mod through_the_registry {
fs::write(path, contents)?;
}
fs::write(stage.path().join(STAMP_NAME), hash)?;
crate::plugins::builtin::publish_snapshot(stage.path(), &destination)?;
crate::builtin::publish_snapshot(stage.path(), &destination)?;
}
Ok(destination)
}
@@ -859,7 +859,7 @@ mod through_the_registry {
// Discover v2 but do not carry (GC raced ahead of the registry).
let root = write_bundle(&f.h.snapshots(), "fixture", &same_caps(b"v2")).unwrap();
let uncarried = crate::plugins::discovery::discover_with_config(&f.config(root));
let uncarried = crate::discovery::discover_with_config(&f.config(root));
let bundle = EmbeddedBundle {
name: "fixture",
digest: digest(&same_caps(b"v2")),
@@ -886,7 +886,7 @@ mod through_the_registry {
#[test]
#[ignore = "set CW_GC_REHEARSAL_HOME to a copy of a real home"]
fn rehearsal_against_a_copy_of_a_real_home() {
use crate::plugins::context::PluginDiscoveryContext;
use crate::context::PluginDiscoveryContext;
let Some(home) = std::env::var_os("CW_GC_REHEARSAL_HOME").map(PathBuf::from) else {
return;
@@ -894,8 +894,8 @@ fn rehearsal_against_a_copy_of_a_real_home() {
if let Some(origin) = std::env::var_os("CW_GC_REHEARSAL_ORIGIN") {
remap_copied_ids(Path::new(&origin), &home);
}
let _lock = crate::test_support::lock_test_env();
let _home = crate::test_support::EnvVarGuard::set("CODEWHALE_HOME", &home);
let _lock = codewhale_runtime::test_support::lock_test_env();
let _home = codewhale_runtime::test_support::EnvVarGuard::set("CODEWHALE_HOME", &home);
let workspace = tempfile::tempdir().unwrap();
let registry =
PluginDiscoveryContext::capture_pre_dotenv().registry_for_workspace(workspace.path());
File renamed without changes.
@@ -6,7 +6,7 @@ use tempfile::TempDir;
use super::PluginRegistry;
use super::discovery::{DiscoveryConfig, discover_with_config};
pub(crate) struct DeclarativePluginFixture {
pub struct DeclarativePluginFixture {
_temp: TempDir,
pub workspace: PathBuf,
pub marker: PathBuf,
@@ -14,6 +14,12 @@ pub(crate) struct DeclarativePluginFixture {
pub registry: PluginRegistry,
}
impl Default for DeclarativePluginFixture {
fn default() -> Self {
Self::new()
}
}
impl DeclarativePluginFixture {
pub fn new() -> Self {
let temp = TempDir::new().expect("plugin fixture tempdir");
@@ -7,7 +7,7 @@ use super::managed_policy::{
MANAGED_POLICY_FILE_NAME, MANAGED_POLICY_PATH_ENV, MANAGED_POLICY_SCHEMA_VERSION,
ManagedPluginPolicy,
};
use super::manifest::{PluginCompatibility, capability_hash_v1, capability_hash_v2};
use super::manifest::PluginCompatibility;
use super::types::{PluginDiagnosticLevel, PluginTrustStatus};
fn config(root: &Path) -> DiscoveryConfig {
@@ -49,7 +49,7 @@ fn on_disk_catalog_change_nudges_reload_once_until_rediscover() {
let mut last_nudged = None;
assert!(
crate::plugins::plugin_reload_nudge(&registry, &mut last_nudged).is_none(),
crate::plugin_reload_nudge(&registry, &mut last_nudged).is_none(),
"unchanged catalog must not nudge"
);
@@ -59,17 +59,17 @@ fn on_disk_catalog_change_nudges_reload_once_until_rediscover() {
"a new bundle directory is a catalog change"
);
assert_eq!(
crate::plugins::plugin_reload_nudge(&registry, &mut last_nudged),
Some(crate::plugins::PLUGIN_RELOAD_NUDGE)
crate::plugin_reload_nudge(&registry, &mut last_nudged),
Some(crate::PLUGIN_RELOAD_NUDGE)
);
assert!(
crate::plugins::plugin_reload_nudge(&registry, &mut last_nudged).is_none(),
crate::plugin_reload_nudge(&registry, &mut last_nudged).is_none(),
"the same unseen catalog must nudge only once"
);
let reloaded = registry.rediscover_for_workspace(&config.workspace);
assert!(!reloaded.on_disk_catalog_changed());
assert!(crate::plugins::plugin_reload_nudge(&reloaded, &mut last_nudged).is_none());
assert!(crate::plugin_reload_nudge(&reloaded, &mut last_nudged).is_none());
}
#[test]
@@ -387,69 +387,6 @@ fn revoking_trust_does_not_rewrite_enablement() {
assert!(!plugin.active());
}
fn write_mixed_bundle(config: &DiscoveryConfig) -> PathBuf {
let plugin = write_plugin(
config,
"\n[skills]\npath = \"skills\"\n[commands]\npath = \"commands\"\n[hooks]\npath = \"hooks\"\n[lsp]\npath = \"lsp\"\n",
);
fs::create_dir_all(plugin.join("skills/demo")).unwrap();
fs::write(
plugin.join("skills/demo/SKILL.md"),
"---\nname: demo\ndescription: first\n---\nbody\n",
)
.unwrap();
fs::create_dir_all(plugin.join("commands")).unwrap();
fs::create_dir_all(plugin.join("hooks")).unwrap();
fs::create_dir_all(plugin.join("lsp")).unwrap();
plugin
}
#[test]
fn mixed_supported_and_unsupported_components_activate_only_supported_surfaces() {
let tmp = tempfile::tempdir().unwrap();
let config = config(tmp.path());
write_mixed_bundle(&config);
let mut registry = discover_with_config(&config);
let plugin = registry.get("demo").unwrap();
assert_eq!(plugin.compatibility(), PluginCompatibility::Partial);
assert_eq!(
plugin.inventory.supported_labels(),
vec!["skills", "commands", "hooks"]
);
assert_eq!(plugin.inventory.unsupported_labels(), vec!["lsp"]);
assert!(
plugin.diagnostics.iter().any(|diagnostic| {
diagnostic.level == PluginDiagnosticLevel::Warning
&& diagnostic.code == "component-inactive"
&& diagnostic.message.contains("lsp")
}),
"inactive components must stay visible in diagnostics: {:?}",
plugin.diagnostics
);
registry.trust("demo").unwrap();
registry.enable("demo").unwrap();
let plugin = registry.get("demo").unwrap();
assert!(plugin.active());
assert_eq!(plugin.state_label(), "active");
assert_eq!(plugin.compatibility(), PluginCompatibility::Partial);
assert_eq!(plugin.inventory.commands, 1);
assert_eq!(plugin.inventory.hooks, 1);
assert_eq!(plugin.skill_snapshots.len(), 1);
assert_eq!(plugin.skill_snapshots[0].name, "demo");
assert!(plugin.component_active(PluginActivationCapability::Skills));
assert!(plugin.component_active(PluginActivationCapability::Commands));
assert!(plugin.component_active(PluginActivationCapability::Hooks));
assert!(!plugin.component_active(PluginActivationCapability::Lsp));
let skills = crate::skills::discover_from_directories_with_plugins(
Vec::<PathBuf>::new(),
Some(&registry),
);
assert_eq!(skills.get("demo:demo").unwrap().body.trim(), "body");
}
#[test]
fn all_unsupported_bundles_can_be_reviewed_but_not_enabled() {
let tmp = tempfile::tempdir().unwrap();
@@ -507,135 +444,6 @@ fn fatal_manifest_errors_fail_closed_and_do_not_activate_mixed_looking_bundles()
assert!(!registry.is_active("demo"));
}
#[test]
fn mixed_bundle_revocation_and_trust_changes_deactivate_supported_surfaces() {
let tmp = tempfile::tempdir().unwrap();
let config = config(tmp.path());
let plugin = write_mixed_bundle(&config);
let mut registry = discover_with_config(&config);
registry.trust("demo").unwrap();
registry.enable("demo").unwrap();
assert!(registry.is_active("demo"));
registry.revoke_trust("demo").unwrap();
let revoked = registry.get("demo").unwrap();
assert!(revoked.enabled);
assert!(!revoked.trusted());
assert!(!revoked.active());
assert_eq!(revoked.compatibility(), PluginCompatibility::Partial);
let skills = crate::skills::discover_from_directories_with_plugins(
Vec::<PathBuf>::new(),
Some(&registry),
);
assert!(
skills.get("demo:demo").is_none(),
"revoking trust must drop the supported Skill adapter"
);
registry.trust("demo").unwrap();
assert!(
!registry.is_active("demo"),
"re-trust must not reuse the previous enablement bit"
);
registry.enable("demo").unwrap();
assert!(registry.is_active("demo"));
fs::write(
plugin.join("skills/demo/SKILL.md"),
"---\nname: demo\ndescription: changed\n---\nbody two\n",
)
.unwrap();
let changed = discover_with_config(&config);
let plugin = changed.get("demo").unwrap();
assert_eq!(plugin.trust_status, PluginTrustStatus::ContentChanged);
assert!(plugin.enabled);
assert!(!plugin.active());
let skills = crate::skills::discover_from_directories_with_plugins(
Vec::<PathBuf>::new(),
Some(&changed),
);
assert!(skills.get("demo:demo").is_none());
}
#[test]
fn legacy_trust_receipts_fail_closed_as_needs_review_under_v3() {
let tmp = tempfile::tempdir().unwrap();
let config = config(tmp.path());
let plugin = write_plugin(&config, "\n[skills]\npath = \"skills\"\n");
fs::create_dir_all(plugin.join("skills/demo")).unwrap();
fs::write(
plugin.join("skills/demo/SKILL.md"),
"---\nname: demo\ndescription: first\n---\nbody\n",
)
.unwrap();
let mut first = discover_with_config(&config);
first.trust("demo").unwrap();
first.enable("demo").unwrap();
assert!(first.is_active("demo"));
let plugin = first.get("demo").unwrap();
let legacy_hashes = [
("v1", capability_hash_v1(&plugin.inventory)),
("v2", capability_hash_v2(&plugin.inventory)),
];
let v3_hash = plugin.capability_hash.clone();
assert!(
legacy_hashes.iter().all(|(_, hash)| hash != &v3_hash),
"v3 receipts must not collide with either historical domain"
);
let content_hash = plugin.content_hash.clone();
let raw = fs::read_to_string(&config.state_path).unwrap();
for (version, legacy_hash) in legacy_hashes {
let mut parsed: serde_json::Value = serde_json::from_str(&raw).unwrap();
replace_capability_hashes(&mut parsed, &v3_hash, &legacy_hash);
fs::write(
&config.state_path,
serde_json::to_string_pretty(&parsed).unwrap(),
)
.unwrap();
let restarted = discover_with_config(&config);
let plugin = restarted.get("demo").unwrap();
assert_eq!(plugin.content_hash, content_hash);
assert_eq!(plugin.capability_hash, v3_hash);
assert_eq!(plugin.trust_status, PluginTrustStatus::CapabilitiesChanged);
assert!(plugin.enabled);
assert!(!plugin.trusted());
assert!(!plugin.active());
assert!(restarted.authority_for("demo").is_some());
let skills = crate::skills::discover_from_directories_with_plugins(
Vec::<PathBuf>::new(),
Some(&restarted),
);
assert!(
skills.get("demo:demo").is_none(),
"a {version} receipt must not activate Skills after the v3 policy binding"
);
}
}
fn replace_capability_hashes(value: &mut serde_json::Value, from: &str, to: &str) {
match value {
serde_json::Value::Object(map) => {
for (key, child) in map.iter_mut() {
if key == "capability_hash" && child.as_str() == Some(from) {
*child = serde_json::Value::String(to.to_string());
} else {
replace_capability_hashes(child, from, to);
}
}
}
serde_json::Value::Array(items) => {
for item in items {
replace_capability_hashes(item, from, to);
}
}
_ => {}
}
}
#[test]
fn stale_concurrent_registries_do_not_lose_updates() {
let tmp = tempfile::tempdir().unwrap();
@@ -957,36 +765,6 @@ fn staging_rejects_root_swaps_symlinked_runtime_parents_and_hardlinks() {
assert!(hardlinked.trust("demo").is_err());
}
#[test]
fn workspace_scoped_registries_do_not_cross_load_skills() {
let tmp = tempfile::tempdir().unwrap();
let left_config = config(&tmp.path().join("left"));
let right_config = config(&tmp.path().join("right"));
for (config, body) in [(&left_config, "left body"), (&right_config, "right body")] {
let plugin = write_plugin(config, "\n[skills]\npath = \"skills\"\n");
fs::create_dir_all(plugin.join("skills/only")).unwrap();
fs::write(
plugin.join("skills/only/SKILL.md"),
format!("---\nname: only\ndescription: scoped\n---\n{body}\n"),
)
.unwrap();
}
let mut left = discover_with_config(&left_config);
left.trust("demo").unwrap();
left.enable("demo").unwrap();
let mut right = discover_with_config(&right_config);
right.trust("demo").unwrap();
right.enable("demo").unwrap();
let left_skills =
crate::skills::discover_from_directories_with_plugins(Vec::<PathBuf>::new(), Some(&left));
let right_skills =
crate::skills::discover_from_directories_with_plugins(Vec::<PathBuf>::new(), Some(&right));
assert_eq!(left_skills.get("demo:only").unwrap().body, "left body");
assert_eq!(right_skills.get("demo:only").unwrap().body, "right body");
assert_ne!(left.workspace(), right.workspace());
}
// ─────────────────────────────────────────────────────────────────────────────
// Install on-ramp integration (#5182)
// ─────────────────────────────────────────────────────────────────────────────
@@ -1025,134 +803,6 @@ fn write_install_source(root: &Path, name: &str) -> PathBuf {
source
}
/// A DSH bundle package: one remote MCP row and one skill directory.
fn write_dsh_package(root: &Path, url: &str) -> PathBuf {
let package = root.join("dsh-source");
fs::create_dir_all(package.join("pack-skills/guide")).unwrap();
fs::write(
package.join("package.json"),
serde_json::json!({"name": "@demo/docs-dsh", "version": "1.0.0",
"dsh": {"bundle": {"patch": "./cordis.patch.yml"}}})
.to_string(),
)
.unwrap();
fs::write(
package.join("cordis.patch.yml"),
format!(
"- insert:\n - id: docs\n name: '@deepseek-ai/dsh-mcp-client'\n config: {{serverName: docs, transport: streamable-http, url: '{url}'}}\n - id: skills\n name: '@deepseek-ai/dsh-skill-filesystem'\n config: {{customSkillDirs: [pack-skills]}}\n - id: theme\n name: '@deepseek-ai/dsh-client-ui-theme'\n"
),
)
.unwrap();
fs::write(
package.join("pack-skills/guide/SKILL.md"),
"---\nname: guide\ndescription: Bundled guide\n---\nBody.\n",
)
.unwrap();
package
}
/// DSH import is the ordinary reviewed install: the preview hash is what an
/// exact install stages, the bundle lands disabled and untrusted, update
/// re-converts the recorded package, and a changed package cannot be
/// installed against a stale review or keep its trust.
#[test]
fn dsh_packages_import_through_the_reviewed_install_and_update_flow() {
let tmp = tempfile::tempdir().unwrap();
let _home = crate::test_support::SealedHome::at(tmp.path());
let config = config(tmp.path());
let network = allow_all_network();
let package = write_dsh_package(tmp.path(), "https://docs.example.invalid/mcp");
let (conversion, reviewed) = super::install::preview_dsh(&package).unwrap();
assert_eq!(conversion.plugin_name, "docs-dsh");
assert_eq!(conversion.remote_servers, ["docs"]);
assert_eq!(conversion.skills, ["guide"]);
assert!(
conversion
.outcomes
.iter()
.any(|o| o.needs_manual_port() && o.row.as_deref() == Some("theme"))
);
let source = super::install::PluginInstallSource::parse(package.to_str().unwrap()).unwrap();
assert!(
matches!(source, super::install::PluginInstallSource::Dsh(_)),
"{source:?}"
);
let outcome = block_on(super::install::install_with_expected_content_hash(
source,
&config.user_plugins_dir,
super::install::DEFAULT_MAX_SIZE_BYTES,
&network,
&|_| None,
&reviewed,
))
.unwrap();
let super::install::PluginInstallOutcome::Installed(installed) = outcome else {
panic!("DSH import must install");
};
assert_eq!(installed.content_hash, reviewed);
let marker = fs::read_to_string(
config
.user_plugins_dir
.join("docs-dsh")
.join(codewhale_skill_install::install::INSTALLED_FROM_MARKER),
)
.unwrap();
assert!(marker.contains("dsh:"), "{marker}");
let mut registry = discover_with_config(&config);
let plugin = registry.get("docs-dsh").unwrap();
assert!(!plugin.enabled && !plugin.trusted());
registry.trust("docs-dsh").unwrap();
registry.enable("docs-dsh").unwrap();
assert!(registry.is_active("docs-dsh"));
let unchanged = block_on(super::install::update(
"docs-dsh",
&config.user_plugins_dir,
super::install::DEFAULT_MAX_SIZE_BYTES,
&network,
))
.unwrap();
assert!(matches!(
unchanged,
super::install::PluginUpdateResult::NoChange
));
fs::remove_dir_all(&package).unwrap();
write_dsh_package(tmp.path(), "https://docs-v2.example.invalid/mcp");
let stale = block_on(super::install::install_with_expected_content_hash(
super::install::PluginInstallSource::Dsh(package.clone()),
&tmp.path().join("other-plugins"),
super::install::DEFAULT_MAX_SIZE_BYTES,
&network,
&|_| None,
&reviewed,
))
.unwrap_err();
assert!(
stale.to_string().contains("changed after review"),
"{stale:#}"
);
let updated = block_on(super::install::update(
"docs-dsh",
&config.user_plugins_dir,
super::install::DEFAULT_MAX_SIZE_BYTES,
&network,
))
.unwrap();
assert!(matches!(
updated,
super::install::PluginUpdateResult::Updated(_)
));
let registry = discover_with_config(&config);
assert!(
!registry.get("docs-dsh").unwrap().trusted(),
"changed converted bytes invalidate the trust receipt"
);
}
#[test]
fn installed_bundles_land_disabled_and_untrusted_then_follow_the_trust_flow() {
let tmp = tempfile::tempdir().unwrap();
@@ -124,7 +124,7 @@ pub struct PluginSkillSnapshot {
pub legacy_activation_name: Option<String>,
pub description: String,
pub localized_descriptions: HashMap<String, String>,
pub invocation: crate::skills::SkillInvocation,
pub invocation: codewhale_skill_install::skill_parse::SkillInvocation,
pub aliases: Vec<String>,
pub argument_hint: Option<String>,
pub body: String,
+1
View File
@@ -22,6 +22,7 @@ flate2 = "1.1"
futures-util = "0.3.31"
reqwest = { workspace = true, features = ["stream"] }
serde.workspace = true
regex = "1.11"
serde_json.workspace = true
sha2.workspace = true
tar = "0.4"
+2
View File
@@ -1,3 +1,5 @@
pub mod frontmatter;
pub mod install;
pub mod package_digest;
pub mod recommend;
pub mod skill_parse;
@@ -5,7 +5,7 @@
//! then this matcher ranks its metadata. A suggestion is never an install,
//! trust decision, or activation.
use codewhale_skill_install::install::{RegistryDocument, RegistryEntry};
use crate::install::{RegistryDocument, RegistryEntry};
const MIN_QUERY_CHARS: usize = 3;
const MAX_EXPLANATIONS: usize = 3;
+295
View File
@@ -0,0 +1,295 @@
use std::collections::HashMap;
use std::path::{Path, PathBuf};
use crate::frontmatter::{
SkillValidationMode, parse_frontmatter, parse_frontmatter_bool, validate_skill_frontmatter,
};
pub const MAX_SKILL_NAME_CHARS: usize = 64;
/// Maximum directory-traversal depth when discovering skills.
///
/// Defends against pathological configurations (e.g. a user pointing
/// `skills_dir` at `~`) without artificially limiting realistic
/// vendored layouts like `<root>/<org>/<repo>/<skill>/SKILL.md`.
pub const MAX_DISCOVERY_DEPTH: usize = 8;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SkillInvocation {
ModelAndUser,
ExplicitOnly,
ModelOnly,
Disabled,
}
impl SkillInvocation {
pub fn model_invocable(self) -> bool {
matches!(self, Self::ModelAndUser | Self::ModelOnly)
}
pub fn user_invocable(self) -> bool {
matches!(self, Self::ModelAndUser | Self::ExplicitOnly)
}
pub fn from_frontmatter(value: Option<&str>) -> Self {
match value.map(str::trim).map(|value| value.to_ascii_lowercase()) {
Some(value) if value == "explicit-only" || value == "explicit_only" => {
Self::ExplicitOnly
}
_ => Self::ModelAndUser,
}
}
}
#[derive(Debug, Clone)]
pub struct ParsedSkill {
pub name: String,
pub legacy_activation_name: Option<String>,
pub description: String,
pub localized_descriptions: HashMap<String, String>,
pub invocation: SkillInvocation,
pub aliases: Vec<String>,
pub argument_hint: Option<String>,
pub body: String,
pub path: PathBuf,
}
#[derive(Debug, Clone)]
pub struct NormalizedSkillName {
pub name: String,
pub legacy_activation_name: Option<String>,
pub warning: Option<String>,
}
pub fn normalize_skill_name(name: &str, skill_path: &Path) -> NormalizedSkillName {
let legacy = legacy_skill_name_for_lookup(name);
let normalized = normalize_skill_name_for_lookup(name);
let supported_unicode_name =
!name.is_ascii() && !name.chars().any(char::is_control) && is_valid_skill_name(&normalized);
let legacy_activation_name = (legacy != normalized).then_some(legacy);
let mut resolved = name.to_string();
let mut warning = None;
if normalized != name || !is_valid_skill_name(name) {
let original = std::mem::replace(&mut resolved, normalized);
if !supported_unicode_name {
warning = Some(format!(
"Skill name `{original}` in {} is not a safe command name; using `{resolved}` instead.",
skill_path.display(),
));
}
}
NormalizedSkillName {
name: resolved,
legacy_activation_name,
warning,
}
}
pub fn parse_verified_content(
path: &Path,
content: &str,
) -> std::result::Result<(ParsedSkill, Vec<String>), String> {
let (mut skill, mut warnings) = parse_skill_and_warnings(path, content)?;
skill.path = path.to_path_buf();
let normalized = normalize_skill_name(&skill.name, path);
skill.legacy_activation_name = normalized.legacy_activation_name;
skill.name = normalized.name;
warnings.extend(normalized.warning);
Ok((skill, warnings))
}
pub fn parse_skill_and_warnings(
path: &Path,
content: &str,
) -> std::result::Result<(ParsedSkill, Vec<String>), String> {
// Try to parse frontmatter block first. If absent, fall back to
// extracting the first `# Heading` as the skill name so that plain
// Markdown files (no `---` fence) are accepted instead of rejected.
let parsed = parse_frontmatter(content)?;
let warnings = validate_skill_frontmatter(
parsed.as_ref().map(|(metadata, _)| metadata),
Some(path),
SkillValidationMode::Lenient,
)?;
if let Some((metadata, body)) = parsed {
let name = metadata
.get("name")
.filter(|name| !name.is_empty())
.cloned()
.ok_or_else(|| "missing required frontmatter field: name".to_string())?;
let mut description = metadata.get("description").cloned().unwrap_or_default();
if let Some(trigger) = metadata
.get("when_to_use")
.filter(|value| !value.trim().is_empty())
{
if !description.is_empty() {
description.push(' ');
}
description.push_str("Use when: ");
description.push_str(trigger.trim());
}
let explicit =
SkillInvocation::from_frontmatter(metadata.get("invocation").map(String::as_str))
== SkillInvocation::ExplicitOnly;
let model_allowed = !explicit
&& metadata
.get("disable-model-invocation")
.is_none_or(|value| parse_frontmatter_bool(value) == Some(false));
let user_allowed = metadata
.get("user-invocable")
.is_none_or(|value| parse_frontmatter_bool(value) == Some(true));
let invocation = match (model_allowed, user_allowed) {
(true, true) => SkillInvocation::ModelAndUser,
(false, true) => SkillInvocation::ExplicitOnly,
(true, false) => SkillInvocation::ModelOnly,
(false, false) => SkillInvocation::Disabled,
};
let aliases = metadata
.get("aliases-for")
.into_iter()
.flat_map(|value| value.split([',', ' ', '\t']))
.map(str::trim)
.filter(|alias| !alias.is_empty())
.map(normalize_skill_name_for_lookup)
.filter(|alias| is_valid_skill_name(alias))
.collect();
// Collect `description_<tag>:` frontmatter keys (already lowercased
// above) into locale-specific descriptions, e.g. `description_zh`.
let localized_descriptions = metadata
.iter()
.filter_map(|(key, value)| {
key.strip_prefix("description_")
.filter(|tag| !tag.is_empty())
.map(|tag| (tag.to_string(), value.clone()))
})
.collect();
return Ok((
ParsedSkill {
name,
legacy_activation_name: None,
description,
localized_descriptions,
invocation,
aliases,
argument_hint: metadata
.get("argument-hint")
.filter(|value| !value.trim().is_empty())
.cloned(),
body: body.trim().to_string(),
// Filled in by `discover` after parse succeeds; default to an
// empty path so direct constructors (e.g. tests) compile.
path: PathBuf::new(),
},
warnings,
));
}
// Graceful degradation: no frontmatter fence found.
// Extract the first `# Heading` as the skill name.
let heading_re = regex::Regex::new(r"(?m)^#\s+(.+)$").expect("static regex is valid");
let name = heading_re
.captures(content)
.and_then(|c| c.get(1))
.map(|m| m.as_str().trim().to_string())
.filter(|s| !s.is_empty())
.ok_or_else(|| {
"no frontmatter and no `# Heading` found to use as skill name".to_string()
})?;
Ok((
ParsedSkill {
name,
legacy_activation_name: None,
description: String::new(),
localized_descriptions: HashMap::new(),
invocation: SkillInvocation::ModelAndUser,
aliases: Vec::new(),
argument_hint: None,
body: content.trim().to_string(),
path: PathBuf::new(),
},
warnings,
))
}
pub fn is_valid_skill_name(name: &str) -> bool {
let char_count = name.chars().count();
char_count > 0
&& char_count <= MAX_SKILL_NAME_CHARS
&& name
.chars()
.next()
.is_some_and(|ch| ch.is_ascii_lowercase() || ch.is_ascii_digit())
&& name
.chars()
.all(|ch| ch.is_ascii_lowercase() || ch.is_ascii_digit() || ch == '-')
}
pub fn normalize_skill_name_for_lookup(name: &str) -> String {
normalize_qualified_skill_name(name, normalize_skill_name_segment)
}
fn legacy_skill_name_for_lookup(name: &str) -> String {
normalize_qualified_skill_name(name, legacy_skill_name_segment)
}
fn normalize_qualified_skill_name(name: &str, segment: fn(&str) -> String) -> String {
if let Some((plugin, skill)) = name.trim().split_once(':')
&& !plugin.is_empty()
&& !skill.is_empty()
&& !skill.contains(':')
{
return format!("{}:{}", segment(plugin), segment(skill));
}
segment(name)
}
pub fn normalize_skill_name_segment(name: &str) -> String {
let name = name.trim();
let legacy = legacy_skill_name_segment(name);
if name.is_ascii() {
return legacy;
}
// Preserve ASCII identities, but distinguish UTF-8 source names the old
// slug folded together. No transliteration or Unicode normalization.
let digest = crate::install::sha256_hex(name.to_ascii_lowercase().as_bytes());
let prefix = legacy[..legacy.len().min(31)].trim_end_matches('-');
format!("{prefix}-{}", &digest[..32])
}
fn legacy_skill_name_segment(name: &str) -> String {
let mut out = String::new();
let mut pending_dash = false;
for ch in name.trim().chars() {
if ch.is_ascii_alphanumeric() {
if pending_dash && !out.is_empty() && out.len() < MAX_SKILL_NAME_CHARS {
out.push('-');
}
pending_dash = false;
if out.len() < MAX_SKILL_NAME_CHARS {
out.push(ch.to_ascii_lowercase());
}
} else {
pending_dash = true;
}
if out.len() >= MAX_SKILL_NAME_CHARS {
break;
}
}
while out.ends_with('-') {
out.pop();
}
if out.is_empty() {
"skill".to_string()
} else {
out
}
}
+1
View File
@@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- **Sandboxed commands cannot plant git hooks or edit git config:** in workspace-write mode, `.git/hooks` and `.git/config` (and a linked worktree's shared `hooks` and `config`) are read-only to sandboxed commands, so a command cannot leave a hook or `core.hooksPath` that later runs outside the sandbox. The workspace's `.codewhale/` is protected even before it exists (on Linux it is created first, because bubblewrap can only protect existing paths). `git commit` and ordinary file writes are unaffected; `git config` writes and hook edits need an approved unsandboxed command.
- **Interrupted session hint stays visible (#7095):** when a plain `codewhale` launch finds an in-flight session snapshot, the "Run `codewhale --continue` to resume it" hint now appears in the TUI's status line instead of being printed to the terminal just before the TUI covered it.
- **`codewhale-plugins` crate:** plugin discovery, review, install and marketplace parsing move out of `codewhale-tui` into a new crate; MCP server config types move to `codewhale-mcp`, and the skill parse core and recommendations to `codewhale-skill-install`. Review and trust behavior is unchanged. (S19, CW-6129)
- **Crate split S24a (internal):** the workshop byte-budget slot moves to `codewhale-runtime`, `AutoRouteDecisionEvidence` to `codewhale-models`, and cost-status session persistence to a tui `usage_persistence` module, so billing no longer reaches the session manager or model routing. No behavior change. (CW-6129)
- **Nested and deletable threads (Runtime API):** a fork (including a backtrack fork) records `parent_thread_id`, `POST /v1/threads` accepts one naming an existing thread, and `GET /v1/threads/summary` returns it so clients can nest child conversations. `DELETE /v1/threads/{id}` removes an idle thread with its turns, items, events and goal, and its saved session unless another thread shares it or a terminal holds it open; a running thread answers 409.
- **Hunk actions in Review (Runtime API):** `POST /v1/git/stage`, `/v1/git/unstage` and `/v1/git/discard` take a `patch` holding hunks of the one path in `paths` and apply only those hunks through `git apply`, under the same `expect` preconditions as whole-path writes. A patch that no longer applies fails with git's message and writes nothing; renames, mode changes and binary patches are refused. The tasks tool can also `suggest` a task, shown as a Start/Hide card, without starting anything.
+2 -1
View File
@@ -26,7 +26,6 @@ exclude = [
"src/conformance/**",
"src/lib/terminal_mode_test_cases_*.rs",
"src/llm_client/mock.rs",
"src/plugins/test_fixture.rs",
"src/skills/catalog_matrix.rs",
"src/compaction/survival_contract.rs",
"src/compaction/SURVIVAL_CONTRACT.md",
@@ -115,6 +114,7 @@ codewhale-dependencies = { path = "../dependencies", version = "0.10.2" }
codewhale-lsp = { path = "../lsp", version = "0.10.2" }
codewhale-repl = { path = "../repl", version = "0.10.2" }
codewhale-snapshot = { path = "../snapshot", version = "0.10.2" }
codewhale-plugins = { path = "../plugins", version = "0.10.2" }
codewhale-skill-install = { path = "../skill-install", version = "0.10.2" }
codewhale-util = { path = "../util", version = "0.10.2" }
codewhale-tools = { path = "../tools", version = "0.10.2" }
@@ -224,6 +224,7 @@ codewhale-runtime = { path = "../runtime", version = "0.10.2", features = [
"test-thread-scoped-state",
] }
codewhale-sandbox = { path = "../sandbox", version = "0.10.2", features = ["test-support"] }
codewhale-plugins = { path = "../plugins", version = "0.10.2", features = ["test-support"] }
codewhale-skill-install = { path = "../skill-install", version = "0.10.2", features = ["test-support"] }
codewhale-util = { path = "../util", version = "0.10.2", features = ["test-support"] }
codewhale-models = { path = "../models", version = "0.10.2", features = [
+1 -1
View File
@@ -14,7 +14,7 @@ const outdir = join(here, 'dist')
mkdirSync(outdir, { recursive: true })
const compatibilityBytes = readFileSync(join(here, 'src/dsh/compatibility.json'))
const compatibility = JSON.parse(compatibilityBytes)
writeFileSync(join(outdir, 'dsh-compatibility.json'), compatibilityBytes)
writeFileSync(join(here, '../../plugins/assets/dsh-compatibility.json'), compatibilityBytes)
const reusedPure = compatibility.pure_api.reuse
const reusedRoot = join(here, 'node_modules', reusedPure.package)
const reusedPackage = JSON.parse(readFileSync(join(reusedRoot, 'package.json'), 'utf8'))
+2 -2
View File
@@ -29,7 +29,7 @@ pub async fn run_acp_server(
config: Config,
model: String,
default_cwd: PathBuf,
plugins: Arc<crate::plugins::PluginDiscoveryContext>,
plugins: Arc<codewhale_plugins::PluginDiscoveryContext>,
config_path: Option<PathBuf>,
config_profile: Option<String>,
) -> Result<()> {
@@ -1351,7 +1351,7 @@ mod tests {
sessions_dir: None,
max_active_threads: 4,
};
let plugins = Arc::new(crate::plugins::PluginRegistry::empty(&workspace));
let plugins = Arc::new(codewhale_plugins::PluginRegistry::empty(&workspace));
let manager = Arc::new(if acp {
RuntimeThreadManager::open_host(
config.clone(),
+5 -5
View File
@@ -1907,16 +1907,16 @@ impl CodewhaleClient {
destination.url().origin() == base.origin(),
"plugin request escaped its reviewed provider origin"
);
let policy = crate::plugins::activation::extension_host_policy_enabled();
let policy = codewhale_plugins::activation::extension_host_policy_enabled();
let read_only = self.plugin_oauth_read_only;
let token = tokio::task::spawn_blocking(move || -> Result<String> {
let _scope = crate::plugins::activation::PolicyScope::propagate(policy);
let _scope = codewhale_plugins::activation::PolicyScope::propagate(policy);
let descriptor = provider
.oauth
.as_ref()
.context("plugin provider requires host-managed OAuth")?;
let empty_headers = std::collections::HashMap::new();
crate::plugins::providers::verify_provider_binding(
codewhale_plugins::providers::verify_provider_binding(
&authority,
&name,
&base_url,
@@ -1929,9 +1929,9 @@ impl CodewhaleClient {
)?;
// Refresh may wait on the issuer. Do not send a model request if
// the review was revoked while that HTTP request was in flight.
crate::plugins::registry::verify_plugin_component_authority(
codewhale_plugins::registry::verify_plugin_component_authority(
&authority,
crate::plugins::activation::PluginActivationCapability::Providers,
codewhale_plugins::activation::PluginActivationCapability::Providers,
)
.map_err(anyhow::Error::msg)?;
Ok(token)
File diff suppressed because it is too large. Load diff
+23 -19
View File
@@ -160,7 +160,7 @@ pub(crate) fn apply_preset(app: &mut App, name: &str, persist: bool) -> ConfigRe
/// Show the current value of a single setting.
fn config_context_window_override(app: &App) -> Option<u32> {
let mut config = crate::plugins::providers::load_config(
let mut config = codewhale_plugins::providers::load_config(
app.config_path.clone(),
app.config_profile.as_deref(),
)
@@ -228,7 +228,7 @@ pub(crate) fn show_single_setting(app: &App, key: &str) -> ConfigReply {
"approval_mode" | "approval" => Some(app.approval_mode.permission_chip_label().to_string()),
"allow_shell" | "shell" | "exec_shell" => Some(app.allow_shell.to_string()),
"base_url" => {
let config = match crate::plugins::providers::load_config(
let config = match codewhale_plugins::providers::load_config(
app.config_path.clone(),
app.config_profile.as_deref(),
) {
@@ -248,7 +248,7 @@ pub(crate) fn show_single_setting(app: &App, key: &str) -> ConfigReply {
.unwrap_or_else(|| "(unset)".to_string()),
),
"provider_url" | "provider_base_url" | "endpoint" => {
let config = match crate::plugins::providers::load_config(
let config = match codewhale_plugins::providers::load_config(
app.config_path.clone(),
app.config_profile.as_deref(),
) {
@@ -1178,8 +1178,11 @@ fn stream_chunk_timeout_value_label(raw: u64, resolved: u64) -> String {
}
fn load_command_config(app: &App) -> Result<Config, String> {
crate::plugins::providers::load_config(app.config_path.clone(), app.config_profile.as_deref())
.map_err(|err| format!("Failed to load config: {err}"))
codewhale_plugins::providers::load_config(
app.config_path.clone(),
app.config_profile.as_deref(),
)
.map_err(|err| format!("Failed to load config: {err}"))
}
/// The compatibility default_model command describes saved DeepSeek config,
@@ -3378,7 +3381,7 @@ mod tests {
assert!(!result.is_error, "{:?}", result.message);
assert_eq!(app.title_default.as_deref(), Some("Ev]0;PWNEDil Beta"));
assert!(app.needs_redraw);
let loaded = crate::plugins::providers::load_config(Some(config_path), None)
let loaded = codewhale_plugins::providers::load_config(Some(config_path), None)
.expect("reload saved config");
assert_eq!(loaded.title.as_deref(), Some("Ev]0;PWNEDil Beta"));
}
@@ -4040,7 +4043,7 @@ mod tests {
Some("auto")
);
let loaded =
crate::plugins::providers::load_config(Some(config_path.clone()), None).unwrap();
codewhale_plugins::providers::load_config(Some(config_path.clone()), None).unwrap();
assert_eq!(
loaded.default_model(),
"auto",
@@ -4264,7 +4267,7 @@ mod tests {
let mut app = create_test_app();
app.config_path = Some(config_path.clone());
app.set_provider_identity_record(
crate::plugins::providers::load_config(Some(config_path.clone()), None)
codewhale_plugins::providers::load_config(Some(config_path.clone()), None)
.expect("captured hosted config")
.active_provider_identity()
.expect("captured hosted identity"),
@@ -4547,7 +4550,7 @@ mod tests {
// this variable on drop.
unsafe { env::set_var("DEEPSEEK_ALLOW_SHELL", "false") };
let config =
crate::plugins::providers::load_config(Some(config_path.clone()), None).unwrap();
codewhale_plugins::providers::load_config(Some(config_path.clone()), None).unwrap();
let mut app = create_test_app_with_config(&config);
app.config_path = Some(config_path);
app.set_agent_shell_access(false);
@@ -4843,9 +4846,10 @@ completion_sound = "bell"
app.config_path = Some(config_path.clone());
// This fixture starts App with defaults; seed its current Config view
// as the real constructor does before asking a live-session query.
app.notification_settings = crate::plugins::providers::load_config(Some(config_path), None)
.unwrap()
.notifications_config();
app.notification_settings =
codewhale_plugins::providers::load_config(Some(config_path), None)
.unwrap()
.notifications_config();
let search = config_command(&mut app, Some("search.provider"));
assert!(!search.is_error, "{:?}", search.message);
@@ -4944,7 +4948,7 @@ api_key = "tvly-autodetected"
assert!(saved.contains("prompt_suggestion = true"), "{saved}");
assert!(saved.contains("method = \"osc9\""), "{saved}");
let loaded = crate::plugins::providers::load_config(Some(config_path), None)
let loaded = codewhale_plugins::providers::load_config(Some(config_path), None)
.expect("reloaded config");
assert_eq!(loaded.search_provider(), SearchProvider::DuckDuckGo);
assert!(loaded.prompt_suggestion_enabled());
@@ -5364,7 +5368,7 @@ context_window = 262144
}
fs::write(&path, body).unwrap();
let config =
crate::plugins::providers::load_config(Some(path.clone()), Some("work.team"))
codewhale_plugins::providers::load_config(Some(path.clone()), Some("work.team"))
.unwrap();
let mut app = create_test_app_with_config(&config);
app.config_path = Some(path.clone());
@@ -5374,7 +5378,7 @@ context_window = 262144
assert!(!result.is_error, "{result:?}");
assert_eq!(app.posture_bar, ChromeRowPreset::Compact);
let reloaded =
crate::plugins::providers::load_config(Some(path.clone()), Some("work.team"))
codewhale_plugins::providers::load_config(Some(path.clone()), Some("work.team"))
.unwrap();
let restarted = create_test_app_with_config(&reloaded);
assert_eq!(restarted.posture_bar, app.posture_bar);
@@ -5913,7 +5917,7 @@ context_window = 262144
assert!(saved.contains("approval_policy = \"on-request\""));
let loaded =
crate::plugins::providers::load_config(Some(config_path.clone()), None).unwrap();
codewhale_plugins::providers::load_config(Some(config_path.clone()), None).unwrap();
assert_eq!(loaded.approval_policy.as_deref(), Some("on-request"));
let mut restarted = create_test_app_with_config(&loaded);
@@ -5929,7 +5933,7 @@ context_window = 262144
})
);
assert_eq!(restarted.approval_mode, ApprovalMode::Auto);
let reloaded = crate::plugins::providers::load_config(Some(config_path), None).unwrap();
let reloaded = codewhale_plugins::providers::load_config(Some(config_path), None).unwrap();
assert_eq!(reloaded.approval_policy.as_deref(), Some("auto"));
}
@@ -5949,7 +5953,7 @@ context_window = 262144
)
.unwrap();
let loaded =
crate::plugins::providers::load_config(Some(config_path.clone()), None).unwrap();
codewhale_plugins::providers::load_config(Some(config_path.clone()), None).unwrap();
let mut app = create_test_app_with_config(&loaded);
app.config_path = Some(config_path.clone());
@@ -5983,7 +5987,7 @@ context_window = 262144
)
.unwrap();
let loaded =
crate::plugins::providers::load_config(Some(config_path.clone()), None).unwrap();
codewhale_plugins::providers::load_config(Some(config_path.clone()), None).unwrap();
let mut app = create_test_app_with_config(&loaded);
app.config_path = Some(config_path.clone());
// The production constructor receives the path up front and marks a
@@ -99,7 +99,7 @@ mod tests {
"no fresh roster, no claim"
);
let config = crate::plugins::providers::load_config(
let config = codewhale_plugins::providers::load_config(
app.config_path.clone(),
app.config_profile.as_deref(),
)
+95 -81
View File
@@ -2776,7 +2776,7 @@ impl CommandProjectContext for ProjectAdapter<'_> {
/// The single new skills-specific host adapter.
///
/// Owns every concrete skills touch: `App` skill state, `crate::skills`
/// discovery/mutation/install/recommend services, `crate::plugins` authority
/// discovery/mutation/install/recommend services, `codewhale_plugins` authority
/// verification, `SnapshotRepo`, config/network policy, and the async bridge
/// (`tokio::task::block_in_place`). Portable handlers never name these
/// subsystems (D3); every method returns portable contract values or safe
@@ -2804,7 +2804,7 @@ where
/// network-gated install rather than a silent crash. Mirrors
/// `groups/skills/skills.rs::installer_settings`.
fn installer_settings() -> (NetworkPolicy, u64, String) {
let cfg = crate::plugins::providers::load_config(None, None).unwrap_or_default();
let cfg = codewhale_plugins::providers::load_config(None, None).unwrap_or_default();
let network = cfg
.network
.clone()
@@ -3290,7 +3290,7 @@ impl CommandSkillGroupContext for SkillGroupAdapter<'_> {
match registry {
Ok(codewhale_skill_install::install::RegistryFetchResult::Loaded(doc)) => {
let recommendations =
crate::skills::recommend::recommend_remote_skills(task, &doc, 3);
codewhale_skill_install::recommend::recommend_remote_skills(task, &doc, 3);
Ok(recommendations
.into_iter()
.map(|recommendation| SkillRecommendation {
@@ -3460,11 +3460,15 @@ pub(crate) struct PluginAdapter<'a> {
}
/// Convert a TUI-owned diagnostic to the portable contract diagnostic.
fn portable_diagnostic(diagnostic: &crate::plugins::types::PluginDiagnostic) -> PluginDiagnostic {
fn portable_diagnostic(
diagnostic: &codewhale_plugins::types::PluginDiagnostic,
) -> PluginDiagnostic {
PluginDiagnostic {
level: match diagnostic.level {
crate::plugins::types::PluginDiagnosticLevel::Warning => PluginDiagnosticLevel::Warning,
crate::plugins::types::PluginDiagnosticLevel::Error => PluginDiagnosticLevel::Error,
codewhale_plugins::types::PluginDiagnosticLevel::Warning => {
PluginDiagnosticLevel::Warning
}
codewhale_plugins::types::PluginDiagnosticLevel::Error => PluginDiagnosticLevel::Error,
},
code: diagnostic.code.to_string(),
message: diagnostic.message.clone(),
@@ -3474,12 +3478,14 @@ fn portable_diagnostic(diagnostic: &crate::plugins::types::PluginDiagnostic) ->
/// Convert a TUI marketplace diagnostic into the portable contract diagnostic.
fn portable_marketplace_diagnostic(
diagnostic: &crate::plugins::marketplace::types::MarketplaceDiagnostic,
diagnostic: &codewhale_plugins::marketplace::types::MarketplaceDiagnostic,
) -> PluginDiagnostic {
PluginDiagnostic {
level: match diagnostic.level {
crate::plugins::types::PluginDiagnosticLevel::Warning => PluginDiagnosticLevel::Warning,
crate::plugins::types::PluginDiagnosticLevel::Error => PluginDiagnosticLevel::Error,
codewhale_plugins::types::PluginDiagnosticLevel::Warning => {
PluginDiagnosticLevel::Warning
}
codewhale_plugins::types::PluginDiagnosticLevel::Error => PluginDiagnosticLevel::Error,
},
code: diagnostic.code.clone(),
message: diagnostic.message.clone(),
@@ -3488,7 +3494,7 @@ fn portable_marketplace_diagnostic(
}
/// Convert a TUI-owned loaded plugin into the portable list summary.
fn portable_summary(plugin: &crate::plugins::types::LoadedPlugin) -> PluginSummary {
fn portable_summary(plugin: &codewhale_plugins::types::LoadedPlugin) -> PluginSummary {
PluginSummary {
name: plugin.name().to_string(),
id: plugin.id.as_str().to_string(),
@@ -3504,7 +3510,10 @@ fn portable_summary(plugin: &crate::plugins::types::LoadedPlugin) -> PluginSumma
}
/// Convert one TUI MCP server config into the portable review detail.
fn portable_mcp_server(name: &str, server: &crate::mcp::McpServerConfig) -> PluginMcpServerDetail {
fn portable_mcp_server(
name: &str,
server: &codewhale_mcp::config::McpServerConfig,
) -> PluginMcpServerDetail {
let transport = if server.url.is_some() {
PluginMcpTransport::Http
} else if server.command.is_some() {
@@ -3545,7 +3554,7 @@ fn portable_mcp_server(name: &str, server: &crate::mcp::McpServerConfig) -> Plug
}
/// Convert a TUI-owned loaded plugin into the portable full detail.
fn portable_detail(plugin: &crate::plugins::types::LoadedPlugin) -> PluginDetail {
fn portable_detail(plugin: &codewhale_plugins::types::LoadedPlugin) -> PluginDetail {
let mcp_servers = plugin
.manifest
.mcp_servers
@@ -3603,23 +3612,25 @@ fn portable_detail(plugin: &crate::plugins::types::LoadedPlugin) -> PluginDetail
/// Convert a TUI mutation receipt into the portable contract receipt.
fn portable_plugin_mutation_receipt(
receipt: &crate::plugins::mutation::PluginMutationReceipt,
receipt: &codewhale_plugins::mutation::PluginMutationReceipt,
) -> PluginMutationReceipt {
let outcome = match &receipt.outcome {
crate::plugins::mutation::PluginMutationOutcome::Installed => {
codewhale_plugins::mutation::PluginMutationOutcome::Installed => {
PluginMutationOutcome::Installed
}
crate::plugins::mutation::PluginMutationOutcome::Updated => PluginMutationOutcome::Updated,
crate::plugins::mutation::PluginMutationOutcome::NoChange => {
codewhale_plugins::mutation::PluginMutationOutcome::Updated => {
PluginMutationOutcome::Updated
}
codewhale_plugins::mutation::PluginMutationOutcome::NoChange => {
PluginMutationOutcome::NoChange
}
crate::plugins::mutation::PluginMutationOutcome::Uninstalled => {
codewhale_plugins::mutation::PluginMutationOutcome::Uninstalled => {
PluginMutationOutcome::Uninstalled
}
crate::plugins::mutation::PluginMutationOutcome::NeedsApproval(host) => {
codewhale_plugins::mutation::PluginMutationOutcome::NeedsApproval(host) => {
PluginMutationOutcome::NeedsApproval(host.clone())
}
crate::plugins::mutation::PluginMutationOutcome::NetworkDenied(host) => {
codewhale_plugins::mutation::PluginMutationOutcome::NetworkDenied(host) => {
PluginMutationOutcome::NetworkDenied(host.clone())
}
};
@@ -3634,7 +3645,7 @@ fn portable_plugin_mutation_receipt(
/// Convert a TUI export receipt into the portable contract receipt.
fn portable_export_receipt(
receipt: &crate::plugins::export::PluginExportReceipt,
receipt: &codewhale_plugins::export::PluginExportReceipt,
) -> PluginExportReceipt {
PluginExportReceipt {
exported_name: receipt.exported_name.clone(),
@@ -3669,11 +3680,11 @@ fn portable_legacy_tool(
/// Convert one TUI marketplace candidate into the portable value.
fn portable_marketplace_candidate(
entry: &crate::plugins::marketplace::store::StoredMarketplaceCatalog,
candidate: &crate::plugins::marketplace::types::MarketplaceCandidate,
registry: &crate::plugins::PluginRegistry,
entry: &codewhale_plugins::marketplace::store::StoredMarketplaceCatalog,
candidate: &codewhale_plugins::marketplace::types::MarketplaceCandidate,
registry: &codewhale_plugins::PluginRegistry,
) -> PluginMarketplaceCandidate {
use crate::plugins::marketplace::document::{
use codewhale_plugins::marketplace::document::{
CatalogInstallResolution, resolve_candidate_install,
};
let install_plan = match resolve_candidate_install(entry, candidate, registry) {
@@ -3723,8 +3734,8 @@ fn portable_marketplace_candidate(
/// Convert one stored TUI marketplace catalog (with its source path).
fn portable_marketplace_catalog_with_source(
entry: &crate::plugins::marketplace::store::StoredMarketplaceCatalog,
registry: &crate::plugins::PluginRegistry,
entry: &codewhale_plugins::marketplace::store::StoredMarketplaceCatalog,
registry: &codewhale_plugins::PluginRegistry,
) -> PluginMarketplaceCatalog {
let catalog = &entry.catalog;
PluginMarketplaceCatalog {
@@ -3762,7 +3773,7 @@ fn scan_managed_plugins_portable(
use std::path::PathBuf;
const MAX_MANAGED_CHILDREN: usize = 128;
const KIMI_PLUGIN_JSON_NAME: &str = crate::plugins::agent_plugin::KIMI_PLUGIN_JSON_NAME;
const KIMI_PLUGIN_JSON_NAME: &str = codewhale_plugins::agent_plugin::KIMI_PLUGIN_JSON_NAME;
struct Candidate {
name: String,
@@ -3792,15 +3803,14 @@ fn scan_managed_plugins_portable(
canonical_path.display()
));
}
let validated = crate::plugins::manifest::PluginManifest::validate_from_path(
&manifest_path,
)
.map_err(|error| {
format!(
"Kimi manifest invalid at {}: {error}",
canonical_path.display()
)
})?;
let validated =
codewhale_plugins::manifest::PluginManifest::validate_from_path(&manifest_path)
.map_err(|error| {
format!(
"Kimi manifest invalid at {}: {error}",
canonical_path.display()
)
})?;
let name = validated.manifest.plugin.name.clone();
if canonical_path.file_name().and_then(|part| part.to_str()) != Some(name.as_str()) {
return Err(format!(
@@ -4053,8 +4063,11 @@ impl CommandPluginContext for PluginAdapter<'_> {
fn reload_nudge(&mut self) -> Option<String> {
let mut app = self.host.app.borrow_mut();
let registry = app.plugin_registry.clone();
crate::plugins::plugin_reload_nudge(registry.as_ref(), &mut app.plugin_reload_nudge_stamp)
.map(str::to_string)
codewhale_plugins::plugin_reload_nudge(
registry.as_ref(),
&mut app.plugin_reload_nudge_stamp,
)
.map(str::to_string)
}
fn state_path(&self) -> Option<PathBuf> {
@@ -4072,26 +4085,26 @@ impl CommandPluginContext for PluginAdapter<'_> {
return Err("Usage: /plugin suggest <task of at least 3 characters>".to_string());
}
let app = self.host.app.borrow();
let marketplace = crate::plugins::recommend::load_marketplace_candidates(
let marketplace = codewhale_plugins::recommend::load_marketplace_candidates(
app.plugin_registry.state_path(),
);
let recommendations = crate::plugins::recommend::recommend_plugins_for_task(
let recommendations = codewhale_plugins::recommend::recommend_plugins_for_task(
task,
app.plugin_registry.as_ref(),
&marketplace,
crate::plugins::recommend::RecommendOptions::default(),
codewhale_plugins::recommend::RecommendOptions::default(),
);
Ok(recommendations
.into_iter()
.map(|recommendation| {
let description = match &recommendation.source {
crate::plugins::recommend::PluginMatchSource::Installed { id } => app
codewhale_plugins::recommend::PluginMatchSource::Installed { id } => app
.plugin_registry
.get(id)
.and_then(|plugin| plugin.manifest.plugin.description.clone())
.filter(|description| !description.trim().is_empty())
.unwrap_or_else(|| "No description provided.".to_string()),
crate::plugins::recommend::PluginMatchSource::Marketplace { catalog_id } => {
codewhale_plugins::recommend::PluginMatchSource::Marketplace { catalog_id } => {
marketplace
.iter()
.find(|candidate| {
@@ -4104,12 +4117,12 @@ impl CommandPluginContext for PluginAdapter<'_> {
}
};
let state_label = match &recommendation.source {
crate::plugins::recommend::PluginMatchSource::Installed { id } => app
codewhale_plugins::recommend::PluginMatchSource::Installed { id } => app
.plugin_registry
.get(id)
.map(|plugin| plugin.state_label().to_string())
.unwrap_or_else(|| "installed".to_string()),
crate::plugins::recommend::PluginMatchSource::Marketplace { .. } => {
codewhale_plugins::recommend::PluginMatchSource::Marketplace { .. } => {
"not installed".to_string()
}
};
@@ -4129,7 +4142,7 @@ impl CommandPluginContext for PluginAdapter<'_> {
let app = self.host.app.borrow();
app.plugin_registry
.get(selector)
.map(crate::plugins::types::LoadedPlugin::review_token)
.map(codewhale_plugins::types::LoadedPlugin::review_token)
.ok_or_else(|| format!("no plugin named {selector}"))?
};
if token != expected {
@@ -4193,8 +4206,8 @@ impl CommandPluginContext for PluginAdapter<'_> {
source: &str,
expected_content_hash: Option<&str>,
) -> Result<PluginMutationReceipt, String> {
use crate::plugins::install::PluginInstallSource;
use crate::plugins::mutation::{
use codewhale_plugins::install::PluginInstallSource;
use codewhale_plugins::mutation::{
PluginMutationContext, PluginMutationOutcome, PluginMutationRequest,
};
@@ -4212,7 +4225,7 @@ impl CommandPluginContext for PluginAdapter<'_> {
let outcome = run_async(async move {
let ctx = PluginMutationContext {
network: &network,
max_size: crate::plugins::install::DEFAULT_MAX_SIZE_BYTES,
max_size: codewhale_plugins::install::DEFAULT_MAX_SIZE_BYTES,
};
let request = match expected_for_request {
Some(expected_content_hash) => PluginMutationRequest::InstallExact {
@@ -4223,7 +4236,7 @@ impl CommandPluginContext for PluginAdapter<'_> {
source: plugin_source,
},
};
crate::plugins::mutation::execute(request, &ctx, registry).await
codewhale_plugins::mutation::execute(request, &ctx, registry).await
});
match outcome {
Ok(receipt) => {
@@ -4241,7 +4254,7 @@ impl CommandPluginContext for PluginAdapter<'_> {
}
fn update(&mut self, selector: &str) -> Result<PluginMutationReceipt, String> {
use crate::plugins::mutation::{
use codewhale_plugins::mutation::{
PluginMutationContext, PluginMutationOutcome, PluginMutationRequest,
};
let network = plugin_network_policy();
@@ -4251,9 +4264,9 @@ impl CommandPluginContext for PluginAdapter<'_> {
let outcome = run_async(async move {
let ctx = PluginMutationContext {
network: &network,
max_size: crate::plugins::install::DEFAULT_MAX_SIZE_BYTES,
max_size: codewhale_plugins::install::DEFAULT_MAX_SIZE_BYTES,
};
crate::plugins::mutation::execute(
codewhale_plugins::mutation::execute(
PluginMutationRequest::Update {
selector: selector_owned,
},
@@ -4277,7 +4290,7 @@ impl CommandPluginContext for PluginAdapter<'_> {
}
fn uninstall(&mut self, selector: &str) -> Result<PluginMutationReceipt, String> {
use crate::plugins::mutation::{
use codewhale_plugins::mutation::{
PluginMutationContext, PluginMutationOutcome, PluginMutationRequest,
};
let network = plugin_network_policy();
@@ -4287,9 +4300,9 @@ impl CommandPluginContext for PluginAdapter<'_> {
let outcome = run_async(async move {
let ctx = PluginMutationContext {
network: &network,
max_size: crate::plugins::install::DEFAULT_MAX_SIZE_BYTES,
max_size: codewhale_plugins::install::DEFAULT_MAX_SIZE_BYTES,
};
crate::plugins::mutation::execute(
codewhale_plugins::mutation::execute(
PluginMutationRequest::Uninstall {
selector: selector_owned,
},
@@ -4317,8 +4330,9 @@ impl CommandPluginContext for PluginAdapter<'_> {
fn uninstall_path(&mut self, name: &str, plugins_dir: &Path) -> Result<(), String> {
// File-level rollback removal for a bundle whose content hash
// mismatched; no registry resolution, rediscovery, or skill side
// effects (FEAT-020 D1 — the `crate::plugins` call stays host-side).
crate::plugins::install::uninstall(name, plugins_dir).map_err(|error| format!("{error:#}"))
// effects (FEAT-020 D1 — the `codewhale_plugins` call stays host-side).
codewhale_plugins::install::uninstall(name, plugins_dir)
.map_err(|error| format!("{error:#}"))
}
fn export(&self, selector: &str, target: &Path) -> Result<PluginExportReceipt, String> {
@@ -4340,7 +4354,7 @@ impl CommandPluginContext for PluginAdapter<'_> {
} else {
app.workspace.join(target)
};
crate::plugins::export::export_plugin_bundle(&plugin, &target, &existing_names)
codewhale_plugins::export::export_plugin_bundle(&plugin, &target, &existing_names)
.map(|receipt| portable_export_receipt(&receipt))
.map_err(|error| format!("Export of `{}` failed: {}", plugin.name(), error))
}
@@ -4396,7 +4410,7 @@ impl CommandPluginContext for PluginAdapter<'_> {
let canonical = package
.canonicalize()
.map_err(|_| format!("DSH package not found at {}", package.display()))?;
let (conversion, content_hash) = crate::plugins::install::preview_dsh(&canonical)
let (conversion, content_hash) = codewhale_plugins::install::preview_dsh(&canonical)
.map_err(|error| format!("{error:#}"))?;
Ok(codewhale_command_contract::facets::PluginDshPreview {
package_path: canonical,
@@ -4451,8 +4465,8 @@ impl CommandPluginContext for PluginAdapter<'_> {
canonical_path: &Path,
expected_content_hash: &str,
) -> Result<PluginMutationReceipt, String> {
use crate::plugins::install::PluginInstallSource;
use crate::plugins::mutation::{
use codewhale_plugins::install::PluginInstallSource;
use codewhale_plugins::mutation::{
PluginMutationContext, PluginMutationOutcome, PluginMutationRequest,
};
let network = plugin_network_policy();
@@ -4463,9 +4477,9 @@ impl CommandPluginContext for PluginAdapter<'_> {
let outcome = run_async(async move {
let ctx = PluginMutationContext {
network: &network,
max_size: crate::plugins::install::DEFAULT_MAX_SIZE_BYTES,
max_size: codewhale_plugins::install::DEFAULT_MAX_SIZE_BYTES,
};
crate::plugins::mutation::execute(
codewhale_plugins::mutation::execute(
PluginMutationRequest::InstallExact {
source: PluginInstallSource::LocalPath(path),
expected_content_hash,
@@ -4491,7 +4505,7 @@ impl CommandPluginContext for PluginAdapter<'_> {
fn marketplace_state(&self) -> Result<PluginMarketplaceState, String> {
let app = self.host.app.borrow();
let store = crate::plugins::marketplace::store::MarketplaceStore::open(
let store = codewhale_plugins::marketplace::store::MarketplaceStore::open(
app.plugin_registry.state_path(),
)
.ok_or_else(|| {
@@ -4516,7 +4530,7 @@ impl CommandPluginContext for PluginAdapter<'_> {
path: &Path,
) -> Result<PluginMarketplaceAddReceipt, String> {
let app = self.host.app.borrow();
let store = crate::plugins::marketplace::store::MarketplaceStore::open(
let store = codewhale_plugins::marketplace::store::MarketplaceStore::open(
app.plugin_registry.state_path(),
)
.ok_or_else(|| {
@@ -4524,7 +4538,7 @@ impl CommandPluginContext for PluginAdapter<'_> {
.to_string()
})?;
let raw_path = path.to_string_lossy();
let loaded = crate::plugins::marketplace::document::load_catalog_document(
let loaded = codewhale_plugins::marketplace::document::load_catalog_document(
name,
&app.workspace,
&raw_path,
@@ -4544,7 +4558,7 @@ impl CommandPluginContext for PluginAdapter<'_> {
fn marketplace_remove(&mut self, name: &str) -> Result<bool, String> {
let app = self.host.app.borrow();
let store = crate::plugins::marketplace::store::MarketplaceStore::open(
let store = codewhale_plugins::marketplace::store::MarketplaceStore::open(
app.plugin_registry.state_path(),
)
.ok_or_else(|| {
@@ -4560,7 +4574,7 @@ impl CommandPluginContext for PluginAdapter<'_> {
candidate: &str,
) -> Result<PluginMutationReceipt, String> {
let app = self.host.app.borrow();
let store = crate::plugins::marketplace::store::MarketplaceStore::open(
let store = codewhale_plugins::marketplace::store::MarketplaceStore::open(
app.plugin_registry.state_path(),
)
.ok_or_else(|| {
@@ -4576,22 +4590,22 @@ impl CommandPluginContext for PluginAdapter<'_> {
let candidate_entry = entry.catalog.candidate_by_name(candidate).ok_or_else(|| {
format!("No candidate `{candidate_text}` in marketplace `{catalog_text}`.")
})?;
let spec = match crate::plugins::marketplace::document::resolve_candidate_install(
let spec = match codewhale_plugins::marketplace::document::resolve_candidate_install(
&entry,
candidate_entry,
&app.plugin_registry,
) {
crate::plugins::marketplace::document::CatalogInstallResolution::Supported {
codewhale_plugins::marketplace::document::CatalogInstallResolution::Supported {
spec,
..
} => spec,
crate::plugins::marketplace::document::CatalogInstallResolution::AlreadyPresent {
codewhale_plugins::marketplace::document::CatalogInstallResolution::AlreadyPresent {
plugin,
reason,
} => {
// Installing a bundle Codewhale ships succeeds as a no-op
// (B5); any other occupied name stays a refusal.
if plugin.scope == crate::plugins::types::PluginScope::Builtin {
if plugin.scope == codewhale_plugins::types::PluginScope::Builtin {
return Ok(PluginMutationReceipt {
name: plugin.id.as_str().to_string(),
path: None,
@@ -4603,7 +4617,7 @@ impl CommandPluginContext for PluginAdapter<'_> {
}
return Err(escape_review_text(&reason));
}
crate::plugins::marketplace::document::CatalogInstallResolution::Unsupported {
codewhale_plugins::marketplace::document::CatalogInstallResolution::Unsupported {
reason,
} => {
let localized = key_to_plugin_message_id(&reason)
@@ -4614,7 +4628,7 @@ impl CommandPluginContext for PluginAdapter<'_> {
escape_review_text(&localized)
));
}
crate::plugins::marketplace::document::CatalogInstallResolution::HasErrors {
codewhale_plugins::marketplace::document::CatalogInstallResolution::HasErrors {
diagnostics,
} => {
return Err(format!(
@@ -6716,16 +6730,16 @@ mod tests {
// Discover only the demo bundle: the host's real `~/.codewhale/plugins`
// and materialized builtin plugins must not leak diagnostics into this
// assertion (they did on a shared CI agent).
let plugin_config = crate::plugins::discovery::DiscoveryConfig {
let plugin_config = codewhale_plugins::discovery::DiscoveryConfig {
workspace: tmp.path().to_path_buf(),
user_plugins_dir: tmp.path().join("user-plugins"),
workspace_plugins_dir: tmp.path().join(".codewhale/plugins"),
builtin_plugin_dirs: Vec::new(),
state_path: tmp.path().join("user-plugins/state.json"),
};
let discovery = crate::plugins::PluginDiscoveryContext::from_config_and_environment(
let discovery = codewhale_plugins::PluginDiscoveryContext::from_config_and_environment(
&plugin_config,
crate::plugins::HostEnvironment::capture(),
codewhale_plugins::HostEnvironment::capture(),
);
app.plugin_registry = discovery.registry_for_workspace(tmp.path());
let mut bundle = app.command_contexts();
@@ -6803,16 +6817,16 @@ mod tests {
// Discover only the demo bundle: the host's real `~/.codewhale/plugins`
// and materialized builtin plugins must not leak diagnostics into this
// assertion (they did on a shared CI agent).
let plugin_config = crate::plugins::discovery::DiscoveryConfig {
let plugin_config = codewhale_plugins::discovery::DiscoveryConfig {
workspace: tmp.path().to_path_buf(),
user_plugins_dir: tmp.path().join("user-plugins"),
workspace_plugins_dir: tmp.path().join(".codewhale/plugins"),
builtin_plugin_dirs: Vec::new(),
state_path: tmp.path().join("user-plugins/state.json"),
};
let discovery = crate::plugins::PluginDiscoveryContext::from_config_and_environment(
let discovery = codewhale_plugins::PluginDiscoveryContext::from_config_and_environment(
&plugin_config,
crate::plugins::HostEnvironment::capture(),
codewhale_plugins::HostEnvironment::capture(),
);
app.plugin_registry = discovery.registry_for_workspace(tmp.path());
// Capture the review token before borrowing the mutable facet.
@@ -78,7 +78,7 @@ impl CommandConfigStatusContext for ConfigStatusAdapter<'_> {
}
fn project_status(app: &App) -> ConfigStatusView {
let config = crate::plugins::providers::load_config(
let config = codewhale_plugins::providers::load_config(
app.config_path.clone(),
app.config_profile.as_deref(),
)
@@ -20,7 +20,7 @@ use crate::extension_host::tests::{
FixturePlugins, fake_authority, installed, node_for_tests, plugin_id,
};
use crate::extension_host::tier::HostTier;
use crate::plugins::activation::TestPolicyGuard;
use codewhale_plugins::activation::TestPolicyGuard;
/// The real catalog answers to every built-in name, every alias and the fixed
/// mode aliases the dispatcher handles ahead of the registry, and an extension
+1 -1
View File
@@ -1,9 +1,9 @@
//! `/hf` - Hugging Face MCP and provider concept helpers.
use crate::commands::traits::{CommandInfo, RegisterCommand};
use crate::mcp::{McpConfig, McpServerConfig};
use crate::tui::app::App;
use codewhale_localization::MessageId;
use codewhale_mcp::config::{McpConfig, McpServerConfig};
use super::CommandResult;
@@ -79,7 +79,7 @@ fn review(plugin: &dyn CommandPluginContext, path: &str) -> CommandResult {
"Native host code (JavaScript; runs in the experimental extension host)",
&preview.native_rows,
);
if !crate::plugins::activation::extension_host_policy_enabled() {
if !codewhale_plugins::activation::extension_host_policy_enabled() {
output.push_str(
" Requires: [features] extension_host = true (off now, so this bundle cannot be enabled)\n",
);
@@ -25,7 +25,7 @@ fn create_test_app(root: &Path) -> (App, TempDir) {
..crate::tui::test_support::test_tui_options(root)
};
let config = Config::default();
let discovery = crate::plugins::PluginDiscoveryContext::capture_pre_dotenv();
let discovery = codewhale_plugins::PluginDiscoveryContext::capture_pre_dotenv();
let registry = discovery.registry_for_workspace(root);
let mut app = App::new_with_plugin_registry(options, &config, registry);
app.ui_locale = Locale::En;
@@ -301,7 +301,7 @@ fn doctor(
plugin: &mut dyn CommandPluginContext,
fix: bool,
) -> CommandResult {
use crate::plugins::registry::gc;
use codewhale_plugins::registry::gc;
let Some(state_path) = plugin.state_path() else {
return CommandResult::error("Plugin registry has no persistence store; nothing to check");
@@ -809,7 +809,7 @@ fn rollback_hash_mismatch(
) -> CommandResult {
let missing_destination = translate(presentation, "plugin_kimi_rollback_destination_missing");
// File-level rollback removal crosses the boundary through the plugin
// facet (D1); the host adapter owns the `crate::plugins::install::uninstall`
// facet (D1); the host adapter owns the `codewhale_plugins::install::uninstall`
// call.
let rollback = installed_path
.and_then(Path::parent)
@@ -914,7 +914,7 @@ fn uninstall_bundle(
/// Read the active network policy for plugin downloads (host-side, D11).
pub(crate) fn plugin_network_policy() -> codewhale_net::network_policy::NetworkPolicy {
crate::plugins::providers::load_config(None, None)
codewhale_plugins::providers::load_config(None, None)
.unwrap_or_default()
.network
.map(codewhale_net::network_policy::NetworkPolicy::from)
@@ -40,7 +40,7 @@ pub(super) fn render_bundle_detail(
} else {
"none".to_string()
};
let extension_host = crate::plugins::activation::extension_host_policy_enabled();
let extension_host = codewhale_plugins::activation::extension_host_policy_enabled();
let (content_hash, capability_hash) = if include_hashes {
(
detail.content_hash.as_str(),
@@ -99,7 +99,7 @@ pub(super) fn render_bundle_detail(
/// The hashed label itself (`PluginActivationCapability::as_str`) never
/// changes.
fn display_component_labels(labels: &[String]) -> String {
let extension_host = crate::plugins::activation::extension_host_policy_enabled();
let extension_host = codewhale_plugins::activation::extension_host_policy_enabled();
labels
.iter()
.map(|label| match label.as_str() {
@@ -370,10 +370,10 @@ fn _diagnostic_path(diagnostic: &PluginDiagnostic) -> Option<String> {
/// the state-file effect. Every dynamic string is escaped; paths and ids come
/// from the filesystem and from plugin state, which a bundle can influence.
pub(super) fn render_gc_report(
report: &crate::plugins::registry::gc::GcReport,
report: &codewhale_plugins::registry::gc::GcReport,
fixed: bool,
) -> String {
use crate::plugins::registry::gc::format_bytes;
use codewhale_plugins::registry::gc::format_bytes;
const SHOWN: usize = 40;
let mut out = String::new();
@@ -395,7 +395,7 @@ pub(super) fn render_gc_report(
let records = report
.items
.iter()
.filter(|item| item.kind == crate::plugins::registry::gc::GcKind::Record)
.filter(|item| item.kind == codewhale_plugins::registry::gc::GcKind::Record)
.count();
let _ = writeln!(
out,
@@ -112,7 +112,7 @@ fn create_test_app(root: &Path) -> (App, TempDir) {
__cw_config_init_0_value.tools = __cw_config_init_0_0;
__cw_config_init_0_value
};
let discovery = crate::plugins::PluginDiscoveryContext::capture_pre_dotenv();
let discovery = codewhale_plugins::PluginDiscoveryContext::capture_pre_dotenv();
let registry = discovery.registry_for_workspace(root);
let mut app = App::new_with_plugin_registry(options, &config, registry);
app.ui_locale = Locale::En;
@@ -238,7 +238,7 @@ fn list_preserves_the_one_shot_on_disk_reload_nudge() {
.message
.unwrap();
assert!(
first.contains(crate::plugins::PLUGIN_RELOAD_NUDGE),
first.contains(codewhale_plugins::PLUGIN_RELOAD_NUDGE),
"{first}"
);
@@ -246,7 +246,7 @@ fn list_preserves_the_one_shot_on_disk_reload_nudge() {
.message
.unwrap();
assert!(
!second.contains(crate::plugins::PLUGIN_RELOAD_NUDGE),
!second.contains(codewhale_plugins::PLUGIN_RELOAD_NUDGE),
"nudge must appear once per catalog stamp: {second}"
);
}
@@ -905,7 +905,7 @@ fn export_verb_writes_agent_plugins_bundle() {
let bundle = root.path().join("exported/demo");
let plugin_json: serde_json::Value =
serde_json::from_str(&fs::read_to_string(bundle.join("plugin.json")).unwrap()).unwrap();
crate::plugins::agent_plugin::validate_plugin_json(&plugin_json).unwrap();
codewhale_plugins::agent_plugin::validate_plugin_json(&plugin_json).unwrap();
assert_eq!(plugin_json["name"], "demo");
assert_eq!(plugin_json["description"], "Import spreadsheet data safely");
assert!(bundle.join("skills/hello/SKILL.md").is_file());
@@ -996,7 +996,7 @@ fn doctor_reports_read_only_then_fix_retires_stale_records_with_a_backup() {
"review_history": [{
"content_hash": "c",
"capability_hash": "k",
"reviewed_capabilities": crate::plugins::manifest::PluginInventory::default(),
"reviewed_capabilities": codewhale_plugins::manifest::PluginInventory::default(),
"reviewed_at": "2026-01-01T00:00:00+00:00"
}]
}
+1 -1
View File
@@ -3040,7 +3040,7 @@ mod tests {
..crate::tui::test_support::test_tui_options(tmpdir.path())
};
let mut app = App::new(options, &Config::default());
let discovery = crate::plugins::PluginDiscoveryContext::capture_pre_dotenv();
let discovery = codewhale_plugins::PluginDiscoveryContext::capture_pre_dotenv();
app.plugin_registry = discovery.registry_for_workspace(tmpdir.path());
app
}
+11 -11
View File
@@ -23,7 +23,7 @@ struct UserCommandRegistryState {
workspace: Option<PathBuf>,
command_dirs_snapshot: Vec<CommandDirSnapshot>,
plugin_workspace: Option<PathBuf>,
plugin_sources: Vec<crate::plugins::runtime::PluginComponentSource>,
plugin_sources: Vec<codewhale_plugins::runtime::PluginComponentSource>,
plugin_errors: Vec<String>,
/// `extension_host::command::epoch()` when `registry` was built: it
/// reloads when the live extension commands may have changed.
@@ -150,7 +150,7 @@ impl UserCommandRegistry {
pub(crate) fn load_with_sources(
md_dirs: &[PathBuf],
workflow_dirs: &[PathBuf],
plugin_sources: &[crate::plugins::runtime::PluginComponentSource],
plugin_sources: &[codewhale_plugins::runtime::PluginComponentSource],
plugin_errors: &[String],
) -> Self {
let mut registry = Self::load_from_paths(md_dirs);
@@ -604,7 +604,7 @@ fn is_protected_builtin_command(name: &str) -> bool {
fn plugin_command_is_current(command: &UserCommandMetadata) -> bool {
command.plugin_authority.as_ref().is_none_or(|authority| {
crate::plugins::registry::verify_plugin_state_authority(authority).is_ok()
codewhale_plugins::registry::verify_plugin_state_authority(authority).is_ok()
})
}
@@ -703,7 +703,7 @@ fn command_dirs_snapshot(workspace: Option<&Path>) -> Vec<CommandDirSnapshot> {
fn command_dirs_snapshot_with_plugins(
workspace: Option<&Path>,
plugin_sources: &[crate::plugins::runtime::PluginComponentSource],
plugin_sources: &[codewhale_plugins::runtime::PluginComponentSource],
) -> Vec<CommandDirSnapshot> {
user_commands::commands_dirs(workspace)
.into_iter()
@@ -870,11 +870,11 @@ pub fn with_registry_for_workspace<R>(
/// before expanding the command body.
pub fn install_plugin_registry(
workspace: &Path,
plugins: &crate::plugins::PluginRegistry,
plugins: &codewhale_plugins::PluginRegistry,
) -> Vec<String> {
let (sources, errors) = crate::plugins::runtime::active_component_sources(
let (sources, errors) = codewhale_plugins::runtime::active_component_sources(
plugins,
crate::plugins::activation::PluginActivationCapability::Commands,
codewhale_plugins::activation::PluginActivationCapability::Commands,
);
let mut guard = registry_lock()
.write()
@@ -887,7 +887,7 @@ pub fn install_plugin_registry(
}
pub fn with_registry_for_plugins<R>(
plugins: &crate::plugins::PluginRegistry,
plugins: &codewhale_plugins::PluginRegistry,
f: impl FnOnce(&UserCommandRegistry) -> R,
) -> R {
with_registry_for_workspace(Some(plugins.workspace()), |base| {
@@ -935,9 +935,9 @@ pub fn try_dispatch(app: &mut App, input: &str) -> Option<CommandResult> {
}));
}
if let Some(authority) = metadata.plugin_authority.as_ref()
&& let Err(reason) = crate::plugins::registry::verify_plugin_component_authority(
&& let Err(reason) = codewhale_plugins::registry::verify_plugin_component_authority(
authority,
crate::plugins::activation::PluginActivationCapability::Commands,
codewhale_plugins::activation::PluginActivationCapability::Commands,
)
{
return Some(CommandResult::error(format!(
@@ -1218,7 +1218,7 @@ mod tests {
#[test]
fn plugin_command_dispatch_survives_restart_and_revocation_is_visible() {
let _lock = crate::test_support::lock_test_env();
let fixture = crate::plugins::test_fixture::DeclarativePluginFixture::new();
let fixture = codewhale_plugins::test_fixture::DeclarativePluginFixture::new();
let mut app = test_app(fixture.workspace.clone());
install_plugin_registry(&fixture.workspace, &fixture.registry);
+45 -45
View File
@@ -77,7 +77,7 @@ fn remembered_deepseek_cn_and_layered_root_models_keep_their_precedence() {
)
.unwrap();
assert_eq!(
crate::plugins::providers::load_config(None, None)
codewhale_plugins::providers::load_config(None, None)
.unwrap()
.default_model(),
"deepseek-v4-pro"
@@ -85,14 +85,14 @@ fn remembered_deepseek_cn_and_layered_root_models_keep_their_precedence() {
codewhale_runtime::config_persistence::persist_root_bool_key(Some(&path), "allow_shell", false)
.unwrap();
assert_eq!(
crate::plugins::providers::load_config(None, None)
codewhale_plugins::providers::load_config(None, None)
.unwrap()
.default_model(),
"deepseek-v4-pro"
);
fs::write(&path, "route_preferences_version = 1\nprovider = 'deepseek'\n[providers.deepseek]\nmodel = 'deepseek-v4-flash'\n[profiles.pro]\ndefault_text_model = 'deepseek-v4-pro'\n").unwrap();
let profile = crate::plugins::providers::load_config(None, Some("pro")).unwrap();
let profile = codewhale_plugins::providers::load_config(None, Some("pro")).unwrap();
assert_eq!(profile.default_model(), "deepseek-v4-pro");
assert_eq!(
crate::route_runtime::resolve_runtime_route(&profile, ProviderKind::Deepseek, None)
@@ -109,7 +109,7 @@ fn remembered_deepseek_cn_and_layered_root_models_keep_their_precedence() {
);
fs::write(&path, source).unwrap();
assert_eq!(
crate::plugins::providers::load_config(None, None)
codewhale_plugins::providers::load_config(None, None)
.unwrap()
.default_model(),
"deepseek-v4-pro"
@@ -138,7 +138,7 @@ fn loaded_startup_selection_is_shared_and_does_not_rewrite_saved_files() {
let saved = "[provider_models]\nzai = 'GLM-5.3'\n";
fs::write(&path, source).unwrap();
fs::write(home.path().join("settings.toml"), saved).unwrap();
let mut config = crate::plugins::providers::load_config(None, None).unwrap();
let mut config = codewhale_plugins::providers::load_config(None, None).unwrap();
assert_eq!(config.default_model(), "GLM-5.3");
assert_eq!(
crate::model_inventory::provider_default_model(
@@ -202,7 +202,7 @@ fn startup_memory_yields_to_explicit_launch_scoped_config_and_profile() {
.unwrap();
{
let _explicit = EnvVarGuard::set("CODEWHALE_MODEL", "GLM-5.2");
let config = crate::plugins::providers::load_config(None, None).unwrap();
let config = codewhale_plugins::providers::load_config(None, None).unwrap();
assert_eq!(config.default_model(), "GLM-5.2");
assert_eq!(
crate::route_runtime::resolve_runtime_route(&config, ProviderKind::Zai, None)
@@ -211,14 +211,14 @@ fn startup_memory_yields_to_explicit_launch_scoped_config_and_profile() {
"GLM-5.2"
);
}
let profile = crate::plugins::providers::load_config(None, Some("pinned")).unwrap();
let profile = codewhale_plugins::providers::load_config(None, Some("pinned")).unwrap();
assert_eq!(profile.default_model(), "GLM-5.1");
assert!(!profile.remembered_selection_is_applicable());
let project = home.path().join("project");
fs::create_dir(&project).unwrap();
let path = project.join("config.toml");
fs::write(&path, source).unwrap();
let config = crate::plugins::providers::load_config(Some(path), None).unwrap();
let config = codewhale_plugins::providers::load_config(Some(path), None).unwrap();
assert_eq!(config.default_model(), "GLM-5.2");
assert!(!config.remembered_selection_is_applicable());
}
@@ -336,7 +336,7 @@ fn provider_environment_model_outranks_startup_memory() {
)
.unwrap();
let _model = EnvVarGuard::set("OPENAI_MODEL", "gpt-5.6-luna");
let config = crate::plugins::providers::load_config(None, None).unwrap();
let config = codewhale_plugins::providers::load_config(None, None).unwrap();
assert_eq!(config.default_model(), "gpt-5.6-luna");
assert_eq!(
crate::route_runtime::resolve_runtime_route(&config, ProviderKind::Openai, None)
@@ -1094,7 +1094,7 @@ default_text_model = "deepseek-chat"
"CODEWHALE_BASE_URL",
"https://generic-gateway.example.test/v1",
);
let config = crate::plugins::providers::load_config(Some(config_path.clone()), None)?;
let config = codewhale_plugins::providers::load_config(Some(config_path.clone()), None)?;
assert!(
config.provider_uses_custom_endpoint(
&config.test_identity_for_kind(ProviderKind::Deepseek)
@@ -1130,7 +1130,7 @@ model = "openai/gpt-5"
),
] {
let _base = EnvVarGuard::set(env_name, endpoint);
let config = crate::plugins::providers::load_config(Some(config_path.clone()), None)?;
let config = codewhale_plugins::providers::load_config(Some(config_path.clone()), None)?;
assert_eq!(config.active_route_base_url(), endpoint);
assert!(config.provider_uses_custom_endpoint(
&config.test_identity_for_kind(ProviderKind::Openrouter)
@@ -1162,7 +1162,7 @@ base_url = "https://file-deepseek-gateway.example.test/v1"
default_text_model = "private-deepseek-model"
"#,
)?;
let root = crate::plugins::providers::load_config(Some(config_path.clone()), None)?;
let root = codewhale_plugins::providers::load_config(Some(config_path.clone()), None)?;
assert_eq!(root.active_route_api_key()?, "file-root-key");
assert!(active_provider_has_config_api_key(&root));
assert!(has_api_key_for(
@@ -1180,7 +1180,7 @@ base_url = "https://file-openrouter-gateway.example.test/v1"
model = "private-openrouter-model"
"#,
)?;
let provider_key = crate::plugins::providers::load_config(Some(config_path.clone()), None)?;
let provider_key = codewhale_plugins::providers::load_config(Some(config_path.clone()), None)?;
assert_eq!(provider_key.active_route_api_key()?, "file-provider-key");
assert!(active_provider_has_config_api_key(&provider_key));
assert!(has_api_key_for(
@@ -1198,7 +1198,7 @@ base_url = "https://file-openrouter-gateway.example.test/v1"
model = "private-openrouter-model"
"#,
)?;
let route_env = crate::plugins::providers::load_config(Some(config_path), None)?;
let route_env = codewhale_plugins::providers::load_config(Some(config_path), None)?;
assert_eq!(route_env.active_route_api_key()?, "file-env-route-key");
assert!(active_provider_has_env_api_key(&route_env));
assert!(has_api_key_for(
@@ -1239,7 +1239,7 @@ fn generic_base_url_override_never_reaches_pinned_child_routes() -> Result<()> {
for env_name in ["CODEWHALE_BASE_URL", "DEEPSEEK_BASE_URL"] {
let session_host = "https://session-gateway.example.test/v1";
let _base = EnvVarGuard::set(env_name, session_host);
let config = crate::plugins::providers::load_config(Some(config_path.clone()), None)?;
let config = codewhale_plugins::providers::load_config(Some(config_path.clone()), None)?;
// Documented behavior for the active DeepSeek route is unchanged.
assert_eq!(
@@ -1300,7 +1300,7 @@ fn provider_scoped_base_url_env_applies_only_to_its_own_route() -> Result<()> {
// Without a generic override the active DeepSeek route keeps its default:
// a provider-scoped variable names exactly one provider.
let config = crate::plugins::providers::load_config(Some(config_path.clone()), None)?;
let config = codewhale_plugins::providers::load_config(Some(config_path.clone()), None)?;
assert_eq!(config.active_route_base_url(), DEFAULT_DEEPSEEK_BASE_URL);
assert_eq!(
config.base_url_for_route(&config.test_identity_for_kind(ProviderKind::Zai)),
@@ -1317,7 +1317,7 @@ fn provider_scoped_base_url_env_applies_only_to_its_own_route() -> Result<()> {
// With both set, each override stays on its own route.
let session_host = "https://session-gateway.example.test/v1";
let _base = EnvVarGuard::set("CODEWHALE_BASE_URL", session_host);
let config = crate::plugins::providers::load_config(Some(config_path), None)?;
let config = codewhale_plugins::providers::load_config(Some(config_path), None)?;
assert_eq!(config.active_route_base_url(), session_host);
assert_eq!(
config.base_url_for_route(&config.test_identity_for_kind(ProviderKind::Moonshot)),
@@ -1356,7 +1356,7 @@ default_text_model = "deepseek-chat"
let _source = EnvVarGuard::set(codewhale_config::CLI_API_KEY_SOURCE_ENV, "cli");
let _cli_key = EnvVarGuard::set(codewhale_config::CLI_API_KEY_ENV, "explicit-cli-key");
let config = crate::plugins::providers::load_config(Some(config_path), None)?;
let config = codewhale_plugins::providers::load_config(Some(config_path), None)?;
assert_eq!(config.active_route_api_key()?, "explicit-cli-key");
assert!(!active_provider_has_config_api_key(&config));
assert!(active_provider_has_env_api_key(&config));
@@ -1395,7 +1395,7 @@ model = "managed-model"
"https://lower-env-gateway.example.test/v1",
);
let config = crate::plugins::providers::load_config(Some(config_path), None)?;
let config = codewhale_plugins::providers::load_config(Some(config_path), None)?;
assert_eq!(
config.active_route_base_url(),
"https://managed-gateway.example.test/v1"
@@ -1441,7 +1441,7 @@ consent_version = 1
)?;
codewhale_runtime::external_credentials::reset_side_effect_trap();
let config = crate::plugins::providers::load_config(Some(config_path), None)?;
let config = codewhale_plugins::providers::load_config(Some(config_path), None)?;
assert!(
config
.provider_config_for(&config.test_identity_for_kind(ProviderKind::OpenaiCodex))
@@ -1508,7 +1508,7 @@ consent_version = 1
)?;
let _auth_path = EnvVarGuard::set("OPENAI_CODEX_AUTH_FILE", &external_path);
codewhale_runtime::external_credentials::reset_side_effect_trap();
let config = crate::plugins::providers::load_config(Some(config_path), None)?;
let config = codewhale_plugins::providers::load_config(Some(config_path), None)?;
let effective = config
.provider_config_for(&config.test_identity_for_kind(ProviderKind::OpenaiCodex))
.and_then(|provider| provider.external_credentials.as_ref())
@@ -1668,7 +1668,7 @@ fn provider_sentinel_falls_through_to_route_env_then_fixture_store() -> Result<(
&config_path,
format!("provider = \"openai\"\n\n[providers.openai]\napi_key = {sentinel:?}\n"),
)?;
let config = crate::plugins::providers::load_config(Some(config_path.clone()), None)?;
let config = codewhale_plugins::providers::load_config(Some(config_path.clone()), None)?;
assert_eq!(
config
.provider_config()
@@ -1702,7 +1702,7 @@ fn provider_sentinel_falls_through_to_route_env_then_fixture_store() -> Result<(
"provider = \"openai\"\n\n[providers.openai]\napi_key = {sentinel:?}\napi_key_env = \"OFFICIAL_SENTINEL_ROUTE_KEY\"\n"
),
)?;
let config = crate::plugins::providers::load_config(Some(config_path.clone()), None)?;
let config = codewhale_plugins::providers::load_config(Some(config_path.clone()), None)?;
assert_eq!(
config.active_route_api_key()?,
"FIXTURE-ENV-KEY",
@@ -1712,7 +1712,7 @@ fn provider_sentinel_falls_through_to_route_env_then_fixture_store() -> Result<(
assert!(active_provider_has_env_api_key(&config));
fs::write(&config_path, format!("api_key = {sentinel:?}\n"))?;
let root = crate::plugins::providers::load_config(Some(config_path.clone()), None)?;
let root = codewhale_plugins::providers::load_config(Some(config_path.clone()), None)?;
assert!(!active_provider_has_config_api_key(&root));
assert!(!has_api_key_for(
&root,
@@ -1744,7 +1744,7 @@ fn custom_route_sentinel_is_never_a_key_and_requires_a_route_binding() -> Result
"provider = \"acme\"\n\n[providers.acme]\nkind = \"openai-compatible\"\nbase_url = \"https://acme.example.test/v1\"\nmodel = \"acme-model\"\napi_key = {sentinel:?}\n"
),
)?;
let config = crate::plugins::providers::load_config(Some(config_path.clone()), None)?;
let config = codewhale_plugins::providers::load_config(Some(config_path.clone()), None)?;
assert!(config.should_skip_secret_store_for_provider(
&config.test_identity_for_kind(ProviderKind::Custom)
));
@@ -1765,7 +1765,7 @@ fn custom_route_sentinel_is_never_a_key_and_requires_a_route_binding() -> Result
"provider = \"acme\"\n\n[providers.acme]\nkind = \"openai-compatible\"\nbase_url = \"https://acme.example.test/v1\"\nmodel = \"acme-model\"\napi_key = {sentinel:?}\napi_key_env = \"CUSTOM_SENTINEL_ROUTE_KEY\"\n"
),
)?;
let config = crate::plugins::providers::load_config(Some(config_path.clone()), None)?;
let config = codewhale_plugins::providers::load_config(Some(config_path.clone()), None)?;
assert_eq!(config.active_route_api_key()?, "FIXTURE-CUSTOM-ENV-KEY");
assert!(!active_provider_has_config_api_key(&config));
assert!(active_provider_has_env_api_key(&config));
@@ -1777,7 +1777,7 @@ fn custom_route_sentinel_is_never_a_key_and_requires_a_route_binding() -> Result
"provider = \"openrouter\"\n\n[providers.openrouter]\nbase_url = \"https://gateway.example.test/v1\"\napi_key = {API_KEYRING_SENTINEL:?}\n"
),
)?;
let custom_endpoint = crate::plugins::providers::load_config(Some(config_path), None)?;
let custom_endpoint = codewhale_plugins::providers::load_config(Some(config_path), None)?;
assert!(custom_endpoint.should_skip_secret_store_for_provider(
&custom_endpoint.test_identity_for_kind(ProviderKind::Openrouter)
));
@@ -1828,7 +1828,7 @@ fn xiaomi_mimo_custom_env_url_does_not_inherit_ambient_key() -> Result<()> {
env::set_var("MIMO_MODEL", "mimo-v2.5");
}
let config = crate::plugins::providers::load_config(None, None)?;
let config = codewhale_plugins::providers::load_config(None, None)?;
assert_eq!(
config.active_provider_identity().unwrap().provider,
ProviderKind::XiaomiMimo
@@ -1868,7 +1868,7 @@ fn wanjie_ark_custom_env_url_does_not_inherit_ambient_key() -> Result<()> {
env::set_var("WANJIE_ARK_MODEL", "wanjie-model-id");
}
let config = crate::plugins::providers::load_config(None, None)?;
let config = codewhale_plugins::providers::load_config(None, None)?;
assert_eq!(
config.active_provider_identity().unwrap().provider,
ProviderKind::WanjieArk
@@ -1909,7 +1909,7 @@ fn openai_custom_env_url_does_not_inherit_ambient_key() -> Result<()> {
env::set_var("OPENAI_MODEL", "glm-5");
}
let config = crate::plugins::providers::load_config(None, None)?;
let config = codewhale_plugins::providers::load_config(None, None)?;
assert_eq!(
config.active_provider_identity().unwrap().provider,
ProviderKind::Openai
@@ -1950,7 +1950,7 @@ fn openai_facade_custom_url_does_not_inherit_ambient_key() -> Result<()> {
env::set_var("DEEPSEEK_MODEL", "glm-5");
}
let config = crate::plugins::providers::load_config(None, None)?;
let config = codewhale_plugins::providers::load_config(None, None)?;
assert_eq!(
config.active_provider_identity().unwrap().provider,
ProviderKind::Openai
@@ -1991,7 +1991,7 @@ fn volcengine_custom_env_url_does_not_inherit_ambient_key() -> Result<()> {
env::set_var("VOLCENGINE_ARK_MODEL", "DeepSeek-V4-Flash");
}
let config = crate::plugins::providers::load_config(None, None)?;
let config = codewhale_plugins::providers::load_config(None, None)?;
assert_eq!(
config.active_provider_identity().unwrap().provider,
ProviderKind::Volcengine
@@ -2337,7 +2337,7 @@ fn siliconflow_custom_env_url_does_not_inherit_ambient_key() -> Result<()> {
env::set_var("SILICONFLOW_MODEL", "deepseek-v4-flash");
}
let config = crate::plugins::providers::load_config(None, None)?;
let config = codewhale_plugins::providers::load_config(None, None)?;
assert_eq!(
config.active_provider_identity().unwrap().provider,
ProviderKind::Siliconflow
@@ -2377,7 +2377,7 @@ fn arcee_custom_env_url_does_not_inherit_ambient_key() -> Result<()> {
env::set_var("ARCEE_MODEL", "arcee-trinity-large-preview");
}
let config = crate::plugins::providers::load_config(None, None)?;
let config = codewhale_plugins::providers::load_config(None, None)?;
assert_eq!(
config.active_provider_identity().unwrap().provider,
ProviderKind::Arcee
@@ -2423,7 +2423,7 @@ model = "deepseek-reasoner"
"#,
)?;
let config = crate::plugins::providers::load_config(None, None)?;
let config = codewhale_plugins::providers::load_config(None, None)?;
assert_eq!(
config.active_provider_identity().unwrap().provider,
ProviderKind::SiliconflowCN
@@ -2487,7 +2487,7 @@ api_key = "stale-api-key"
"#,
)?;
let config = crate::plugins::providers::load_config(None, None)?;
let config = codewhale_plugins::providers::load_config(None, None)?;
assert_eq!(
config.active_provider_identity().unwrap().provider,
ProviderKind::Moonshot
@@ -2631,7 +2631,7 @@ base_url = "https://api.kimi.com/coding/v1"
"#,
)?;
let config = crate::plugins::providers::load_config(None, None)?;
let config = codewhale_plugins::providers::load_config(None, None)?;
assert_eq!(
config.active_provider_identity().unwrap().provider,
ProviderKind::Moonshot
@@ -2678,7 +2678,7 @@ api_key = "kimi-code-env-key"
env::set_var("CODEWHALE_BASE_URL", "https://api.kimi.com/coding/v1");
}
let config = crate::plugins::providers::load_config(None, None)?;
let config = codewhale_plugins::providers::load_config(None, None)?;
assert_eq!(
config.active_provider_identity().unwrap().provider,
ProviderKind::Moonshot
@@ -2947,7 +2947,7 @@ fn modelstudio_variants_share_one_secret_slot_and_key_availability() -> Result<(
// Every variant — active or not — resolves the family key, so the picker
// badge stops showing three bogus "missing key" rows after one save.
let inactive_variants =
crate::plugins::providers::load_config(Some(config_path.clone()), None)?;
codewhale_plugins::providers::load_config(Some(config_path.clone()), None)?;
for variant in variants {
assert_eq!(
provider_secret_store_api_key(
@@ -2977,7 +2977,7 @@ fn modelstudio_variants_share_one_secret_slot_and_key_availability() -> Result<(
Some("ms-family-key-v2".to_string())
);
assert_eq!(secrets.get("modelstudio-coding-plan-anthropic")?, None);
let reloaded = crate::plugins::providers::load_config(Some(config_path), None)?;
let reloaded = codewhale_plugins::providers::load_config(Some(config_path), None)?;
for variant in variants {
assert_eq!(
provider_secret_store_api_key(&reloaded, &(reloaded).test_identity_for_kind(variant))
@@ -3606,7 +3606,7 @@ fn huggingface_custom_env_urls_do_not_inherit_ambient_keys() -> Result<()> {
env::set_var("HF_MODEL", "fallback/model");
}
let config = crate::plugins::providers::load_config(None, None)?;
let config = codewhale_plugins::providers::load_config(None, None)?;
assert_eq!(
config.active_provider_identity().unwrap().provider,
ProviderKind::Huggingface
@@ -3635,7 +3635,7 @@ fn huggingface_custom_env_urls_do_not_inherit_ambient_keys() -> Result<()> {
env::set_var("HF_MODEL", "meta-llama/Llama-3-70B");
}
let config = crate::plugins::providers::load_config(None, None)?;
let config = codewhale_plugins::providers::load_config(None, None)?;
assert_eq!(
config.active_provider_identity().unwrap().provider,
ProviderKind::Huggingface
@@ -3676,7 +3676,7 @@ fn huggingface_short_custom_env_url_does_not_inherit_ambient_key() -> Result<()>
env::set_var("HF_MODEL", "org/short-model");
}
let config = crate::plugins::providers::load_config(None, None)?;
let config = codewhale_plugins::providers::load_config(None, None)?;
assert_eq!(
config.active_provider_identity().unwrap().provider,
ProviderKind::Huggingface
@@ -3765,7 +3765,7 @@ fn dispatch_endpoint_and_billing_receipts_agree_for_every_resolved_route() -> Re
fs::write(&config_path, "provider = \"deepseek\"\n")?;
let _base = EnvVarGuard::set("CODEWHALE_BASE_URL", "https://env-gateway.example.test/v1");
let config = crate::plugins::providers::load_config(Some(config_path), None)?;
let config = codewhale_plugins::providers::load_config(Some(config_path), None)?;
// `for_route` reads the ambient config; `for_dispatched_route` reads the
// endpoint the client is actually built from. After the resolver became
@@ -3813,7 +3813,7 @@ fn readiness_and_inventory_classify_the_resolved_route_not_the_session_host() ->
fs::write(&config_path, "provider = \"deepseek\"\n")?;
let _base = EnvVarGuard::set("CODEWHALE_BASE_URL", "http://127.0.0.1:11434/v1");
let config = crate::plugins::providers::load_config(Some(config_path), None)?;
let config = codewhale_plugins::providers::load_config(Some(config_path), None)?;
// Readiness: the active route is on a local custom host and classifies as
// keyless-local; the sibling identity is still the canonical hosted
@@ -111,7 +111,7 @@ fn moving_root_default_off_the_root_does_not_resurrect_a_shadowed_legacy_model()
doc["providers"]["openai"]["model"].as_str(),
Some("gpui-fixture")
);
let switched = crate::plugins::providers::load_config(Some(path.clone()), None).unwrap();
let switched = codewhale_plugins::providers::load_config(Some(path.clone()), None).unwrap();
assert_eq!(
switched.active_provider_identity().unwrap().provider,
ProviderKind::Openrouter
@@ -125,7 +125,7 @@ fn moving_root_default_off_the_root_does_not_resurrect_a_shadowed_legacy_model()
assert_ne!(switched.default_model(), "gpui-fixture");
persist_provider_selection(Some(&path), &selection_identity(&path, "openai"), None).unwrap();
let returned = crate::plugins::providers::load_config(Some(path.clone()), None).unwrap();
let returned = codewhale_plugins::providers::load_config(Some(path.clone()), None).unwrap();
assert_eq!(
returned.active_provider_identity().unwrap().provider,
ProviderKind::Openai
@@ -179,7 +179,7 @@ fn switching_provider_away_and_back_preserves_every_route_selection() {
doc["providers"]["deepseek"]["model"].as_str(),
Some("deepseek-v4-pro")
);
let restored = crate::plugins::providers::load_config(Some(path.clone()), None)
let restored = codewhale_plugins::providers::load_config(Some(path.clone()), None)
.expect("saved provider switch must remain loadable");
assert_eq!(
restored.active_provider_identity().unwrap().provider,
@@ -191,7 +191,7 @@ fn switching_provider_away_and_back_preserves_every_route_selection() {
// it was stored on its own leaf or in the root fallback.
persist_provider_selection(Some(&path), &selection_identity(&path, "zai"), None)
.unwrap();
let returned = crate::plugins::providers::load_config(Some(path.clone()), None)
let returned = codewhale_plugins::providers::load_config(Some(path.clone()), None)
.expect("switching back must remain loadable");
assert_eq!(
returned.active_provider_identity().unwrap().provider,
@@ -234,12 +234,12 @@ fn a_bare_switch_relocates_a_root_alias_the_incoming_route_cannot_serve() {
Some("ark-private-id"),
"the displaced choice moves onto its own route's leaf, it is not dropped"
);
crate::plugins::providers::load_config(Some(path.clone()), None)
codewhale_plugins::providers::load_config(Some(path.clone()), None)
.expect("a bare provider switch must remain loadable");
persist_provider_selection(Some(&path), &selection_identity(&path, "volcengine"), None)
.unwrap();
let returned = crate::plugins::providers::load_config(Some(path.clone()), None)
let returned = codewhale_plugins::providers::load_config(Some(path.clone()), None)
.expect("switching back must remain loadable");
assert_eq!(
returned.active_provider_identity().unwrap().provider,
@@ -258,12 +258,12 @@ fn bare_switch_from_legacy_custom_commits_a_loadable_config() {
let path = home.path().join("config.toml");
let original = "route_preferences_version = 1\nprovider = 'custom'\nbase_url = 'https://proxy.example.test/v1'\ndefault_text_model = 'proxy-wire-id'\n[providers.deepseek]\nbase_url = 'https://api.deepseek.com/beta'\n";
fs::write(&path, original).unwrap();
crate::plugins::providers::load_config(Some(path.clone()), None).unwrap();
codewhale_plugins::providers::load_config(Some(path.clone()), None).unwrap();
// The write moves the top-level route into `[providers.custom]`,
// model included (#6394), so the custom model is no longer the
// switch's only copy and a bare switch commits a loadable config.
persist_provider_selection(Some(&path), &selection_identity(&path, "deepseek"), None).unwrap();
let switched = crate::plugins::providers::load_config(Some(path.clone()), None)
let switched = codewhale_plugins::providers::load_config(Some(path.clone()), None)
.expect("a bare provider switch must remain loadable");
assert_eq!(
switched.active_provider_identity().unwrap().provider,
@@ -308,7 +308,7 @@ fn an_unnamed_custom_route_keeps_its_root_model_across_a_switch() {
Some("proxy-wire-id"),
"the custom route's only saved model must survive the switch"
);
let restored = crate::plugins::providers::load_config(Some(path.clone()), None)
let restored = codewhale_plugins::providers::load_config(Some(path.clone()), None)
.expect("the switched config must remain loadable");
assert_eq!(restored.default_model(), "deepseek-v4-pro");
@@ -320,7 +320,7 @@ fn an_unnamed_custom_route_keeps_its_root_model_across_a_switch() {
.replace("provider = 'deepseek'", "provider = 'custom'")
.replace("provider = \"deepseek\"", "provider = 'custom'");
fs::write(&path, returning).unwrap();
let returned = crate::plugins::providers::load_config(Some(path.clone()), None)
let returned = codewhale_plugins::providers::load_config(Some(path.clone()), None)
.expect("returning to the custom route must remain loadable");
assert_eq!(returned.default_model(), "proxy-wire-id");
}
@@ -9,7 +9,7 @@ use codewhale_runtime::oauth::*;
fn plugin_oauth_reviewed_provider_real_client_refresh_chat_catalog_and_revocation() {
use crate::client::CodewhaleClient;
use crate::llm_client::LlmClient;
use crate::plugins::discovery::{DiscoveryConfig, discover_with_config};
use codewhale_plugins::discovery::{DiscoveryConfig, discover_with_config};
use futures_util::StreamExt;
use wiremock::matchers::{body_string_contains, header, method, path};
use wiremock::{Mock, MockServer, ResponseTemplate};
@@ -58,7 +58,7 @@ fn plugin_oauth_reviewed_provider_real_client_refresh_chat_catalog_and_revocatio
config.token_endpoint = format!("{issuer}/token");
let declared_base_url = format!("{issuer}/v1/");
let manifest = serde_json::json!({
"$schema": crate::plugins::agent_plugin::PLUGIN_SCHEMA_URL,
"$schema": codewhale_plugins::agent_plugin::PLUGIN_SCHEMA_URL,
"name":"provider-fixture", "version":"1.0.0",
"extensions":{"net.codewhale":{"providers":{"fixture-gateway":{
"base_url":declared_base_url, "model":"fixture-model", "models":["fixture-model"], "http_headers":{"X-Fixture-Route":"main"}, "oauth":config
@@ -83,8 +83,8 @@ fn plugin_oauth_reviewed_provider_real_client_refresh_chat_catalog_and_revocatio
__cw_config_init_0_value.http_headers = __cw_config_init_0_1;
__cw_config_init_0_value
};
crate::plugins::providers::apply_providers(&mut live, &registry).unwrap();
let auth_entry = crate::plugins::providers::plugin_auth_entry(
codewhale_plugins::providers::apply_providers(&mut live, &registry).unwrap();
let auth_entry = codewhale_plugins::providers::plugin_auth_entry(
&live,
"fixture-gateway",
)
+7 -7
View File
@@ -110,8 +110,8 @@ fn readiness_is_identical_across_unrelated_workspaces() {
std::fs::create_dir_all(&ws_b).expect("ws b");
// Same user-global home, two unrelated folders, no workspace overrides.
let config_a = crate::plugins::providers::load_config(None, None).expect("load from A");
let config_b = crate::plugins::providers::load_config(None, None).expect("load from B");
let config_a = codewhale_plugins::providers::load_config(None, None).expect("load from A");
let config_b = codewhale_plugins::providers::load_config(None, None).expect("load from B");
let readiness_a = deepseek_readiness(&config_a);
let readiness_b = deepseek_readiness(&config_b);
@@ -145,7 +145,7 @@ fn explicit_workspace_config_selects_its_route_without_locking_user_global() {
let config_path = ws_a.join(".codewhale").join("config.toml");
let _home = sealed_env(Some(&config_path));
let config = crate::plugins::providers::load_config(Some(config_path.clone()), None)
let config = codewhale_plugins::providers::load_config(Some(config_path.clone()), None)
.expect("load workspace config");
// The workspace selection IS honored for the session route.
assert_eq!(
@@ -200,7 +200,7 @@ fn write_nested_zai_config(base: &std::path::Path) -> std::path::PathBuf {
fn assert_user_global_survives_workspace(label: &str, config_path: std::path::PathBuf) {
let _home = sealed_env(Some(&config_path));
let config = crate::plugins::providers::load_config(Some(config_path.clone()), None)
let config = codewhale_plugins::providers::load_config(Some(config_path.clone()), None)
.unwrap_or_else(|err| panic!("load from {label}: {err}"));
let deepseek = deepseek_readiness(&config);
assert!(
@@ -234,7 +234,7 @@ fn unavailable_truly_means_unavailable_with_a_reason() {
let _lock = crate::test_support::lock_test_env();
let _home = sealed_env(None);
let config = crate::plugins::providers::load_config(None, None).expect("load config");
let config = codewhale_plugins::providers::load_config(None, None).expect("load config");
// Moonshot has no key anywhere in the sealed fixtures.
let moonshot = resolve_for_model(
&config,
@@ -264,8 +264,8 @@ fn repeated_readers_never_rewrite_configuration() {
// would contend on the process-wide test env lock; the property under
// test is that loading is side-effect-free, which a second load proves.)
for _ in 0..2 {
let config =
crate::plugins::providers::load_config(Some(config_path.clone()), None).expect("load");
let config = codewhale_plugins::providers::load_config(Some(config_path.clone()), None)
.expect("load");
let readiness = deepseek_readiness(&config);
assert!(readiness.can_attempt());
}
+4 -3
View File
@@ -77,9 +77,10 @@ use self::server::{ServerOptions, TranscriptServer};
use super::golden::{self, Failures, Sandbox};
use crate::core::engine::Engine;
use crate::core::events::Event;
use crate::mcp::{McpConfig, McpPool, McpToolApprovalHint};
use crate::mcp::{McpPool, McpToolApprovalHint};
use crate::test_support::EnvVarGuard;
use crate::tools::spec::{RichToolResult, ToolError};
use codewhale_mcp::config::McpConfig;
const FAMILY: &str = "mcp";
const CALL_DEADLINE: Duration = Duration::from_secs(30);
@@ -219,7 +220,7 @@ struct HostMcpDispatch {
inner: McpPoolDispatch,
manager: Arc<crate::extension_host::ExtensionHostManager>,
_manager: crate::extension_host::TestManagerGuard,
_policy: crate::plugins::activation::TestPolicyGuard,
_policy: codewhale_plugins::activation::TestPolicyGuard,
}
impl HostMcpDispatch {
fn boxed(setup: DispatchSetup) -> Box<dyn McpDispatchUnderTest> {
@@ -234,7 +235,7 @@ impl HostMcpDispatch {
..Default::default()
},
));
let policy = crate::plugins::activation::TestPolicyGuard::extension_host(true);
let policy = codewhale_plugins::activation::TestPolicyGuard::extension_host(true);
let manager_guard = crate::extension_host::TestManagerGuard::install(Arc::clone(&manager));
let (tx_event, rx_event) = mpsc::channel(64);
let pool = McpPool::new(setup.config)
+1 -1
View File
@@ -501,7 +501,7 @@ fn base_source_entries(
skills_discovery_mode: crate::skills::SkillDiscoveryMode,
locale_tag: &str,
mode: AppMode,
plugin_registry: Option<&crate::plugins::PluginRegistry>,
plugin_registry: Option<&codewhale_plugins::PluginRegistry>,
context_window_tokens: Option<u32>,
instruction_assembly: Option<&crate::project_context::InstructionAssembly>,
instruction_preview: bool,
+7 -4
View File
@@ -29,7 +29,7 @@ use crate::client::CodewhaleClient;
use crate::compaction::{CompactionConfig, PreparedCompactionEnvelope, compact_messages_safe};
use crate::core::model_client::SharedModelClient;
use crate::error_taxonomy::{ErrorCategory, ErrorEnvelope, ErrorSeverity, StreamError};
use crate::mcp::{McpConfig, McpPool, McpSupervisorUpdate};
use crate::mcp::{McpPool, McpSupervisorUpdate};
use crate::prompts;
use crate::purge::{emit_purge_completed, emit_purge_failed, emit_purge_started, run_purge};
#[cfg(test)]
@@ -64,6 +64,7 @@ use codewhale_config::AppMode;
use codewhale_config::ProviderKind;
use codewhale_config::descriptors::defaults::DEFAULT_TEXT_MODEL;
use codewhale_execpolicy::ApprovalMode;
use codewhale_mcp::config::McpConfig;
#[cfg(test)]
use codewhale_models::ToolCaller;
use codewhale_models::{
@@ -327,7 +328,7 @@ pub struct EngineConfig {
/// Immutable plugin authority snapshot scoped to `workspace`. Normal App
/// hosts provide this explicitly; headless/embed callers that leave it
/// unset receive a fresh workspace-specific snapshot in [`Engine::new`].
pub plugin_registry: Option<Arc<crate::plugins::PluginRegistry>>,
pub plugin_registry: Option<Arc<codewhale_plugins::PluginRegistry>>,
/// Sources injected as `<instructions source="…">` blocks in the system
/// prompt (#454). Each entry is either a disk path (read at render time)
/// or an inline string. Loaded in declared order from the user's
@@ -1079,7 +1080,7 @@ pub struct Engine {
/// only genuinely stale work.
mcp_event_generation: u64,
/// Workspace-scoped immutable plugin catalogue and authority receipts.
plugin_registry: Arc<crate::plugins::PluginRegistry>,
plugin_registry: Arc<codewhale_plugins::PluginRegistry>,
/// This engine's hold on the process-wide extension host (`[features]
/// extension_host`), carrying `plugin_registry`. `None` with the flag off
/// and for engines without a plugin snapshot of their own (isolated
@@ -2196,7 +2197,9 @@ impl Engine {
.as_ref()
.map(|attachment| attachment.plugin_view())
.or(own_plugin_registry)
.unwrap_or_else(|| Arc::new(crate::plugins::PluginRegistry::empty(&config.workspace)));
.unwrap_or_else(|| {
Arc::new(codewhale_plugins::PluginRegistry::empty(&config.workspace))
});
// Create clients for both providers
let (codewhale_client, codewhale_client_error) = match host
+1 -1
View File
@@ -1465,7 +1465,7 @@ mod tests {
) else {
return;
};
let _policy = crate::plugins::activation::TestPolicyGuard::extension_host(true);
let _policy = codewhale_plugins::activation::TestPolicyGuard::extension_host(true);
let fixture = crate::extension_host::tests::FixturePlugins::new(&["slow-tool"]).await;
let manager = fixture.manager(node);
let _manager = crate::extension_host::TestManagerGuard::install(Arc::clone(&manager));
@@ -8,7 +8,7 @@ async fn typescript_mod_rewrites_denies_and_regates_native_tools_on_the_real_eng
else {
return;
};
let _policy = crate::plugins::activation::TestPolicyGuard::extension_host(true);
let _policy = codewhale_plugins::activation::TestPolicyGuard::extension_host(true);
let fixture = crate::extension_host::tests::FixturePlugins::new(&["hook-policy"]).await;
for (name, text) in [
("before.txt", "original"),
@@ -769,7 +769,7 @@ async fn extension_tool_is_deferred_gated_and_attributed_on_the_model_path() {
) else {
return;
};
let _policy = crate::plugins::activation::TestPolicyGuard::extension_host(true);
let _policy = codewhale_plugins::activation::TestPolicyGuard::extension_host(true);
let fixture = crate::extension_host::tests::FixturePlugins::new(&["dsh-workspace-deps"]).await;
let manager = fixture.manager(node);
let warm = manager.attach(fixture.registry());
@@ -898,7 +898,7 @@ async fn an_isolated_chat_engine_never_revokes_another_engines_extension() {
) else {
return;
};
let _policy = crate::plugins::activation::TestPolicyGuard::extension_host(true);
let _policy = codewhale_plugins::activation::TestPolicyGuard::extension_host(true);
let fixture = crate::extension_host::tests::FixturePlugins::new(&["slow-tool"]).await;
let plugin_id = fixture
.registry()
@@ -993,10 +993,10 @@ async fn extension_host_flag_off_never_spawns_the_host() {
use crate::llm_client::mock::{MockLlmClient, canned};
let fixture = {
let _policy = crate::plugins::activation::TestPolicyGuard::extension_host(true);
let _policy = codewhale_plugins::activation::TestPolicyGuard::extension_host(true);
crate::extension_host::tests::FixturePlugins::new(&["dsh-workspace-deps"]).await
};
let _policy = crate::plugins::activation::TestPolicyGuard::extension_host(false);
let _policy = codewhale_plugins::activation::TestPolicyGuard::extension_host(false);
let manager = Arc::new(crate::extension_host::ExtensionHostManager::new(
crate::extension_host::ExtensionHostOptions::default(),
));
@@ -1177,7 +1177,7 @@ async fn mcp_server_instructions_reach_the_request_labelled_once_and_only_for_vi
},
&Config::default(),
);
let mut pool = McpPool::new(crate::mcp::McpConfig::default());
let mut pool = McpPool::new(codewhale_mcp::config::McpConfig::default());
pool.insert_test_connection(
"guided",
&["search"],
@@ -1255,7 +1255,7 @@ async fn configured_mcp_servers_are_named_to_the_model_once_without_loading_them
&Config::default(),
);
let pinned_prompt = engine.session.system_prompt.clone();
let mut config = crate::mcp::McpConfig::default();
let mut config = codewhale_mcp::config::McpConfig::default();
for (name, server) in [
("shoreline", json!({"command":"unused"})),
("switched_off", json!({"command":"unused", "enabled":false})),
@@ -1350,7 +1350,7 @@ async fn no_configured_mcp_servers_adds_nothing_to_history() {
.record_mcp_configured_servers(&policy, &catalog)
.await;
engine.mcp_pool = Some(Arc::new(AsyncMutex::new(McpPool::new(
crate::mcp::McpConfig::default(),
codewhale_mcp::config::McpConfig::default(),
))));
engine
.record_mcp_configured_servers(&policy, &catalog)
+1 -1
View File
@@ -8953,7 +8953,7 @@ pub(crate) async fn run_tool_call_before_hooks_for_context(
model: &str,
) -> Result<ToolCallBeforeHookOutcome, ToolError> {
if context.is_none()
&& crate::plugins::activation::extension_host_policy_enabled()
&& codewhale_plugins::activation::extension_host_policy_enabled()
&& (hooks.is_some() || attachment.is_some())
{
return Err(ToolError::not_available(
+6 -6
View File
@@ -200,7 +200,7 @@ fn secret_files_needing_tightening() -> Vec<(PathBuf, u32)> {
fn broken_mcp_entries(
config: &codewhale_runtime::config::Config,
workspace: &Path,
plugins: &crate::plugins::PluginRegistry,
plugins: &codewhale_plugins::PluginRegistry,
) -> Vec<(PathBuf, Vec<String>)> {
let global_path = config.mcp_config_path();
let project_path = mcp::workspace_mcp_config_path(workspace);
@@ -247,7 +247,7 @@ fn missing_user_directories(config: &codewhale_runtime::config::Config) -> Vec<P
pub(crate) fn plan_fixes(
config: &codewhale_runtime::config::Config,
workspace: &Path,
plugins: &crate::plugins::PluginRegistry,
plugins: &codewhale_plugins::PluginRegistry,
) -> DoctorFixPlan {
let mut actions = Vec::new();
actions.extend(
@@ -658,7 +658,7 @@ mod tests {
let plan = plan_fixes(
&config,
workspace.path(),
&crate::plugins::PluginRegistry::default(),
&codewhale_plugins::PluginRegistry::default(),
);
let temp_deletions = plan
.actions
@@ -700,7 +700,7 @@ mod tests {
fn missing_user_directories_are_planned_and_scaffolding_is_idempotent() {
let (home, config) = ScratchHome::new();
let workspace = tempfile::tempdir().expect("workspace");
let registry = crate::plugins::PluginRegistry::default();
let registry = codewhale_plugins::PluginRegistry::default();
let plan = plan_fixes(&config, workspace.path(), &registry);
assert!(
plan.actions
@@ -741,7 +741,7 @@ mod tests {
.expect("loosen");
let workspace = tempfile::tempdir().expect("workspace");
let registry = crate::plugins::PluginRegistry::default();
let registry = codewhale_plugins::PluginRegistry::default();
let plan = plan_fixes(&config, workspace.path(), &registry);
let tighten = plan
.actions
@@ -799,7 +799,7 @@ mod tests {
let mut config = config;
config.mcp_config_path = Some(mcp_path.display().to_string());
let registry = crate::plugins::PluginRegistry::default();
let registry = codewhale_plugins::PluginRegistry::default();
let plan = plan_fixes(&config, workspace.path(), &registry);
let disable = plan
.actions
+2 -2
View File
@@ -405,7 +405,7 @@ pub(crate) async fn run_exec_agent(
append_system_prompt: Option<String>,
tool_authority_json: Option<String>,
exec_hooks_enabled: bool,
plugin_registry: std::sync::Arc<crate::plugins::PluginRegistry>,
plugin_registry: std::sync::Arc<codewhale_plugins::PluginRegistry>,
// #6510: plain `exec` (no tool-surface flag). The caller passes an empty
// allowlist; this also skips workspace snapshots, LSP and the automation
// store, and writes the one-shot `--json` receipt shape.
@@ -648,7 +648,7 @@ pub(crate) async fn run_exec_agent(
engine_features.disable(codewhale_runtime::features::Feature::Mcp);
}
let engine_plugin_registry = if fleet_authority_active {
std::sync::Arc::new(crate::plugins::PluginRegistry::empty(&workspace))
std::sync::Arc::new(codewhale_plugins::PluginRegistry::empty(&workspace))
} else {
plugin_registry
};
+7 -7
View File
@@ -5,13 +5,13 @@ use super::ManagerShared;
use super::protocol::{RegisterParams, RegisterResult};
use super::supervisor::HostRequestContext;
use super::tier::HostTier;
use crate::plugins::{
activation::PluginActivationCapability,
manifest::{PluginManifest, ValidatedManifest},
};
use base64::{Engine as _, engine::general_purpose::STANDARD};
use codewhale_config::PluginAuthority;
use codewhale_config::{EntryRef, OwnerRef};
use codewhale_plugins::{
activation::PluginActivationCapability,
manifest::{PluginManifest, ValidatedManifest},
};
use codewhale_ratatui::avatar::{MAX_PACK_BYTES, MAX_PNG_BYTES, Pack, RegisteredPack};
use sha2::{Digest, Sha256};
use std::{
@@ -71,7 +71,7 @@ fn reviewed_bytes(
}
fn snapshot(authority: &PluginAuthority, relative: &Path) -> Result<(Pack, Vec<Vec<u8>>), String> {
crate::plugins::registry::verify_plugin_component_authority(
codewhale_plugins::registry::verify_plugin_component_authority(
authority,
PluginActivationCapability::Native,
)?;
@@ -96,7 +96,7 @@ fn snapshot(authority: &PluginAuthority, relative: &Path) -> Result<(Pack, Vec<V
.map_err(|_| "avatar PNG cannot be decoded")?;
images.push(png);
}
crate::plugins::registry::verify_plugin_component_authority(
codewhale_plugins::registry::verify_plugin_component_authority(
authority,
PluginActivationCapability::Native,
)?;
@@ -191,7 +191,7 @@ async fn live(workspace: std::path::PathBuf) -> Vec<AvatarRegistration> {
let Some(authority) = authority else { continue };
if authority.workspace != workspace
|| authority.content_hash != entry.content_hash
|| crate::plugins::registry::verify_plugin_component_authority(
|| codewhale_plugins::registry::verify_plugin_component_authority(
&authority,
PluginActivationCapability::Native,
)
+2 -2
View File
@@ -93,7 +93,7 @@ pub struct ExtensionCommandRef {
/// The workspace whose user registry loaded the command: what the handler
/// is told as the place the user ran it.
pub workspace: std::path::PathBuf,
pub selection: Option<super::composition_scope::SelectionRevision>,
pub selection: Option<codewhale_plugins::composition_scope::SelectionRevision>,
pub scope: Option<codewhale_config::EntryRef>,
pub content_hash: String,
}
@@ -171,7 +171,7 @@ pub struct ExtensionCommandEntry {
pub authority: PluginAuthority,
/// The workspace this entry was loaded for.
pub workspace: std::path::PathBuf,
pub selection: Option<super::composition_scope::SelectionRevision>,
pub selection: Option<codewhale_plugins::composition_scope::SelectionRevision>,
}
impl ExtensionCommandEntry {
@@ -182,3 +182,7 @@ async fn monitor_memory(pid: u32, cap: u64) -> Result<(), String> {
}
}
}
pub(crate) fn install() {
codewhale_plugins::composition_review::install_composition_reviewer(review);
}
@@ -1,279 +0,0 @@
//! Caller snapshots in AttachmentState. No second registry, store or owner token.
use codewhale_config::EntryRef;
use serde::{Deserialize, Serialize};
use std::collections::BTreeMap;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct SelectionRevision {
pub attachment_id: u64,
pub revision: u64,
}
/// A core-selected entry from the owner's existing reviewed Native inventory.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct NativePresetRef {
pub plugin_id: String,
pub content_hash: String,
pub entry: EntryRef,
}
#[derive(Debug, Clone, Default)]
pub struct CompositionSelection {
pub revision: Option<SelectionRevision>,
pub desired: BTreeMap<String, String>,
pub entries: Vec<NativePresetRef>,
}
impl CompositionSelection {
pub fn includes(&self, plugin_id: &str, content_hash: &str, scope: Option<&EntryRef>) -> bool {
self.desired
.get(plugin_id)
.is_some_and(|hash| hash == content_hash)
&& scope.is_none_or(|scope| {
self.entries.iter().any(|entry| {
entry.plugin_id == plugin_id
&& entry.content_hash == content_hash
&& entry.entry == *scope
})
})
}
}
/// Separate scopes may spell a name alike; core names remain fenced unconditionally.
pub(super) fn name_key(owner: &str, name: &str, scope: Option<&EntryRef>) -> String {
match scope {
None => name.to_string(),
Some(scope) => format!("{owner}\0{}\0{}\0{name}", scope.path, scope.sha256),
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::extension_host::protocol::{RegisterKind, RegisterParams, RegisterSpecWire};
use crate::extension_host::registry::{MAX_TOOLS_PER_OWNER, OwnerRegistry};
use crate::extension_host::tests::fake_authority;
use crate::extension_host::tier::HostTier;
use crate::extension_host::{ExtensionHostManager, ExtensionHostOptions};
use crate::plugins::PluginRegistry;
use codewhale_config::OwnerRef;
use std::sync::Arc;
fn scope(name: &str) -> EntryRef {
EntryRef {
path: format!("/reviewed/{name}.mjs"),
sha256: name.repeat(64),
}
}
fn tool(owner: &OwnerRef, scope: Option<EntryRef>, name: &str) -> RegisterParams {
RegisterParams {
owner: owner.clone(),
scope,
kind: RegisterKind::Tool,
spec: RegisterSpecWire {
name: name.into(),
description: "scoped fixture".into(),
input_schema: Some(
serde_json::json!({"type":"object","properties":{}})
.as_object()
.unwrap()
.clone(),
),
argument_hint: None,
},
}
}
fn owner(registry: &mut OwnerRegistry) -> OwnerRef {
registry
.begin_owner(
HostTier::Plugin,
"local/scoped",
"scoped",
Some(fake_authority("local/scoped")),
"build",
)
.unwrap()
}
#[test]
fn scoped_name_identity_includes_owner_even_for_identical_staged_entry_paths() {
let entry = scope("a");
assert_ne!(
name_key("workspace-a", "echo", Some(&entry)),
name_key("workspace-b", "echo", Some(&entry))
);
assert_eq!(name_key("a", "core", None), name_key("b", "core", None));
}
#[test]
fn composition_selection_requires_exact_owner_build_and_entry() {
let a = scope("a");
let b = scope("b");
let selected = CompositionSelection {
revision: Some(SelectionRevision {
attachment_id: 1,
revision: 2,
}),
desired: BTreeMap::from([("owner".into(), "build".into())]),
entries: vec![NativePresetRef {
plugin_id: "owner".into(),
content_hash: "build".into(),
entry: a.clone(),
}],
};
assert!(selected.includes("owner", "build", Some(&a)));
assert!(!selected.includes("owner", "build", Some(&b)));
assert!(!selected.includes("foreign", "build", Some(&a)));
assert!(!selected.includes("owner", "changed", Some(&a)));
}
#[test]
fn scoped_equal_names_keep_distinct_handles_and_withdraw_exact_entry() {
let mut registry = OwnerRegistry::default();
let owner = owner(&mut registry);
let a = scope("a");
let b = scope("b");
registry.begin_scope(&owner, a.clone()).unwrap();
registry.begin_scope(&owner, b.clone()).unwrap();
let first = registry
.register(&tool(&owner, Some(a.clone()), "scoped_echo"))
.unwrap();
let second = registry
.register(&tool(&owner, Some(b.clone()), "scoped_echo"))
.unwrap();
assert_ne!(first, second);
assert!(registry.mark_scope_active(&owner, &a));
assert!(registry.mark_scope_active(&owner, &b));
assert!(registry.mark_active(&owner));
assert_eq!(registry.live_tools().len(), 2);
assert!(
registry
.register(&tool(&owner, None, "unscoped_escape"))
.is_err()
);
let retired = registry.revoke_scope(&owner, &a);
assert_eq!(retired, [first]);
assert!(!registry.is_live(first, &owner));
assert!(registry.is_live(second, &owner));
registry.host_exited(HostTier::Builtin, "fixture");
assert!(registry.is_live(second, &owner));
registry.host_exited(HostTier::Plugin, "fixture");
assert!(registry.live_tools().is_empty());
assert!(registry.owner(&owner.plugin_id).is_none());
}
#[test]
fn scoped_aggregate_owner_limit_cannot_be_multiplied_by_entries() {
let mut registry = OwnerRegistry::default();
let owner = owner(&mut registry);
let a = scope("a");
let b = scope("b");
registry.begin_scope(&owner, a.clone()).unwrap();
registry.begin_scope(&owner, b.clone()).unwrap();
for index in 0..MAX_TOOLS_PER_OWNER {
registry
.register(&tool(
&owner,
Some(if index % 2 == 0 { a.clone() } else { b.clone() }),
&format!("scoped_{index}"),
))
.unwrap();
}
assert!(
registry
.register(&tool(&owner, Some(b), "one_too_many"))
.unwrap_err()
.contains("at most")
);
}
#[test]
fn caller_revision_withdrawal_does_not_revoke_another_attachment() {
let manager = Arc::new(ExtensionHostManager::new(ExtensionHostOptions::default()));
let one = manager.attach(Arc::new(PluginRegistry::empty(std::path::Path::new(
"/workspace",
))));
let two = manager.attach(Arc::new(PluginRegistry::empty(std::path::Path::new(
"/workspace",
))));
let entry = scope("a");
let selected = NativePresetRef {
plugin_id: "owner".into(),
content_hash: "build".into(),
entry: entry.clone(),
};
{
let mut attachments = manager.shared.attachments.lock().unwrap();
for state in attachments.values_mut() {
state.selection = CompositionSelection {
revision: state.plugins.caller_selection(),
desired: BTreeMap::from([("owner".into(), "build".into())]),
entries: vec![selected.clone()],
};
}
}
let old = one.plugin_view();
let other = two.plugin_view();
assert!(
manager
.shared
.check_selection(
old.caller_selection(),
Some(&old),
"owner",
"build",
Some(&entry)
)
.is_ok()
);
assert!(
manager
.shared
.check_selection(
old.caller_selection(),
Some(&other),
"owner",
"build",
Some(&entry)
)
.is_err()
);
one.set_plugins(Arc::new(PluginRegistry::empty(std::path::Path::new(
"/workspace",
))));
assert!(
manager
.shared
.check_selection(
old.caller_selection(),
Some(&old),
"owner",
"build",
Some(&entry)
)
.is_err()
);
assert!(
manager
.shared
.check_selection(
other.caller_selection(),
Some(&other),
"owner",
"build",
Some(&entry)
)
.is_ok()
);
}
#[test]
fn failed_scope_requires_reload_and_explicit_null_cannot_erase_scope() {
let mut registry = OwnerRegistry::default();
let owner = owner(&mut registry);
let a = scope("a");
registry.begin_scope(&owner, a.clone()).unwrap();
registry.fail_scope(&owner, &a);
assert!(registry.begin_scope(&owner, a.clone()).is_err());
registry.forget_inactive();
assert!(registry.begin_scope(&owner, a.clone()).is_ok());
let mut wire = serde_json::to_value(tool(&owner, Some(a), "scoped_fixture")).unwrap();
wire["scope"] = serde_json::Value::Null;
assert!(serde_json::from_value::<RegisterParams>(wire).is_err());
}
}
+1 -1
View File
@@ -257,7 +257,7 @@ pub(crate) struct Invocation {
host_generation: u64,
owner: OwnerRef,
scope: Option<codewhale_config::EntryRef>,
plugins: Option<Arc<crate::plugins::PluginRegistry>>,
plugins: Option<Arc<codewhale_plugins::PluginRegistry>>,
content_hash: String,
calls: CodemodeInvoker,
gate: NestedCallGate,
@@ -26,7 +26,6 @@ use super::supervisor::HostRequestContext;
use super::tests::{FixturePlugins, fake_authority, node_for_tests};
use super::tier::HostTier;
use super::{ExtensionHostManager, ExtensionHostOptions, HostAttachment, SupervisionOptions};
use crate::plugins::activation::TestPolicyGuard;
use crate::tools::codemode::{
ExtensionCaller, NestedCallGate, NestedCallRequest, NestedCallVerdict, NestedDecision,
};
@@ -34,6 +33,7 @@ use crate::tools::spec::{
ApprovalRequirement, ToolCapability, ToolContext, ToolError, ToolResult, ToolSpec,
};
use codewhale_config::OwnerRef;
use codewhale_plugins::activation::TestPolicyGuard;
// ---------------------------------------------------------------------------
// A stand-in for the turn loop's gate
@@ -529,7 +529,7 @@ async fn cancel_revoke_and_host_exit_withdraw_a_pending_core_call() {
assert_eq!(manager.shared.core_calls.live_tickets(), 0);
// The host exits while a card is open (the plugin is enabled again first).
crate::plugins::discovery::discover_with_config(&fixture.config)
codewhale_plugins::discovery::discover_with_config(&fixture.config)
.enable("core-call")
.unwrap();
engine.set_plugins(fixture.registry());
+7 -7
View File
@@ -18,11 +18,11 @@ use super::supervisor::HostRequestContext;
use super::ticket::{Grant, Presented, Ticket, TicketKind};
use super::tier::HostTier;
use super::{ExtensionHostManager, ManagerShared, activation};
use crate::plugins::PluginRegistry;
use crate::plugins::activation::PluginActivationCapability;
use crate::tools::spec::{ToolContext, ToolError, ToolResult};
use codewhale_config::OwnerRef;
use codewhale_config::PluginAuthority;
use codewhale_plugins::PluginRegistry;
use codewhale_plugins::activation::PluginActivationCapability;
const DEADLINE: Duration = Duration::from_secs(120);
const MAX_JOBS: usize = 32;
@@ -334,7 +334,7 @@ impl Job {
#[cfg(test)]
let _env_scope = crate::test_support::join_env_scope(env_scope);
for authority in &job.caller.authorities {
crate::plugins::registry::verify_plugin_component_authority(
codewhale_plugins::registry::verify_plugin_component_authority(
authority,
PluginActivationCapability::Native,
)?;
@@ -1481,7 +1481,7 @@ mod tests {
#[tokio::test(flavor = "current_thread")]
async fn actual_review_host_matches_frozen_core_cases_and_accepts_over_one_mib() {
let _home = crate::test_support::SealedHome::new();
let _policy = crate::plugins::activation::TestPolicyGuard::extension_host(false);
let _policy = codewhale_plugins::activation::TestPolicyGuard::extension_host(false);
let Some(node) = super::super::tests::node_for_tests("review_captured_parity") else {
return;
};
@@ -1594,7 +1594,7 @@ mod tests {
async fn actual_github_broker_returns_private_core_permission_fault() {
use codewhale_net::network_policy::{DecisionToml, NetworkPolicy, NetworkPolicyDecider};
let _home = crate::test_support::SealedHome::new();
let _policy = crate::plugins::activation::TestPolicyGuard::extension_host(false);
let _policy = codewhale_plugins::activation::TestPolicyGuard::extension_host(false);
let Some(node) = super::super::tests::node_for_tests("github_private_core_fault") else {
return;
};
@@ -1644,7 +1644,7 @@ mod tests {
#[tokio::test(flavor = "current_thread")]
async fn actual_ocr_native_cancel_retains_job_quota_until_framework_worker_exits() {
let _home = crate::test_support::SealedHome::new();
let _policy = crate::plugins::activation::TestPolicyGuard::extension_host(false);
let _policy = codewhale_plugins::activation::TestPolicyGuard::extension_host(false);
let Some(node) = super::super::tests::node_for_tests("ocr_native_quota") else {
return;
};
@@ -1705,7 +1705,7 @@ mod tests {
async fn actual_ocr_broker_decodes_exact_ordered_grants_and_refuses_replay_or_withdrawal() {
use std::os::unix::fs::PermissionsExt;
let _home = crate::test_support::SealedHome::new();
let _policy = crate::plugins::activation::TestPolicyGuard::extension_host(false);
let _policy = codewhale_plugins::activation::TestPolicyGuard::extension_host(false);
let Some(node) = super::super::tests::node_for_tests("ocr_exact_grants") else {
return;
};
+2 -2
View File
@@ -170,7 +170,7 @@ mod tests {
let Some(node) = node_for_tests("real_host_hook_timeout_and_revocation") else {
return;
};
let _policy = crate::plugins::activation::TestPolicyGuard::extension_host(true);
let _policy = codewhale_plugins::activation::TestPolicyGuard::extension_host(true);
let fixture = FixturePlugins::new(&["hook-policy"]).await;
let manager = Arc::new(super::super::ExtensionHostManager::new(
super::super::ExtensionHostOptions {
@@ -205,7 +205,7 @@ mod tests {
);
let other_workspace = tempfile::tempdir().unwrap();
let other = manager.attach(Arc::new(crate::plugins::PluginRegistry::empty(
let other = manager.attach(Arc::new(codewhale_plugins::PluginRegistry::empty(
other_workspace.path(),
)));
other.sync().await.unwrap();
+5 -4
View File
@@ -1,5 +1,6 @@
//! The pinned SDK owns MCP framing; Rust owns every process/network operation.
//! The selected Host backend immediately adopts proc/* and FetchProxy sessions.
use crate::mcp::McpServerConfigExt;
use std::collections::HashMap;
use std::sync::{Arc, Mutex};
use std::time::{Duration, Instant};
@@ -19,7 +20,8 @@ use super::tier::HostTier;
use super::{ExtensionHostManager, ManagerShared};
use crate::core::engine::HumanDecision;
use crate::mcp::process_broker::BrokerSession;
use crate::mcp::{MAX_MCP_RESPONSE_BYTES, McpPool, McpServerConfig};
use crate::mcp::{MAX_MCP_RESPONSE_BYTES, McpPool};
use codewhale_mcp::config::McpServerConfig;
const MAX_SESSIONS: usize = 64;
const MAX_PENDING: usize = 256;
@@ -111,7 +113,7 @@ impl Session {
.ok_or_else(|| "MCP builtin owner is no longer live".to_string())
})
.map_err(anyhow::Error::msg)?;
if let Some(source) = self.config.reviewed_plugin.as_ref() {
if let Some(source) = self.config.reviewed_plugin_source() {
source.validate_before_use(&self.name, "broker operation")?;
}
Ok(())
@@ -407,8 +409,7 @@ impl Broker {
session.validate(shared, generation, &params.owner)?;
if session
.config
.reviewed_plugin
.as_ref()
.reviewed_plugin_source()
.is_some_and(|source| source.plugin_name() == crate::mcp::COMPUTER_USE_PLUGIN_NAME)
{
let key = crate::mcp::random_key()?;
@@ -3,9 +3,10 @@
use super::*;
use crate::extension_host::TestManagerGuard;
use crate::extension_host::tests::{FixturePlugins, node_for_tests};
use crate::mcp::{McpConfig, McpPool};
use crate::plugins::activation::TestPolicyGuard;
use crate::mcp::McpPool;
use codewhale_config::McpBackend;
use codewhale_mcp::config::McpConfig;
use codewhale_plugins::activation::TestPolicyGuard;
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::net::{TcpListener, TcpStream};
+4 -3
View File
@@ -3,9 +3,10 @@
use super::*;
use crate::extension_host::tests::{FixturePlugins, node_for_tests};
use crate::extension_host::{ExtensionHostOptions, TestManagerGuard};
use crate::mcp::{McpConnection, McpTimeouts, McpTransport};
use crate::plugins::activation::TestPolicyGuard;
use crate::mcp::{McpConnection, McpTransport};
use codewhale_config::McpBackend;
use codewhale_mcp::config::McpTimeouts;
use codewhale_plugins::activation::TestPolicyGuard;
use std::sync::atomic::Ordering;
const PEER: &str = r#"
@@ -766,7 +767,7 @@ async fn selected_host_without_node_uses_runtime_diagnostic_not_native_gate() {
let _manager = TestManagerGuard::install(Arc::clone(&manager));
let config: McpServerConfig =
serde_json::from_value(json!({"command":"missing-peer"})).unwrap();
let mut pool = crate::mcp::McpPool::new(crate::mcp::McpConfig {
let mut pool = crate::mcp::McpPool::new(codewhale_mcp::config::McpConfig {
servers: [("missing-runtime".to_string(), config)].into(),
..Default::default()
});
Loaded 100 of 209 files, more files were not shown because too many files have changed in this diff. Show more