mirror of
https://github.com/codewhale-hq/Codewhale.git
synced 2026-11-08 17:10:50 -10:00
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:
1 parent
7d3d46c0d7
commit
786a0e4b33
209 files changed
+7595
-7344
No files matched your search
@@ -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'):
|
||||
|
||||
@@ -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
@@ -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",
|
||||
|
||||
@@ -21,6 +21,7 @@ members = [
|
||||
"crates/notify",
|
||||
"crates/palette",
|
||||
"crates/paths",
|
||||
"crates/plugins",
|
||||
"crates/protocol",
|
||||
"crates/release",
|
||||
"crates/runtime",
|
||||
|
||||
@@ -11,5 +11,6 @@ description = "Bounded MCP guidance normalization for Codewhale"
|
||||
workspace = true
|
||||
|
||||
[dependencies]
|
||||
serde.workspace = true
|
||||
serde_json.workspace = true
|
||||
tracing.workspace = true
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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"] }
|
||||
File renamed without changes.
File renamed without changes.
@@ -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.
@@ -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)
|
||||
}
|
||||
@@ -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());
|
||||
+1
-1
@@ -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;
|
||||
+3
-3
@@ -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()
|
||||
+9
-11
@@ -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()
|
||||
+7
-7
@@ -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| {
|
||||
File renamed without changes.
+2
-2
@@ -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,
|
||||
+5
-5
@@ -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,
|
||||
}),
|
||||
+1
-1
@@ -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,
|
||||
+1
-1
@@ -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,
|
||||
+1
-1
@@ -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,
|
||||
+3
-3
@@ -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());
|
||||
+5
-5
@@ -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, ®istry)
|
||||
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, ®istry) {
|
||||
@@ -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, ®istry) {
|
||||
File renamed without changes.
@@ -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, ®istry);
|
||||
}
|
||||
@@ -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, ®istry).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, ®istry).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, ®istry).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, ®istry).is_err(),
|
||||
"accepted non-model route override {override_field}"
|
||||
);
|
||||
}
|
||||
let snapshot = config.providers.as_ref().unwrap().custom.len();
|
||||
assert!(apply_providers(&mut config, ®istry).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 {
|
||||
+11
-11
@@ -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(®istry, &mut last_nudged).is_none(),
|
||||
crate::plugin_reload_nudge(®istry, &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(®istry, &mut last_nudged),
|
||||
Some(crate::plugins::PLUGIN_RELOAD_NUDGE)
|
||||
crate::plugin_reload_nudge(®istry, &mut last_nudged),
|
||||
Some(crate::PLUGIN_RELOAD_NUDGE)
|
||||
);
|
||||
assert!(
|
||||
crate::plugins::plugin_reload_nudge(®istry, &mut last_nudged).is_none(),
|
||||
crate::plugin_reload_nudge(®istry, &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(®istry),
|
||||
);
|
||||
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(®istry),
|
||||
);
|
||||
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,
|
||||
@@ -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"
|
||||
|
||||
@@ -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;
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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 = [
|
||||
|
||||
@@ -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'))
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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)
|
||||
|
||||
+1482
-1479
File diff suppressed because it is too large.
Load diff
@@ -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(),
|
||||
)
|
||||
|
||||
@@ -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,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"
|
||||
}]
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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, ®istry).unwrap();
|
||||
let auth_entry = crate::plugins::providers::plugin_auth_entry(
|
||||
codewhale_plugins::providers::apply_providers(&mut live, ®istry).unwrap();
|
||||
let auth_entry = codewhale_plugins::providers::plugin_auth_entry(
|
||||
&live,
|
||||
"fixture-gateway",
|
||||
)
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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(), ®istry);
|
||||
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(), ®istry);
|
||||
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(), ®istry);
|
||||
let disable = plan
|
||||
.actions
|
||||
|
||||
@@ -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
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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, ¶ms.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};
|
||||
|
||||
|
||||
@@ -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
Reference in new issue
Block a user