mirror of
https://github.com/codewhale-hq/Codewhale.git
synced 2026-11-13 18:17:39 -10:00
Compare commits
23
Commits
No files matched your search
@@ -86,7 +86,7 @@ jobs:
|
||||
timeout-minutes: 25
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- uses: actions/setup-java@v6.2.0
|
||||
- uses: actions/setup-java@v6.1.0
|
||||
with:
|
||||
distribution: temurin
|
||||
java-version: '17'
|
||||
|
||||
@@ -286,7 +286,7 @@ jobs:
|
||||
mirror: true
|
||||
- name: Install Java for the OpenHarmony binary signer
|
||||
if: matrix.platform == 'ohos-arm64'
|
||||
uses: actions/setup-java@77dd5b2ce225ea11469928ac317850245a74ae78 # v4
|
||||
uses: actions/setup-java@b843ebf9b913146b38da2eaa7aeddf76074ae1ad # v4
|
||||
with:
|
||||
distribution: temurin
|
||||
java-version: '17'
|
||||
|
||||
@@ -68,6 +68,8 @@ jobs:
|
||||
|
||||
echo "sha=${actual}" >> "${GITHUB_OUTPUT}"
|
||||
echo "version=${workspace_version}" >> "${GITHUB_OUTPUT}"
|
||||
- name: Check the first-party catalog snapshot is current
|
||||
run: ./scripts/refresh-first-party-marketplace.sh --check
|
||||
- name: Check version and OHOS release contracts
|
||||
# --require-dated-release, as auto-tag.yml and release.yml run it:
|
||||
# release.yml only accepts an RC receipt for the exact tag SHA, so an
|
||||
|
||||
+16
-2
@@ -7,6 +7,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
- **Agent `cwd` in sibling worktrees:** spawning an agent with `cwd` pointing to an existing linked worktree (or a subdirectory inside it) under the repository's `.codewhale-worktrees/<repo>/` root is now accepted when both share the same repository common git directory; outside directories and foreign or plain directories under `.codewhale-worktrees/` stay refused. (CW-8215)
|
||||
- **DSH market npm installs and extension catalog service:** DSH market `npm` entries with an exact semantic version (and optional `https` registry) now resolve to reviewed registry-tarball install plans (`npm:<pkg>@<ver>` or exact `.tgz` URL) without executing `npm` or bypassing plugin trust review. Inside the extension host, the `dsh/catalog` broker arm now plans reviewed installs without writing to disk, returns only verified approved provider routes scoped to the calling plugin, and serves account balance read-only while withholding credentials and account mutations (CW-7988).
|
||||
- **Plugin settings service backend (0.10.3):** adds the internal `settings` host-service handler (`describe`, `update`, `replace`, `mutate`) and extension-host adapter helper so plugins can inspect and atomically edit only their own reviewed `[plugins."<name>".config]` keys in `config.toml` through the session permission gate and live write posture, leaving comments, unrelated tables and other plugins' configuration untouched (writes return `applies: "reload"` and take effect at the next reload). This backend is not yet wired into live host-service dispatch or plugin grants, so no active plugin exposes it yet. (CW-7983)
|
||||
- **Runtime thread completion wakes:** idle non-ACP runtime threads automatically coalesce finished background shell jobs and completed child sub-agents into a bounded runtime continuation turn without dropping completions during busy turns or triggering wakes on cancelled, archived, or already-drained work. (CW-8215)
|
||||
- **Plugin install summary builder (crate API):** `codewhale-plugins` adds `install::build_summary`, a pure one-line install and update summary builder with stable message IDs for plugins and DSH packages; no CLI, TUI or desktop caller uses it yet. (CW-8182)
|
||||
- **Live reload for external plugin and MCP changes:** when another process installs, enables or disables a plugin or updates the configured MCP file, an idle TUI session now applies the change automatically and records any added or removed skills in the transcript (provider plugin changes still note they apply in a new session); while a turn is running, a toast notifies you and applies the change once idle. (CW-8183)
|
||||
- **First-party plugin catalog updates:** saved `codewhale` marketplace catalogs that came from an earlier embedded snapshot revision now update to the shipped snapshot on load while leaving custom, moving-branch, desktop-bundled, and removed catalogs untouched; release-candidate checks also verify that the embedded snapshot matches the marketplace files it installs from. (CW-8185)
|
||||
- **Network guard and portable config bundles:** `/network allow` now admits a local or private address only for `fetch_url`, `web.run`, and `config import` when the host is a `localhost` name (resolving to loopback only) or a local/private IP literal you typed and explicitly allowed (with IPv6 `deny` rules matching across bracketed and uncompressed spellings and wildcard rules never matching IP literals); domain names resolving to private addresses, skill and plugin installs, extension-host requests, and all cloud metadata forms (including `64:ff9b:1::/48` and `::ffff:0:0:0/96` embedded addresses) plus `0.0.0.0/8` stay refused on every hop. `codewhale config import <url>` now validates every redirect hop and refuses cross-host redirects to visibly local hosts, and portable config bundles reject on import and omit on export visibly local `network.allow` entries (including `localhost` names, local-domain wildcards such as `.localdomain` and `*.localhost`, and local IP literals).
|
||||
- **Skill install and update controller:** installing an already installed skill now updates it in place (reporting any replaced source and preserving `skills_state.toml` enabled/disabled state), local folders and `.tar.gz`/`.tgz` archives install and update as managed skills with path-safety and archive-bound checks, person-started global installs are trusted automatically without marking project skills, and mutations emit a one-line outcome receipt. (CW-8184)
|
||||
- **Removed (crate API):** the unwired `codewhale-hooks` dispatcher and its stdout, JSONL and Unix-socket sinks; `codewhale-core`'s `session`, `journal` and `ids` modules and the `hooks` argument of `Runtime::new`; `codewhale-runtime::resource_telemetry`; ten unreferenced public functions; and the duplicate `codewhale-agent` crate. `hook_sinks.unix_socket_path` still parses and is ignored. CLI, TUI and app-server behavior is unchanged.
|
||||
- **First-run setup:** installer downloads retry up to three times and remove partial files after a failed attempt. Folder trust uses 1 to trust, 2 to continue untrusted, and 3 or Esc to quit; letters do nothing. Provider pickers consistently say "needs key", and the unconfigured custom placeholder says "needs endpoint" while configured-route errors remain visible.
|
||||
- **Quota-window reset text:** a rate-limited turn now gives the reset time in plain words such as "19h29m" instead of Rust debug output like `Some(70168s)`, and leaves the reset clause out when the provider gives no reset time. An OpenCode Go message points at that provider's own key with `codewhale auth set --provider opencode-go`, without repeating a hint the message already carries. (CW-8209)
|
||||
@@ -23,6 +32,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
- **Charts in the transcript:** the model can now show numbers as a small chart with the new read-only `show_data` tool: a one-row sparkline, up to 24 labelled bars, or a multi-row trend. The TUI paints it in the transcript with the terminal kit, including after a session is restored, and every value is also in words, so the model and other clients get the same facts as text. Input the chart cannot show honestly (a non-finite number, more than 1024 points or 24 bars, a label over 120 characters) is refused with the limit it broke.
|
||||
- **Charts in the transcript:** the model can now show numbers as a small chart with the new read-only `show_data` tool: a one-row sparkline, up to 24 labelled bars, or a multi-row trend. The TUI paints it in the transcript with the terminal kit, including after a session is restored, and a plain-text summary accompanies each chart for the model and other clients. Input the chart cannot show honestly (a non-finite number, more than 1024 points or 24 bars, a label over 120 characters) is refused with the limit it broke.
|
||||
- **Terminal widgets:** narrow action hints wrap, scrollbars reach the final row, progress bars show fractional cells, focused tabs and panels are clearer, and the shared terminal kit supports measured ocean themes and reduced-motion-aware water drift.
|
||||
- **Plugin lifecycle from the shell:** `codewhale plugins` now has `install <spec>`, `show <name>`, `update <name>` and `uninstall <name>` (each with `--json`) alongside `status`, `restage`, `trust`, `enable`, `disable` and `revoke`. `plugins install` uses the same reviewed installer as `codewhale install`, `show` prints the capability review without prompting or trusting, `update` refreshes from the recorded source and reports either the new version or `already current`, and `uninstall` disables an enabled bundle first and asks in a terminal unless `--yes` is passed (non-interactive and `--json` runs require `--yes`). (CW-8161)
|
||||
|
||||
- **Renaming sessions with Chinese titles:** the session picker no longer rejects a Chinese, Japanese, Korean or emoji title of more than about 33 characters as "Title must be 1–100 characters". It now counts characters, as the message says and as `/rename` and the Runtime API already did, so any title up to 100 characters is accepted. Thanks [@luochen211](https://github.com/luochen211) (PR 7322).
|
||||
- **Non-ASCII file names:** `/diff`, the `@git` and `@diff` mentions and the Runtime API workspace diff now show paths with Chinese, emoji and other non-ASCII characters as written instead of as quoted octal escapes, and the turn context no longer lists the workspace root as an empty "Active paths" entry. Thanks [@luochen211](https://github.com/luochen211) (PR 7317).
|
||||
@@ -39,7 +49,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
- **Website maintainer and cron secrets:** the manual cron trigger is rate limited, admin sign-in and the cron secret require a 32 to 512 character secret with at least 8 distinct characters that is not a repeated pattern, and changing the maintainer token signs out existing admin sessions.
|
||||
- **Chat bridges thread ownership:** Chat bridges now list and resume only the threads the chat itself created: `/threads` shows owned threads, `/resume` refuses any other thread id with one fixed message, and `/new` always starts a new thread. In an existing thread map each chat keeps only the thread it is bound to, so threads it created earlier and left are no longer listed or resumable; a thread that several chats already pointed at is usable from none of them (send `/new`). Review `thread-map.json` after upgrading.
|
||||
- **Skill and plugin installs:** skill and plugin installs now enforce the uncompressed size cap on the whole decompressed archive, and reject archive entries with names longer than 4096 bytes.
|
||||
|
||||
- **Built-in execution rules:** a `Bash` deny rule now also blocks built-in test and verifier runs (`Run` / `run_tests` / `run_verifiers`), `workflow` runs with `verify: true`, and delayed-message scheduling (`send_later` `schedule`) while keeping task reads, status checks, management actions, and the bounded verifier's own default verification runs working.
|
||||
- **README and contributor credit:** the repository front page now opens the way the website does: the line "The model can be anyone's. The agent should be yours.", one sentence on what Codewhale is, the install command and one picture, with reference detail moved into the linked guides; the 18 translated READMEs follow the same structure and say they are machine-drafted. The contributor wall and the website now count the same 433 people (407 GitHub accounts and the commit authors whose email is not linked to an account), where the website had counted accounts only. `DEPENDENCY_SECURITY.md` moved into `docs/dependency-maintenance.md`, and `.github/SUPPORT.md` says where to ask for help.
|
||||
- **Website Plugins page:** The website's Plugins page now describes what the released version does, and a labelled section says what 0.10.3 adds: one Plugins place, Standard and Creator mode, and the Permissions control.
|
||||
- **Website `/app` captures:** the `/app` page's desktop build screenshots (`conversation.webp`, `approval.webp`, `model-picker.webp`) and their alt and caption text have been refreshed from the current 0.10.3 desktop UI.
|
||||
@@ -123,7 +133,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Credential redaction:** text, JSON, and workflow-disclosure redaction now masks multi-line private-key blocks across PGP armor headers (including `Comment:` URLs), longer preambles, and 16-character wrapped lines; masks credential values inside nested or escaped JSON assignments, escaped quotes, `\u003c`/`\u003e` delimiters, and spaced or nested JSON objects and arrays; and treats plural token keys (`API_TOKENS`, `ACCESS_TOKENS`) and credential-shaped `tokens` values as credentials while keeping model and tokenizer metric names (`max_tokens`, `tokenizer`, `token_limit`, `tokens=5`) visible.
|
||||
- Child processes now inherit only the standard locale variables (`LC_ALL`, `LC_CTYPE` and the other locale categories, plus `LC_TERMINAL` and `LC_TERMINAL_VERSION`) and a listed set of `DOTNET_` host, SDK and runtime settings. Other `DOTNET_` variables, including application configuration keys, are no longer passed to child processes or MCP servers.
|
||||
- **Deny rules and command safety:** a command whose command word is not a plain literal, or that redefines a command name earlier in the same call, is now refused while a deny rule is configured; the background destroyer hold reads commands the shell tokenizer rejects and decodes ANSI-C escapes inside shell payloads; and `sed`, `find`, `sort`, `rg`, `fd`, `uniq`, `env`, `less`, `more`, `ag`, `info` and `git` count as read-only only when their words are literal and use read-only flags, with `codewhale --version` and Windows trailing-backslash paths normalized before checking.
|
||||
- **Starting after a hard kill:** the Engine now starts when its process was killed and its data directory was then deleted. A socket owner record whose process is dead and whose data directory, the one this start selected, no longer exists is discarded instead of failing every start with "Failed to resolve ... tasks/runtime". A home reached through a symbolic link, such as one under macOS /tmp, is not covered yet.
|
||||
- The install guide's note for a first message that gets no answer now describes v0.10.2: connect a model with `/provider`, and restart if messages stay saved for later after you connect. It described v0.10.0's key screen. The Chinese guide carries the same correction.
|
||||
- Project activity on the site now comes from a server-side snapshot with a freshness record, the browser no longer calls GitHub, and count sentences use the right plural form in every language.
|
||||
@@ -475,6 +487,8 @@ work is declared complete here. Items moved to 0.10.4 by name are marked below.
|
||||
|
||||
### Changed
|
||||
|
||||
- **Terminal Plugins screen:** The terminal's Extensions screen is now called Plugins; its tabs filter by kind (Installed, Skills, Connectors, Hooks, Marketplace), and `/plugin` is listed with the main commands. `/skills`, `/mcp` and `/hooks` work as before.
|
||||
|
||||
- **`read` pages in lines only (breaking).** One `read` call returns at
|
||||
most 2,000 lines — the default and the maximum `limit` — inside a 50 KiB
|
||||
output cap, and a line longer than 2,000 chars comes back truncated with
|
||||
@@ -710,7 +724,7 @@ work is declared complete here. Items moved to 0.10.4 by name are marked below.
|
||||
- **`codewhale exec --add-dir <path>`** (repeatable) and `addDirs` on `POST /v1/threads` let the file tools read and write a sibling directory, a second checkout or a shared scratch root without turning on Full Access.
|
||||
- **Unsettled usage is persisted in production, not only in tests.** The interactive sink now records a legacy usage receipt when a provider response has no settlement, so children and turns stop reporting settlement failure.
|
||||
- **A full usage journal no longer fails a turn before dispatch.** The request runs with cost unknown instead of stopping on “native usage journal capacity reached”.
|
||||
- **Reverting a file works in any posture.** Restoring a file the thread's own turns changed no longer requires `/trust` or Full Access; the exact snapshot and reviewed-hash checks still apply. **`fetch_url` can reach a local or private host you allowed** with `/network allow <host>`; cloud metadata stays blocked. Both refusals now name the setting.
|
||||
- **Reverting a file works in any posture.** Restoring a file the thread's own turns changed no longer requires `/trust` or Full Access; the exact snapshot and reviewed-hash checks still apply. **`fetch_url` can reach a typed `localhost` name or local IP literal you allowed** with `/network allow <host>` (domain names resolving to private addresses and cloud metadata stay blocked). Both refusals now name the setting.
|
||||
- **`codewhale exec` offers tools by default**, still under the configured sandbox and approval posture. `--no-tools` is the one-shot. A pipe on stdin is the prompt when no arguments are given, or is appended to them.
|
||||
- **A child no longer dies when usage settlement is not acknowledged.** The turn continues and cost stays unknown, matching the parent path.
|
||||
- **Workspace memory works without a git origin.** A plain folder or a repo with no `origin` remote gets a stable workspace id. `codewhale plugin list` is an alias of `plugins status`. A heavy command runs after 30 seconds instead of waiting forever for an admission slot. An MCP registration 4xx names the status and the manual client-id path.
|
||||
|
||||
Generated
+4
@@ -1037,6 +1037,7 @@ dependencies = [
|
||||
"codewhale-engine",
|
||||
"codewhale-execpolicy",
|
||||
"codewhale-lane",
|
||||
"codewhale-net",
|
||||
"codewhale-paths",
|
||||
"codewhale-release",
|
||||
"codewhale-runtime",
|
||||
@@ -1716,9 +1717,11 @@ dependencies = [
|
||||
"codewhale-runtime",
|
||||
"codewhale-skill-install",
|
||||
"codewhale-util",
|
||||
"flate2",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sha2 0.11.1",
|
||||
"tar",
|
||||
"tempfile",
|
||||
"tokio",
|
||||
"toml 1.1.6+spec-1.1.0",
|
||||
@@ -1940,6 +1943,7 @@ name = "codewhale-workflow"
|
||||
version = "0.10.3"
|
||||
dependencies = [
|
||||
"codewhale-protocol",
|
||||
"codewhale-sanitize",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sha2 0.11.1",
|
||||
|
||||
@@ -34,6 +34,7 @@ codewhale-engine = { path = "../engine", version = "0.10.3" }
|
||||
codewhale-app-server = { path = "../app-server", version = "0.10.3" }
|
||||
codewhale-config = { path = "../config", version = "0.10.3" }
|
||||
codewhale-lane = { path = "../lane", version = "0.10.3" }
|
||||
codewhale-net = { path = "../net", version = "0.10.3" }
|
||||
codewhale-workflow = { path = "../workflow", version = "0.10.3" }
|
||||
codewhale-execpolicy = { path = "../execpolicy", version = "0.10.3" }
|
||||
codewhale-paths = { path = "../paths", version = "0.10.3" }
|
||||
|
||||
@@ -15,6 +15,9 @@
|
||||
//! - Input size is capped (5 MiB, matching the skill installer's cap).
|
||||
//! - HTTPS only for remote fetch, except plain `http` on loopback; redirects
|
||||
//! are followed at most a bounded number of times within the same scheme.
|
||||
//! The first request and every redirect honor the `[network]` policy and
|
||||
//! refuse loopback, private, link-local and cloud-metadata destinations,
|
||||
//! except a visibly local host the person listed or typed as the URL.
|
||||
//! - Bundle-declared file paths must resolve inside the target config
|
||||
//! directory; traversal and symlink escapes are refused.
|
||||
//! - Project scope never mutates the user-global document and vice versa.
|
||||
@@ -573,13 +576,29 @@ fn nonportable_path_reason(path: &str) -> Option<&'static str> {
|
||||
None
|
||||
}
|
||||
|
||||
fn is_network_allow_path(path: &str) -> bool {
|
||||
let mut segments = path.split('.').map(normalize_bundle_key).rev();
|
||||
matches!(
|
||||
(segments.next().as_deref(), segments.next().as_deref()),
|
||||
(Some("allow"), Some("network"))
|
||||
)
|
||||
}
|
||||
|
||||
/// Telemetry opt-out is safe to move between machines, but opt-in is durable
|
||||
/// user consent coupled to SetupState. A portable bundle may tighten that
|
||||
/// consent (`false`); it must never manufacture or transfer `true`.
|
||||
fn nonportable_value_reason(path: &str, value: &toml::Value) -> Option<&'static str> {
|
||||
let top_level_telemetry = !path.contains('.') && normalize_bundle_key(path) == "telemetry";
|
||||
(top_level_telemetry && matches!(value, toml::Value::Boolean(true)))
|
||||
.then_some("would port telemetry opt-in consent between machines")
|
||||
if top_level_telemetry && matches!(value, toml::Value::Boolean(true)) {
|
||||
return Some("would port telemetry opt-in consent between machines");
|
||||
}
|
||||
if is_network_allow_path(path)
|
||||
&& let toml::Value::String(host) = value
|
||||
&& codewhale_net::guard::is_visibly_local_host(host)
|
||||
{
|
||||
return Some("network.allow entry would grant local-network authority");
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -798,67 +817,85 @@ pub fn resolve_bounded_path(base_dir: &Path, candidate: &str) -> Result<PathBuf>
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Fetch a bundle over HTTPS (or plain http on loopback only) with a hard
|
||||
/// size cap, a timeout, and bounded redirects. Mirrors the skill installer's
|
||||
/// fetch bounds.
|
||||
pub fn fetch_bundle(url: &str) -> Result<Vec<u8>> {
|
||||
let mut current_url = reqwest::Url::parse(url).map_err(|_| anyhow!("invalid bundle URL"))?;
|
||||
validate_bundle_url(¤t_url)?;
|
||||
let initial_scheme = current_url.scheme().to_string();
|
||||
/// size cap, a timeout, and bounded redirects. The first request and every
|
||||
/// redirect go through the shared guarded download: the network policy
|
||||
/// applies, the host is resolved and checked, and loopback, private,
|
||||
/// link-local and cloud-metadata destinations are refused unless the person
|
||||
/// listed a visibly local host in `network.allow`. A loopback host typed as
|
||||
/// the URL itself is admitted without a listing.
|
||||
pub fn fetch_bundle(
|
||||
url: &str,
|
||||
network: &codewhale_net::network_policy::NetworkPolicy,
|
||||
) -> Result<Vec<u8>> {
|
||||
let initial_url = reqwest::Url::parse(url).map_err(|_| anyhow!("invalid bundle URL"))?;
|
||||
validate_bundle_url(&initial_url)?;
|
||||
let initial_scheme = initial_url.scheme().to_string();
|
||||
let initial_host = initial_url
|
||||
.host_str()
|
||||
.context("bundle URL must include a host")?
|
||||
.to_string();
|
||||
|
||||
let client = codewhale_release::platform_blocking_http_client_builder()
|
||||
.timeout(std::time::Duration::from_secs(FETCH_TIMEOUT_SECS))
|
||||
// Redirect targets must pass the same scheme/host policy as the
|
||||
// initial request, so redirects are followed explicitly below.
|
||||
.redirect(reqwest::redirect::Policy::none())
|
||||
.build()
|
||||
.map_err(|_| anyhow!("building bundle fetch client failed"))?;
|
||||
let mut redirects = 0usize;
|
||||
let response = loop {
|
||||
let response = client
|
||||
.get(current_url.clone())
|
||||
.send()
|
||||
// reqwest errors can include the full URL (including its query or
|
||||
// userinfo), so keep transport failures deliberately URL-free.
|
||||
.map_err(|_| anyhow!("bundle fetch request failed"))?;
|
||||
|
||||
if !response.status().is_redirection() {
|
||||
break response;
|
||||
}
|
||||
if redirects >= MAX_REDIRECTS {
|
||||
bail!("bundle fetch exceeded the five-redirect limit");
|
||||
}
|
||||
let location = response
|
||||
.headers()
|
||||
.get(reqwest::header::LOCATION)
|
||||
.ok_or_else(|| anyhow!("bundle redirect is missing a valid Location header"))?
|
||||
.to_str()
|
||||
.map_err(|_| anyhow!("bundle redirect is missing a valid Location header"))?;
|
||||
let next_url = current_url
|
||||
.join(location)
|
||||
.map_err(|_| anyhow!("bundle redirect Location is invalid"))?;
|
||||
validate_bundle_redirect(&initial_scheme, &next_url)?;
|
||||
current_url = next_url;
|
||||
redirects += 1;
|
||||
};
|
||||
|
||||
if !response.status().is_success() {
|
||||
bail!(
|
||||
"bundle fetch failed with HTTP status {}",
|
||||
response.status().as_u16()
|
||||
);
|
||||
let mut policy = network.clone();
|
||||
if is_loopback_bundle_host(&initial_host) {
|
||||
policy.add_allow(&initial_host);
|
||||
}
|
||||
let check =
|
||||
move |next: &reqwest::Url| validate_bundle_redirect(&initial_scheme, &initial_host, next);
|
||||
let request = codewhale_net::guard::GuardedGet::new(
|
||||
url,
|
||||
&policy,
|
||||
"config_import",
|
||||
codewhale_net::guard::PrivateTargets::TypedLocalHosts,
|
||||
)
|
||||
.timeouts(
|
||||
std::time::Duration::from_secs(FETCH_TIMEOUT_SECS),
|
||||
std::time::Duration::from_secs(FETCH_TIMEOUT_SECS),
|
||||
)
|
||||
.max_redirects(MAX_REDIRECTS)
|
||||
.redirect_check(&check);
|
||||
|
||||
// Read at most MAX_BUNDLE_BYTES + 1 so an oversize body is detected
|
||||
// rather than silently truncated.
|
||||
let mut buffer = Vec::new();
|
||||
let body = response;
|
||||
body.take(MAX_BUNDLE_BYTES + 1)
|
||||
.read_to_end(&mut buffer)
|
||||
.map_err(|_| anyhow!("reading remote bundle failed"))?;
|
||||
if buffer.len() as u64 > MAX_BUNDLE_BYTES {
|
||||
bail!("remote bundle exceeds the {MAX_BUNDLE_BYTES} byte limit; refused");
|
||||
}
|
||||
Ok(buffer)
|
||||
run_blocking(async move {
|
||||
let mut response = request.send().await?;
|
||||
if !response.status().is_success() {
|
||||
bail!(
|
||||
"bundle fetch failed with HTTP status {}",
|
||||
response.status().as_u16()
|
||||
);
|
||||
}
|
||||
if response
|
||||
.content_length()
|
||||
.is_some_and(|length| length > MAX_BUNDLE_BYTES)
|
||||
{
|
||||
bail!("remote bundle exceeds the {MAX_BUNDLE_BYTES} byte limit; refused");
|
||||
}
|
||||
let mut buffer = Vec::new();
|
||||
while let Some(chunk) = response
|
||||
.chunk()
|
||||
.await
|
||||
.map_err(|_| anyhow!("reading remote bundle failed"))?
|
||||
{
|
||||
if (buffer.len() + chunk.len()) as u64 > MAX_BUNDLE_BYTES {
|
||||
bail!("remote bundle exceeds the {MAX_BUNDLE_BYTES} byte limit; refused");
|
||||
}
|
||||
buffer.extend_from_slice(&chunk);
|
||||
}
|
||||
Ok(buffer)
|
||||
})
|
||||
}
|
||||
|
||||
fn run_blocking<T: Send>(future: impl std::future::Future<Output = Result<T>> + Send) -> Result<T> {
|
||||
std::thread::scope(|scope| {
|
||||
scope
|
||||
.spawn(|| {
|
||||
tokio::runtime::Builder::new_current_thread()
|
||||
.enable_all()
|
||||
.build()
|
||||
.map_err(|_| anyhow!("building bundle fetch runtime failed"))?
|
||||
.block_on(future)
|
||||
})
|
||||
.join()
|
||||
.map_err(|_| anyhow!("bundle fetch thread failed"))?
|
||||
})
|
||||
}
|
||||
|
||||
fn validate_bundle_url(url: &reqwest::Url) -> Result<()> {
|
||||
@@ -877,19 +914,37 @@ fn validate_bundle_url(url: &reqwest::Url) -> Result<()> {
|
||||
}
|
||||
}
|
||||
|
||||
fn validate_bundle_redirect(initial_scheme: &str, next_url: &reqwest::Url) -> Result<()> {
|
||||
fn validate_bundle_redirect(
|
||||
initial_scheme: &str,
|
||||
initial_host: &str,
|
||||
next_url: &reqwest::Url,
|
||||
) -> Result<()> {
|
||||
validate_bundle_url(next_url)?;
|
||||
if next_url.scheme() != initial_scheme {
|
||||
bail!("bundle redirects may not change URL scheme");
|
||||
}
|
||||
let next_host = next_url
|
||||
.host_str()
|
||||
.context("bundle URL must include a host")?;
|
||||
if codewhale_net::guard::is_visibly_local_host(next_host)
|
||||
&& !same_bundle_host(initial_host, next_host)
|
||||
{
|
||||
bail!("bundle redirects may not target a different local host");
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn same_bundle_host(left: &str, right: &str) -> bool {
|
||||
let normalize = |host: &str| host.trim().trim_end_matches('.').to_ascii_lowercase();
|
||||
normalize(left) == normalize(right)
|
||||
}
|
||||
|
||||
fn is_loopback_bundle_host(host: &str) -> bool {
|
||||
let normalized = host
|
||||
.strip_prefix('[')
|
||||
.and_then(|value| value.strip_suffix(']'))
|
||||
.unwrap_or(host);
|
||||
.unwrap_or(host)
|
||||
.trim_end_matches('.');
|
||||
normalized.eq_ignore_ascii_case("localhost")
|
||||
|| normalized.to_ascii_lowercase().ends_with(".localhost")
|
||||
|| normalized
|
||||
@@ -1571,7 +1626,13 @@ pub fn run_import(
|
||||
}
|
||||
buffer
|
||||
} else if remote_source {
|
||||
fetch_bundle(&args.source)?
|
||||
let network = store
|
||||
.config
|
||||
.network
|
||||
.clone()
|
||||
.map(codewhale_net::network_policy::NetworkPolicy::from)
|
||||
.unwrap_or_default();
|
||||
fetch_bundle(&args.source, &network)?
|
||||
} else {
|
||||
let path = PathBuf::from(&args.source);
|
||||
let metadata = std::fs::metadata(&path)
|
||||
@@ -1672,6 +1733,7 @@ pub fn run_export(args: &ExportArgs, store: &codewhale_config::ConfigStore) -> R
|
||||
mod tests {
|
||||
use super::*;
|
||||
use codewhale_config::ConfigStore;
|
||||
use codewhale_net::network_policy::NetworkPolicy;
|
||||
use std::io::Write;
|
||||
use std::net::{Ipv4Addr, TcpListener};
|
||||
|
||||
@@ -4183,7 +4245,7 @@ label = "safe-nested-workspace-label"
|
||||
|
||||
#[test]
|
||||
fn http_non_loopback_fetch_is_refused_without_network_access() {
|
||||
let err = fetch_bundle("http://example.com/bundle.toml")
|
||||
let err = fetch_bundle("http://example.com/bundle.toml", &NetworkPolicy::default())
|
||||
.expect_err("plain http to a public host must be refused");
|
||||
assert!(err.to_string().contains("loopback"), "{err:#}");
|
||||
assert!(!err.to_string().contains("example.com"), "{err:#}");
|
||||
@@ -4196,7 +4258,8 @@ label = "safe-nested-workspace-label"
|
||||
let response = http_response("302 Found", &[("Location", location.as_str())], b"");
|
||||
let (url, server) = spawn_bundle_http_server(vec![response]);
|
||||
|
||||
let error = fetch_bundle(&url).expect_err("redirect target must be revalidated");
|
||||
let error = fetch_bundle(&url, &NetworkPolicy::default())
|
||||
.expect_err("redirect target must be revalidated");
|
||||
let rendered = format!("{error:#}");
|
||||
assert!(rendered.contains("loopback"), "{rendered}");
|
||||
assert!(!rendered.contains("example.com"), "{rendered}");
|
||||
@@ -4204,13 +4267,149 @@ label = "safe-nested-workspace-label"
|
||||
assert_eq!(server.join().expect("server joins"), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn visibly_local_network_allow_entries_are_rejected_while_public_hosts_import() {
|
||||
let local_bundle = parse_bundle_str(
|
||||
r#"
|
||||
schema_version = 1
|
||||
kind = "codewhale.portable-config"
|
||||
|
||||
[global.network]
|
||||
default = "prompt"
|
||||
allow = ["docs.example.com", "localhost", "10.0.0.5"]
|
||||
"#,
|
||||
"local-allow.toml",
|
||||
)
|
||||
.expect("local allow bundle parses");
|
||||
let rejected = find_rejected_entries(&local_bundle);
|
||||
assert_eq!(rejected.len(), 1, "{rejected:?}");
|
||||
assert_eq!(rejected[0].key, "global.network");
|
||||
assert!(rejected[0].reason.contains("network.allow"), "{rejected:?}");
|
||||
assert!(!rejected[0].reason.contains("localhost"), "{rejected:?}");
|
||||
assert!(!rejected[0].reason.contains("10.0.0.5"), "{rejected:?}");
|
||||
|
||||
let mut store = isolated_store();
|
||||
let before = std::fs::read(store.path()).expect("config before import");
|
||||
let dir = store.path().parent().expect("config dir").to_path_buf();
|
||||
apply_bundle(&local_bundle, &mut store, BundleScope::Global, &dir)
|
||||
.expect_err("bundle adding visibly local network.allow entries must fail");
|
||||
assert_eq!(
|
||||
std::fs::read(store.path()).expect("config after rejected import"),
|
||||
before
|
||||
);
|
||||
|
||||
let public_bundle = parse_bundle_str(
|
||||
r#"
|
||||
schema_version = 1
|
||||
kind = "codewhale.portable-config"
|
||||
|
||||
[global.network]
|
||||
default = "prompt"
|
||||
allow = ["docs.example.com"]
|
||||
"#,
|
||||
"public-allow.toml",
|
||||
)
|
||||
.expect("public allow bundle parses");
|
||||
assert!(find_rejected_entries(&public_bundle).is_empty());
|
||||
let receipt = apply_bundle(&public_bundle, &mut store, BundleScope::Global, &dir)
|
||||
.expect("public network.allow entry must import");
|
||||
assert!(receipt.plan.rejected.is_empty());
|
||||
assert_eq!(
|
||||
store
|
||||
.config
|
||||
.network
|
||||
.as_ref()
|
||||
.map(|network| network.allow.as_slice()),
|
||||
Some(&["docs.example.com".to_string()][..])
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn localdomain_and_localhost_wildcard_network_allow_entries_are_rejected_and_scrubbed() {
|
||||
for (section, entry) in [
|
||||
("global", ".localdomain"),
|
||||
("global", "*.localdomain"),
|
||||
("global", ".localhost"),
|
||||
("global", "*.localhost"),
|
||||
("global", "*.dev.localhost"),
|
||||
("project", "*.localdomain"),
|
||||
] {
|
||||
let bundle_text = format!(
|
||||
r#"
|
||||
schema_version = 1
|
||||
kind = "codewhale.portable-config"
|
||||
|
||||
[{section}.network]
|
||||
default = "prompt"
|
||||
allow = ["docs.example.com", "{entry}"]
|
||||
"#
|
||||
);
|
||||
let bundle = parse_bundle_str(&bundle_text, "wildcard-local-allow.toml")
|
||||
.expect("wildcard allow bundle parses");
|
||||
let rejected = find_rejected_entries(&bundle);
|
||||
assert_eq!(rejected.len(), 1, "{section} {entry}: {rejected:?}");
|
||||
assert_eq!(rejected[0].key, format!("{section}.network"));
|
||||
assert!(rejected[0].reason.contains("network.allow"), "{rejected:?}");
|
||||
assert!(!rejected[0].reason.contains(entry), "{rejected:?}");
|
||||
}
|
||||
|
||||
let combined = parse_bundle_str(
|
||||
r#"
|
||||
schema_version = 1
|
||||
kind = "codewhale.portable-config"
|
||||
|
||||
[global.network]
|
||||
default = "prompt"
|
||||
allow = [".localdomain", "*.localdomain", "*.localhost"]
|
||||
"#,
|
||||
"combined-wildcard-local-allow.toml",
|
||||
)
|
||||
.expect("combined wildcard bundle parses");
|
||||
let mut store = isolated_store();
|
||||
let before = std::fs::read(store.path()).expect("config before import");
|
||||
let dir = store.path().parent().expect("config dir").to_path_buf();
|
||||
apply_bundle(&combined, &mut store, BundleScope::Global, &dir)
|
||||
.expect_err("bundle adding local-domain wildcard network.allow entries must fail");
|
||||
assert_eq!(
|
||||
std::fs::read(store.path()).expect("config after rejected import"),
|
||||
before
|
||||
);
|
||||
|
||||
let config: ConfigToml = toml::from_str(
|
||||
r#"
|
||||
[network]
|
||||
default = "prompt"
|
||||
allow = ["docs.example.com", ".localdomain", "*.localdomain", ".localhost", "*.localhost", "localhost", "10.0.0.5"]
|
||||
"#,
|
||||
)
|
||||
.expect("config with local network.allow entries parses");
|
||||
let exported = export_bundle(&config, BundleScope::Global, BundleMetadata::default())
|
||||
.expect("local network.allow entries are scrubbed on export");
|
||||
let body = serialize_bundle(&exported).expect("serialize scrubbed export");
|
||||
for forbidden in [
|
||||
".localdomain",
|
||||
"*.localdomain",
|
||||
".localhost",
|
||||
"*.localhost",
|
||||
"\"localhost\"",
|
||||
"10.0.0.5",
|
||||
] {
|
||||
assert!(
|
||||
!body.contains(forbidden),
|
||||
"export retained {forbidden}: {body}"
|
||||
);
|
||||
}
|
||||
assert!(body.contains("docs.example.com"), "{body}");
|
||||
assert!(find_rejected_entries(&exported).is_empty(), "{exported:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn https_redirect_cannot_downgrade_to_loopback_http() {
|
||||
let secret = "downgrade-secret-must-not-leak";
|
||||
let target = reqwest::Url::parse(&format!("http://127.0.0.1/internal?token={secret}"))
|
||||
.expect("test target URL");
|
||||
|
||||
let error = validate_bundle_redirect("https", &target)
|
||||
let error = validate_bundle_redirect("https", "example.com", &target)
|
||||
.expect_err("HTTPS redirect must not downgrade to loopback HTTP");
|
||||
let rendered = format!("{error:#}");
|
||||
assert!(rendered.contains("scheme"), "{rendered}");
|
||||
@@ -4218,6 +4417,59 @@ label = "safe-nested-workspace-label"
|
||||
assert!(!rendered.contains("127.0.0.1"), "{rendered}");
|
||||
}
|
||||
|
||||
fn spawn_counting_server(
|
||||
response: Vec<u8>,
|
||||
) -> (u16, std::sync::Arc<std::sync::atomic::AtomicUsize>) {
|
||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
let listener = TcpListener::bind((Ipv4Addr::LOCALHOST, 0)).expect("bind counting fixture");
|
||||
let port = listener.local_addr().expect("fixture address").port();
|
||||
let hits = std::sync::Arc::new(AtomicUsize::new(0));
|
||||
let counter = std::sync::Arc::clone(&hits);
|
||||
std::thread::spawn(move || {
|
||||
for stream in listener.incoming() {
|
||||
let Ok(mut stream) = stream else { return };
|
||||
counter.fetch_add(1, Ordering::SeqCst);
|
||||
let mut chunk = [0_u8; 1024];
|
||||
let _ = stream.read(&mut chunk);
|
||||
let _ = stream.write_all(&response);
|
||||
}
|
||||
});
|
||||
(port, hits)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn redirect_to_another_local_host_is_refused() {
|
||||
use std::sync::atomic::Ordering;
|
||||
let (port, hits) = spawn_counting_server(http_response(
|
||||
"200 OK",
|
||||
&[("Content-Type", "text/plain")],
|
||||
VALID_TOML.as_bytes(),
|
||||
));
|
||||
let location = format!("http://localhost:{port}/bundle.toml");
|
||||
let redirect = http_response("302 Found", &[("Location", location.as_str())], b"");
|
||||
let (url, entry_server) = spawn_bundle_http_server(vec![redirect.clone()]);
|
||||
|
||||
let error = fetch_bundle(&url, &NetworkPolicy::default())
|
||||
.expect_err("a hop to another local host must be refused");
|
||||
assert!(!format!("{error:#}").contains("bundle.toml"), "{error:#}");
|
||||
assert_eq!(entry_server.join().expect("entry joins"), 1);
|
||||
assert_eq!(hits.load(Ordering::SeqCst), 0);
|
||||
|
||||
let (listed_url, listed_entry_server) = spawn_bundle_http_server(vec![redirect]);
|
||||
let listed_policy = NetworkPolicy {
|
||||
allow: vec!["localhost".to_string()],
|
||||
..NetworkPolicy::default()
|
||||
};
|
||||
let listed_error = fetch_bundle(&listed_url, &listed_policy)
|
||||
.expect_err("even a listed local host must be refused on redirect from another host");
|
||||
assert!(
|
||||
!format!("{listed_error:#}").contains("bundle.toml"),
|
||||
"{listed_error:#}"
|
||||
);
|
||||
assert_eq!(listed_entry_server.join().expect("listed entry joins"), 1);
|
||||
assert_eq!(hits.load(Ordering::SeqCst), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn relative_loopback_redirect_fetches_bundle() {
|
||||
let redirect = http_response("302 Found", &[("Location", "/bundle.toml")], b"");
|
||||
@@ -4225,7 +4477,8 @@ label = "safe-nested-workspace-label"
|
||||
let success = http_response("200 OK", &[("Content-Type", "text/plain")], body);
|
||||
let (url, server) = spawn_bundle_http_server(vec![redirect, success]);
|
||||
|
||||
let fetched = fetch_bundle(&url).expect("relative redirect remains allowed");
|
||||
let fetched = fetch_bundle(&url, &NetworkPolicy::default())
|
||||
.expect("relative redirect remains allowed");
|
||||
assert_eq!(fetched, body);
|
||||
assert_eq!(server.join().expect("server joins"), 2);
|
||||
}
|
||||
@@ -4240,16 +4493,23 @@ label = "safe-nested-workspace-label"
|
||||
.collect();
|
||||
let (url, server) = spawn_bundle_http_server(responses);
|
||||
|
||||
let error = fetch_bundle(&url).expect_err("sixth redirect must be refused");
|
||||
assert!(error.to_string().contains("five-redirect"), "{error:#}");
|
||||
let error = fetch_bundle(&url, &NetworkPolicy::default())
|
||||
.expect_err("sixth redirect must be refused");
|
||||
assert!(
|
||||
error.to_string().contains("too many redirects"),
|
||||
"{error:#}"
|
||||
);
|
||||
assert_eq!(server.join().expect("server joins"), MAX_REDIRECTS + 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bundle_url_credentials_are_rejected_without_echoing_them() {
|
||||
let secret = "credential-secret-must-not-leak";
|
||||
let error = fetch_bundle(&format!("https://user:{secret}@example.com/bundle.toml"))
|
||||
.expect_err("URL userinfo must be refused");
|
||||
let error = fetch_bundle(
|
||||
&format!("https://user:{secret}@example.com/bundle.toml"),
|
||||
&NetworkPolicy::default(),
|
||||
)
|
||||
.expect_err("URL userinfo must be refused");
|
||||
let rendered = format!("{error:#}");
|
||||
assert!(rendered.contains("credentials"), "{rendered}");
|
||||
assert!(!rendered.contains(secret), "{rendered}");
|
||||
@@ -4258,7 +4518,8 @@ label = "safe-nested-workspace-label"
|
||||
|
||||
#[test]
|
||||
fn unsupported_schemes_are_refused() {
|
||||
let err = fetch_bundle("file:///etc/passwd").expect_err("file scheme refused");
|
||||
let err = fetch_bundle("file:///etc/passwd", &NetworkPolicy::default())
|
||||
.expect_err("file scheme refused");
|
||||
assert!(err.to_string().contains("scheme"), "{err:#}");
|
||||
}
|
||||
|
||||
|
||||
@@ -234,10 +234,10 @@ enum Commands {
|
||||
#[command(about = "Install a plugin bundle without trusting or enabling it")]
|
||||
Install(TuiPassthroughArgs),
|
||||
#[command(
|
||||
about = "Inspect, review, trust, enable, disable or revoke plugin bundles",
|
||||
about = "Install, inspect, review, trust, enable, disable, update, uninstall or revoke plugin bundles",
|
||||
alias = "plugin",
|
||||
override_usage = "codewhale plugins <COMMAND>",
|
||||
after_help = "Commands:\n status [NAME] [--json] Inspect plugin status and diagnostics\n list [NAME] [--json] Alias of status\n restage <NAME> [--json] Restore an eligible reviewed plugin snapshot\n trust <NAME> [--json] Show the capability review; in a terminal, then ask\n trust <NAME> --review-token T [--yes] [--json] Trust exactly the content the bundle review token names\n enable <NAME> [--json] Enable a trusted plugin\n disable <NAME> [--json] Disable a plugin, keeping its trust\n revoke <NAME> [--json] Revoke trust\n\nThe bundle review token ties trust to exact content; it does not show that anyone read the review,\nand it is not the host-service review token. A --yes trust is recorded as the command line. When\nCODEWHALE_AGENT_PROJECT marks the process, as it does every command the agent runs, trust and enable\nare the agent's, for that project only; that mark is a label the agent could remove in Full Access."
|
||||
after_help = "Commands:\n install <SPEC> [--json] Install a plugin bundle without trusting or enabling it\n status [NAME] [--json] Inspect plugin status and diagnostics\n list [NAME] [--json] Alias of status\n show <NAME> [--json] Show the capability review without prompting or trusting\n restage <NAME> [--json] Restore an eligible reviewed plugin snapshot\n trust <NAME> [--json] Show the capability review; in a terminal, then ask\n trust <NAME> --review-token T [--yes] [--json] Trust exactly the content the bundle review token names\n enable <NAME> [--json] Enable a trusted plugin\n disable <NAME> [--json] Disable a plugin, keeping its trust\n update <NAME> [--json] Update an installed plugin bundle from its recorded source\n uninstall <NAME> [--yes] [--json] Disable if needed and remove an installed plugin bundle\n revoke <NAME> [--json] Revoke trust\n\nThe bundle review token ties trust to exact content; it does not show that anyone read the review,\nand it is not the host-service review token. A --yes trust is recorded as the command line. When\nCODEWHALE_AGENT_PROJECT marks the process, as it does every command the agent runs, trust and enable\nare the agent's, for that project only; that mark is a label the agent could remove in Full Access."
|
||||
)]
|
||||
Plugins(TuiPassthroughArgs),
|
||||
/// Bootstrap MCP config and/or skills directories.
|
||||
|
||||
@@ -0,0 +1,193 @@
|
||||
import { Service } from '@deepseek-ai/cordis'
|
||||
import { ErrorCode } from '../protocol.ts'
|
||||
import { RpcError } from '../rpc.ts'
|
||||
import type { AdapterHost, DshAdapter } from './adapters.ts'
|
||||
|
||||
export interface ApprovedRoute {
|
||||
provider: string
|
||||
base_url: string
|
||||
default_model: string | null
|
||||
models: string[]
|
||||
}
|
||||
|
||||
export interface ResolvedRoute extends ApprovedRoute {
|
||||
model: string | null
|
||||
}
|
||||
|
||||
export interface InstallPlan {
|
||||
status: 'review_required' | 'already_present'
|
||||
package: string
|
||||
version: string
|
||||
plugin: string
|
||||
spec?: string
|
||||
command: string
|
||||
message: string
|
||||
}
|
||||
|
||||
export type BalanceAnswer =
|
||||
| { status: 'known', currency: string, total: string, topped_up: string, granted: string }
|
||||
| { status: 'unknown' }
|
||||
| { status: 'absent' }
|
||||
|
||||
interface AccountWallet {
|
||||
currency: string
|
||||
balance: string
|
||||
}
|
||||
|
||||
type AccountBalance = { status: 'ready', value: AccountWallet[], bonusWallets: AccountWallet[] } | { status: 'failed' }
|
||||
|
||||
interface ChangeResult {
|
||||
changed: false
|
||||
application: 'failed'
|
||||
stage: 'install' | 'remove'
|
||||
target: string
|
||||
error: { code: string, diagnostic: string, message: string }
|
||||
}
|
||||
|
||||
const EXACT_VERSION_ONLY = 'Codewhale plans a plugin install only for one exact npm version, written [email protected]'
|
||||
const REMOVE_IN_CODEWHALE = 'Codewhale removes a plugin only from its own plugin manager, after you disable it: /plugin uninstall'
|
||||
const WITHHELD_ACCOUNT_METHODS = [
|
||||
'getState', 'getProfile', 'getUnnotifiedBonuses', 'ackBonusNotified', 'startSignIn', 'cancelSignIn', 'signOut', 'watch',
|
||||
'resolveToken', 'rejectToken', 'getPlatformSession', 'getDeviceIdentity',
|
||||
] as const
|
||||
|
||||
function invalid(what: string): RpcError {
|
||||
return new RpcError(ErrorCode.Internal, `invalid catalog broker ${what}`)
|
||||
}
|
||||
|
||||
function record(value: unknown, what: string): Record<string, unknown> {
|
||||
if (value === null || typeof value !== 'object' || Array.isArray(value)) throw invalid(what)
|
||||
return value as Record<string, unknown>
|
||||
}
|
||||
|
||||
function text(value: unknown, what: string): string {
|
||||
if (typeof value !== 'string' || value.length === 0) throw invalid(what)
|
||||
return value
|
||||
}
|
||||
|
||||
function named(value: unknown, what: string): string {
|
||||
if (typeof value !== 'string' || value.length === 0) throw new RpcError(ErrorCode.InvalidParams, `${what} is a non-empty string`)
|
||||
return value
|
||||
}
|
||||
|
||||
function approvedRoute(value: unknown): ApprovedRoute {
|
||||
const row = record(value, 'route')
|
||||
const models = row.models
|
||||
const defaultModel = row.default_model
|
||||
if (!Array.isArray(models) || models.some((model) => typeof model !== 'string') || (defaultModel !== null && typeof defaultModel !== 'string')) throw invalid('route')
|
||||
return { provider: text(row.provider, 'route'), base_url: text(row.base_url, 'route'), default_model: defaultModel, models: [...models] }
|
||||
}
|
||||
|
||||
export async function approvedRoutes(host: AdapterHost, ctx: unknown): Promise<ApprovedRoute[]> {
|
||||
const routes = record(await host.broker(ctx, 'catalog', 'routes.list', {}), 'routes').routes
|
||||
if (!Array.isArray(routes)) throw invalid('routes')
|
||||
return routes.map(approvedRoute)
|
||||
}
|
||||
|
||||
export async function resolveRoute(host: AdapterHost, ctx: unknown, provider: unknown, model?: unknown): Promise<ResolvedRoute> {
|
||||
const input = model === undefined || model === null
|
||||
? { provider: named(provider, 'a provider route') }
|
||||
: { provider: named(provider, 'a provider route'), model: named(model, 'a model') }
|
||||
const answer = record(await host.broker(ctx, 'catalog', 'routes.resolve', input), 'route')
|
||||
const resolved = answer.model
|
||||
if (resolved !== null && typeof resolved !== 'string') throw invalid('route')
|
||||
return { ...approvedRoute(answer.route), model: resolved }
|
||||
}
|
||||
|
||||
export async function accountBalance(host: AdapterHost, ctx: unknown): Promise<BalanceAnswer> {
|
||||
const answer = record(await host.broker(ctx, 'catalog', 'balance', {}), 'balance')
|
||||
const status = answer.status
|
||||
if (status === 'unknown' || status === 'absent') return { status }
|
||||
const toppedUp = answer.topped_up
|
||||
const granted = answer.granted
|
||||
if (status !== 'known' || typeof toppedUp !== 'string' || typeof granted !== 'string') throw invalid('balance')
|
||||
return { status, currency: text(answer.currency, 'balance'), total: text(answer.total, 'balance'), topped_up: toppedUp, granted }
|
||||
}
|
||||
|
||||
export async function planInstall(host: AdapterHost, ctx: unknown, spec: unknown, registry?: unknown): Promise<InstallPlan> {
|
||||
const exact = typeof spec === 'string' ? spec.lastIndexOf('@') : -1
|
||||
if (typeof spec !== 'string' || exact <= 0 || exact === spec.length - 1) throw new RpcError(ErrorCode.InvalidParams, EXACT_VERSION_ONLY)
|
||||
const request = { package: spec.slice(0, exact), version: spec.slice(exact + 1) }
|
||||
const input = registry === undefined || registry === null ? request : { ...request, registry: named(registry, 'a registry URL') }
|
||||
const plan = record(await host.broker(ctx, 'catalog', 'install.plan', input), 'install plan')
|
||||
const status = plan.status
|
||||
const planned = plan.spec
|
||||
if ((status !== 'review_required' && status !== 'already_present') || (planned !== undefined && typeof planned !== 'string')) throw invalid('install plan')
|
||||
return {
|
||||
status,
|
||||
package: text(plan.package, 'install plan'),
|
||||
version: text(plan.version, 'install plan'),
|
||||
plugin: text(plan.plugin, 'install plan'),
|
||||
...(planned === undefined ? {} : { spec: planned }),
|
||||
command: text(plan.command, 'install plan'),
|
||||
message: text(plan.message, 'install plan'),
|
||||
}
|
||||
}
|
||||
|
||||
function notChanged(stage: ChangeResult['stage'], target: unknown, code: string, diagnostic: string): ChangeResult {
|
||||
return { changed: false, application: 'failed', stage, target: typeof target === 'string' ? target : '', error: { code, diagnostic, message: diagnostic } }
|
||||
}
|
||||
|
||||
function declined(error: unknown): string | undefined {
|
||||
if (!(error instanceof Error)) return undefined
|
||||
if (error instanceof RpcError && error.code === ErrorCode.InvalidParams && error.data === undefined) return error.message
|
||||
const refusal = (error as { data?: { dsh_refusal?: unknown } }).data?.dsh_refusal
|
||||
return refusal === 'refused' || refusal === 'invalid_input' ? error.message : undefined
|
||||
}
|
||||
|
||||
export const pluginManagerAdapter: DshAdapter = {
|
||||
service: 'pluginManager',
|
||||
define(host) {
|
||||
class PluginManagerService extends Service {
|
||||
constructor(ctx: any) { super(ctx, 'pluginManager') }
|
||||
|
||||
async installBundle(spec: unknown, options?: { registry?: unknown } | null): Promise<ChangeResult> {
|
||||
try {
|
||||
const plan = await planInstall(host, this.ctx, spec, options?.registry)
|
||||
return notChanged('install', spec, plan.status === 'already_present' ? 'already-installed' : 'management-required', plan.message)
|
||||
} catch (error) {
|
||||
const diagnostic = declined(error)
|
||||
if (diagnostic === undefined) throw error
|
||||
return notChanged('install', spec, 'invalid-spec', diagnostic)
|
||||
}
|
||||
}
|
||||
|
||||
async removeBundle(target: unknown): Promise<ChangeResult> {
|
||||
return notChanged('remove', target, 'management-required', REMOVE_IN_CODEWHALE)
|
||||
}
|
||||
|
||||
async cancelInstall(): Promise<{ status: 'not-running' }> {
|
||||
return { status: 'not-running' }
|
||||
}
|
||||
}
|
||||
Object.freeze(PluginManagerService.prototype)
|
||||
return PluginManagerService
|
||||
},
|
||||
}
|
||||
|
||||
export const deepseekAccountAdapter: DshAdapter = {
|
||||
service: 'deepseekAccount',
|
||||
define(host) {
|
||||
class DeepSeekAccountService extends Service {
|
||||
constructor(ctx: any) { super(ctx, 'deepseekAccount') }
|
||||
|
||||
async getBalance(): Promise<AccountBalance | null> {
|
||||
const answer = await accountBalance(host, this.ctx)
|
||||
if (answer.status === 'absent') return null
|
||||
if (answer.status === 'unknown') return { status: 'failed' }
|
||||
const { currency, total, topped_up: toppedUp, granted } = answer
|
||||
const wallet = (balance: string): AccountWallet[] => balance.length > 0 ? [{ currency, balance }] : []
|
||||
return { status: 'ready', value: wallet(toppedUp.length > 0 ? toppedUp : total), bonusWallets: wallet(granted) }
|
||||
}
|
||||
}
|
||||
for (const method of WITHHELD_ACCOUNT_METHODS) {
|
||||
Object.defineProperty(DeepSeekAccountService.prototype, method, {
|
||||
value() {
|
||||
throw new RpcError(ErrorCode.NotAvailable, `deepseekAccount.${method} is not provided: Codewhale answers a read-only balance and never gives a plugin an account credential or a sign-in`)
|
||||
},
|
||||
})
|
||||
}
|
||||
Object.freeze(DeepSeekAccountService.prototype)
|
||||
return DeepSeekAccountService
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
import { Service } from '@deepseek-ai/cordis'
|
||||
import { ErrorCode } from '../protocol.ts'
|
||||
import { RpcError } from '../rpc.ts'
|
||||
import type { AdapterHost, DshAdapter } from './adapters.ts'
|
||||
|
||||
export interface SettingsDescriptor {
|
||||
ns: string
|
||||
writable: boolean
|
||||
roots: string[]
|
||||
value: Record<string, unknown>
|
||||
revision: string
|
||||
applied: boolean | null
|
||||
applies: 'reload'
|
||||
}
|
||||
|
||||
export type SettingsPathOp =
|
||||
| { op: 'set', path: readonly string[], value: unknown }
|
||||
| { op: 'unset', path: readonly string[] }
|
||||
|
||||
export interface SettingsWrite {
|
||||
written: boolean
|
||||
revision: string
|
||||
applies: 'reload'
|
||||
}
|
||||
|
||||
export class SettingsConflictError extends Error {
|
||||
readonly code = 'SETTINGS_CONFLICT'
|
||||
constructor(readonly ns: string, readonly expected: string | undefined, readonly actual: string) {
|
||||
super(`settings namespace "${ns}" changed since it was read`)
|
||||
this.name = 'SettingsConflictError'
|
||||
}
|
||||
}
|
||||
|
||||
function plain(value: unknown, what: string): Record<string, unknown> {
|
||||
if (typeof value !== 'object' || value === null || Array.isArray(value)) throw new RpcError(ErrorCode.InvalidParams, `${what} is a plain object`)
|
||||
return value as Record<string, unknown>
|
||||
}
|
||||
|
||||
function revision(value: unknown): string | undefined {
|
||||
if (value === undefined) return undefined
|
||||
if (typeof value !== 'string' || value.length === 0) throw new RpcError(ErrorCode.InvalidParams, 'an expected revision is the revision string describe returned')
|
||||
return value
|
||||
}
|
||||
|
||||
function descriptor(value: unknown): SettingsDescriptor {
|
||||
const row = value as Partial<SettingsDescriptor> | null
|
||||
if (row === null || typeof row !== 'object' || typeof row.ns !== 'string' || typeof row.revision !== 'string' || typeof row.writable !== 'boolean'
|
||||
|| !Array.isArray(row.roots) || typeof row.value !== 'object' || row.value === null || row.applies !== 'reload') {
|
||||
throw new RpcError(ErrorCode.Internal, 'invalid settings broker response')
|
||||
}
|
||||
return { ns: row.ns, writable: row.writable, roots: row.roots.map(String), value: row.value, revision: row.revision, applied: typeof row.applied === 'boolean' ? row.applied : null, applies: 'reload' }
|
||||
}
|
||||
|
||||
export const settingsAdapter: DshAdapter = {
|
||||
service: 'settings',
|
||||
define(host: AdapterHost) {
|
||||
class SettingsService extends Service {
|
||||
constructor(ctx: any) { super(ctx, 'settings') }
|
||||
|
||||
async describe(): Promise<SettingsDescriptor[]> {
|
||||
return [descriptor(await host.broker(this.ctx, 'settings', 'describe', {}))]
|
||||
}
|
||||
|
||||
async update(ns: unknown, patch: unknown, expectedRevision?: unknown): Promise<SettingsWrite> {
|
||||
return this.write(ns, 'update', { patch: plain(patch, 'a settings patch') }, expectedRevision)
|
||||
}
|
||||
|
||||
async replace(ns: unknown, section: unknown, expectedRevision?: unknown): Promise<SettingsWrite> {
|
||||
return this.write(ns, 'replace', { value: plain(section, 'a settings section') }, expectedRevision)
|
||||
}
|
||||
|
||||
async mutate(ns: unknown, ops: unknown, expectedRevision?: unknown): Promise<SettingsWrite> {
|
||||
if (!Array.isArray(ops)) throw new RpcError(ErrorCode.InvalidParams, 'settings edits are an array of path operations')
|
||||
return this.write(ns, 'mutate', { ops }, expectedRevision)
|
||||
}
|
||||
|
||||
private async write(ns: unknown, method: 'update' | 'replace' | 'mutate', input: Record<string, unknown>, expectedRevision: unknown): Promise<SettingsWrite> {
|
||||
if (typeof ns !== 'string' || ns.length === 0) throw new RpcError(ErrorCode.InvalidParams, 'a settings namespace is a non-empty string')
|
||||
const owner = host.ownerOf(this.ctx)
|
||||
if (owner === undefined || ns !== owner.pluginName) throw new RpcError(ErrorCode.Refused, 'a plugin may edit only its own settings namespace')
|
||||
const expected = revision(expectedRevision)
|
||||
const result = await host.broker(this.ctx, 'settings', method, expected === undefined ? input : { ...input, expected_revision: expected }) as { written?: unknown, conflict?: unknown, revision?: unknown } | null
|
||||
if (result === null || typeof result !== 'object' || typeof result.written !== 'boolean' || typeof result.revision !== 'string') throw new RpcError(ErrorCode.Internal, 'invalid settings broker response')
|
||||
if (result.conflict === true) throw new SettingsConflictError(ns, expected, result.revision)
|
||||
return { written: result.written, revision: result.revision, applies: 'reload' }
|
||||
}
|
||||
}
|
||||
Object.freeze(SettingsService.prototype)
|
||||
return SettingsService
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
import { test, before } from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import { mkdtempSync, rmSync } from 'node:fs'
|
||||
import { join, dirname } from 'node:path'
|
||||
import { fileURLToPath, pathToFileURL } from 'node:url'
|
||||
import { build } from 'esbuild'
|
||||
|
||||
const here = dirname(fileURLToPath(import.meta.url))
|
||||
let catalog
|
||||
|
||||
before(async () => {
|
||||
const dir = mkdtempSync(join(here, '..', 'node_modules', '.cw-catalog-'))
|
||||
const outfile = join(dir, 'catalog.mjs')
|
||||
await build({ entryPoints: [join(here, '../src/dsh/catalog.ts')], outfile, bundle: true, platform: 'node', format: 'esm', packages: 'external', absWorkingDir: join(here, '..'), logLevel: 'silent' })
|
||||
catalog = await import(pathToFileURL(outfile).href)
|
||||
process.on('exit', () => rmSync(dir, { recursive: true, force: true }))
|
||||
})
|
||||
|
||||
function brokerHost(answers) {
|
||||
const calls = []
|
||||
return {
|
||||
calls,
|
||||
host: {
|
||||
tier: 'plugin',
|
||||
broker: async (ctx, service, method, input) => {
|
||||
calls.push({ service, method, input })
|
||||
const answer = typeof answers[method] === 'function' ? answers[method](input) : answers[method]
|
||||
if (answer instanceof Error) throw answer
|
||||
return answer
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function serviceOf(adapter, host) {
|
||||
const service = Object.create(adapter.define(host).prototype)
|
||||
service.ctx = {}
|
||||
return service
|
||||
}
|
||||
|
||||
const brokerRefusal = (code, refusal, message) => Object.assign(new Error(message), { code, data: { dsh_refusal: refusal, service: 'catalog' } })
|
||||
|
||||
test('a balance widget reads an absent account, an unknown balance and a known balance, and never a key', async () => {
|
||||
const answers = { balance: { status: 'absent' } }
|
||||
const { host, calls } = brokerHost(answers)
|
||||
const account = serviceOf(catalog.deepseekAccountAdapter, host)
|
||||
const client = { version: '0.4.2', locale: 'zh_CN', timezoneOffsetSeconds: 28800 }
|
||||
|
||||
assert.equal(await account.getBalance(client), null)
|
||||
answers.balance = { status: 'unknown' }
|
||||
assert.deepEqual(await account.getBalance(client), { status: 'failed' })
|
||||
answers.balance = { status: 'known', currency: 'CNY', total: '123.45', topped_up: '100.00', granted: '23.45' }
|
||||
const ready = await account.getBalance(client)
|
||||
assert.deepEqual(ready, { status: 'ready', value: [{ currency: 'CNY', balance: '100.00' }], bonusWallets: [{ currency: 'CNY', balance: '23.45' }] })
|
||||
const shown = [...ready.value, ...ready.bonusWallets].reduce((sum, wallet) => sum + Number(wallet.balance), 0)
|
||||
assert.equal(shown.toFixed(2), '123.45')
|
||||
answers.balance = { status: 'known', currency: 'USD', total: '12.50', topped_up: '', granted: '' }
|
||||
assert.deepEqual(await account.getBalance(client), { status: 'ready', value: [{ currency: 'USD', balance: '12.50' }], bonusWallets: [] })
|
||||
|
||||
assert.deepEqual(calls.map((call) => [call.service, call.method, call.input]), Array(4).fill(['catalog', 'balance', {}]))
|
||||
for (const method of ['resolveToken', 'getPlatformSession', 'getState', 'startSignIn', 'signOut', 'rejectToken']) {
|
||||
assert.throws(() => account[method]('https://api.deepseek.com'), (error) => error.code === -32001 && error.message.includes(`deepseekAccount.${method} is not provided`))
|
||||
}
|
||||
assert.equal(calls.length, 4)
|
||||
|
||||
answers.balance = { status: 'known', currency: 'CNY', total: '1', topped_up: '1', granted: '0', api_key: 'sk-raw' }
|
||||
assert.deepEqual(Object.keys(await catalog.accountBalance(host, {})).sort(), ['currency', 'granted', 'status', 'topped_up', 'total'])
|
||||
answers.balance = { status: 'ready', value: [] }
|
||||
await assert.rejects(account.getBalance(client), /invalid catalog broker balance/)
|
||||
answers.balance = brokerRefusal(-32001, 'not_available', 'the catalog broker is not available yet')
|
||||
await assert.rejects(account.getBalance(client), /not available yet/)
|
||||
})
|
||||
|
||||
test('a market plugin asking to install gets the reviewed-install answer, and nothing installs or removes', async () => {
|
||||
const review = {
|
||||
status: 'review_required', package: '@acme/review', version: '1.10.0', plugin: 'review', spec: 'npm:@acme/[email protected]',
|
||||
command: '/plugin install npm:@acme/[email protected]',
|
||||
message: 'Codewhale installs a plugin only through its reviewed install flow; nothing was installed. Review and install @acme/[email protected] with `/plugin install npm:@acme/[email protected]`.',
|
||||
}
|
||||
const answers = { 'install.plan': review }
|
||||
const { host, calls } = brokerHost(answers)
|
||||
const manager = serviceOf(catalog.pluginManagerAdapter, host)
|
||||
|
||||
const requested = await manager.installBundle('@acme/[email protected]', { requestId: 'r1', registry: 'https://registry.example.com' })
|
||||
assert.deepEqual(requested, {
|
||||
changed: false, application: 'failed', stage: 'install', target: '@acme/[email protected]',
|
||||
error: { code: 'management-required', diagnostic: review.message, message: review.message },
|
||||
})
|
||||
assert.deepEqual(calls[0], { service: 'catalog', method: 'install.plan', input: { package: '@acme/review', version: '1.10.0', registry: 'https://registry.example.com' } })
|
||||
await manager.installBundle('[email protected]')
|
||||
assert.deepEqual(calls[1].input, { package: 'dsh-demo', version: '1.2.3' })
|
||||
|
||||
answers['install.plan'] = { status: 'already_present', package: 'dsh-demo', version: '1.2.3', plugin: 'dsh-demo', command: '/plugin trust dsh-demo', message: 'A plugin named `dsh-demo` is already present; nothing was installed.' }
|
||||
assert.equal((await manager.installBundle('[email protected]')).error.code, 'already-installed')
|
||||
|
||||
answers['install.plan'] = brokerRefusal(-32002, 'refused', '`dsh-demo@^1.2.3` is not an exact npm package version Codewhale can fetch')
|
||||
const range = await manager.installBundle('dsh-demo@^1.2.3')
|
||||
assert.equal(range.error.code, 'invalid-spec')
|
||||
assert.match(range.error.diagnostic, /not an exact npm package version/)
|
||||
|
||||
const sent = calls.length
|
||||
for (const spec of ['dsh-demo', '@acme/review', 'git+https://github.com/acme/review.git#0123456789abcdef0123456789abcdef01234567', 'dsh-demo@', 7]) {
|
||||
const refused = await manager.installBundle(spec)
|
||||
assert.equal(refused.error.code, 'invalid-spec', String(spec))
|
||||
assert.equal(refused.application, 'failed')
|
||||
}
|
||||
assert.equal((await manager.installBundle('[email protected]', { registry: 7 })).error.code, 'invalid-spec')
|
||||
assert.deepEqual(await manager.removeBundle('dsh-demo'), {
|
||||
changed: false, application: 'failed', stage: 'remove', target: 'dsh-demo',
|
||||
error: { code: 'management-required', diagnostic: 'Codewhale removes a plugin only from its own plugin manager, after you disable it: /plugin uninstall', message: 'Codewhale removes a plugin only from its own plugin manager, after you disable it: /plugin uninstall' },
|
||||
})
|
||||
assert.deepEqual(await manager.cancelInstall('r1'), { status: 'not-running' })
|
||||
assert.equal(calls.length, sent, 'a spec that is not name@exact-version, a removal and a cancel never reach the broker')
|
||||
|
||||
answers['install.plan'] = brokerRefusal(-32002, 'ticket_refused', 'the invocation this dsh/broker:catalog belongs to has ended')
|
||||
await assert.rejects(manager.installBundle('[email protected]'), /has ended/)
|
||||
answers['install.plan'] = { status: 'installed', package: 'dsh-demo', version: '1.2.3', plugin: 'dsh-demo', command: 'x', message: 'x' }
|
||||
await assert.rejects(manager.installBundle('[email protected]'), /invalid catalog broker install plan/)
|
||||
})
|
||||
|
||||
test('a model-proxy directory is built from approved provider routes, and an unapproved route is the refusal', async () => {
|
||||
const gateway = { provider: 'gateway', base_url: 'https://gateway.example/api', default_model: 'tiny-model', models: ['tiny-model', 'large-model'] }
|
||||
const answers = {
|
||||
'routes.list': { routes: [gateway] },
|
||||
'routes.resolve': (input) => input.provider === 'gateway'
|
||||
? { route: gateway, model: input.model ?? gateway.default_model }
|
||||
: brokerRefusal(-32002, 'refused', `\`${input.provider}\` is not a provider route approved for this plugin`),
|
||||
}
|
||||
const { host, calls } = brokerHost(answers)
|
||||
|
||||
const routes = await catalog.approvedRoutes(host, {})
|
||||
const directory = {
|
||||
providers: routes.map((route) => ({ provider: route.provider, active: true })),
|
||||
models: routes.map((route) => ({ provider: route.provider, models: route.models.map((id) => ({ id })) })),
|
||||
}
|
||||
assert.deepEqual(directory, {
|
||||
providers: [{ provider: 'gateway', active: true }],
|
||||
models: [{ provider: 'gateway', models: [{ id: 'tiny-model' }, { id: 'large-model' }] }],
|
||||
})
|
||||
|
||||
assert.deepEqual(await catalog.resolveRoute(host, {}, 'gateway', 'large-model'), { ...gateway, model: 'large-model' })
|
||||
assert.deepEqual(await catalog.resolveRoute(host, {}, 'gateway'), { ...gateway, model: 'tiny-model' })
|
||||
await assert.rejects(catalog.resolveRoute(host, {}, 'openai', 'gpt-x'), /not a provider route approved for this plugin/)
|
||||
assert.deepEqual(calls.map((call) => [call.service, call.method, call.input]), [
|
||||
['catalog', 'routes.list', {}],
|
||||
['catalog', 'routes.resolve', { provider: 'gateway', model: 'large-model' }],
|
||||
['catalog', 'routes.resolve', { provider: 'gateway' }],
|
||||
['catalog', 'routes.resolve', { provider: 'openai', model: 'gpt-x' }],
|
||||
])
|
||||
|
||||
await assert.rejects(catalog.resolveRoute(host, {}, '', 'x'), (error) => error.code === -32602)
|
||||
await assert.rejects(catalog.resolveRoute(host, {}, 'gateway', 7), (error) => error.code === -32602)
|
||||
assert.equal(calls.length, 4)
|
||||
answers['routes.list'] = { routes: [{ provider: 'gateway', base_url: 'https://gateway.example/api', default_model: null, models: [7] }] }
|
||||
await assert.rejects(catalog.approvedRoutes(host, {}), /invalid catalog broker route/)
|
||||
answers['routes.list'] = [gateway]
|
||||
await assert.rejects(catalog.approvedRoutes(host, {}), /invalid catalog broker routes/)
|
||||
})
|
||||
@@ -0,0 +1,83 @@
|
||||
import { test, before } from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import { mkdtempSync, rmSync } from 'node:fs'
|
||||
import { join, dirname } from 'node:path'
|
||||
import { fileURLToPath, pathToFileURL } from 'node:url'
|
||||
import { build } from 'esbuild'
|
||||
|
||||
const here = dirname(fileURLToPath(import.meta.url))
|
||||
let settingsAdapter
|
||||
let SettingsConflictError
|
||||
|
||||
before(async () => {
|
||||
const dir = mkdtempSync(join(here, '..', 'node_modules', '.cw-settings-'))
|
||||
const outfile = join(dir, 'settings.mjs')
|
||||
await build({ entryPoints: [join(here, '../src/dsh/settings.ts')], outfile, bundle: true, platform: 'node', format: 'esm', packages: 'external', absWorkingDir: join(here, '..'), logLevel: 'silent' })
|
||||
;({ settingsAdapter, SettingsConflictError } = await import(pathToFileURL(outfile).href))
|
||||
process.on('exit', () => rmSync(dir, { recursive: true, force: true }))
|
||||
})
|
||||
|
||||
function service(answers, pluginName = 'fixture.plugin') {
|
||||
const calls = []
|
||||
const host = {
|
||||
tier: 'plugin',
|
||||
ownerOf: () => ({ pluginName }),
|
||||
broker: async (ctx, name, method, input) => {
|
||||
calls.push({ name, method, input })
|
||||
const answer = answers[method]
|
||||
if (answer instanceof Error) throw answer
|
||||
return answer
|
||||
},
|
||||
}
|
||||
const instance = Object.create(settingsAdapter.define(host).prototype)
|
||||
instance.ctx = {}
|
||||
return { settings: instance, calls }
|
||||
}
|
||||
|
||||
const row = { ns: 'fixture.plugin', writable: true, roots: ['model'], value: { model: 'small' }, revision: 'r1', applied: true, applies: 'reload' }
|
||||
|
||||
test('settings read and edit go to the settings broker as typed methods', async () => {
|
||||
const { settings, calls } = service({
|
||||
describe: row,
|
||||
update: { written: true, conflict: false, revision: 'r2', applies: 'reload' },
|
||||
replace: { written: true, conflict: false, revision: 'r3', applies: 'reload' },
|
||||
mutate: { written: false, conflict: false, revision: 'r3', applies: 'reload' },
|
||||
})
|
||||
assert.deepEqual(await settings.describe(), [row])
|
||||
assert.deepEqual(await settings.update('fixture.plugin', { model: 'large' }, 'r1'), { written: true, revision: 'r2', applies: 'reload' })
|
||||
await settings.replace('fixture.plugin', { model: 'tiny' })
|
||||
assert.equal((await settings.mutate('fixture.plugin', [{ op: 'unset', path: ['model'] }])).written, false)
|
||||
assert.deepEqual(calls, [
|
||||
{ name: 'settings', method: 'describe', input: {} },
|
||||
{ name: 'settings', method: 'update', input: { patch: { model: 'large' }, expected_revision: 'r1' } },
|
||||
{ name: 'settings', method: 'replace', input: { value: { model: 'tiny' } } },
|
||||
{ name: 'settings', method: 'mutate', input: { ops: [{ op: 'unset', path: ['model'] }] } },
|
||||
])
|
||||
})
|
||||
|
||||
test('another namespace, a malformed edit and a malformed answer never become a broker write', async () => {
|
||||
const { settings, calls } = service({ describe: { ns: 'fixture.plugin' }, update: { written: 'yes' } })
|
||||
await assert.rejects(settings.update('other.plugin', { model: 'x' }), /only its own settings namespace/)
|
||||
await assert.rejects(settings.update('', { model: 'x' }), /non-empty string/)
|
||||
await assert.rejects(settings.update('fixture.plugin', ['model']), /plain object/)
|
||||
await assert.rejects(settings.replace('fixture.plugin', null), /plain object/)
|
||||
await assert.rejects(settings.mutate('fixture.plugin', { op: 'set' }), /array of path operations/)
|
||||
await assert.rejects(settings.update('fixture.plugin', { model: 'x' }, 7), /expected revision/)
|
||||
assert.deepEqual(calls, [])
|
||||
await assert.rejects(settings.describe(), /invalid settings broker response/)
|
||||
await assert.rejects(settings.update('fixture.plugin', { model: 'x' }), /invalid settings broker response/)
|
||||
})
|
||||
|
||||
test('a stale edit is a conflict and a broker refusal reaches the plugin as the refusal', async () => {
|
||||
const stale = service({ update: { written: false, conflict: true, revision: 'r9', applies: 'reload' } })
|
||||
await assert.rejects(stale.settings.update('fixture.plugin', { model: 'x' }, 'r1'), (error) => {
|
||||
assert.ok(error instanceof SettingsConflictError)
|
||||
assert.equal(error.code, 'SETTINGS_CONFLICT')
|
||||
assert.equal(error.expected, 'r1')
|
||||
assert.equal(error.actual, 'r9')
|
||||
return true
|
||||
})
|
||||
const refused = service({ update: new Error('that setting is outside this plugin\'s reviewed settings'), describe: new Error('the settings broker is not available yet') })
|
||||
await assert.rejects(refused.settings.update('fixture.plugin', { internal: 1 }), /outside this plugin's reviewed settings/)
|
||||
await assert.rejects(refused.settings.describe(), /not available yet/)
|
||||
})
|
||||
@@ -2889,6 +2889,17 @@ mod tests {
|
||||
"cat junk | dd of=/dev/sda",
|
||||
"timeout 30 mkfs /dev/sda1",
|
||||
r#"echo \";mkfs.ext4 /dev/sda1"#,
|
||||
r#"mk""fs.ext4 -L \; /dev/sda1"#,
|
||||
r#"shr""ed /dev/sda \;"#,
|
||||
r#"d""d if=/dev/zero of=/dev/sda 2>\;"#,
|
||||
r#"wip""efs -a /dev/sda \"#,
|
||||
r#"m\kfs.ext4 /dev/sda1 \;"#,
|
||||
"mk\"\"fs.ext4 /dev/sda1\necho \"",
|
||||
r#"sh -c $'echo x\nwipefs -a /dev/sda'"#,
|
||||
r#"sh -c $'echo x\nmkfs.ext4 /dev/sda1'"#,
|
||||
r#"sh -c $'echo x\nshred /dev/sda'"#,
|
||||
r#"sh -c $'echo x\ndd if=/dev/zero of=/dev/sda'"#,
|
||||
r#"sh -c $'echo x\rwipefs -a /dev/sda'"#,
|
||||
] {
|
||||
let ctx = ctx_for(
|
||||
"exec_shell",
|
||||
@@ -2900,6 +2911,41 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn auto_review_asks_for_prefixed_writable_and_windows_backslash_commands() {
|
||||
let policy = AutoReviewPolicy::default();
|
||||
for command in [
|
||||
"FOO=1>/tmp/out codewhale --version",
|
||||
"PATH=/tmp/bin codewhale --version",
|
||||
"LD_PRELOAD=/tmp/lib.so codewhale --version",
|
||||
"git stash list --output=/tmp/out",
|
||||
"rg --hostname-bin=/tmp/tool foo",
|
||||
"less -o /tmp/out file",
|
||||
"less -O /tmp/out file",
|
||||
"less --log-file=/tmp/out file",
|
||||
"less --LOG-FILE=/tmp/out file",
|
||||
"ag --pager=\"sh -c id\" foo",
|
||||
"info -o /tmp/out",
|
||||
r"find C:\ -delete",
|
||||
r"rg C:\ --pre=id foo",
|
||||
r"find \\?\ -delete",
|
||||
r"rg \\?\ --pre=id foo",
|
||||
] {
|
||||
let ctx = ctx_for(
|
||||
"exec_shell",
|
||||
json!({ "command": command }),
|
||||
RunOrigin::Interactive,
|
||||
ApprovalMode::Auto,
|
||||
);
|
||||
let decision = policy.evaluate(&ctx);
|
||||
assert_eq!(
|
||||
decision.action,
|
||||
AutoReviewAction::AskUser,
|
||||
"{command:?} should require review in Auto mode: {decision:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
fn destroyer_held(workspace: &std::path::Path, command: &str) -> bool {
|
||||
let ctx = AutoReviewContext::from_tool_call(
|
||||
"exec_shell",
|
||||
|
||||
@@ -710,7 +710,7 @@ fn shell_denial_filters_search_catalog_without_expanding_allow_grants() {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shell_denial_preserves_task_reads_and_bounded_verification_actions() {
|
||||
fn shell_denial_preserves_task_reads_and_denies_verification_actions() {
|
||||
let mut tasks = catalog_tool("tasks");
|
||||
tasks.input_schema =
|
||||
json!({"type":"object", "properties":{"action":{"enum":["list", "read", "gate_run"]}}});
|
||||
@@ -737,21 +737,293 @@ fn shell_denial_preserves_task_reads_and_bounded_verification_actions() {
|
||||
assert!(surface.denies_call("tasks", &json!({"action":"gate_run"})));
|
||||
assert!(!surface.denies_call("task_shell_wait", &json!({})));
|
||||
assert!(!surface.denies_call("terminal/cancel", &json!({})));
|
||||
assert!(!surface.denies_call(
|
||||
"Run",
|
||||
&json!({"action":"tests", "args":"-p fixture selected_test"})
|
||||
));
|
||||
assert!(!surface.denies_call("Run", &json!({"action":"verifiers", "commands":[]})));
|
||||
assert!(surface.denies_call(
|
||||
"Run",
|
||||
&json!({"action":"tests", "args":"--config build.rustc=malicious"})
|
||||
));
|
||||
for input in [
|
||||
json!({"action":"tests"}),
|
||||
json!({"action":"tests", "args":""}),
|
||||
json!({"action":"tests", "args":"-p fixture selected_test"}),
|
||||
json!({"action":"tests", "args":"--config build.rustc=malicious"}),
|
||||
json!({"action":"verifiers"}),
|
||||
json!({"action":"verifiers", "profile":"node"}),
|
||||
json!({"action":"verifiers", "commands":[]}),
|
||||
json!({"action":"verifiers", "commands":[{"program":"sh"}]}),
|
||||
] {
|
||||
assert!(surface.denies_call("Run", &input), "{input}");
|
||||
}
|
||||
for name in ["run_tests", "run_verifiers"] {
|
||||
assert!(surface.denies_call(name, &json!({})), "{name}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shell_denial_covers_every_code_executing_action_or_names_an_exemption() {
|
||||
use crate::tools::execution_envelope::{CallClass, classify_call, tool_call_denied};
|
||||
use crate::tools::spec::ToolCapability;
|
||||
use codewhale_agent_contract::worker_profile::ShellPolicy;
|
||||
|
||||
const EXEMPT: &[(&str, &str)] = &[
|
||||
("automation/pause", "stops a stored automation from firing"),
|
||||
("automation/delete", "removes a stored automation"),
|
||||
("send_later/cancel", "cancels a pending delayed message"),
|
||||
("tasks/cancel", "stops a durable task"),
|
||||
("tasks/suggest", "offers a task card and starts nothing"),
|
||||
("tasks/pr_attempt_record", "records a stored patch artifact"),
|
||||
(
|
||||
"tasks/pr_attempt_preflight",
|
||||
"git apply --check of a stored patch",
|
||||
),
|
||||
("terminal/cancel", "stops an existing terminal session"),
|
||||
("terminal/wait", "reads an existing terminal session"),
|
||||
(
|
||||
"rlm/open",
|
||||
"loads content into a context; running code in it is rlm/eval, and a url is gated by fetch_url",
|
||||
),
|
||||
(
|
||||
"workflow/start",
|
||||
"runs sub-agents that inherit this deny list; the verify input is denied by the flag list below",
|
||||
),
|
||||
(
|
||||
"workflow/run",
|
||||
"runs sub-agents that inherit this deny list; the verify input is denied by the flag list below",
|
||||
),
|
||||
(
|
||||
"workflow/cancel",
|
||||
"stops a workflow run and starts nothing",
|
||||
),
|
||||
];
|
||||
const EXECUTING_FLAGS: &[(&str, &str)] = &[("workflow", "verify")];
|
||||
|
||||
let tmp = tempdir().expect("tempdir");
|
||||
let mut options = crate::tools::AgentToolSurfaceOptions::new(ShellPolicy::Full);
|
||||
options.apply_patch_enabled = true;
|
||||
options.web_search_enabled = true;
|
||||
options.memory_tool_enabled = true;
|
||||
let pool = std::sync::Arc::new(tokio::sync::Mutex::new(crate::mcp::McpPool::new(
|
||||
codewhale_mcp::config::McpConfig::default(),
|
||||
)));
|
||||
let client = CodewhaleClient::new(
|
||||
&Config::default().with_legacy_root(Some("test-key".to_string()), None),
|
||||
)
|
||||
.expect("stub client");
|
||||
let manager = crate::tools::subagent::new_shared_subagent_manager(tmp.path().to_path_buf(), 4);
|
||||
let runtime = SubAgentRuntime::new(
|
||||
client.clone(),
|
||||
"sweep-model".to_string(),
|
||||
crate::tools::ToolContext::new(tmp.path().to_path_buf()),
|
||||
true,
|
||||
None,
|
||||
manager.clone(),
|
||||
);
|
||||
let registry = crate::tools::ToolRegistryBuilder::new()
|
||||
.with_full_agent_surface_options(
|
||||
Some(client),
|
||||
"sweep-model".to_string(),
|
||||
manager,
|
||||
runtime,
|
||||
options,
|
||||
crate::tools::todo::new_shared_todo_list(),
|
||||
crate::tools::plan::new_shared_plan_state(),
|
||||
)
|
||||
.with_registry_mcp_sync_tool()
|
||||
.with_runtime_mcp_tool(pool.clone())
|
||||
.with_registry_mcp_start_tool(pool)
|
||||
.build(crate::tools::ToolContext::new(tmp.path().to_path_buf()));
|
||||
|
||||
let rules = vec!["Bash".to_string()];
|
||||
for name in ["workflow", "agent", "Run"] {
|
||||
assert!(
|
||||
registry.all().iter().any(|tool| tool.name() == name),
|
||||
"the sweep surface lacks {name}"
|
||||
);
|
||||
}
|
||||
for (name, flag) in EXECUTING_FLAGS {
|
||||
let tool = registry
|
||||
.all()
|
||||
.into_iter()
|
||||
.find(|tool| tool.name() == *name)
|
||||
.unwrap_or_else(|| panic!("{name} is not registered"));
|
||||
assert_eq!(
|
||||
tool.input_schema()
|
||||
.pointer(&format!("/properties/{flag}/type"))
|
||||
.and_then(Value::as_str),
|
||||
Some("boolean"),
|
||||
"{name} no longer declares a boolean {flag}"
|
||||
);
|
||||
assert!(
|
||||
tool_call_denied(Some(&rules), name, &json!({"action":"run", (*flag): true})),
|
||||
"{name} with {flag} starts workspace code and a Bash rule does not deny it"
|
||||
);
|
||||
}
|
||||
let mut swept = 0usize;
|
||||
let mut unaccounted = Vec::new();
|
||||
let mut stale = Vec::new();
|
||||
for tool in registry.all() {
|
||||
let schema = tool.input_schema();
|
||||
let actions: Vec<Option<String>> = schema
|
||||
.pointer("/properties/action/enum")
|
||||
.and_then(Value::as_array)
|
||||
.map(|actions| {
|
||||
actions
|
||||
.iter()
|
||||
.filter_map(|action| action.as_str().map(|action| Some(action.to_string())))
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_else(|| vec![None]);
|
||||
for action in actions {
|
||||
let input = action
|
||||
.as_ref()
|
||||
.map_or_else(|| json!({}), |action| json!({"action": action}));
|
||||
let label = match &action {
|
||||
Some(action) => format!("{}/{action}", tool.name()),
|
||||
None => tool.name().to_string(),
|
||||
};
|
||||
let denied = tool_call_denied(Some(&rules), tool.name(), &input);
|
||||
let executes = tool.capabilities().contains(&ToolCapability::ExecutesCode)
|
||||
&& classify_call(tool.name(), &input, &*tool) != CallClass::Bounded;
|
||||
let exempt = EXEMPT.iter().any(|(name, _)| *name == label);
|
||||
if executes {
|
||||
swept += 1;
|
||||
}
|
||||
if executes && !denied && !exempt {
|
||||
unaccounted.push(label.clone());
|
||||
}
|
||||
if exempt && denied {
|
||||
stale.push(label);
|
||||
}
|
||||
}
|
||||
}
|
||||
assert!(swept > 10, "the sweep found only {swept} executing actions");
|
||||
assert!(
|
||||
unaccounted.is_empty(),
|
||||
"executing actions neither denied by a Bash rule nor exempt: {unaccounted:?}"
|
||||
);
|
||||
assert!(
|
||||
stale.is_empty(),
|
||||
"exempt actions a Bash rule already denies: {stale:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shell_denial_keeps_only_the_bounded_verifier_its_default_gate() {
|
||||
use crate::tools::execution_envelope::{enforce_tool_denial, holds_bounded_verification};
|
||||
use crate::tools::spec::{
|
||||
ToolAuthorityEnvelope, ToolMutationAuthority, ToolShellAuthority,
|
||||
ToolVerificationAuthority,
|
||||
};
|
||||
let tmp = tempdir().expect("tempdir");
|
||||
let rules: Vec<String> = codewhale_agent_contract::role::RAW_SHELL_DENYLIST
|
||||
.iter()
|
||||
.map(|rule| (*rule).to_string())
|
||||
.collect();
|
||||
|
||||
let mut operator = crate::tools::ToolContext::new(tmp.path().to_path_buf());
|
||||
operator.disallowed_tools = rules.clone();
|
||||
assert!(!holds_bounded_verification(&operator));
|
||||
|
||||
let mut verifier = crate::tools::ToolContext::new(tmp.path().to_path_buf())
|
||||
.with_tool_authority(ToolAuthorityEnvelope {
|
||||
schema_version: 1,
|
||||
owner: "verifier-1".to_string(),
|
||||
authority: ToolMutationAuthority::ReadOnly,
|
||||
network_access: Some(false),
|
||||
shell: ToolShellAuthority::None,
|
||||
verification: ToolVerificationAuthority::Bounded,
|
||||
writable_roots: Vec::new(),
|
||||
writable_files: Vec::new(),
|
||||
coordination_contracts: Vec::new(),
|
||||
})
|
||||
.expect("bounded verifier authority");
|
||||
verifier.disallowed_tools = rules;
|
||||
assert!(holds_bounded_verification(&verifier));
|
||||
|
||||
for input in [
|
||||
json!({"action":"tests"}),
|
||||
json!({"action":"tests", "args":"-p fixture selected_test"}),
|
||||
json!({"action":"verifiers"}),
|
||||
] {
|
||||
assert!(enforce_tool_denial(&operator, "Run", &input).is_err(), "{input}");
|
||||
assert!(enforce_tool_denial(&verifier, "Run", &input).is_ok(), "{input}");
|
||||
}
|
||||
for input in [
|
||||
json!({"action":"tests", "args":"--config build.rustc=malicious"}),
|
||||
json!({"action":"verifiers", "commands":[{"program":"sh"}]}),
|
||||
] {
|
||||
assert!(enforce_tool_denial(&operator, "Run", &input).is_err(), "{input}");
|
||||
assert!(enforce_tool_denial(&verifier, "Run", &input).is_err(), "{input}");
|
||||
}
|
||||
let workflow_verify = json!({"action":"run", "script":"return 1;", "verify":true});
|
||||
assert!(enforce_tool_denial(&operator, "workflow", &workflow_verify).is_err());
|
||||
assert!(enforce_tool_denial(&verifier, "workflow", &workflow_verify).is_ok());
|
||||
|
||||
let surface = ToolSurfacePolicy::new(
|
||||
crate::tools::ToolRegistry::new(verifier.clone()),
|
||||
Some(vec![catalog_tool("Run")]),
|
||||
AppMode::Agent,
|
||||
&HashSet::new(),
|
||||
&[],
|
||||
false,
|
||||
None,
|
||||
verifier.disallowed_tools.clone().into(),
|
||||
None,
|
||||
crate::core::engine::tool_catalog::ToolMode::Direct,
|
||||
);
|
||||
assert!(!surface.denies_call("Run", &json!({"action":"verifiers"})));
|
||||
assert!(surface.denies_call(
|
||||
"Run",
|
||||
&json!({"action":"verifiers", "commands":[{"program":"sh"}]})
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shell_denial_covers_workflow_completion_verification_but_not_plain_runs() {
|
||||
use crate::tools::execution_envelope::tool_call_denied;
|
||||
let rules = vec!["Bash".to_string()];
|
||||
for input in [
|
||||
json!({"verify":true}),
|
||||
json!({"action":"start", "verify":true}),
|
||||
json!({"action":"spawn", "verify":true, "script":"return 1;"}),
|
||||
json!({"action":"run", "verify":true, "script":"return 1;"}),
|
||||
json!({"action":"wait", "verify":true}),
|
||||
json!({"action":" RUN ", "verify":true}),
|
||||
] {
|
||||
assert!(tool_call_denied(Some(&rules), "workflow", &input), "{input}");
|
||||
}
|
||||
for input in [
|
||||
json!({"action":"run", "script":"return 1;"}),
|
||||
json!({"action":"run", "verify":false}),
|
||||
json!({"action":"status", "run_id":"workflow_1"}),
|
||||
json!({"action":"cancel", "run_id":"workflow_1"}),
|
||||
] {
|
||||
assert!(!tool_call_denied(Some(&rules), "workflow", &input), "{input}");
|
||||
}
|
||||
assert!(!tool_call_denied(
|
||||
None,
|
||||
"workflow",
|
||||
&json!({"action":"run", "verify":true})
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shell_denial_covers_delayed_message_scheduling_but_not_its_management() {
|
||||
use crate::tools::execution_envelope::tool_call_denied;
|
||||
let rules = vec!["Bash".to_string()];
|
||||
assert!(tool_call_denied(
|
||||
Some(&rules),
|
||||
"send_later",
|
||||
&json!({"action":"schedule", "delay_minutes":5, "message":"go", "thread_id":""})
|
||||
));
|
||||
for action in ["list", "read", "cancel"] {
|
||||
assert!(
|
||||
!tool_call_denied(Some(&rules), "send_later", &json!({"action": action})),
|
||||
"{action}"
|
||||
);
|
||||
}
|
||||
assert!(!tool_call_denied(
|
||||
None,
|
||||
"send_later",
|
||||
&json!({"action":"schedule"})
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shell_denial_applies_to_new_durable_execution_without_hiding_management() {
|
||||
use crate::tools::execution_envelope::tool_call_denied;
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
//! tool suggestions, and the small set of built-in advanced tools that are not
|
||||
//! registered by the normal runtime tool registry.
|
||||
|
||||
use crate::tools::execution_envelope::tool_call_denied;
|
||||
use crate::tools::execution_envelope::{holds_bounded_verification, tool_call_denied_for};
|
||||
use codewhale_tools::names::{
|
||||
LEGACY_TOOL_SEARCH_BM25_NAME, LEGACY_TOOL_SEARCH_REGEX_NAME, TOOL_SEARCH_NAME,
|
||||
is_tool_search_tool,
|
||||
@@ -625,6 +625,7 @@ pub(super) struct ToolSurfacePolicy {
|
||||
pub(super) strict_tool_mode: bool,
|
||||
allowed_tools: Option<Vec<String>>,
|
||||
disallowed_tools: Option<Vec<String>>,
|
||||
holds_bounded_verification: bool,
|
||||
/// Hard per-turn cap on admitted tool calls (#4415). The turn loop copies
|
||||
/// this limit into its own admission counter at turn start; `None` means
|
||||
/// unlimited (the default), which keeps the admission gate inert.
|
||||
@@ -645,6 +646,7 @@ impl ToolSurfacePolicy {
|
||||
max_tool_calls: Option<u32>,
|
||||
tool_mode: ToolMode,
|
||||
) -> Self {
|
||||
let holds_bounded_verification = holds_bounded_verification(registry.context());
|
||||
let mut catalog = tools.unwrap_or_default();
|
||||
if !catalog.is_empty() {
|
||||
ensure_advanced_tooling(&mut catalog, mode, always_load, tool_mode);
|
||||
@@ -669,10 +671,11 @@ impl ToolSurfacePolicy {
|
||||
.and_then(Value::as_array_mut)
|
||||
{
|
||||
actions.retain(|action| {
|
||||
!tool_call_denied(
|
||||
!tool_call_denied_for(
|
||||
disallowed_tools.as_deref(),
|
||||
&tool.name,
|
||||
&json!({"action": action}),
|
||||
holds_bounded_verification,
|
||||
)
|
||||
});
|
||||
}
|
||||
@@ -697,6 +700,7 @@ impl ToolSurfacePolicy {
|
||||
strict_tool_mode,
|
||||
allowed_tools,
|
||||
disallowed_tools,
|
||||
holds_bounded_verification,
|
||||
max_tool_calls,
|
||||
}
|
||||
}
|
||||
@@ -738,7 +742,12 @@ impl ToolSurfacePolicy {
|
||||
}
|
||||
|
||||
pub(super) fn denies_call(&self, name: &str, input: &Value) -> bool {
|
||||
tool_call_denied(self.disallowed_tools.as_deref(), name, input)
|
||||
tool_call_denied_for(
|
||||
self.disallowed_tools.as_deref(),
|
||||
name,
|
||||
input,
|
||||
self.holds_bounded_verification,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1866,7 +1866,11 @@ async fn dsh_broker_tickets_bind_plugin_service_and_session_and_every_refusal_is
|
||||
for service in DshService::ALL.into_iter().filter(|s| {
|
||||
!matches!(
|
||||
s,
|
||||
DshService::Network | DshService::Remote | DshService::Terminal | DshService::Workspace
|
||||
DshService::Network
|
||||
| DshService::Remote
|
||||
| DshService::Terminal
|
||||
| DshService::Workspace
|
||||
| DshService::Catalog
|
||||
)
|
||||
}) {
|
||||
let ticket = &tickets_a[service.name()];
|
||||
@@ -2145,7 +2149,11 @@ async fn dsh_broker_services_are_granted_by_one_check_and_an_ungranted_service_i
|
||||
}
|
||||
if !matches!(
|
||||
service,
|
||||
DshService::Network | DshService::Remote | DshService::Terminal | DshService::Workspace
|
||||
DshService::Network
|
||||
| DshService::Remote
|
||||
| DshService::Terminal
|
||||
| DshService::Workspace
|
||||
| DshService::Catalog
|
||||
) {
|
||||
let result = f
|
||||
.broker(
|
||||
@@ -4229,3 +4237,347 @@ async fn every_way_a_plugin_can_end_its_call_after_an_act_returns_the_record_thr
|
||||
}
|
||||
manager.shutdown().await;
|
||||
}
|
||||
|
||||
fn catalog_without_an_account(
|
||||
call: super::dsh_broker::BrokerCall,
|
||||
) -> super::dsh_broker::BrokerFuture {
|
||||
super::dsh_catalog::serve_with(
|
||||
call,
|
||||
Some(super::dsh_catalog::AccountBalance {
|
||||
available: false,
|
||||
reading: None,
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
fn catalog_with_a_balance(call: super::dsh_broker::BrokerCall) -> super::dsh_broker::BrokerFuture {
|
||||
super::dsh_catalog::serve_with(
|
||||
call,
|
||||
Some(super::dsh_catalog::AccountBalance {
|
||||
available: true,
|
||||
reading: Some(crate::pricing::BalanceInfo {
|
||||
currency: "CNY".into(),
|
||||
total_balance: "123.45".into(),
|
||||
topped_up_balance: "100.00".into(),
|
||||
granted_balance: "23.45".into(),
|
||||
}),
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn dsh_catalog_arm_plans_reviewed_installs_approved_routes_and_a_keyless_balance() {
|
||||
use super::dsh_broker::{Arm, TABLE, TestGrants};
|
||||
use super::protocol::DshService;
|
||||
use codewhale_plugins::discovery::{DiscoveryConfig, discover_with_config};
|
||||
|
||||
let _grants = TestGrants::only(&[DshService::Workspace, DshService::Catalog]);
|
||||
let mut f = fixture(true);
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let workspace = temp.path().join("workspace");
|
||||
let user_plugins = temp.path().join("plugins");
|
||||
std::fs::create_dir_all(&workspace).unwrap();
|
||||
let bundle = |name: &str, extension: Value| {
|
||||
let dir = user_plugins.join(name);
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let manifest = json!({
|
||||
"$schema": codewhale_plugins::agent_plugin::PLUGIN_SCHEMA_URL,
|
||||
"name": name,
|
||||
"version": "1.0.0",
|
||||
"extensions": { "net.codewhale": extension },
|
||||
});
|
||||
std::fs::write(dir.join("plugin.json"), manifest.to_string()).unwrap();
|
||||
};
|
||||
bundle(
|
||||
"route-demo",
|
||||
json!({"providers": {"gateway": {
|
||||
"base_url": "https://gateway.example/api",
|
||||
"model": "tiny-model",
|
||||
"models": ["tiny-model"],
|
||||
"oauth": {
|
||||
"issuer": "https://gateway.example",
|
||||
"authorization_endpoint": "https://gateway.example/authorize",
|
||||
"token_endpoint": "https://gateway.example/token",
|
||||
"client_id": "custom-cli",
|
||||
"scopes": ["models:invoke"],
|
||||
},
|
||||
}}}),
|
||||
);
|
||||
bundle("idle-demo", json!({}));
|
||||
let discovery = DiscoveryConfig {
|
||||
workspace: workspace.clone(),
|
||||
user_plugins_dir: user_plugins.clone(),
|
||||
workspace_plugins_dir: workspace.join(".codewhale/plugins"),
|
||||
builtin_plugin_dirs: Vec::new(),
|
||||
state_path: temp.path().join("state/plugins.json"),
|
||||
};
|
||||
let mut reviewed = discover_with_config(&discovery);
|
||||
reviewed.trust("route-demo").unwrap();
|
||||
reviewed.enable("route-demo").unwrap();
|
||||
let reviewed = discover_with_config(&discovery);
|
||||
assert!(reviewed.is_active("route-demo"));
|
||||
assert!(
|
||||
reviewed
|
||||
.get("idle-demo")
|
||||
.is_some_and(|plugin| !plugin.active())
|
||||
);
|
||||
|
||||
let owner = {
|
||||
let mut owners = f.manager.shared.registry.lock().unwrap();
|
||||
let owner = owners
|
||||
.begin_owner(
|
||||
HostTier::Plugin,
|
||||
"route-demo",
|
||||
"route-demo",
|
||||
Some(fake_authority("route-demo")),
|
||||
"hash",
|
||||
)
|
||||
.unwrap();
|
||||
owners.mark_active(&owner);
|
||||
owner
|
||||
};
|
||||
f.context = f.context.clone().with_plugin_registry(Arc::new(reviewed));
|
||||
f.in_session("catalog-session-a");
|
||||
let guard = f.begin(7, &owner);
|
||||
let catalog = broker_ticket(&guard, "catalog").unwrap();
|
||||
let workspace_ticket = broker_ticket(&guard, "workspace").unwrap();
|
||||
let ask = async |method: &'static str, input: Value| {
|
||||
f.broker(TABLE, &owner, &catalog, "catalog", method, input)
|
||||
.await
|
||||
};
|
||||
let declined = (error_code::REFUSED, "refused".to_string());
|
||||
|
||||
let plan = ask(
|
||||
"install.plan",
|
||||
json!({"package": "dsh-demo", "version": "1.2.3"}),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(plan["status"], "review_required");
|
||||
assert_eq!(plan["plugin"], "dsh-demo");
|
||||
assert_eq!(plan["spec"], "npm:[email protected]");
|
||||
assert_eq!(plan["command"], "/plugin install npm:[email protected]");
|
||||
assert!(
|
||||
plan["message"]
|
||||
.as_str()
|
||||
.unwrap()
|
||||
.contains("nothing was installed")
|
||||
);
|
||||
let mirrored = ask(
|
||||
"install.plan",
|
||||
json!({"package": "@acme/dsh-demo", "version": "1.2.3", "registry": "https://registry.example.com/npm"}),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
mirrored["spec"],
|
||||
"https://registry.example.com/npm/@acme/dsh-demo/-/dsh-demo-1.2.3.tgz"
|
||||
);
|
||||
let present = ask(
|
||||
"install.plan",
|
||||
json!({"package": "@acme/idle-demo", "version": "2.0.0"}),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(present["status"], "already_present");
|
||||
assert_eq!(present["command"], "/plugin trust idle-demo");
|
||||
assert!(present.get("spec").is_none());
|
||||
for unreviewable in [
|
||||
json!({"package": "dsh-demo", "version": "^1.2.3"}),
|
||||
json!({"package": "dsh-demo", "version": "latest"}),
|
||||
json!({"package": "../dsh-demo", "version": "1.2.3"}),
|
||||
json!({"package": "dsh-demo", "version": "1.2.3", "registry": "http://registry.example.com"}),
|
||||
] {
|
||||
let result = ask("install.plan", unreviewable.clone()).await;
|
||||
assert_eq!(dsh_refusal(&result), declined, "{unreviewable}");
|
||||
}
|
||||
let forced = ask(
|
||||
"install.plan",
|
||||
json!({"package": "dsh-demo", "version": "1.2.3", "force": true}),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(
|
||||
dsh_refusal(&forced),
|
||||
(error_code::INVALID_PARAMS, "invalid_input".into())
|
||||
);
|
||||
for method in ["install", "install.run", "routes.register", "balance.topup"] {
|
||||
let result = ask(method, json!({"package": "dsh-demo", "version": "1.2.3"})).await;
|
||||
assert_eq!(
|
||||
dsh_refusal(&result),
|
||||
(error_code::INVALID_PARAMS, "unknown_method".into()),
|
||||
"{method}"
|
||||
);
|
||||
}
|
||||
assert!(
|
||||
!user_plugins.join("dsh-demo").exists(),
|
||||
"a plan installs nothing"
|
||||
);
|
||||
|
||||
let gateway = json!({
|
||||
"provider": "gateway",
|
||||
"base_url": "https://gateway.example/api",
|
||||
"default_model": "tiny-model",
|
||||
"models": ["tiny-model"],
|
||||
});
|
||||
assert_eq!(
|
||||
ask("routes.list", json!({})).await.unwrap(),
|
||||
json!({"routes": [gateway.clone()]})
|
||||
);
|
||||
assert_eq!(
|
||||
ask(
|
||||
"routes.resolve",
|
||||
json!({"provider": "gateway", "model": "tiny-model"})
|
||||
)
|
||||
.await
|
||||
.unwrap(),
|
||||
json!({"route": gateway.clone(), "model": "tiny-model"})
|
||||
);
|
||||
assert_eq!(
|
||||
ask("routes.resolve", json!({"provider": "gateway"}))
|
||||
.await
|
||||
.unwrap()["model"],
|
||||
"tiny-model"
|
||||
);
|
||||
for unapproved in [
|
||||
json!({"provider": "openai"}),
|
||||
json!({"provider": "Gateway"}),
|
||||
json!({"provider": "gateway", "model": "large-model"}),
|
||||
] {
|
||||
let result = ask("routes.resolve", unapproved.clone()).await;
|
||||
assert_eq!(dsh_refusal(&result), declined, "{unapproved}");
|
||||
}
|
||||
|
||||
assert_eq!(
|
||||
ask("balance", json!({})).await.unwrap(),
|
||||
json!({"status": "unknown"})
|
||||
);
|
||||
let steered = ask(
|
||||
"balance",
|
||||
json!({"provider": "deepseek", "api_key": "sk-x"}),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(
|
||||
dsh_refusal(&steered),
|
||||
(error_code::INVALID_PARAMS, "invalid_input".into())
|
||||
);
|
||||
let absent: &[(DshService, Arm)] = &[(DshService::Catalog, catalog_without_an_account as Arm)];
|
||||
assert_eq!(
|
||||
f.broker(absent, &owner, &catalog, "catalog", "balance", json!({}))
|
||||
.await
|
||||
.unwrap(),
|
||||
json!({"status": "absent"})
|
||||
);
|
||||
let known: &[(DshService, Arm)] = &[(DshService::Catalog, catalog_with_a_balance as Arm)];
|
||||
assert_eq!(
|
||||
f.broker(known, &owner, &catalog, "catalog", "balance", json!({}))
|
||||
.await
|
||||
.unwrap(),
|
||||
json!({"status": "known", "currency": "CNY", "total": "123.45", "topped_up": "100.00", "granted": "23.45"})
|
||||
);
|
||||
|
||||
for (presenter, ticket) in [
|
||||
(&f.other, catalog.as_str()),
|
||||
(&owner, workspace_ticket.as_str()),
|
||||
(&owner, guard.ticket()),
|
||||
] {
|
||||
let result = f
|
||||
.broker(
|
||||
TABLE,
|
||||
presenter,
|
||||
ticket,
|
||||
"catalog",
|
||||
"routes.list",
|
||||
json!({}),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(
|
||||
dsh_refusal(&result),
|
||||
(error_code::REFUSED, "ticket_refused".into())
|
||||
);
|
||||
}
|
||||
let other_guard = f.begin(7, &f.other);
|
||||
let other_catalog = broker_ticket(&other_guard, "catalog").unwrap();
|
||||
assert_eq!(
|
||||
f.broker(
|
||||
TABLE,
|
||||
&f.other,
|
||||
&other_catalog,
|
||||
"catalog",
|
||||
"routes.list",
|
||||
json!({})
|
||||
)
|
||||
.await
|
||||
.unwrap(),
|
||||
json!({"routes": []}),
|
||||
"another plugin has no approved routes and sees none of this plugin's"
|
||||
);
|
||||
let borrowed = f
|
||||
.broker(
|
||||
TABLE,
|
||||
&f.other,
|
||||
&other_catalog,
|
||||
"catalog",
|
||||
"routes.resolve",
|
||||
json!({"provider": "gateway"}),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(dsh_refusal(&borrowed), declined);
|
||||
drop(other_guard);
|
||||
|
||||
let session_a = f.context.clone();
|
||||
f.context = ToolContext::new(&session_a.workspace);
|
||||
f.in_session("catalog-session-b");
|
||||
let guard_b = f.begin(7, &owner);
|
||||
let catalog_b = broker_ticket(&guard_b, "catalog").unwrap();
|
||||
f.context = session_a;
|
||||
let in_b = f
|
||||
.broker(
|
||||
TABLE,
|
||||
&owner,
|
||||
&catalog_b,
|
||||
"catalog",
|
||||
"routes.list",
|
||||
json!({}),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(
|
||||
dsh_refusal(&in_b),
|
||||
(error_code::NOT_AVAILABLE, "not_available".into()),
|
||||
"session b's invocation has no plugin snapshot and never borrows session a's"
|
||||
);
|
||||
assert_eq!(
|
||||
f.broker(TABLE, &owner, &catalog, "catalog", "routes.list", json!({}))
|
||||
.await
|
||||
.unwrap(),
|
||||
json!({"routes": [gateway]})
|
||||
);
|
||||
|
||||
discover_with_config(&discovery)
|
||||
.revoke_trust("route-demo")
|
||||
.unwrap();
|
||||
for (method, input) in [
|
||||
("routes.list", json!({})),
|
||||
("routes.resolve", json!({"provider": "gateway"})),
|
||||
] {
|
||||
let result = f
|
||||
.broker(TABLE, &owner, &catalog, "catalog", method, input)
|
||||
.await;
|
||||
assert_eq!(
|
||||
dsh_refusal(&result),
|
||||
declined,
|
||||
"{method} after the review is withdrawn"
|
||||
);
|
||||
assert!(result.unwrap_err().message.contains("no longer holds"));
|
||||
}
|
||||
f.manager.shared.core_calls.revoke_owner("route-demo");
|
||||
let revoked = f
|
||||
.broker(TABLE, &owner, &catalog, "catalog", "balance", json!({}))
|
||||
.await;
|
||||
assert_eq!(
|
||||
dsh_refusal(&revoked),
|
||||
(error_code::REFUSED, "ticket_refused".into())
|
||||
);
|
||||
drop(guard_b);
|
||||
drop(guard);
|
||||
assert_eq!(f.manager.shared.core_calls.live_tickets(), 0);
|
||||
}
|
||||
@@ -1,6 +1,337 @@
|
||||
use super::dsh_broker::{BrokerCall, BrokerFuture, not_available};
|
||||
use super::protocol::DshService;
|
||||
use std::sync::Arc;
|
||||
|
||||
pub(super) fn serve(_call: BrokerCall) -> BrokerFuture {
|
||||
not_available(DshService::Catalog)
|
||||
use codewhale_config::PluginAuthority;
|
||||
use codewhale_plugins::PluginRegistry;
|
||||
use codewhale_plugins::activation::{
|
||||
PluginActivationCapability, PolicyScope, extension_host_policy_enabled,
|
||||
};
|
||||
use codewhale_plugins::install::dsh::{plan_npm_install, refusal_text};
|
||||
use codewhale_plugins::providers::{PluginProviderDeclaration, validate_declarations};
|
||||
use codewhale_plugins::recommend::{
|
||||
PluginMatchSource, PluginNextStep, PluginTaskRecommendation, lookup_reviewable_plugin,
|
||||
};
|
||||
use codewhale_plugins::registry::verify_plugin_component_authority;
|
||||
use serde::Deserialize;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::dsh_broker::{
|
||||
BrokerCall, BrokerEffect, BrokerFuture, BrokerMethods, BrokerRefusal, decode,
|
||||
};
|
||||
use super::protocol::DshService;
|
||||
use crate::pricing::BalanceInfo;
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(tag = "method", content = "input", deny_unknown_fields)]
|
||||
enum CatalogMethod {
|
||||
#[serde(rename = "install.plan")]
|
||||
InstallPlan(InstallRequest),
|
||||
#[serde(rename = "routes.list")]
|
||||
RoutesList(NoInput),
|
||||
#[serde(rename = "routes.resolve")]
|
||||
RoutesResolve(RouteRequest),
|
||||
#[serde(rename = "balance")]
|
||||
Balance(NoInput),
|
||||
}
|
||||
|
||||
impl BrokerMethods for CatalogMethod {
|
||||
const NAMES: &'static [&'static str] =
|
||||
&["install.plan", "routes.list", "routes.resolve", "balance"];
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
struct NoInput {}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
struct InstallRequest {
|
||||
package: String,
|
||||
version: String,
|
||||
#[serde(default)]
|
||||
registry: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
struct RouteRequest {
|
||||
provider: String,
|
||||
#[serde(default)]
|
||||
model: Option<String>,
|
||||
}
|
||||
|
||||
pub(super) struct AccountBalance {
|
||||
pub available: bool,
|
||||
pub reading: Option<BalanceInfo>,
|
||||
}
|
||||
|
||||
type ApprovedRoutes = Vec<(String, PluginProviderDeclaration)>;
|
||||
|
||||
pub(super) fn serve(call: BrokerCall) -> BrokerFuture {
|
||||
serve_with(call, None)
|
||||
}
|
||||
|
||||
pub(super) fn serve_with(call: BrokerCall, account: Option<AccountBalance>) -> BrokerFuture {
|
||||
Box::pin(async move {
|
||||
let caller = call.owner.plugin_id.clone();
|
||||
match decode::<CatalogMethod>(&call)? {
|
||||
CatalogMethod::InstallPlan(request) => {
|
||||
let plugins = plugins(&call)?;
|
||||
call.context
|
||||
.act("install.plan", BrokerEffect::Read, Some(&request.package))
|
||||
.await?;
|
||||
install_plan(&plugins, &caller, request)
|
||||
}
|
||||
CatalogMethod::RoutesList(NoInput {}) => {
|
||||
let plugins = plugins(&call)?;
|
||||
call.context
|
||||
.act("routes.list", BrokerEffect::Read, None)
|
||||
.await?;
|
||||
let routes = approved_routes(plugins, &caller).await?;
|
||||
let routes: Vec<Value> = routes
|
||||
.iter()
|
||||
.map(|(name, declaration)| route(name, declaration))
|
||||
.collect();
|
||||
Ok(json!({ "routes": routes }))
|
||||
}
|
||||
CatalogMethod::RoutesResolve(request) => {
|
||||
let plugins = plugins(&call)?;
|
||||
call.context
|
||||
.act(
|
||||
"routes.resolve",
|
||||
BrokerEffect::Read,
|
||||
Some(&request.provider),
|
||||
)
|
||||
.await?;
|
||||
let routes = approved_routes(plugins, &caller).await?;
|
||||
resolve_route(&caller, routes, request)
|
||||
}
|
||||
CatalogMethod::Balance(NoInput {}) => {
|
||||
call.context
|
||||
.act("balance", BrokerEffect::Read, None)
|
||||
.await?;
|
||||
Ok(balance(account))
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fn plugins(call: &BrokerCall) -> Result<Arc<PluginRegistry>, BrokerRefusal> {
|
||||
call.context
|
||||
.tool()
|
||||
.plugin_registry
|
||||
.clone()
|
||||
.ok_or(BrokerRefusal::NotAvailable(DshService::Catalog))
|
||||
}
|
||||
|
||||
fn refused(message: impl Into<String>) -> BrokerRefusal {
|
||||
BrokerRefusal::Refused {
|
||||
service: DshService::Catalog,
|
||||
message: message.into(),
|
||||
}
|
||||
}
|
||||
|
||||
fn review_command(name: &str, next_step: PluginNextStep) -> String {
|
||||
PluginTaskRecommendation {
|
||||
name: name.to_string(),
|
||||
source: PluginMatchSource::Installed {
|
||||
id: name.to_string(),
|
||||
},
|
||||
matched_terms: Vec::new(),
|
||||
score: 0,
|
||||
next_step,
|
||||
}
|
||||
.command()
|
||||
}
|
||||
|
||||
fn install_plan(
|
||||
plugins: &PluginRegistry,
|
||||
caller: &str,
|
||||
request: InstallRequest,
|
||||
) -> Result<Value, BrokerRefusal> {
|
||||
let plan = plan_npm_install(
|
||||
&request.package,
|
||||
&request.version,
|
||||
request.registry.as_deref(),
|
||||
)
|
||||
.map_err(|reason| {
|
||||
tracing::info!(target: "codewhale::dsh_catalog", plugin = %caller, "install plan refused");
|
||||
refused(reason)
|
||||
})?;
|
||||
let existing = plugins.get(&plan.plugin_name);
|
||||
let (status, command, message) = match existing {
|
||||
Some(existing) => {
|
||||
let command = lookup_reviewable_plugin(existing.name(), plugins, &[])
|
||||
.map(|reviewable| reviewable.command())
|
||||
.unwrap_or_else(|| review_command(existing.name(), PluginNextStep::Inspect));
|
||||
let message = format!(
|
||||
"A plugin named `{}` is already present; nothing was installed. Review it with `{command}`. A market listing does not establish that it is the same bundle.",
|
||||
existing.name()
|
||||
);
|
||||
("already_present", command, message)
|
||||
}
|
||||
None => {
|
||||
let command = review_command(
|
||||
&plan.plugin_name,
|
||||
PluginNextStep::SourceInstall {
|
||||
spec: plan.spec.clone(),
|
||||
},
|
||||
);
|
||||
let message = format!(
|
||||
"Codewhale installs a plugin only through its reviewed install flow; nothing was installed. Review and install {}@{} with `{command}`.",
|
||||
request.package, request.version
|
||||
);
|
||||
("review_required", command, message)
|
||||
}
|
||||
};
|
||||
tracing::info!(
|
||||
target: "codewhale::dsh_catalog",
|
||||
plugin = %caller,
|
||||
package = %request.package,
|
||||
version = %request.version,
|
||||
status,
|
||||
"install planned for review; nothing installed"
|
||||
);
|
||||
let mut answer = json!({
|
||||
"status": status,
|
||||
"package": request.package,
|
||||
"version": request.version,
|
||||
"plugin": plan.plugin_name,
|
||||
"command": command,
|
||||
"message": message,
|
||||
});
|
||||
if existing.is_none() {
|
||||
answer["spec"] = Value::String(plan.spec);
|
||||
}
|
||||
Ok(answer)
|
||||
}
|
||||
|
||||
fn declared_routes(
|
||||
plugins: &PluginRegistry,
|
||||
caller: &str,
|
||||
) -> Result<Option<(ApprovedRoutes, PluginAuthority)>, String> {
|
||||
let Some(plugin) = plugins.get(caller).filter(|plugin| plugin.active()) else {
|
||||
return Ok(None);
|
||||
};
|
||||
if plugin.manifest.providers.is_empty() {
|
||||
return Ok(None);
|
||||
}
|
||||
validate_declarations(&plugin.manifest.providers)?;
|
||||
let authority = plugins
|
||||
.authority_for(caller)
|
||||
.ok_or("the plugin has no review receipt")?;
|
||||
let routes = plugin
|
||||
.manifest
|
||||
.providers
|
||||
.iter()
|
||||
.map(|(name, declaration)| (name.clone(), declaration.clone()))
|
||||
.collect();
|
||||
Ok(Some((routes, authority)))
|
||||
}
|
||||
|
||||
async fn approved_routes(
|
||||
plugins: Arc<PluginRegistry>,
|
||||
caller: &str,
|
||||
) -> Result<ApprovedRoutes, BrokerRefusal> {
|
||||
let receipt = |reason: String| {
|
||||
tracing::info!(
|
||||
target: "codewhale::dsh_catalog",
|
||||
plugin = %caller,
|
||||
reason = %reason,
|
||||
"provider routes refused: the review receipt does not hold"
|
||||
);
|
||||
refused(
|
||||
"the review that approved this plugin's provider routes no longer holds; review the plugin again in Codewhale",
|
||||
)
|
||||
};
|
||||
let Some((routes, authority)) = declared_routes(&plugins, caller).map_err(receipt)? else {
|
||||
return Ok(Vec::new());
|
||||
};
|
||||
let policy = extension_host_policy_enabled();
|
||||
#[cfg(any(test, feature = "test-support"))]
|
||||
let env_scope = crate::test_support::env_scope_ticket();
|
||||
let verified = tokio::task::spawn_blocking(move || {
|
||||
#[cfg(any(test, feature = "test-support"))]
|
||||
let _env_scope = crate::test_support::join_env_scope(env_scope);
|
||||
let _policy = PolicyScope::propagate(policy);
|
||||
verify_plugin_component_authority(&authority, PluginActivationCapability::Providers)
|
||||
})
|
||||
.await;
|
||||
match verified {
|
||||
Ok(Ok(())) => Ok(routes),
|
||||
Ok(Err(reason)) => Err(receipt(reason)),
|
||||
Err(error) => Err(receipt(error.to_string())),
|
||||
}
|
||||
}
|
||||
|
||||
fn resolve_route(
|
||||
caller: &str,
|
||||
routes: ApprovedRoutes,
|
||||
request: RouteRequest,
|
||||
) -> Result<Value, BrokerRefusal> {
|
||||
let Some((name, declaration)) = routes
|
||||
.into_iter()
|
||||
.find(|(name, _)| *name == request.provider)
|
||||
else {
|
||||
tracing::info!(
|
||||
target: "codewhale::dsh_catalog",
|
||||
plugin = %caller,
|
||||
"provider route refused: not approved for this plugin"
|
||||
);
|
||||
return Err(refused(format!(
|
||||
"`{}` is not a provider route approved for this plugin. A plugin cannot register a model adapter in Codewhale: a model route is a provider route you approve in the plugin's review",
|
||||
refusal_text(&request.provider)
|
||||
)));
|
||||
};
|
||||
let model = match request.model {
|
||||
None => declaration.model.clone(),
|
||||
Some(model) => {
|
||||
let offered = if declaration.models.is_empty() {
|
||||
declaration
|
||||
.model
|
||||
.as_deref()
|
||||
.is_none_or(|default| default == model)
|
||||
} else {
|
||||
declaration.models.contains(&model)
|
||||
};
|
||||
if !offered {
|
||||
return Err(refused(format!(
|
||||
"model `{}` is not on the approved provider route `{name}`",
|
||||
refusal_text(&model)
|
||||
)));
|
||||
}
|
||||
Some(model)
|
||||
}
|
||||
};
|
||||
Ok(json!({ "route": route(&name, &declaration), "model": model }))
|
||||
}
|
||||
|
||||
fn route(name: &str, declaration: &PluginProviderDeclaration) -> Value {
|
||||
json!({
|
||||
"provider": name,
|
||||
"base_url": declaration.base_url,
|
||||
"default_model": declaration.model,
|
||||
"models": declaration.models,
|
||||
})
|
||||
}
|
||||
|
||||
fn balance(account: Option<AccountBalance>) -> Value {
|
||||
let Some(account) = account else {
|
||||
return json!({ "status": "unknown" });
|
||||
};
|
||||
if !account.available {
|
||||
return json!({ "status": "absent" });
|
||||
}
|
||||
match account
|
||||
.reading
|
||||
.filter(|reading| !reading.total_balance.trim().is_empty())
|
||||
{
|
||||
Some(reading) => json!({
|
||||
"status": "known",
|
||||
"currency": reading.currency,
|
||||
"total": reading.total_balance,
|
||||
"topped_up": reading.topped_up_balance,
|
||||
"granted": reading.granted_balance,
|
||||
}),
|
||||
None => json!({ "status": "unknown" }),
|
||||
}
|
||||
}
|
||||
@@ -12,6 +12,7 @@ use super::dsh_broker::{
|
||||
use super::protocol::DshService;
|
||||
use crate::tools::spec::ToolContext;
|
||||
use crate::tools::web::guard::validate_fetch_target;
|
||||
use codewhale_net::guard::PrivateTargets;
|
||||
|
||||
pub const CREDENTIAL_HANDLE_PREFIX: &str = "codewhale-credential:";
|
||||
const MAX_REF_LEN: usize = 128;
|
||||
@@ -92,6 +93,8 @@ pub(super) struct NetworkAuthority {
|
||||
pub hosts: BTreeSet<String>,
|
||||
pub tool: Option<ToolContext>,
|
||||
pub vault: Option<Secrets>,
|
||||
#[cfg(test)]
|
||||
pub test_dns_pin: Option<(String, std::net::IpAddr)>,
|
||||
}
|
||||
|
||||
impl NetworkAuthority {
|
||||
@@ -100,6 +103,8 @@ impl NetworkAuthority {
|
||||
hosts: BTreeSet::new(),
|
||||
tool: None,
|
||||
vault: None,
|
||||
#[cfg(test)]
|
||||
test_dns_pin: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -128,6 +133,8 @@ pub(super) fn serve_with(call: BrokerCall, authority: NetworkAuthority) -> Broke
|
||||
&authority.hosts,
|
||||
tool,
|
||||
authority.vault.as_ref(),
|
||||
#[cfg(test)]
|
||||
authority.test_dns_pin.as_ref(),
|
||||
input,
|
||||
)
|
||||
.await
|
||||
@@ -332,6 +339,7 @@ async fn fetch(
|
||||
hosts: &BTreeSet<String>,
|
||||
tool: &ToolContext,
|
||||
vault: Option<&Secrets>,
|
||||
#[cfg(test)] test_dns_pin: Option<&(String, std::net::IpAddr)>,
|
||||
input: FetchInput,
|
||||
) -> Result<Value, BrokerRefusal> {
|
||||
let mut url = reqwest::Url::parse(&input.url).map_err(|_| invalid("the URL is not valid"))?;
|
||||
@@ -393,9 +401,26 @@ async fn fetch(
|
||||
let mut substituted: Option<Substituted> = None;
|
||||
let response = loop {
|
||||
egress_host(&url, hosts)?;
|
||||
let pin = validate_fetch_target(&url, tool, AUDIT_LABEL)
|
||||
.await
|
||||
.map_err(|error| invalid(format!("egress refused: {error}")))?;
|
||||
let pin = {
|
||||
#[cfg(test)]
|
||||
if let Some((pinned_host, pinned_ip)) = test_dns_pin
|
||||
&& url
|
||||
.host_str()
|
||||
.is_some_and(|candidate| candidate.eq_ignore_ascii_case(pinned_host))
|
||||
{
|
||||
crate::tools::web::guard::validate_network_policy(&host, tool, AUDIT_LABEL)
|
||||
.map_err(|error| invalid(format!("egress refused: {error}")))?;
|
||||
Some((pinned_host.clone(), *pinned_ip))
|
||||
} else {
|
||||
validate_fetch_target(&url, tool, AUDIT_LABEL, PrivateTargets::Refuse)
|
||||
.await
|
||||
.map_err(|error| invalid(format!("egress refused: {error}")))?
|
||||
}
|
||||
#[cfg(not(test))]
|
||||
validate_fetch_target(&url, tool, AUDIT_LABEL, PrivateTargets::Refuse)
|
||||
.await
|
||||
.map_err(|error| invalid(format!("egress refused: {error}")))?
|
||||
};
|
||||
let substitution = match substituted.take() {
|
||||
Some(done) => done,
|
||||
None => substitute_headers(plugin, vault, &input.headers)?,
|
||||
@@ -556,6 +581,22 @@ mod tests {
|
||||
hosts: hosts.iter().map(|h| (*h).to_string()).collect(),
|
||||
tool: Some(tool),
|
||||
vault: Some(vault.clone()),
|
||||
test_dns_pin: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn authority_with_pin(
|
||||
hosts: &[&str],
|
||||
tool: ToolContext,
|
||||
vault: &Secrets,
|
||||
pinned_host: &str,
|
||||
) -> NetworkAuthority {
|
||||
NetworkAuthority {
|
||||
test_dns_pin: Some((
|
||||
pinned_host.to_string(),
|
||||
std::net::IpAddr::V4(std::net::Ipv4Addr::LOCALHOST),
|
||||
)),
|
||||
..authority(hosts, tool, vault)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -665,11 +706,16 @@ mod tests {
|
||||
"a",
|
||||
"fetch",
|
||||
json!({
|
||||
"url": format!("{}/v1/models", server.uri()),
|
||||
"url": format!("http://api.example.com:{}/v1/models", server.address().port()),
|
||||
"headers": [["authorization", "Bearer codewhale-credential:KEY"]],
|
||||
}),
|
||||
),
|
||||
authority(&["127.0.0.1"], policy(&["127.0.0.1"], &[]), &vault),
|
||||
authority_with_pin(
|
||||
&["api.example.com"],
|
||||
policy(&["api.example.com"], &[]),
|
||||
&vault,
|
||||
"api.example.com",
|
||||
),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
@@ -725,6 +771,16 @@ mod tests {
|
||||
)
|
||||
.await;
|
||||
assert!(message(&unapproved).contains("egress refused"));
|
||||
let local_allowed = serve_with(
|
||||
call("a", "fetch", json!({"url": url})),
|
||||
authority(&["127.0.0.1"], policy(&["127.0.0.1"], &[]), &vault),
|
||||
)
|
||||
.await;
|
||||
assert!(
|
||||
message(&local_allowed).contains("restricted"),
|
||||
"{}",
|
||||
message(&local_allowed)
|
||||
);
|
||||
let metadata = serve_with(
|
||||
call(
|
||||
"a",
|
||||
@@ -815,16 +871,18 @@ mod tests {
|
||||
let vault = vault();
|
||||
vault.set(&vault_slot("a", "KEY"), SECRET).unwrap();
|
||||
let headers = json!([["authorization", "codewhale-credential:KEY"]]);
|
||||
let both = authority(
|
||||
&["127.0.0.1", "localhost"],
|
||||
policy(&["127.0.0.1", "localhost"], &[]),
|
||||
let base = format!("http://api.example.com:{}", server.address().port());
|
||||
let both = authority_with_pin(
|
||||
&["api.example.com", "localhost"],
|
||||
policy(&["api.example.com", "localhost"], &[]),
|
||||
&vault,
|
||||
"api.example.com",
|
||||
);
|
||||
let away = serve_with(
|
||||
call(
|
||||
"a",
|
||||
"fetch",
|
||||
json!({"url": format!("{}/away", server.uri()), "headers": headers}),
|
||||
json!({"url": format!("{base}/away"), "headers": headers}),
|
||||
),
|
||||
both,
|
||||
)
|
||||
@@ -834,12 +892,13 @@ mod tests {
|
||||
call(
|
||||
"a",
|
||||
"fetch",
|
||||
json!({"url": format!("{}/rename", server.uri()), "headers": headers}),
|
||||
json!({"url": format!("{base}/rename"), "headers": headers}),
|
||||
),
|
||||
authority(
|
||||
&["127.0.0.1", "localhost"],
|
||||
policy(&["127.0.0.1", "localhost"], &[]),
|
||||
authority_with_pin(
|
||||
&["api.example.com", "localhost"],
|
||||
policy(&["api.example.com", "localhost"], &[]),
|
||||
&vault,
|
||||
"api.example.com",
|
||||
),
|
||||
)
|
||||
.await;
|
||||
@@ -848,9 +907,14 @@ mod tests {
|
||||
call(
|
||||
"a",
|
||||
"fetch",
|
||||
json!({"url": format!("{}/hop", server.uri()), "headers": headers}),
|
||||
json!({"url": format!("{base}/hop"), "headers": headers}),
|
||||
),
|
||||
authority_with_pin(
|
||||
&["api.example.com"],
|
||||
policy(&["api.example.com"], &[]),
|
||||
&vault,
|
||||
"api.example.com",
|
||||
),
|
||||
authority(&["127.0.0.1"], policy(&["127.0.0.1"], &[]), &vault),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -38,7 +38,7 @@ pub const MAX_PLUGIN_CONFIG_BYTES: usize = 16 * 1024;
|
||||
/// Deepest nesting of tables and arrays accepted.
|
||||
const MAX_CONFIG_DEPTH: usize = 16;
|
||||
/// How the `toml` crate serializes a date-time (a one-key table).
|
||||
const TOML_DATETIME_KEY: &str = "$__toml_private_datetime";
|
||||
pub(super) const TOML_DATETIME_KEY: &str = "$__toml_private_datetime";
|
||||
|
||||
/// The config one activation is given, and its digest.
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
@@ -89,7 +89,7 @@ fn has_datetime(value: &Value) -> bool {
|
||||
}
|
||||
|
||||
/// One plugin's `[plugins."<name>".config]`, checked.
|
||||
fn convert(name: &str, settings: &PluginSettings) -> Result<PluginConfig, String> {
|
||||
pub(super) fn convert(name: &str, settings: &PluginSettings) -> Result<PluginConfig, String> {
|
||||
let Some(table) = &settings.config else {
|
||||
return Ok(PluginConfig::empty());
|
||||
};
|
||||
|
||||
@@ -1835,6 +1835,7 @@ pub(super) enum RuntimeTurnInputSource {
|
||||
Scheduled {
|
||||
task_id: String,
|
||||
},
|
||||
AsyncCompletionWake,
|
||||
}
|
||||
|
||||
impl RuntimeTurnInputSource {
|
||||
@@ -1850,7 +1851,7 @@ impl RuntimeTurnInputSource {
|
||||
Self::GoalContinuation {
|
||||
continuation_index, ..
|
||||
} if *continuation_index > 0 => crate::core::ops::UserInputProvenance::GoalContinuation,
|
||||
Self::GoalContinuation { .. } | Self::Scheduled { .. } => {
|
||||
Self::GoalContinuation { .. } | Self::Scheduled { .. } | Self::AsyncCompletionWake => {
|
||||
crate::core::ops::UserInputProvenance::Runtime
|
||||
}
|
||||
}
|
||||
@@ -1863,7 +1864,9 @@ impl RuntimeTurnInputSource {
|
||||
| Self::ExtensionCommand { .. }
|
||||
| Self::WatchEvent(_) => None,
|
||||
Self::AgentMail { message_id, .. } => Some(message_id),
|
||||
Self::GoalContinuation { .. } | Self::Scheduled { .. } => None,
|
||||
Self::GoalContinuation { .. } | Self::Scheduled { .. } | Self::AsyncCompletionWake => {
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1874,6 +1877,7 @@ impl RuntimeTurnInputSource {
|
||||
| Self::Review(_)
|
||||
| Self::ExtensionCommand { .. }
|
||||
| Self::Scheduled { .. } => Some(prompt.to_string()),
|
||||
Self::AsyncCompletionWake => Some("[runtime] Background work finished".to_string()),
|
||||
// The provider projection contains runtime-only framing. Persist
|
||||
// the bounded canonical mail summary instead, so history and app
|
||||
// clients never mistake that projection for typed user input.
|
||||
@@ -1938,6 +1942,9 @@ impl RuntimeTurnInputSource {
|
||||
"input_provenance": "scheduled",
|
||||
"task_id": task_id,
|
||||
})),
|
||||
Self::AsyncCompletionWake => Some(json!({
|
||||
"input_provenance": "async_completion_wake",
|
||||
})),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -6599,6 +6606,695 @@ impl RuntimeThreadManager {
|
||||
self.track_receipt_worker(worker);
|
||||
}
|
||||
|
||||
const ASYNC_COMPLETION_WAKE_DEBOUNCE: std::time::Duration =
|
||||
std::time::Duration::from_millis(75);
|
||||
const IDLE_SHELL_WAKE_POLL_INTERVAL: std::time::Duration =
|
||||
std::time::Duration::from_millis(150);
|
||||
const ASYNC_COMPLETION_WAKE_PROMPT: &'static str = "[runtime] Background work finished";
|
||||
|
||||
pub(super) fn parse_resumed_subagent_completion_count(message: &str) -> Option<usize> {
|
||||
let rest = message.strip_prefix("Resuming turn with ")?;
|
||||
if !rest.contains("sub-agent completion(s)") {
|
||||
return None;
|
||||
}
|
||||
rest.split_whitespace().next()?.parse::<usize>().ok()
|
||||
}
|
||||
|
||||
fn async_wake_arm_op(
|
||||
is_shell: bool,
|
||||
manager_key: usize,
|
||||
owner: Option<std::sync::Weak<Mutex<ActiveThreads>>>,
|
||||
thread_id: &str,
|
||||
arm: bool,
|
||||
) -> bool {
|
||||
type WakeOwnerMap = HashMap<(usize, String), std::sync::Weak<Mutex<ActiveThreads>>>;
|
||||
static ARMED_WAKES: std::sync::OnceLock<parking_lot::Mutex<WakeOwnerMap>> =
|
||||
std::sync::OnceLock::new();
|
||||
static ARMED_SHELL_MONITORS: std::sync::OnceLock<parking_lot::Mutex<WakeOwnerMap>> =
|
||||
std::sync::OnceLock::new();
|
||||
let table = if is_shell {
|
||||
ARMED_SHELL_MONITORS.get_or_init(|| parking_lot::Mutex::new(HashMap::new()))
|
||||
} else {
|
||||
ARMED_WAKES.get_or_init(|| parking_lot::Mutex::new(HashMap::new()))
|
||||
};
|
||||
let mut guard = table.lock();
|
||||
guard.retain(|_, existing| existing.upgrade().is_some());
|
||||
let key = (manager_key, thread_id.to_string());
|
||||
if arm {
|
||||
let Some(owner) = owner else {
|
||||
return false;
|
||||
};
|
||||
if guard.contains_key(&key) {
|
||||
return false;
|
||||
}
|
||||
guard.insert(key, owner);
|
||||
true
|
||||
} else {
|
||||
guard.remove(&key).is_some()
|
||||
}
|
||||
}
|
||||
|
||||
fn async_completion_wake_arm(&self, thread_id: &str) -> bool {
|
||||
Self::async_wake_arm_op(
|
||||
false,
|
||||
Arc::as_ptr(&self.active) as usize,
|
||||
Some(Arc::downgrade(&self.active)),
|
||||
thread_id,
|
||||
true,
|
||||
)
|
||||
}
|
||||
|
||||
fn async_completion_wake_release_key(manager_key: usize, thread_id: &str) {
|
||||
let _ = Self::async_wake_arm_op(false, manager_key, None, thread_id, false);
|
||||
}
|
||||
|
||||
fn async_shell_monitor_arm(&self, thread_id: &str) -> bool {
|
||||
Self::async_wake_arm_op(
|
||||
true,
|
||||
Arc::as_ptr(&self.active) as usize,
|
||||
Some(Arc::downgrade(&self.active)),
|
||||
thread_id,
|
||||
true,
|
||||
)
|
||||
}
|
||||
|
||||
fn async_shell_monitor_release_key(manager_key: usize, thread_id: &str) {
|
||||
let _ = Self::async_wake_arm_op(true, manager_key, None, thread_id, false);
|
||||
}
|
||||
|
||||
fn async_subagent_wake_op(
|
||||
&self,
|
||||
thread_id: &str,
|
||||
op: u8,
|
||||
agent_id: Option<&str>,
|
||||
flag: bool,
|
||||
count: usize,
|
||||
ids: &[String],
|
||||
) -> (bool, Vec<String>, HashSet<String>) {
|
||||
#[derive(Default)]
|
||||
struct SubAgentWakeEntry {
|
||||
owner: std::sync::Weak<Mutex<ActiveThreads>>,
|
||||
pending: Vec<String>,
|
||||
late_after_turn_complete: Vec<String>,
|
||||
delivered: HashSet<String>,
|
||||
active_turn_ids: Vec<String>,
|
||||
active_turn_drained: usize,
|
||||
saw_turn_complete: bool,
|
||||
}
|
||||
|
||||
static STATES: std::sync::OnceLock<
|
||||
parking_lot::Mutex<HashMap<(usize, String), SubAgentWakeEntry>>,
|
||||
> = std::sync::OnceLock::new();
|
||||
let table = STATES.get_or_init(|| parking_lot::Mutex::new(HashMap::new()));
|
||||
let manager_key = Arc::as_ptr(&self.active) as usize;
|
||||
let mut guard = table.lock();
|
||||
guard.retain(|_, entry| entry.owner.upgrade().is_some());
|
||||
let entry = guard
|
||||
.entry((manager_key, thread_id.to_string()))
|
||||
.or_insert_with(|| SubAgentWakeEntry {
|
||||
owner: Arc::downgrade(&self.active),
|
||||
..Default::default()
|
||||
});
|
||||
entry.owner = Arc::downgrade(&self.active);
|
||||
|
||||
match op {
|
||||
0 => {
|
||||
entry.active_turn_ids.clear();
|
||||
for pending_id in entry.pending.drain(..) {
|
||||
if !entry.delivered.contains(&pending_id)
|
||||
&& !entry.active_turn_ids.contains(&pending_id)
|
||||
{
|
||||
entry.active_turn_ids.push(pending_id);
|
||||
}
|
||||
}
|
||||
for late in entry.late_after_turn_complete.drain(..) {
|
||||
if !entry.delivered.contains(&late) && !entry.active_turn_ids.contains(&late) {
|
||||
entry.active_turn_ids.push(late);
|
||||
}
|
||||
}
|
||||
entry.active_turn_drained = 0;
|
||||
entry.saw_turn_complete = false;
|
||||
(false, Vec::new(), HashSet::new())
|
||||
}
|
||||
1 => {
|
||||
entry.saw_turn_complete = true;
|
||||
(false, Vec::new(), HashSet::new())
|
||||
}
|
||||
2 => {
|
||||
entry.active_turn_drained = entry.active_turn_drained.saturating_add(count);
|
||||
(false, Vec::new(), HashSet::new())
|
||||
}
|
||||
3 => {
|
||||
let Some(id) = agent_id else {
|
||||
return (false, Vec::new(), HashSet::new());
|
||||
};
|
||||
if entry.delivered.contains(id) {
|
||||
return (false, Vec::new(), HashSet::new());
|
||||
}
|
||||
if flag {
|
||||
if !entry.pending.iter().any(|existing| existing == id) {
|
||||
entry.pending.push(id.to_string());
|
||||
}
|
||||
(true, Vec::new(), HashSet::new())
|
||||
} else if entry.saw_turn_complete {
|
||||
if !entry
|
||||
.late_after_turn_complete
|
||||
.iter()
|
||||
.any(|existing| existing == id)
|
||||
{
|
||||
entry.late_after_turn_complete.push(id.to_string());
|
||||
}
|
||||
(false, Vec::new(), HashSet::new())
|
||||
} else {
|
||||
if !entry.active_turn_ids.iter().any(|existing| existing == id) {
|
||||
entry.active_turn_ids.push(id.to_string());
|
||||
}
|
||||
(false, Vec::new(), HashSet::new())
|
||||
}
|
||||
}
|
||||
4 => {
|
||||
let mut remaining_drain = entry.active_turn_drained;
|
||||
let active_ids = std::mem::take(&mut entry.active_turn_ids);
|
||||
for id in active_ids {
|
||||
if entry.delivered.contains(&id) {
|
||||
continue;
|
||||
}
|
||||
if remaining_drain > 0 {
|
||||
remaining_drain -= 1;
|
||||
entry.delivered.insert(id);
|
||||
} else if !entry.pending.iter().any(|existing| existing == &id) {
|
||||
entry.pending.push(id);
|
||||
}
|
||||
}
|
||||
for late in entry.late_after_turn_complete.drain(..) {
|
||||
if !entry.delivered.contains(&late)
|
||||
&& !entry.pending.iter().any(|existing| existing == &late)
|
||||
{
|
||||
entry.pending.push(late);
|
||||
}
|
||||
}
|
||||
entry.active_turn_drained = 0;
|
||||
entry.saw_turn_complete = false;
|
||||
(!entry.pending.is_empty(), Vec::new(), HashSet::new())
|
||||
}
|
||||
5 => (false, Vec::new(), entry.delivered.clone()),
|
||||
6 => (false, entry.pending.clone(), entry.delivered.clone()),
|
||||
7 => {
|
||||
let keep: HashSet<&str> = ids.iter().map(String::as_str).collect();
|
||||
entry.pending.retain(|id| keep.contains(id.as_str()));
|
||||
(false, Vec::new(), HashSet::new())
|
||||
}
|
||||
8 => {
|
||||
let mark: HashSet<&str> = ids.iter().map(String::as_str).collect();
|
||||
entry.pending.retain(|id| !mark.contains(id.as_str()));
|
||||
for id in ids {
|
||||
entry.delivered.insert(id.clone());
|
||||
}
|
||||
(false, Vec::new(), HashSet::new())
|
||||
}
|
||||
_ => (false, Vec::new(), HashSet::new()),
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn async_wake_on_turn_started(&self, thread_id: &str) {
|
||||
let _ = self.async_subagent_wake_op(thread_id, 0, None, false, 0, &[]);
|
||||
}
|
||||
|
||||
pub(super) fn async_wake_on_turn_complete(&self, thread_id: &str) {
|
||||
let _ = self.async_subagent_wake_op(thread_id, 1, None, false, 0, &[]);
|
||||
}
|
||||
|
||||
pub(super) fn async_wake_on_resumed_status(&self, thread_id: &str, count: usize) {
|
||||
if count > 0 {
|
||||
let _ = self.async_subagent_wake_op(thread_id, 2, None, false, count, &[]);
|
||||
}
|
||||
}
|
||||
|
||||
fn async_wake_on_agent_complete(
|
||||
&self,
|
||||
thread_id: &str,
|
||||
agent_id: &str,
|
||||
idle_now: bool,
|
||||
) -> bool {
|
||||
self.async_subagent_wake_op(thread_id, 3, Some(agent_id), idle_now, 0, &[])
|
||||
.0
|
||||
}
|
||||
|
||||
fn async_wake_settle_turn(&self, thread_id: &str, turn_completed: bool) -> bool {
|
||||
self.async_subagent_wake_op(thread_id, 4, None, turn_completed, 0, &[])
|
||||
.0
|
||||
}
|
||||
|
||||
fn async_wake_delivered_ids(&self, thread_id: &str) -> HashSet<String> {
|
||||
self.async_subagent_wake_op(thread_id, 5, None, false, 0, &[])
|
||||
.2
|
||||
}
|
||||
|
||||
fn async_wake_pending_snapshot(&self, thread_id: &str) -> (Vec<String>, HashSet<String>) {
|
||||
let (_, pending, delivered) =
|
||||
self.async_subagent_wake_op(thread_id, 6, None, false, 0, &[]);
|
||||
(pending, delivered)
|
||||
}
|
||||
|
||||
fn async_wake_prune_pending(&self, thread_id: &str, keep_ids: &[String]) {
|
||||
let _ = self.async_subagent_wake_op(thread_id, 7, None, false, 0, keep_ids);
|
||||
}
|
||||
|
||||
fn async_wake_mark_delivered(&self, thread_id: &str, ids: &[String]) {
|
||||
if !ids.is_empty() {
|
||||
let _ = self.async_subagent_wake_op(thread_id, 8, None, false, 0, ids);
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn weak_wake_manager(
|
||||
&self,
|
||||
) -> Arc<dyn Fn() -> Option<RuntimeThreadManager> + Send + Sync> {
|
||||
let host_profile = self.host_profile;
|
||||
let config = self.config.clone();
|
||||
let workspace = self.workspace.clone();
|
||||
let plugin_registry = self.plugin_registry.clone();
|
||||
let store = self.store.clone();
|
||||
let weak_process_owner_lock = Arc::downgrade(&self._process_owner_lock);
|
||||
let config_admission = self.config_admission.clone();
|
||||
let engine_load = self.engine_load.clone();
|
||||
let shutdown_drain = self.shutdown_drain.clone();
|
||||
let engine_workers = self.engine_workers.clone();
|
||||
let subagent_manager = self.subagent_manager.clone();
|
||||
let turn_monitors = self.turn_monitors.clone();
|
||||
let event_monitors = self.event_monitors.clone();
|
||||
let event_reader_wake = self.event_reader_wake.clone();
|
||||
let event_pump_stop = self.event_pump_stop.clone();
|
||||
let weak_event_pump_owner = Arc::downgrade(&self._event_pump_owner);
|
||||
let weak_active = Arc::downgrade(&self.active);
|
||||
let event_emit = self.event_emit.clone();
|
||||
let projection_locks = self.projection_locks.clone();
|
||||
let event_tx = self.event_tx.clone();
|
||||
let manager_cfg = self.manager_cfg.clone();
|
||||
let runtime_mcp_servers = self.runtime_mcp_servers.clone();
|
||||
let cancel_token = self.cancel_token.clone();
|
||||
let task_manager = self.task_manager.clone();
|
||||
let task_execution_lease = self.task_execution_lease.clone();
|
||||
let automations = self.automations.clone();
|
||||
let pending_approvals = self.pending_approvals.clone();
|
||||
let grants = self.grants.clone();
|
||||
let pending_user_inputs = self.pending_user_inputs.clone();
|
||||
let pending_dynamic_tools = self.pending_dynamic_tools.clone();
|
||||
let pending_commands = self.pending_commands.clone();
|
||||
let recovery_receipts = self.recovery_receipts.clone();
|
||||
let notices = self.notices.clone();
|
||||
let recovery_flush = self.recovery_flush.clone();
|
||||
let hook_base = self.hook_base.clone();
|
||||
#[cfg(any(test, feature = "test-support"))]
|
||||
let snapshot_test_hook = self.snapshot_test_hook.clone();
|
||||
#[cfg(any(test, feature = "test-support"))]
|
||||
let replay_test_hook = self.replay_test_hook.clone();
|
||||
#[cfg(any(test, feature = "test-support"))]
|
||||
let test_model_client = self.test_model_client.clone();
|
||||
#[cfg(any(test, feature = "test-support"))]
|
||||
let test_approval_decision_timeout_ms = self.test_approval_decision_timeout_ms.clone();
|
||||
#[cfg(any(test, feature = "test-support"))]
|
||||
let test_dynamic_tool_result_timeout_ms = self.test_dynamic_tool_result_timeout_ms.clone();
|
||||
|
||||
Arc::new(move || {
|
||||
if cancel_token.is_cancelled() || event_pump_stop.is_cancelled() {
|
||||
return None;
|
||||
}
|
||||
let process_owner_lock = weak_process_owner_lock.upgrade()?;
|
||||
let event_pump_owner = weak_event_pump_owner.upgrade()?;
|
||||
let active = weak_active.upgrade()?;
|
||||
Some(RuntimeThreadManager {
|
||||
host_profile,
|
||||
config: config.clone(),
|
||||
workspace: workspace.clone(),
|
||||
plugin_registry: plugin_registry.clone(),
|
||||
store: store.clone(),
|
||||
_process_owner_lock: process_owner_lock,
|
||||
config_admission: config_admission.clone(),
|
||||
engine_load: engine_load.clone(),
|
||||
shutdown_drain: shutdown_drain.clone(),
|
||||
engine_workers: engine_workers.clone(),
|
||||
subagent_manager: subagent_manager.clone(),
|
||||
turn_monitors: turn_monitors.clone(),
|
||||
event_monitors: event_monitors.clone(),
|
||||
event_reader_wake: event_reader_wake.clone(),
|
||||
event_pump_stop: event_pump_stop.clone(),
|
||||
_event_pump_owner: event_pump_owner,
|
||||
active,
|
||||
event_emit: event_emit.clone(),
|
||||
projection_locks: projection_locks.clone(),
|
||||
event_tx: event_tx.clone(),
|
||||
manager_cfg: manager_cfg.clone(),
|
||||
runtime_mcp_servers: runtime_mcp_servers.clone(),
|
||||
cancel_token: cancel_token.clone(),
|
||||
task_manager: task_manager.clone(),
|
||||
task_execution_lease: task_execution_lease.clone(),
|
||||
automations: automations.clone(),
|
||||
pending_approvals: pending_approvals.clone(),
|
||||
grants: grants.clone(),
|
||||
pending_user_inputs: pending_user_inputs.clone(),
|
||||
pending_dynamic_tools: pending_dynamic_tools.clone(),
|
||||
pending_commands: pending_commands.clone(),
|
||||
recovery_receipts: recovery_receipts.clone(),
|
||||
notices: notices.clone(),
|
||||
recovery_flush: recovery_flush.clone(),
|
||||
hook_base: hook_base.clone(),
|
||||
#[cfg(any(test, feature = "test-support"))]
|
||||
snapshot_test_hook: snapshot_test_hook.clone(),
|
||||
#[cfg(any(test, feature = "test-support"))]
|
||||
replay_test_hook: replay_test_hook.clone(),
|
||||
#[cfg(any(test, feature = "test-support"))]
|
||||
test_model_client: test_model_client.clone(),
|
||||
#[cfg(any(test, feature = "test-support"))]
|
||||
test_approval_decision_timeout_ms: test_approval_decision_timeout_ms.clone(),
|
||||
#[cfg(any(test, feature = "test-support"))]
|
||||
test_dynamic_tool_result_timeout_ms: test_dynamic_tool_result_timeout_ms.clone(),
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
pub(super) async fn handle_agent_complete_async_wake(
|
||||
&self,
|
||||
thread_id: &str,
|
||||
agent_id: &str,
|
||||
subagent_manager: &SharedSubAgentManager,
|
||||
active_weak: &std::sync::Weak<Mutex<ActiveThreads>>,
|
||||
identity: &std::sync::Weak<AsyncRwLock<mpsc::Receiver<EngineEvent>>>,
|
||||
) {
|
||||
if self.cancel_token.is_cancelled()
|
||||
|| self.event_pump_stop.is_cancelled()
|
||||
|| self.is_acp_host()
|
||||
{
|
||||
return;
|
||||
}
|
||||
let delivered = self.async_wake_delivered_ids(thread_id);
|
||||
let eligible = {
|
||||
let guard = subagent_manager.read().await;
|
||||
guard
|
||||
.terminal_results_excluding_for_session(thread_id, &delivered)
|
||||
.into_iter()
|
||||
.any(|result| result.agent_id == agent_id)
|
||||
};
|
||||
if !eligible {
|
||||
return;
|
||||
}
|
||||
let Some(active) = active_weak.upgrade() else {
|
||||
return;
|
||||
};
|
||||
let (owns_receiver, engine_active, turn_is_active) = {
|
||||
let guard = active.lock().await;
|
||||
guard
|
||||
.engines
|
||||
.get(thread_id)
|
||||
.map(|state| {
|
||||
let owns_receiver = identity
|
||||
.upgrade()
|
||||
.is_some_and(|receiver| Arc::ptr_eq(&receiver, &state.engine.rx_event));
|
||||
(
|
||||
owns_receiver,
|
||||
!state.engine.is_cancelled(),
|
||||
state.active_turn.is_some(),
|
||||
)
|
||||
})
|
||||
.unwrap_or((false, false, false))
|
||||
};
|
||||
if !owns_receiver || !engine_active {
|
||||
return;
|
||||
}
|
||||
if self.async_wake_on_agent_complete(thread_id, agent_id, !turn_is_active) {
|
||||
self.spawn_async_completion_wake(thread_id.to_string());
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) async fn handle_non_acp_turn_settled_async_completions(
|
||||
&self,
|
||||
thread_id: &str,
|
||||
turn_completed: bool,
|
||||
) {
|
||||
let has_pending_subagent = self.async_wake_settle_turn(thread_id, turn_completed);
|
||||
if !turn_completed
|
||||
|| self.cancel_token.is_cancelled()
|
||||
|| self.event_pump_stop.is_cancelled()
|
||||
|| self.is_acp_host()
|
||||
{
|
||||
return;
|
||||
}
|
||||
let Ok(thread) = self.get_thread(thread_id).await else {
|
||||
return;
|
||||
};
|
||||
if thread.archived || thread.executor.is_some() {
|
||||
return;
|
||||
}
|
||||
let (engine_ready, shell_manager) = {
|
||||
let active = self.active.lock().await;
|
||||
let engine_ready = active
|
||||
.engines
|
||||
.get(thread_id)
|
||||
.is_some_and(|state| !state.engine.is_cancelled() && state.active_turn.is_none());
|
||||
let shell_manager = active.shell_managers.get(thread_id).cloned();
|
||||
(engine_ready, shell_manager)
|
||||
};
|
||||
if !engine_ready {
|
||||
return;
|
||||
}
|
||||
let (has_finished_shell, may_have_shell) = shell_manager
|
||||
.and_then(|manager| {
|
||||
manager.lock().ok().map(|mut guard| {
|
||||
(
|
||||
guard.has_finished_unreported_jobs_for_session(thread_id),
|
||||
guard.may_have_undelivered_completion_for_session(thread_id),
|
||||
)
|
||||
})
|
||||
})
|
||||
.unwrap_or((false, false));
|
||||
if has_pending_subagent || has_finished_shell {
|
||||
self.spawn_async_completion_wake(thread_id.to_string());
|
||||
}
|
||||
if may_have_shell {
|
||||
self.spawn_idle_shell_completion_monitor(thread_id.to_string());
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) async fn deliver_async_completion_wake(&self, thread_id: &str) -> Result<()> {
|
||||
if self.cancel_token.is_cancelled()
|
||||
|| self.event_pump_stop.is_cancelled()
|
||||
|| self.is_acp_host()
|
||||
{
|
||||
return Ok(());
|
||||
}
|
||||
self.ensure_accepting_execution()?;
|
||||
let thread = match self.get_thread(thread_id).await {
|
||||
Ok(thread) => thread,
|
||||
Err(_) => return Ok(()),
|
||||
};
|
||||
if thread.archived || thread.executor.is_some() {
|
||||
return Ok(());
|
||||
}
|
||||
let (subagent_manager, shell_manager) = {
|
||||
let active = self.active.lock().await;
|
||||
let Some(state) = active.engines.get(thread_id) else {
|
||||
return Ok(());
|
||||
};
|
||||
if state.engine.is_cancelled() || state.active_turn.is_some() {
|
||||
return Ok(());
|
||||
}
|
||||
(
|
||||
state.engine.subagent_manager(),
|
||||
active.shell_managers.get(thread_id).cloned(),
|
||||
)
|
||||
};
|
||||
let has_finished_shell = shell_manager.as_ref().is_some_and(|manager| {
|
||||
manager
|
||||
.lock()
|
||||
.is_ok_and(|mut guard| guard.has_finished_unreported_jobs_for_session(thread_id))
|
||||
});
|
||||
let (pending_ids, delivered_ids) = self.async_wake_pending_snapshot(thread_id);
|
||||
let claimed_subagent_ids = if pending_ids.is_empty() {
|
||||
Vec::new()
|
||||
} else {
|
||||
let eligible_ids: HashSet<String> = {
|
||||
let guard = subagent_manager.read().await;
|
||||
guard
|
||||
.terminal_results_excluding_for_session(thread_id, &delivered_ids)
|
||||
.into_iter()
|
||||
.map(|result| result.agent_id)
|
||||
.collect()
|
||||
};
|
||||
let claimed: Vec<String> = pending_ids
|
||||
.into_iter()
|
||||
.filter(|id| eligible_ids.contains(id))
|
||||
.collect();
|
||||
self.async_wake_prune_pending(thread_id, &claimed);
|
||||
claimed
|
||||
};
|
||||
if !has_finished_shell && claimed_subagent_ids.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
let request = StartTurnRequest {
|
||||
prompt: Self::ASYNC_COMPLETION_WAKE_PROMPT.to_string(),
|
||||
..Default::default()
|
||||
};
|
||||
match Box::pin(self.start_turn_with_source(
|
||||
thread_id,
|
||||
request,
|
||||
RuntimeTurnInputSource::AsyncCompletionWake,
|
||||
None,
|
||||
false,
|
||||
))
|
||||
.await
|
||||
{
|
||||
Ok((_turn, _replayed)) => {
|
||||
self.async_wake_mark_delivered(thread_id, &claimed_subagent_ids);
|
||||
Ok(())
|
||||
}
|
||||
Err(error) if error.to_string().contains("already has an active") => Ok(()),
|
||||
Err(error) => Err(error),
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn spawn_async_completion_wake(&self, thread_id: String) {
|
||||
if self.cancel_token.is_cancelled()
|
||||
|| self.event_pump_stop.is_cancelled()
|
||||
|| self.is_acp_host()
|
||||
{
|
||||
return;
|
||||
}
|
||||
if !self.async_completion_wake_arm(&thread_id) {
|
||||
return;
|
||||
}
|
||||
let manager_key = Arc::as_ptr(&self.active) as usize;
|
||||
let weak = self.weak_wake_manager();
|
||||
let cancel = self.cancel_token.clone();
|
||||
let stop = self.event_pump_stop.clone();
|
||||
let worker = tokio::spawn(async move {
|
||||
struct WakeArmGuard {
|
||||
manager_key: usize,
|
||||
thread_id: String,
|
||||
armed: bool,
|
||||
}
|
||||
impl Drop for WakeArmGuard {
|
||||
fn drop(&mut self) {
|
||||
if self.armed {
|
||||
RuntimeThreadManager::async_completion_wake_release_key(
|
||||
self.manager_key,
|
||||
&self.thread_id,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut guard = WakeArmGuard {
|
||||
manager_key,
|
||||
thread_id: thread_id.clone(),
|
||||
armed: true,
|
||||
};
|
||||
tokio::select! {
|
||||
biased;
|
||||
_ = cancel.cancelled() => return,
|
||||
_ = stop.cancelled() => return,
|
||||
_ = tokio::time::sleep(Self::ASYNC_COMPLETION_WAKE_DEBOUNCE) => {}
|
||||
}
|
||||
guard.armed = false;
|
||||
Self::async_completion_wake_release_key(manager_key, &thread_id);
|
||||
let Some(manager) = weak() else {
|
||||
return;
|
||||
};
|
||||
if let Err(error) = Box::pin(manager.deliver_async_completion_wake(&thread_id)).await {
|
||||
tracing::warn!(
|
||||
thread_id,
|
||||
%error,
|
||||
"Failed to deliver coalesced async completion wake"
|
||||
);
|
||||
}
|
||||
});
|
||||
self.track_receipt_worker(worker);
|
||||
}
|
||||
|
||||
pub(super) fn spawn_idle_shell_completion_monitor(&self, thread_id: String) {
|
||||
if self.cancel_token.is_cancelled()
|
||||
|| self.event_pump_stop.is_cancelled()
|
||||
|| self.is_acp_host()
|
||||
{
|
||||
return;
|
||||
}
|
||||
if !self.async_shell_monitor_arm(&thread_id) {
|
||||
return;
|
||||
}
|
||||
let manager_key = Arc::as_ptr(&self.active) as usize;
|
||||
let weak = self.weak_wake_manager();
|
||||
let cancel = self.cancel_token.clone();
|
||||
let stop = self.event_pump_stop.clone();
|
||||
let worker = tokio::spawn(async move {
|
||||
struct ShellMonitorGuard {
|
||||
manager_key: usize,
|
||||
thread_id: String,
|
||||
}
|
||||
impl Drop for ShellMonitorGuard {
|
||||
fn drop(&mut self) {
|
||||
RuntimeThreadManager::async_shell_monitor_release_key(
|
||||
self.manager_key,
|
||||
&self.thread_id,
|
||||
);
|
||||
}
|
||||
}
|
||||
let _guard = ShellMonitorGuard {
|
||||
manager_key,
|
||||
thread_id: thread_id.clone(),
|
||||
};
|
||||
loop {
|
||||
tokio::select! {
|
||||
biased;
|
||||
_ = cancel.cancelled() => break,
|
||||
_ = stop.cancelled() => break,
|
||||
_ = tokio::time::sleep(Self::IDLE_SHELL_WAKE_POLL_INTERVAL) => {}
|
||||
}
|
||||
let Some(manager) = weak() else {
|
||||
break;
|
||||
};
|
||||
if manager.ensure_accepting_execution().is_err() || manager.is_acp_host() {
|
||||
break;
|
||||
}
|
||||
let Ok(thread) = manager.get_thread(&thread_id).await else {
|
||||
break;
|
||||
};
|
||||
if thread.archived || thread.executor.is_some() {
|
||||
break;
|
||||
}
|
||||
let (engine_alive, is_idle, shell_manager) = {
|
||||
let active = manager.active.lock().await;
|
||||
let engine_alive = active
|
||||
.engines
|
||||
.get(&thread_id)
|
||||
.is_some_and(|state| !state.engine.is_cancelled());
|
||||
let is_idle = active
|
||||
.engines
|
||||
.get(&thread_id)
|
||||
.is_some_and(|state| state.active_turn.is_none());
|
||||
let shell_manager = active.shell_managers.get(&thread_id).cloned();
|
||||
(engine_alive, is_idle, shell_manager)
|
||||
};
|
||||
if !engine_alive {
|
||||
break;
|
||||
}
|
||||
let Some(shell_manager) = shell_manager else {
|
||||
break;
|
||||
};
|
||||
let (has_finished, may_have_pending) = match shell_manager.lock() {
|
||||
Ok(mut guard) => (
|
||||
guard.has_finished_unreported_jobs_for_session(&thread_id),
|
||||
guard.may_have_undelivered_completion_for_session(&thread_id),
|
||||
),
|
||||
Err(_) => break,
|
||||
};
|
||||
if !may_have_pending {
|
||||
break;
|
||||
}
|
||||
if has_finished && is_idle {
|
||||
manager.spawn_async_completion_wake(thread_id.clone());
|
||||
}
|
||||
}
|
||||
});
|
||||
self.track_receipt_worker(worker);
|
||||
}
|
||||
|
||||
pub(super) fn projection_lock(&self, thread_id: &str) -> Arc<Mutex<()>> {
|
||||
let mut locks = self.projection_locks.lock();
|
||||
Arc::clone(
|
||||
@@ -15006,6 +15702,9 @@ source_usage_records: Default::default(),
|
||||
owner: Arc::downgrade(&self._process_owner_lock),
|
||||
};
|
||||
let input_settlement = self.user_input_settlement(&thread.id);
|
||||
let allow_reactive_wake = !self.is_acp_host() && thread.executor.is_none();
|
||||
let weak_wake_manager = self.weak_wake_manager();
|
||||
let pump_cancel = self.cancel_token.clone();
|
||||
let handle = engine.clone();
|
||||
let thread_id = thread.id.clone();
|
||||
let pump = tokio::spawn(async move {
|
||||
@@ -15015,6 +15714,51 @@ source_usage_records: Default::default(),
|
||||
event = async { original.write().await.recv().await } => match event { Some(event) => event, None => break },
|
||||
_ = stop.cancelled() => break,
|
||||
};
|
||||
if allow_reactive_wake
|
||||
&& !stop.is_cancelled()
|
||||
&& !pump_cancel.is_cancelled()
|
||||
&& !handle.is_cancelled()
|
||||
{
|
||||
enum WakeTurnSignal {
|
||||
Started,
|
||||
Complete,
|
||||
Resumed(usize),
|
||||
}
|
||||
let signal = match &event {
|
||||
EngineEvent::TurnStarted { .. } => Some(WakeTurnSignal::Started),
|
||||
EngineEvent::TurnComplete { .. } => Some(WakeTurnSignal::Complete),
|
||||
EngineEvent::Status { message } => {
|
||||
Self::parse_resumed_subagent_completion_count(message)
|
||||
.map(WakeTurnSignal::Resumed)
|
||||
}
|
||||
_ => None,
|
||||
};
|
||||
if let Some(signal) = signal
|
||||
&& let Some(active_arc) = active.upgrade()
|
||||
{
|
||||
let owns_receiver = {
|
||||
let guard = active_arc.lock().await;
|
||||
guard.engines.get(&thread_id).is_some_and(|state| {
|
||||
identity.upgrade().is_some_and(|receiver| {
|
||||
Arc::ptr_eq(&receiver, &state.engine.rx_event)
|
||||
})
|
||||
})
|
||||
};
|
||||
if owns_receiver && let Some(manager) = weak_wake_manager() {
|
||||
match signal {
|
||||
WakeTurnSignal::Started => {
|
||||
manager.async_wake_on_turn_started(&thread_id);
|
||||
}
|
||||
WakeTurnSignal::Complete => {
|
||||
manager.async_wake_on_turn_complete(&thread_id);
|
||||
}
|
||||
WakeTurnSignal::Resumed(count) => {
|
||||
manager.async_wake_on_resumed_status(&thread_id, count);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if matches!(
|
||||
&event,
|
||||
EngineEvent::AgentSpawned { .. }
|
||||
@@ -15057,6 +15801,30 @@ source_usage_records: Default::default(),
|
||||
} else {
|
||||
false
|
||||
};
|
||||
if allow_reactive_wake
|
||||
&& !stop.is_cancelled()
|
||||
&& !pump_cancel.is_cancelled()
|
||||
&& !handle.is_cancelled()
|
||||
&& let EngineEvent::AgentComplete {
|
||||
owner_session_id,
|
||||
id,
|
||||
parent_run_id,
|
||||
..
|
||||
} = &event
|
||||
&& owner_session_id == &thread_id
|
||||
&& parent_run_id.is_none()
|
||||
&& let Some(manager) = weak_wake_manager()
|
||||
{
|
||||
manager
|
||||
.handle_agent_complete_async_wake(
|
||||
&thread_id,
|
||||
id,
|
||||
&sink.manager,
|
||||
&active,
|
||||
&identity,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
if !owns_origin {
|
||||
continue;
|
||||
}
|
||||
@@ -18145,6 +18913,11 @@ source_usage_records: Default::default(),
|
||||
// next one, keeping every wake explicit and bounded to one turn.
|
||||
if !admitted_acp {
|
||||
self.spawn_agent_mail_safe_boundary_delivery(thread_id.clone());
|
||||
self.handle_non_acp_turn_settled_async_completions(
|
||||
&thread_id,
|
||||
turn.status == RuntimeTurnStatus::Completed,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
|
||||
@@ -26146,3 +26146,619 @@ async fn profile_constitution_runtime_admission_binds_the_complete_snapshot_to_r
|
||||
wait_for_terminal_turn(&manager, &turn.id).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn async_completion_wake_coalesces_rapid_idle_subagent_completions() -> Result<()> {
|
||||
use crate::tools::subagent::{SubAgentManager, SubAgentStatus};
|
||||
|
||||
let manager = test_manager(test_runtime_dir())?;
|
||||
let thread = manager
|
||||
.create_thread(CreateThreadRequest::default())
|
||||
.await?;
|
||||
let mut harness = mock_engine_handle();
|
||||
let subagent_manager = Arc::new(tokio::sync::RwLock::new(SubAgentManager::new(
|
||||
thread.workspace.clone(),
|
||||
2,
|
||||
)));
|
||||
harness
|
||||
.handle
|
||||
.set_subagent_manager(subagent_manager.clone());
|
||||
manager
|
||||
.install_test_engine(&thread.id, harness.handle.clone())
|
||||
.await?;
|
||||
let mut rx_op = harness.rx_op;
|
||||
let tx_event = harness.tx_event;
|
||||
|
||||
let (sub_a, sub_b) = {
|
||||
let mut guard = subagent_manager.write().await;
|
||||
let sub_a = guard.insert_test_terminal_direct_child("sub_a", &thread.workspace);
|
||||
guard.assign_test_session_owner(&sub_a, &thread.id);
|
||||
let sub_b = guard.insert_test_terminal_direct_child("sub_b", &thread.workspace);
|
||||
guard.assign_test_session_owner(&sub_b, &thread.id);
|
||||
(sub_a, sub_b)
|
||||
};
|
||||
|
||||
for (index, agent_id) in [sub_a, sub_b].into_iter().enumerate() {
|
||||
if index > 0 {
|
||||
tokio::time::sleep(Duration::from_millis(10)).await;
|
||||
}
|
||||
tx_event
|
||||
.send(EngineEvent::AgentComplete {
|
||||
display_name: Some("worker".into()),
|
||||
owner_session_id: thread.id.clone(),
|
||||
id: agent_id,
|
||||
result: "done".into(),
|
||||
outcome: Some(SubAgentStatus::Completed),
|
||||
parent_run_id: None,
|
||||
spawn_depth: Some(1),
|
||||
continuable: Some(false),
|
||||
usage: None,
|
||||
})
|
||||
.await?;
|
||||
}
|
||||
|
||||
let op = tokio::time::timeout(Duration::from_secs(2), rx_op.recv())
|
||||
.await
|
||||
.context("expected coalesced wake turn")?;
|
||||
let Some(Op::SendMessage(spec)) = op else {
|
||||
bail!("expected Op::SendMessage wake operation");
|
||||
};
|
||||
assert_eq!(
|
||||
spec.provenance,
|
||||
crate::core::ops::UserInputProvenance::Runtime
|
||||
);
|
||||
assert_eq!(spec.content, "[runtime] Background work finished");
|
||||
|
||||
tx_event
|
||||
.send(EngineEvent::TurnStarted {
|
||||
previous_source: None,
|
||||
turn_id: "engine_wake_coalesced".into(),
|
||||
created_at: Utc::now(),
|
||||
route: None,
|
||||
submission_id: None,
|
||||
})
|
||||
.await?;
|
||||
tx_event
|
||||
.send(EngineEvent::MessageStarted { index: 0 })
|
||||
.await?;
|
||||
tx_event
|
||||
.send(EngineEvent::MessageDelta {
|
||||
index: 0,
|
||||
content: "processed coalesced subagents".to_string(),
|
||||
})
|
||||
.await?;
|
||||
tx_event
|
||||
.send(EngineEvent::MessageComplete { index: 0 })
|
||||
.await?;
|
||||
tx_event
|
||||
.send(EngineEvent::TurnComplete {
|
||||
source_accounting: false,
|
||||
turn_id: None,
|
||||
submission_id: None,
|
||||
compaction_id: None,
|
||||
usage: Usage::default(),
|
||||
parent_route_usage: Usage::default(),
|
||||
routed_usage_dropped_records: 0,
|
||||
status: TurnOutcomeStatus::Completed,
|
||||
error: None,
|
||||
tool_catalog: None,
|
||||
base_url: None,
|
||||
})
|
||||
.await?;
|
||||
|
||||
let turns =
|
||||
wait_for_terminal_turn_count(&manager, &thread.id, 1, Duration::from_secs(2)).await?;
|
||||
assert_eq!(turns.len(), 1);
|
||||
assert_eq!(turns[0].status, RuntimeTurnStatus::Completed);
|
||||
let items = manager.store.list_items_for_turn(&turns[0].id)?;
|
||||
let input_item = items
|
||||
.iter()
|
||||
.find(|item| item.kind == TurnItemKind::UserMessage)
|
||||
.context("missing wake input item")?;
|
||||
assert_eq!(
|
||||
input_item.detail.as_deref(),
|
||||
Some("[runtime] Background work finished")
|
||||
);
|
||||
assert_eq!(
|
||||
input_item
|
||||
.metadata
|
||||
.as_ref()
|
||||
.and_then(|meta| meta.get("input_provenance"))
|
||||
.and_then(Value::as_str),
|
||||
Some("async_completion_wake")
|
||||
);
|
||||
|
||||
tokio::time::sleep(Duration::from_millis(120)).await;
|
||||
assert!(rx_op.try_recv().is_err());
|
||||
assert_eq!(manager.store.list_turns_for_thread(&thread.id)?.len(), 1);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn async_completion_wake_skips_cancelled_and_archived_threads() -> Result<()> {
|
||||
use crate::tools::subagent::{SubAgentManager, SubAgentStatus};
|
||||
|
||||
let manager = test_manager(test_runtime_dir())?;
|
||||
let archived_thread = manager
|
||||
.create_thread(CreateThreadRequest::default())
|
||||
.await?;
|
||||
let mut archived_harness = mock_engine_handle();
|
||||
let shared_subagents = Arc::new(tokio::sync::RwLock::new(SubAgentManager::new(
|
||||
archived_thread.workspace.clone(),
|
||||
2,
|
||||
)));
|
||||
archived_harness
|
||||
.handle
|
||||
.set_subagent_manager(shared_subagents.clone());
|
||||
manager
|
||||
.install_test_engine(&archived_thread.id, archived_harness.handle.clone())
|
||||
.await?;
|
||||
|
||||
let cancelled_thread = manager
|
||||
.create_thread(CreateThreadRequest::default())
|
||||
.await?;
|
||||
let mut cancelled_harness = mock_engine_handle();
|
||||
cancelled_harness
|
||||
.handle
|
||||
.set_subagent_manager(shared_subagents.clone());
|
||||
manager
|
||||
.install_test_engine(&cancelled_thread.id, cancelled_harness.handle.clone())
|
||||
.await?;
|
||||
|
||||
let (sub_archived, sub_cancelled) = {
|
||||
let mut guard = shared_subagents.write().await;
|
||||
let a = guard.insert_test_terminal_direct_child("sub_arch", &archived_thread.workspace);
|
||||
guard.assign_test_session_owner(&a, &archived_thread.id);
|
||||
let b = guard.insert_test_terminal_direct_child("sub_canc", &cancelled_thread.workspace);
|
||||
guard.assign_test_session_owner(&b, &cancelled_thread.id);
|
||||
(a, b)
|
||||
};
|
||||
|
||||
manager
|
||||
.update_thread(
|
||||
&archived_thread.id,
|
||||
UpdateThreadRequest {
|
||||
archived: Some(true),
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
cancelled_harness
|
||||
.handle
|
||||
.cancel_with_reason(crate::core::engine::CancelReason::User);
|
||||
|
||||
archived_harness
|
||||
.tx_event
|
||||
.send(EngineEvent::AgentComplete {
|
||||
display_name: None,
|
||||
owner_session_id: archived_thread.id.clone(),
|
||||
id: sub_archived,
|
||||
result: "done".into(),
|
||||
outcome: Some(SubAgentStatus::Completed),
|
||||
parent_run_id: None,
|
||||
spawn_depth: Some(1),
|
||||
continuable: Some(false),
|
||||
usage: None,
|
||||
})
|
||||
.await?;
|
||||
cancelled_harness
|
||||
.tx_event
|
||||
.send(EngineEvent::AgentComplete {
|
||||
display_name: None,
|
||||
owner_session_id: cancelled_thread.id.clone(),
|
||||
id: sub_cancelled,
|
||||
result: "done".into(),
|
||||
outcome: Some(SubAgentStatus::Completed),
|
||||
parent_run_id: None,
|
||||
spawn_depth: Some(1),
|
||||
continuable: Some(false),
|
||||
usage: None,
|
||||
})
|
||||
.await?;
|
||||
|
||||
tokio::time::sleep(Duration::from_millis(140)).await;
|
||||
assert!(archived_harness.rx_op.try_recv().is_err());
|
||||
assert!(cancelled_harness.rx_op.try_recv().is_err());
|
||||
assert!(
|
||||
manager
|
||||
.store
|
||||
.list_turns_for_thread(&archived_thread.id)?
|
||||
.is_empty()
|
||||
);
|
||||
assert!(
|
||||
manager
|
||||
.store
|
||||
.list_turns_for_thread(&cancelled_thread.id)?
|
||||
.is_empty()
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn async_completion_wake_waits_for_busy_turn_settlement_and_wakes_once() -> Result<()> {
|
||||
use crate::tools::subagent::{SubAgentManager, SubAgentStatus};
|
||||
|
||||
let manager = test_manager(test_runtime_dir())?;
|
||||
let thread = manager
|
||||
.create_thread(CreateThreadRequest::default())
|
||||
.await?;
|
||||
let mut harness = mock_engine_handle();
|
||||
let subagent_manager = Arc::new(tokio::sync::RwLock::new(SubAgentManager::new(
|
||||
thread.workspace.clone(),
|
||||
1,
|
||||
)));
|
||||
harness
|
||||
.handle
|
||||
.set_subagent_manager(subagent_manager.clone());
|
||||
manager
|
||||
.install_test_engine(&thread.id, harness.handle.clone())
|
||||
.await?;
|
||||
let mut rx_op = harness.rx_op;
|
||||
let tx_event = harness.tx_event;
|
||||
|
||||
let first_turn = manager
|
||||
.start_turn(
|
||||
&thread.id,
|
||||
StartTurnRequest {
|
||||
prompt: "primary user turn".into(),
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
let Some(Op::SendMessage(first_spec)) =
|
||||
tokio::time::timeout(Duration::from_secs(2), rx_op.recv()).await?
|
||||
else {
|
||||
bail!("missing Turn 1 operation");
|
||||
};
|
||||
assert_eq!(first_spec.content, "primary user turn");
|
||||
|
||||
tx_event
|
||||
.send(EngineEvent::TurnStarted {
|
||||
previous_source: None,
|
||||
turn_id: "engine_busy_1".into(),
|
||||
created_at: Utc::now(),
|
||||
route: None,
|
||||
submission_id: None,
|
||||
})
|
||||
.await?;
|
||||
|
||||
let sub_busy = {
|
||||
let mut guard = subagent_manager.write().await;
|
||||
let id = guard.insert_test_terminal_direct_child("sub_busy", &thread.workspace);
|
||||
guard.assign_test_session_owner(&id, &thread.id);
|
||||
id
|
||||
};
|
||||
tx_event
|
||||
.send(EngineEvent::AgentComplete {
|
||||
display_name: None,
|
||||
owner_session_id: thread.id.clone(),
|
||||
id: sub_busy,
|
||||
result: "done while busy".into(),
|
||||
outcome: Some(SubAgentStatus::Completed),
|
||||
parent_run_id: None,
|
||||
spawn_depth: Some(1),
|
||||
continuable: Some(false),
|
||||
usage: None,
|
||||
})
|
||||
.await?;
|
||||
manager.spawn_async_completion_wake(thread.id.clone());
|
||||
|
||||
tokio::time::sleep(Duration::from_millis(120)).await;
|
||||
assert!(rx_op.try_recv().is_err());
|
||||
|
||||
tx_event
|
||||
.send(EngineEvent::MessageStarted { index: 0 })
|
||||
.await?;
|
||||
tx_event
|
||||
.send(EngineEvent::MessageDelta {
|
||||
index: 0,
|
||||
content: "finished primary user turn".to_string(),
|
||||
})
|
||||
.await?;
|
||||
tx_event
|
||||
.send(EngineEvent::MessageComplete { index: 0 })
|
||||
.await?;
|
||||
tx_event
|
||||
.send(EngineEvent::TurnComplete {
|
||||
source_accounting: false,
|
||||
turn_id: None,
|
||||
submission_id: None,
|
||||
compaction_id: None,
|
||||
usage: Usage::default(),
|
||||
parent_route_usage: Usage::default(),
|
||||
routed_usage_dropped_records: 0,
|
||||
status: TurnOutcomeStatus::Completed,
|
||||
error: None,
|
||||
tool_catalog: None,
|
||||
base_url: None,
|
||||
})
|
||||
.await?;
|
||||
let settled_first = wait_for_terminal_turn(&manager, &first_turn.id).await?;
|
||||
assert_eq!(settled_first.status, RuntimeTurnStatus::Completed);
|
||||
|
||||
let Some(Op::SendMessage(wake_spec)) =
|
||||
tokio::time::timeout(Duration::from_secs(2), rx_op.recv()).await?
|
||||
else {
|
||||
bail!("missing deferred wake operation");
|
||||
};
|
||||
assert_eq!(
|
||||
wake_spec.provenance,
|
||||
crate::core::ops::UserInputProvenance::Runtime
|
||||
);
|
||||
assert_eq!(wake_spec.content, "[runtime] Background work finished");
|
||||
|
||||
tx_event
|
||||
.send(EngineEvent::TurnStarted {
|
||||
previous_source: None,
|
||||
turn_id: "engine_busy_2".into(),
|
||||
created_at: Utc::now(),
|
||||
route: None,
|
||||
submission_id: None,
|
||||
})
|
||||
.await?;
|
||||
tx_event
|
||||
.send(EngineEvent::MessageStarted { index: 0 })
|
||||
.await?;
|
||||
tx_event
|
||||
.send(EngineEvent::MessageDelta {
|
||||
index: 0,
|
||||
content: "handled deferred wake".to_string(),
|
||||
})
|
||||
.await?;
|
||||
tx_event
|
||||
.send(EngineEvent::MessageComplete { index: 0 })
|
||||
.await?;
|
||||
tx_event
|
||||
.send(EngineEvent::TurnComplete {
|
||||
source_accounting: false,
|
||||
turn_id: None,
|
||||
submission_id: None,
|
||||
compaction_id: None,
|
||||
usage: Usage::default(),
|
||||
parent_route_usage: Usage::default(),
|
||||
routed_usage_dropped_records: 0,
|
||||
status: TurnOutcomeStatus::Completed,
|
||||
error: None,
|
||||
tool_catalog: None,
|
||||
base_url: None,
|
||||
})
|
||||
.await?;
|
||||
|
||||
let turns =
|
||||
wait_for_terminal_turn_count(&manager, &thread.id, 2, Duration::from_secs(2)).await?;
|
||||
assert_eq!(turns.len(), 2);
|
||||
assert_eq!(turns[1].status, RuntimeTurnStatus::Completed);
|
||||
let second_items = manager.store.list_items_for_turn(&turns[1].id)?;
|
||||
assert!(second_items.iter().any(|item| {
|
||||
item.metadata
|
||||
.as_ref()
|
||||
.and_then(|meta| meta.get("input_provenance"))
|
||||
.and_then(Value::as_str)
|
||||
== Some("async_completion_wake")
|
||||
}));
|
||||
|
||||
tokio::time::sleep(Duration::from_millis(120)).await;
|
||||
assert!(rx_op.try_recv().is_err());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn async_completion_wake_skips_turn_that_already_drained_subagent() -> Result<()> {
|
||||
use crate::tools::subagent::{SubAgentManager, SubAgentStatus};
|
||||
|
||||
let manager = test_manager(test_runtime_dir())?;
|
||||
let thread = manager
|
||||
.create_thread(CreateThreadRequest::default())
|
||||
.await?;
|
||||
let mut harness = mock_engine_handle();
|
||||
let subagent_manager = Arc::new(tokio::sync::RwLock::new(SubAgentManager::new(
|
||||
thread.workspace.clone(),
|
||||
1,
|
||||
)));
|
||||
harness
|
||||
.handle
|
||||
.set_subagent_manager(subagent_manager.clone());
|
||||
manager
|
||||
.install_test_engine(&thread.id, harness.handle.clone())
|
||||
.await?;
|
||||
let mut rx_op = harness.rx_op;
|
||||
let tx_event = harness.tx_event;
|
||||
|
||||
let turn = manager
|
||||
.start_turn(
|
||||
&thread.id,
|
||||
StartTurnRequest {
|
||||
prompt: "draining turn".into(),
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
assert!(matches!(
|
||||
tokio::time::timeout(Duration::from_secs(2), rx_op.recv()).await?,
|
||||
Some(Op::SendMessage(_))
|
||||
));
|
||||
tx_event
|
||||
.send(EngineEvent::TurnStarted {
|
||||
previous_source: None,
|
||||
turn_id: "engine_drain_1".into(),
|
||||
created_at: Utc::now(),
|
||||
route: None,
|
||||
submission_id: None,
|
||||
})
|
||||
.await?;
|
||||
|
||||
let sub_drained = {
|
||||
let mut guard = subagent_manager.write().await;
|
||||
let id = guard.insert_test_terminal_direct_child("sub_drained", &thread.workspace);
|
||||
guard.assign_test_session_owner(&id, &thread.id);
|
||||
id
|
||||
};
|
||||
tx_event
|
||||
.send(EngineEvent::AgentComplete {
|
||||
display_name: None,
|
||||
owner_session_id: thread.id.clone(),
|
||||
id: sub_drained,
|
||||
result: "already absorbed".into(),
|
||||
outcome: Some(SubAgentStatus::Completed),
|
||||
parent_run_id: None,
|
||||
spawn_depth: Some(1),
|
||||
continuable: Some(false),
|
||||
usage: None,
|
||||
})
|
||||
.await?;
|
||||
tx_event
|
||||
.send(EngineEvent::status(
|
||||
"Resuming turn with 1 late sub-agent completion(s)",
|
||||
))
|
||||
.await?;
|
||||
tx_event
|
||||
.send(EngineEvent::MessageStarted { index: 0 })
|
||||
.await?;
|
||||
tx_event
|
||||
.send(EngineEvent::MessageDelta {
|
||||
index: 0,
|
||||
content: "already consumed child completion".to_string(),
|
||||
})
|
||||
.await?;
|
||||
tx_event
|
||||
.send(EngineEvent::MessageComplete { index: 0 })
|
||||
.await?;
|
||||
tx_event
|
||||
.send(EngineEvent::TurnComplete {
|
||||
source_accounting: false,
|
||||
turn_id: None,
|
||||
submission_id: None,
|
||||
compaction_id: None,
|
||||
usage: Usage::default(),
|
||||
parent_route_usage: Usage::default(),
|
||||
routed_usage_dropped_records: 0,
|
||||
status: TurnOutcomeStatus::Completed,
|
||||
error: None,
|
||||
tool_catalog: None,
|
||||
base_url: None,
|
||||
})
|
||||
.await?;
|
||||
|
||||
let settled = wait_for_terminal_turn(&manager, &turn.id).await?;
|
||||
assert_eq!(settled.status, RuntimeTurnStatus::Completed);
|
||||
tokio::time::sleep(Duration::from_millis(140)).await;
|
||||
assert!(rx_op.try_recv().is_err());
|
||||
assert_eq!(manager.store.list_turns_for_thread(&thread.id)?.len(), 1);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn async_completion_wake_monitors_background_shell_after_turn_settlement() -> Result<()> {
|
||||
let manager = test_manager(test_runtime_dir())?;
|
||||
let thread = manager
|
||||
.create_thread(CreateThreadRequest::default())
|
||||
.await?;
|
||||
let harness = install_mock_engine(&manager, &thread.id).await;
|
||||
let shell_manager = manager
|
||||
.thread_shell_manager(&thread.id, true)
|
||||
.await?
|
||||
.context("expected thread shell manager")?;
|
||||
|
||||
{
|
||||
let mut guard = shell_manager
|
||||
.lock()
|
||||
.map_err(|_| anyhow!("shell manager lock poisoned"))?;
|
||||
guard.execute_with_options_env_for_session(
|
||||
"sleep 0.12",
|
||||
None,
|
||||
5_000,
|
||||
true,
|
||||
None,
|
||||
false,
|
||||
None,
|
||||
HashMap::new(),
|
||||
&thread.id,
|
||||
)?;
|
||||
}
|
||||
|
||||
let mut rx_op = harness.rx_op;
|
||||
let tx_event = harness.tx_event;
|
||||
let thread_id = thread.id.clone();
|
||||
let shell_mgr_for_mock = shell_manager.clone();
|
||||
tokio::spawn(async move {
|
||||
let mut index = 0usize;
|
||||
while let Some(op) = rx_op.recv().await {
|
||||
if let Op::SendMessage(spec) = op {
|
||||
index += 1;
|
||||
if index > 1 {
|
||||
assert_eq!(
|
||||
spec.provenance,
|
||||
crate::core::ops::UserInputProvenance::Runtime
|
||||
);
|
||||
if let Ok(mut guard) = shell_mgr_for_mock.lock() {
|
||||
let _ = guard.drain_finished_jobs_with_evidence_for_session(&thread_id);
|
||||
}
|
||||
}
|
||||
let _ = tx_event
|
||||
.send(EngineEvent::TurnStarted {
|
||||
previous_source: None,
|
||||
turn_id: format!("engine_shell_{index}"),
|
||||
created_at: Utc::now(),
|
||||
route: None,
|
||||
submission_id: None,
|
||||
})
|
||||
.await;
|
||||
let _ = tx_event
|
||||
.send(EngineEvent::MessageStarted { index: 0 })
|
||||
.await;
|
||||
let _ = tx_event
|
||||
.send(EngineEvent::MessageDelta {
|
||||
index: 0,
|
||||
content: format!("shell step {index}"),
|
||||
})
|
||||
.await;
|
||||
let _ = tx_event
|
||||
.send(EngineEvent::MessageComplete { index: 0 })
|
||||
.await;
|
||||
let _ = tx_event
|
||||
.send(EngineEvent::TurnComplete {
|
||||
source_accounting: false,
|
||||
turn_id: None,
|
||||
submission_id: None,
|
||||
compaction_id: None,
|
||||
usage: Usage::default(),
|
||||
parent_route_usage: Usage::default(),
|
||||
routed_usage_dropped_records: 0,
|
||||
status: TurnOutcomeStatus::Completed,
|
||||
error: None,
|
||||
tool_catalog: None,
|
||||
base_url: None,
|
||||
})
|
||||
.await;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
manager
|
||||
.start_turn(
|
||||
&thread.id,
|
||||
StartTurnRequest {
|
||||
prompt: "launch background shell".into(),
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
|
||||
let turns =
|
||||
wait_for_terminal_turn_count(&manager, &thread.id, 2, Duration::from_secs(5)).await?;
|
||||
assert_eq!(turns.len(), 2);
|
||||
assert_eq!(turns[0].status, RuntimeTurnStatus::Completed);
|
||||
assert_eq!(turns[1].status, RuntimeTurnStatus::Completed);
|
||||
let second_items = manager.store.list_items_for_turn(&turns[1].id)?;
|
||||
assert!(second_items.iter().any(|item| {
|
||||
item.metadata
|
||||
.as_ref()
|
||||
.and_then(|meta| meta.get("input_provenance"))
|
||||
.and_then(Value::as_str)
|
||||
== Some("async_completion_wake")
|
||||
}));
|
||||
|
||||
tokio::time::sleep(Duration::from_millis(250)).await;
|
||||
assert_eq!(manager.store.list_turns_for_thread(&thread.id)?.len(), 2);
|
||||
Ok(())
|
||||
}
|
||||
@@ -61,6 +61,7 @@
|
||||
use serde_json::Value;
|
||||
|
||||
use crate::tools::spec::{ApprovalRequirement, ToolCapability, ToolSpec};
|
||||
use codewhale_agent_contract::worker_profile::ShellGrant;
|
||||
use codewhale_execpolicy::canonical_action::canonical_action_alias;
|
||||
|
||||
/// The execution authority a child actually holds, read off the runtime posture
|
||||
@@ -477,6 +478,15 @@ pub fn enforce_execution_envelope(
|
||||
}
|
||||
|
||||
pub fn tool_call_denied(rules: Option<&[String]>, name: &str, input: &Value) -> bool {
|
||||
tool_call_denied_for(rules, name, input, false)
|
||||
}
|
||||
|
||||
pub fn tool_call_denied_for(
|
||||
rules: Option<&[String]>,
|
||||
name: &str,
|
||||
input: &Value,
|
||||
holds_bounded_verification: bool,
|
||||
) -> bool {
|
||||
let action = canonical_action_alias(name, input);
|
||||
codewhale_execpolicy::tool_rules::tool_denied(rules, name)
|
||||
|| codewhale_execpolicy::tool_rules::tool_denied(rules, action)
|
||||
@@ -486,12 +496,40 @@ pub fn tool_call_denied(rules: Option<&[String]>, name: &str, input: &Value) ->
|
||||
.and_then(Value::as_str)
|
||||
.is_some_and(|url| !url.trim().is_empty())
|
||||
&& codewhale_execpolicy::tool_rules::tool_denied(rules, "fetch_url"))
|
||||
|| (matches!(
|
||||
classify_verification(action, input),
|
||||
Some(VerificationBound::Unbounded)
|
||||
) && rules.is_some_and(|rules| {
|
||||
codewhale_execpolicy::tool_rules::tool_matches_any_rule(rules, "Bash")
|
||||
}))
|
||||
|| (starts_workspace_execution(name, action, input, holds_bounded_verification)
|
||||
&& rules.is_some_and(|rules| {
|
||||
codewhale_execpolicy::tool_rules::tool_matches_any_rule(rules, "Bash")
|
||||
}))
|
||||
}
|
||||
|
||||
fn starts_workspace_execution(
|
||||
name: &str,
|
||||
action: &str,
|
||||
input: &Value,
|
||||
holds_bounded_verification: bool,
|
||||
) -> bool {
|
||||
match classify_verification(action, input) {
|
||||
Some(VerificationBound::Unbounded) => true,
|
||||
Some(VerificationBound::Default | VerificationBound::Filter) => !holds_bounded_verification,
|
||||
None => {
|
||||
(name.eq_ignore_ascii_case("send_later")
|
||||
&& input.get("action").and_then(Value::as_str) == Some("schedule"))
|
||||
|| (name.eq_ignore_ascii_case("workflow")
|
||||
&& input.get("verify").and_then(Value::as_bool) == Some(true)
|
||||
&& !holds_bounded_verification)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn holds_bounded_verification(context: &crate::tools::spec::ToolContext) -> bool {
|
||||
context
|
||||
.child_host
|
||||
.as_ref()
|
||||
.is_some_and(|child| child.grant.shell == ShellGrant::Verify)
|
||||
|| context.tool_authority.as_deref().is_some_and(|authority| {
|
||||
authority.verification == crate::tools::spec::ToolVerificationAuthority::Bounded
|
||||
})
|
||||
}
|
||||
|
||||
/// Repeat the command ceiling at native dispatch and direct delegation sinks.
|
||||
@@ -507,7 +545,14 @@ pub fn enforce_tool_denial(
|
||||
&& context.owner_agent_id.is_some()
|
||||
&& context.shell_policy == codewhale_agent_contract::worker_profile::ShellPolicy::ReadOnly
|
||||
&& crate::tools::shell::agent_readonly_bash_input(input);
|
||||
if !bounded_child_read && tool_call_denied(Some(&context.disallowed_tools), name, input) {
|
||||
if !bounded_child_read
|
||||
&& tool_call_denied_for(
|
||||
Some(&context.disallowed_tools),
|
||||
name,
|
||||
input,
|
||||
holds_bounded_verification(context),
|
||||
)
|
||||
{
|
||||
return Err(crate::tools::spec::ToolError::permission_denied(format!(
|
||||
"Tool '{name}' or its execution dependency is in the disallowed-tools list"
|
||||
)));
|
||||
|
||||
@@ -22735,6 +22735,80 @@ fn read_only_with_shell_registry() -> (tempfile::TempDir, ChildCoreProbe) {
|
||||
(tmp, registry)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_in_process_verifier_keeps_its_default_runs_under_its_raw_shell_denial() {
|
||||
use crate::tools::execution_envelope::{enforce_tool_denial, holds_bounded_verification};
|
||||
let (_tmp, registry) = read_only_with_shell_registry();
|
||||
let context = registry.registry.context().clone();
|
||||
assert!(context.disallowed_tools.iter().any(|rule| rule == "Bash"));
|
||||
assert!(holds_bounded_verification(&context));
|
||||
for input in [
|
||||
json!({"action":"tests"}),
|
||||
json!({"action":"tests", "args":"-p fixture selected"}),
|
||||
json!({"action":"verifiers"}),
|
||||
] {
|
||||
assert!(
|
||||
enforce_tool_denial(&context, "Run", &input).is_ok(),
|
||||
"{input}"
|
||||
);
|
||||
}
|
||||
assert!(
|
||||
enforce_tool_denial(
|
||||
&context,
|
||||
"workflow",
|
||||
&json!({"action":"run", "verify":true})
|
||||
)
|
||||
.is_ok()
|
||||
);
|
||||
for input in [
|
||||
json!({"action":"tests", "args":"--config build.rustc=x"}),
|
||||
json!({"action":"verifiers", "commands":[{"program":"sh"}]}),
|
||||
] {
|
||||
assert!(
|
||||
enforce_tool_denial(&context, "Run", &input).is_err(),
|
||||
"{input}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_verifier_spawned_under_a_parent_shell_denial_holds_no_bounded_verification() {
|
||||
use crate::tools::execution_envelope::{enforce_tool_denial, holds_bounded_verification};
|
||||
for parent_rule in ["Bash", "bash", "exec_shell*"] {
|
||||
let tmp = tempdir().expect("tempdir");
|
||||
let authority = codewhale_agent_contract::role::ChildAuthority::from_runtime_role(
|
||||
"verifier",
|
||||
codewhale_protocol::fleet::PermissionCeiling::preset("full").expect("preset"),
|
||||
);
|
||||
let mut runtime =
|
||||
stub_runtime().with_agent_tool_surface_options(enabled_agent_surface_options());
|
||||
runtime.context = ToolContext::new(tmp.path().to_path_buf());
|
||||
runtime.context.disallowed_tools = vec![parent_rule.to_string()];
|
||||
runtime.allow_shell = true;
|
||||
runtime.worker_profile = WorkerRuntimeProfile::for_role(FleetRole::Verifier);
|
||||
runtime.worker_profile.denied_tools = authority.disallowed_tools.clone();
|
||||
let registry = ChildCoreProbe::new(
|
||||
runtime,
|
||||
FleetRole::Verifier,
|
||||
authority.allowed_tools.clone(),
|
||||
Arc::new(Mutex::new(TodoList::new())),
|
||||
Arc::new(Mutex::new(PlanState::default())),
|
||||
);
|
||||
let context = registry.registry.context().clone();
|
||||
assert!(!holds_bounded_verification(&context), "{parent_rule}");
|
||||
for (name, input) in [
|
||||
("Run", json!({"action":"tests"})),
|
||||
("Run", json!({"action":"verifiers"})),
|
||||
("workflow", json!({"action":"run", "verify":true})),
|
||||
] {
|
||||
assert!(
|
||||
enforce_tool_denial(&context, name, &input).is_err(),
|
||||
"{parent_rule} {name} {input}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shell_denial_from_parent_is_not_a_read_only_role_exception() {
|
||||
for role in [FleetRole::Scout, FleetRole::Reviewer, FleetRole::Planner] {
|
||||
|
||||
@@ -29,9 +29,7 @@ pub(super) fn prepare_child_workspace(
|
||||
let discovery_anchor = if let Some(requested_cwd) = requested_cwd {
|
||||
validate_existing_child_cwd(parent_workspace, requested_cwd)?
|
||||
} else {
|
||||
parent_workspace
|
||||
.canonicalize()
|
||||
.unwrap_or_else(|_| parent_workspace.to_path_buf())
|
||||
canonicalize_or_self(parent_workspace)
|
||||
};
|
||||
|
||||
if let Some(worktree) = worktree {
|
||||
@@ -46,6 +44,10 @@ pub(super) fn prepare_child_workspace(
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
fn canonicalize_or_self(path: &Path) -> PathBuf {
|
||||
path.canonicalize().unwrap_or_else(|_| path.to_path_buf())
|
||||
}
|
||||
|
||||
fn validate_existing_child_cwd(
|
||||
parent_workspace: &Path,
|
||||
requested_cwd: &Path,
|
||||
@@ -55,9 +57,7 @@ fn validate_existing_child_cwd(
|
||||
} else {
|
||||
parent_workspace.join(requested_cwd)
|
||||
};
|
||||
let workspace_canonical = parent_workspace
|
||||
.canonicalize()
|
||||
.unwrap_or_else(|_| parent_workspace.to_path_buf());
|
||||
let workspace_canonical = canonicalize_or_self(parent_workspace);
|
||||
let canonical = resolved.canonicalize().map_err(|e| {
|
||||
ToolError::invalid_input(format!(
|
||||
"Invalid cwd '{}': {e}. Allowed cwd: an existing directory under {} (relative paths resolve against it), or omit cwd to use it. The path may not exist yet — use worktree=true to let Codewhale create an isolated checkout.",
|
||||
@@ -65,7 +65,9 @@ fn validate_existing_child_cwd(
|
||||
workspace_canonical.display()
|
||||
))
|
||||
})?;
|
||||
if !canonical.starts_with(&workspace_canonical) {
|
||||
if !canonical.starts_with(&workspace_canonical)
|
||||
&& !is_sibling_worktree_path(parent_workspace, &canonical)
|
||||
{
|
||||
return Err(ToolError::invalid_input(format!(
|
||||
"cwd must be inside the parent workspace: {} is not under {}. Allowed cwd: {} or a directory beneath it (relative paths resolve against it); omit cwd to run the child there.",
|
||||
canonical.display(),
|
||||
@@ -76,6 +78,29 @@ fn validate_existing_child_cwd(
|
||||
Ok(canonical)
|
||||
}
|
||||
|
||||
fn is_sibling_worktree_path(parent_workspace: &Path, canonical: &Path) -> bool {
|
||||
if !canonical.is_dir() {
|
||||
return false;
|
||||
}
|
||||
let Some(repo_root) = try_git_toplevel(parent_workspace) else {
|
||||
return false;
|
||||
};
|
||||
let default_root = default_worktree_root(&repo_root);
|
||||
let Some(rebased) = relative_to_root(canonical, &default_root)
|
||||
.filter(|rest| !rest.as_os_str().is_empty())
|
||||
.map(|rest| normalize_path_lexically(&default_root.join(rest)))
|
||||
else {
|
||||
return false;
|
||||
};
|
||||
if !worktree_path_within_root(&rebased, &default_root) {
|
||||
return false;
|
||||
}
|
||||
match (git_common_dir(parent_workspace), git_common_dir(canonical)) {
|
||||
(Some(parent_common), Some(child_common)) => parent_common == child_common,
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn create_isolated_worktree(
|
||||
parent_workspace: &Path,
|
||||
request: &SubAgentWorktreeRequest,
|
||||
@@ -183,9 +208,7 @@ pub(super) fn remove_unstarted_worktree(worktree: &Path) {
|
||||
|
||||
pub(super) fn git_repo_root(workspace: &Path) -> Result<PathBuf, ToolError> {
|
||||
const MAX_PARENT_LEVELS: usize = 4;
|
||||
let start = workspace
|
||||
.canonicalize()
|
||||
.unwrap_or_else(|_| workspace.to_path_buf());
|
||||
let start = canonicalize_or_self(workspace);
|
||||
let mut paths_tried = Vec::new();
|
||||
let mut current = Some(start.as_path());
|
||||
let mut levels = 0usize;
|
||||
@@ -260,19 +283,31 @@ fn path_looks_like_git_checkout(path: &Path) -> bool {
|
||||
git_path.is_dir() || git_path.is_file()
|
||||
}
|
||||
|
||||
fn try_git_toplevel(path: &Path) -> Option<PathBuf> {
|
||||
let output = Git::output(&["rev-parse", "--show-toplevel"], path).ok()?;
|
||||
fn git_rev_parse_path(args: &[&str], path: &Path) -> Option<PathBuf> {
|
||||
let output = Git::output(args, path).ok()?;
|
||||
if !output.status.success() {
|
||||
return None;
|
||||
}
|
||||
let root = String::from_utf8_lossy(&output.stdout).trim().to_string();
|
||||
if root.is_empty() {
|
||||
let raw = String::from_utf8_lossy(&output.stdout).trim().to_string();
|
||||
if raw.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(PathBuf::from(root))
|
||||
Some(PathBuf::from(raw))
|
||||
}
|
||||
}
|
||||
|
||||
fn try_git_toplevel(path: &Path) -> Option<PathBuf> {
|
||||
git_rev_parse_path(&["rev-parse", "--show-toplevel"], path)
|
||||
}
|
||||
|
||||
fn git_common_dir(path: &Path) -> Option<PathBuf> {
|
||||
git_rev_parse_path(
|
||||
&["rev-parse", "--path-format=absolute", "--git-common-dir"],
|
||||
path,
|
||||
)
|
||||
.and_then(|dir| dir.canonicalize().ok())
|
||||
}
|
||||
|
||||
fn validate_git_branch_name(repo_root: &Path, branch: &str) -> Result<(), ToolError> {
|
||||
let branch = branch.trim();
|
||||
if branch.is_empty() {
|
||||
@@ -350,9 +385,7 @@ fn resolve_worktree_path(
|
||||
None => default_root.join(branch_worktree_slug(branch)),
|
||||
};
|
||||
let normalized = normalize_path_lexically(&path);
|
||||
let repo_canonical = repo_root
|
||||
.canonicalize()
|
||||
.unwrap_or_else(|_| repo_root.to_path_buf());
|
||||
let repo_canonical = canonicalize_or_self(repo_root);
|
||||
if relative_to_root(&normalized, &repo_canonical).is_some() {
|
||||
return Err(ToolError::invalid_input(format!(
|
||||
"worktree_path must not be inside the parent checkout: {} is under {}",
|
||||
@@ -685,4 +718,206 @@ mod tests {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
fn init_test_git_repo(path: &Path) {
|
||||
let init = std::process::Command::new("git")
|
||||
.arg("init")
|
||||
.current_dir(path)
|
||||
.output()
|
||||
.expect("git init");
|
||||
assert!(init.status.success(), "git init failed: {init:?}");
|
||||
let commit = std::process::Command::new("git")
|
||||
.args([
|
||||
"-c",
|
||||
"user.name=codewhale Tests",
|
||||
"-c",
|
||||
"[email protected]",
|
||||
"commit",
|
||||
"--allow-empty",
|
||||
"-m",
|
||||
"init",
|
||||
])
|
||||
.current_dir(path)
|
||||
.output()
|
||||
.expect("git commit");
|
||||
assert!(commit.status.success(), "git commit failed: {commit:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn linked_sibling_worktree_and_subdirs_are_admitted_as_child_cwd() {
|
||||
let harness = tempfile::tempdir().expect("harness");
|
||||
let repo = harness.path().join("repo");
|
||||
std::fs::create_dir_all(&repo).expect("repo dir");
|
||||
init_test_git_repo(&repo);
|
||||
|
||||
let worktree = create_isolated_worktree(
|
||||
&repo,
|
||||
&SubAgentWorktreeRequest {
|
||||
branch: Some("codex/agent-sibling-cwd".to_string()),
|
||||
path: Some(PathBuf::from("sibling-one")),
|
||||
base_ref: None,
|
||||
},
|
||||
Some("sibling-one"),
|
||||
&FleetRole::Worker,
|
||||
)
|
||||
.expect("linked sibling worktree");
|
||||
|
||||
assert_eq!(
|
||||
validate_existing_child_cwd(&repo, &worktree).expect("linked worktree root admitted"),
|
||||
worktree
|
||||
);
|
||||
|
||||
let relative_worktree = PathBuf::from("..")
|
||||
.join(".codewhale-worktrees")
|
||||
.join("repo")
|
||||
.join("sibling-one");
|
||||
assert_eq!(
|
||||
validate_existing_child_cwd(&repo, &relative_worktree)
|
||||
.expect("relative linked worktree admitted"),
|
||||
worktree
|
||||
);
|
||||
|
||||
let tracked_sub = worktree.join("tracked-sub");
|
||||
std::fs::create_dir_all(&tracked_sub).expect("tracked sub");
|
||||
std::fs::write(tracked_sub.join("file.txt"), "hello").expect("write tracked file");
|
||||
let add = std::process::Command::new("git")
|
||||
.args(["add", "tracked-sub/file.txt"])
|
||||
.current_dir(&worktree)
|
||||
.output()
|
||||
.expect("git add");
|
||||
assert!(add.status.success(), "git add failed: {add:?}");
|
||||
let commit = std::process::Command::new("git")
|
||||
.args([
|
||||
"-c",
|
||||
"user.name=codewhale Tests",
|
||||
"-c",
|
||||
"[email protected]",
|
||||
"commit",
|
||||
"-m",
|
||||
"add tracked file",
|
||||
])
|
||||
.current_dir(&worktree)
|
||||
.output()
|
||||
.expect("git commit");
|
||||
assert!(commit.status.success(), "git commit failed: {commit:?}");
|
||||
|
||||
assert_eq!(
|
||||
validate_existing_child_cwd(&repo, &tracked_sub).expect("tracked subdir admitted"),
|
||||
tracked_sub
|
||||
);
|
||||
|
||||
let untracked_sub = worktree.join("untracked-sub");
|
||||
std::fs::create_dir_all(&untracked_sub).expect("untracked sub");
|
||||
assert_eq!(
|
||||
validate_existing_child_cwd(&repo, &untracked_sub).expect("untracked subdir admitted"),
|
||||
untracked_sub
|
||||
);
|
||||
|
||||
let parent_sub = repo.join("parent-sub");
|
||||
std::fs::create_dir_all(&parent_sub).expect("parent sub");
|
||||
assert_eq!(
|
||||
validate_existing_child_cwd(&parent_sub, &worktree)
|
||||
.expect("repo subdir parent admits sibling worktree"),
|
||||
worktree
|
||||
);
|
||||
assert_eq!(
|
||||
prepare_child_workspace(&repo, Some(&worktree), None, None, &FleetRole::Reviewer)
|
||||
.expect("prepare_child_workspace admits sibling worktree"),
|
||||
Some(worktree)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn outside_directories_and_non_managed_worktrees_are_rejected_as_child_cwd() {
|
||||
let harness = tempfile::tempdir().expect("harness");
|
||||
let repo = harness.path().join("repo");
|
||||
std::fs::create_dir_all(&repo).expect("repo dir");
|
||||
init_test_git_repo(&repo);
|
||||
|
||||
let outside = tempfile::tempdir().expect("outside");
|
||||
let outside_err = validate_existing_child_cwd(&repo, outside.path())
|
||||
.expect_err("arbitrary outside directory rejected")
|
||||
.to_string();
|
||||
assert!(
|
||||
outside_err.contains("cwd must be inside the parent workspace")
|
||||
&& outside_err.contains("Allowed cwd"),
|
||||
"{outside_err}"
|
||||
);
|
||||
|
||||
let unmanaged_wt = outside.path().join("unmanaged-wt");
|
||||
let add_wt = std::process::Command::new("git")
|
||||
.args(["worktree", "add", "-b", "codex/agent-unmanaged"])
|
||||
.arg(&unmanaged_wt)
|
||||
.current_dir(&repo)
|
||||
.output()
|
||||
.expect("git worktree add");
|
||||
assert!(
|
||||
add_wt.status.success(),
|
||||
"git worktree add failed: {add_wt:?}"
|
||||
);
|
||||
let unmanaged_err = validate_existing_child_cwd(&repo, &unmanaged_wt)
|
||||
.expect_err("linked worktree outside .codewhale-worktrees rejected")
|
||||
.to_string();
|
||||
assert!(
|
||||
unmanaged_err.contains("cwd must be inside the parent workspace"),
|
||||
"{unmanaged_err}"
|
||||
);
|
||||
|
||||
let repo_root = try_git_toplevel(&repo).expect("repo toplevel");
|
||||
let managed_root = default_worktree_root(&repo_root);
|
||||
std::fs::create_dir_all(&managed_root).expect("managed root");
|
||||
let container_err = validate_existing_child_cwd(&repo, &managed_root)
|
||||
.expect_err("managed worktree container root itself rejected")
|
||||
.to_string();
|
||||
assert!(
|
||||
container_err.contains("cwd must be inside the parent workspace"),
|
||||
"{container_err}"
|
||||
);
|
||||
|
||||
#[cfg(unix)]
|
||||
{
|
||||
let link = managed_root.join("escape-link");
|
||||
std::os::unix::fs::symlink(&unmanaged_wt, &link).expect("symlink");
|
||||
let link_err = validate_existing_child_cwd(&repo, &link)
|
||||
.expect_err("symlink pointing outside managed root rejected")
|
||||
.to_string();
|
||||
assert!(
|
||||
link_err.contains("cwd must be inside the parent workspace"),
|
||||
"{link_err}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn spoofed_and_plain_directories_under_worktree_root_are_rejected_as_child_cwd() {
|
||||
let harness = tempfile::tempdir().expect("harness");
|
||||
let repo = harness.path().join("repo");
|
||||
std::fs::create_dir_all(&repo).expect("repo dir");
|
||||
init_test_git_repo(&repo);
|
||||
|
||||
let repo_root = try_git_toplevel(&repo).expect("repo toplevel");
|
||||
let managed_root = default_worktree_root(&repo_root);
|
||||
let spoof = managed_root.join("spoof");
|
||||
std::fs::create_dir_all(&spoof).expect("spoof dir");
|
||||
init_test_git_repo(&spoof);
|
||||
|
||||
let spoof_err = validate_existing_child_cwd(&repo, &spoof)
|
||||
.expect_err("foreign git repo under worktree root rejected")
|
||||
.to_string();
|
||||
assert!(
|
||||
spoof_err.contains("cwd must be inside the parent workspace")
|
||||
&& spoof_err.contains("Allowed cwd"),
|
||||
"{spoof_err}"
|
||||
);
|
||||
|
||||
let plain = managed_root.join("plain");
|
||||
std::fs::create_dir_all(&plain).expect("plain dir");
|
||||
let plain_err = validate_existing_child_cwd(&repo, &plain)
|
||||
.expect_err("non-git directory under worktree root rejected")
|
||||
.to_string();
|
||||
assert!(
|
||||
plain_err.contains("cwd must be inside the parent workspace"),
|
||||
"{plain_err}"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -515,6 +515,7 @@ impl ToolSpec for RunVerifiersTool {
|
||||
|
||||
/// Run quick auto verifier gates after a successful workflow completion (#4013).
|
||||
pub async fn run_workflow_completion_gates(context: &ToolContext) -> Result<Value, ToolError> {
|
||||
crate::tools::execution_envelope::enforce_tool_denial(context, "run_verifiers", &json!({}))?;
|
||||
let gates = build_gate_plan(
|
||||
context,
|
||||
VerifierProfile::Auto,
|
||||
|
||||
@@ -15,7 +15,7 @@ use super::extract::is_js_shell_error;
|
||||
use super::guard::{validate_fetch_target, validate_network_policy};
|
||||
use crate::tools::spec::{ToolContext, ToolError};
|
||||
use codewhale_agent_contract::worker_profile::ShellPolicy;
|
||||
use codewhale_net::guard::{DnsPin, guarded_reqwest_client_builder};
|
||||
use codewhale_net::guard::{DnsPin, PrivateTargets, guarded_reqwest_client_builder};
|
||||
use codewhale_runtime::features::Feature;
|
||||
|
||||
pub const DEFAULT_TIMEOUT: Duration = Duration::from_secs(15);
|
||||
@@ -535,7 +535,15 @@ async fn fetch_inner(
|
||||
// session cannot be bypassed by a previously cached response.
|
||||
let validated_initial_pin = match test_initial_pin {
|
||||
Some(pin) => pin,
|
||||
None => validate_fetch_target(&initial_url, context, tool_label).await?,
|
||||
None => {
|
||||
validate_fetch_target(
|
||||
&initial_url,
|
||||
context,
|
||||
tool_label,
|
||||
PrivateTargets::TypedLocalHosts,
|
||||
)
|
||||
.await?
|
||||
}
|
||||
};
|
||||
|
||||
if let Some(cached) = (!cache_mode.is_revalidate())
|
||||
@@ -703,14 +711,24 @@ async fn fetch_attempt(
|
||||
let dns_pin = if redirects == 0 {
|
||||
match initial_pin.take() {
|
||||
Some(pin) => Some(pin),
|
||||
None => validate_fetch_target(¤t_url, context, tool_label)
|
||||
.await
|
||||
.map_err(AttemptError::Fatal)?,
|
||||
None => validate_fetch_target(
|
||||
¤t_url,
|
||||
context,
|
||||
tool_label,
|
||||
PrivateTargets::TypedLocalHosts,
|
||||
)
|
||||
.await
|
||||
.map_err(AttemptError::Fatal)?,
|
||||
}
|
||||
} else {
|
||||
validate_fetch_target(¤t_url, context, tool_label)
|
||||
.await
|
||||
.map_err(AttemptError::Fatal)?
|
||||
validate_fetch_target(
|
||||
¤t_url,
|
||||
context,
|
||||
tool_label,
|
||||
PrivateTargets::TypedLocalHosts,
|
||||
)
|
||||
.await
|
||||
.map_err(AttemptError::Fatal)?
|
||||
};
|
||||
|
||||
let remaining = deadline.saturating_duration_since(Instant::now());
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
//! re-invoke [`validate_fetch_target`] on every new Location.
|
||||
|
||||
use crate::tools::spec::{ToolContext, ToolError};
|
||||
use codewhale_net::guard::{DnsPin, fetch_target_host, validate_fetch_destination};
|
||||
use codewhale_net::guard::{DnsPin, PrivateTargets, fetch_target_host, validate_fetch_destination};
|
||||
use codewhale_net::network_policy::Decision;
|
||||
|
||||
/// Validate that `url` is a safe fetch target under SSRF and network policy.
|
||||
@@ -20,10 +20,11 @@ pub async fn validate_fetch_target(
|
||||
url: &reqwest::Url,
|
||||
context: &ToolContext,
|
||||
tool: &str,
|
||||
private: PrivateTargets,
|
||||
) -> Result<DnsPin, ToolError> {
|
||||
let host = fetch_target_host(url)?;
|
||||
validate_network_policy(&host, context, tool)?;
|
||||
validate_fetch_destination(host, context.network_policy.as_ref(), tool, true).await
|
||||
validate_fetch_destination(host, context.network_policy.as_ref(), tool, true, private).await
|
||||
}
|
||||
|
||||
pub fn validate_network_policy(
|
||||
@@ -60,7 +61,7 @@ mod tests {
|
||||
#[tokio::test]
|
||||
async fn redirected_localhost_hostname_is_rejected() {
|
||||
let url = reqwest::Url::parse("http://localhost:8080/admin").unwrap();
|
||||
let err = validate_fetch_target(&url, &ctx(), "fetch_url")
|
||||
let err = validate_fetch_target(&url, &ctx(), "fetch_url", PrivateTargets::TypedLocalHosts)
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert!(format!("{err}").contains("localhost"));
|
||||
@@ -69,7 +70,7 @@ mod tests {
|
||||
#[tokio::test]
|
||||
async fn redirected_private_ip_literal_is_rejected() {
|
||||
let url = reqwest::Url::parse("http://169.254.169.254/latest/meta-data").unwrap();
|
||||
let err = validate_fetch_target(&url, &ctx(), "fetch_url")
|
||||
let err = validate_fetch_target(&url, &ctx(), "fetch_url", PrivateTargets::TypedLocalHosts)
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert!(format!("{err}").contains("restricted address"));
|
||||
@@ -79,7 +80,7 @@ mod tests {
|
||||
#[tokio::test]
|
||||
async fn rejects_ipv6_literal_loopback() {
|
||||
let url = reqwest::Url::parse("http://[::1]/").unwrap();
|
||||
let err = validate_fetch_target(&url, &ctx(), "fetch_url")
|
||||
let err = validate_fetch_target(&url, &ctx(), "fetch_url", PrivateTargets::TypedLocalHosts)
|
||||
.await
|
||||
.expect_err("[::1] must be rejected as restricted");
|
||||
assert!(format!("{err}").contains("restricted"));
|
||||
@@ -88,7 +89,7 @@ mod tests {
|
||||
#[tokio::test]
|
||||
async fn rejects_ipv6_literal_ula() {
|
||||
let url = reqwest::Url::parse("http://[fc00::1]/").unwrap();
|
||||
let err = validate_fetch_target(&url, &ctx(), "fetch_url")
|
||||
let err = validate_fetch_target(&url, &ctx(), "fetch_url", PrivateTargets::TypedLocalHosts)
|
||||
.await
|
||||
.expect_err("[fc00::1] must be rejected as restricted");
|
||||
assert!(format!("{err}").contains("restricted"));
|
||||
@@ -97,7 +98,7 @@ mod tests {
|
||||
#[tokio::test]
|
||||
async fn rejects_ipv6_literal_link_local() {
|
||||
let url = reqwest::Url::parse("http://[fe80::1]/").unwrap();
|
||||
let err = validate_fetch_target(&url, &ctx(), "fetch_url")
|
||||
let err = validate_fetch_target(&url, &ctx(), "fetch_url", PrivateTargets::TypedLocalHosts)
|
||||
.await
|
||||
.expect_err("[fe80::1] must be rejected as restricted");
|
||||
assert!(format!("{err}").contains("restricted"));
|
||||
@@ -106,7 +107,7 @@ mod tests {
|
||||
#[tokio::test]
|
||||
async fn rejects_ipv6_literal_ipv4_mapped_loopback() {
|
||||
let url = reqwest::Url::parse("http://[::ffff:127.0.0.1]/").unwrap();
|
||||
let err = validate_fetch_target(&url, &ctx(), "fetch_url")
|
||||
let err = validate_fetch_target(&url, &ctx(), "fetch_url", PrivateTargets::TypedLocalHosts)
|
||||
.await
|
||||
.expect_err("[::ffff:127.0.0.1] must be rejected as restricted");
|
||||
assert!(format!("{err}").contains("restricted"));
|
||||
@@ -115,7 +116,7 @@ mod tests {
|
||||
#[tokio::test]
|
||||
async fn rejects_ipv6_literal_unspecified() {
|
||||
let url = reqwest::Url::parse("http://[::]/").unwrap();
|
||||
let err = validate_fetch_target(&url, &ctx(), "fetch_url")
|
||||
let err = validate_fetch_target(&url, &ctx(), "fetch_url", PrivateTargets::TypedLocalHosts)
|
||||
.await
|
||||
.expect_err("[::] must be rejected as restricted");
|
||||
assert!(format!("{err}").contains("restricted"));
|
||||
@@ -135,7 +136,7 @@ mod tests {
|
||||
let decider = NetworkPolicyDecider::new(policy, None);
|
||||
let ctx = ToolContext::new(PathBuf::from(".")).with_network_policy(decider);
|
||||
let url = reqwest::Url::parse("https://example.com/redirect-target").unwrap();
|
||||
let err = validate_fetch_target(&url, &ctx, "fetch_url")
|
||||
let err = validate_fetch_target(&url, &ctx, "fetch_url", PrivateTargets::TypedLocalHosts)
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert!(format!("{err}").contains("blocked"));
|
||||
@@ -146,7 +147,7 @@ mod tests {
|
||||
let url =
|
||||
reqwest::Url::parse("https://codewhale-unresolvable-fetch-target.invalid/resource")
|
||||
.unwrap();
|
||||
let err = validate_fetch_target(&url, &ctx(), "fetch_url")
|
||||
let err = validate_fetch_target(&url, &ctx(), "fetch_url", PrivateTargets::TypedLocalHosts)
|
||||
.await
|
||||
.expect_err("unresolved host must fail preflight");
|
||||
let message = format!("{err}");
|
||||
@@ -161,7 +162,7 @@ mod tests {
|
||||
let url =
|
||||
reqwest::Url::parse("https://codewhale-unresolvable-web-run-target.invalid/resource")
|
||||
.unwrap();
|
||||
let err = validate_fetch_target(&url, &ctx(), "web_run")
|
||||
let err = validate_fetch_target(&url, &ctx(), "web_run", PrivateTargets::TypedLocalHosts)
|
||||
.await
|
||||
.expect_err("unresolved host must fail preflight");
|
||||
let message = format!("{err}");
|
||||
|
||||
@@ -1381,6 +1381,7 @@ async fn start_workflow(
|
||||
"Workflow parent session is cancelled",
|
||||
));
|
||||
}
|
||||
crate::tools::execution_envelope::enforce_tool_denial(context, "workflow", &input)?;
|
||||
let mut source = workflow_source(&input, context)?;
|
||||
let workflow_cfg = workflow_config_for(&runtime);
|
||||
if workflow_cfg.max_concurrent == 0 {
|
||||
@@ -6293,6 +6294,76 @@ mod tests {
|
||||
use codewhale_workflow::{IsolationMode, leaf_is_write_capable};
|
||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
|
||||
fn workspace_with_canary_gates(workspace: &std::path::Path) {
|
||||
std::fs::write(
|
||||
workspace.join("Cargo.toml"),
|
||||
"[package]\nname = \"canary\"\nversion = \"0.1.0\"\nedition = \"2021\"\nbuild = \"build.rs\"\n\n[workspace]\n",
|
||||
)
|
||||
.unwrap();
|
||||
std::fs::create_dir_all(workspace.join("src")).unwrap();
|
||||
std::fs::write(workspace.join("src/lib.rs"), "").unwrap();
|
||||
std::fs::write(
|
||||
workspace.join("build.rs"),
|
||||
"fn main() { let d = std::env::var(\"CARGO_MANIFEST_DIR\").unwrap(); std::fs::write(std::path::Path::new(&d).join(\"CANARY_CARGO\"), b\"ran\").unwrap(); }",
|
||||
)
|
||||
.unwrap();
|
||||
std::fs::write(
|
||||
workspace.join("package.json"),
|
||||
r#"{"name":"canary","version":"0.0.0","scripts":{"lint":"node -e \"require('fs').writeFileSync('CANARY_NPM','ran')\""}}"#,
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
fn canary_gates_ran(workspace: &std::path::Path) -> bool {
|
||||
workspace.join("CANARY_CARGO").exists() || workspace.join("CANARY_NPM").exists()
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[allow(clippy::await_holding_lock)]
|
||||
async fn workflow_verify_is_refused_under_a_shell_denial_before_any_gate_runs() {
|
||||
let _retry_guard = workflow_test_retry_guard();
|
||||
let tmp = tempfile::tempdir().expect("tempdir");
|
||||
let workspace = tmp.path().to_path_buf();
|
||||
workspace_with_canary_gates(&workspace);
|
||||
let mut ctx = ToolContext::new(workspace.clone());
|
||||
ctx.disallowed_tools = vec!["Bash".to_string()];
|
||||
let manager = new_shared_subagent_manager(workspace.clone(), 2);
|
||||
let (client, calls, api_config) = fake_chat_client("unused").await;
|
||||
let runtime = SubAgentRuntime::new(
|
||||
client,
|
||||
"deepseek-v4-flash".to_string(),
|
||||
ctx.clone(),
|
||||
true,
|
||||
None,
|
||||
manager.clone(),
|
||||
)
|
||||
.with_api_config(api_config);
|
||||
let tool = WorkflowTool::new(manager, runtime);
|
||||
for action in ["run", "start"] {
|
||||
let result = tool
|
||||
.execute(
|
||||
json!({"action": action, "script": "return 1;", "verify": true, "wait": true}),
|
||||
&ctx,
|
||||
)
|
||||
.await;
|
||||
assert!(result.is_err(), "{action}: {result:?}");
|
||||
}
|
||||
assert!(!canary_gates_ran(&workspace));
|
||||
assert_eq!(calls.load(Ordering::SeqCst), 0);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn the_completion_gate_runner_refuses_under_a_shell_denial() {
|
||||
let tmp = tempfile::tempdir().expect("tempdir");
|
||||
let workspace = tmp.path().to_path_buf();
|
||||
workspace_with_canary_gates(&workspace);
|
||||
let mut ctx = ToolContext::new(workspace.clone());
|
||||
ctx.disallowed_tools = vec!["Bash".to_string()];
|
||||
let outcome = run_workflow_completion_gates(&ctx).await;
|
||||
assert!(outcome.is_err(), "{outcome:?}");
|
||||
assert!(!canary_gates_ran(&workspace));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_clean_slot_ledger_leaves_the_run_completed() {
|
||||
let ledger = SlotLedger {
|
||||
|
||||
@@ -564,7 +564,9 @@ pub fn is_parallel_readonly_command(command: &str) -> bool {
|
||||
/// posture. Keys on the arity-aware canonical form, the literal-program
|
||||
/// hardener, the env-prefix rejection, and the per-command option tables.
|
||||
fn readonly_tokens_admitted(trimmed: &str) -> bool {
|
||||
let tokens = shell_words(trimmed);
|
||||
let Some(tokens) = shlex::split(trimmed) else {
|
||||
return false;
|
||||
};
|
||||
let Some(start) = primary_token_index(&tokens) else {
|
||||
return false;
|
||||
};
|
||||
@@ -2087,12 +2089,31 @@ fn split_command_segments(command: &str) -> Vec<String> {
|
||||
}
|
||||
|
||||
fn shell_words(segment: &str) -> Vec<String> {
|
||||
shlex::split(segment).unwrap_or_else(|| {
|
||||
segment
|
||||
.split_whitespace()
|
||||
.map(|token| token.trim_matches(['"', '\'']).to_string())
|
||||
.collect()
|
||||
})
|
||||
shlex::split(segment).unwrap_or_else(|| lenient_words(segment, false))
|
||||
}
|
||||
|
||||
fn lenient_words(segment: &str, unescape: bool) -> Vec<String> {
|
||||
segment
|
||||
.split_whitespace()
|
||||
.map(|token| {
|
||||
token
|
||||
.chars()
|
||||
.filter(|ch| !matches!(ch, '"' | '\'') && !(unescape && *ch == '\\'))
|
||||
.collect()
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn shell_word_readings(segment: &str) -> Vec<Vec<String>> {
|
||||
let parsed = if segment.contains("$'") {
|
||||
crate::shell_expand::split_ansi_c_shell_words(segment).or_else(|| shlex::split(segment))
|
||||
} else {
|
||||
shlex::split(segment)
|
||||
};
|
||||
match parsed {
|
||||
Some(words) => vec![words],
|
||||
None => vec![lenient_words(segment, false), lenient_words(segment, true)],
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether this is one direct, literal rm invocation. Only that shape may
|
||||
@@ -2161,22 +2182,32 @@ fn walk_invocations(command: &str, quote_blind: bool) -> Option<Vec<Vec<String>>
|
||||
return false;
|
||||
}
|
||||
for segment in split_command_segments(command) {
|
||||
let words = shell_words(&segment);
|
||||
for (index, word) in words.iter().enumerate() {
|
||||
let mut argv = words[index..].to_vec();
|
||||
argv[0] = command_word(word);
|
||||
out.push(argv);
|
||||
// Re-parse a word only when it can be a command line of its
|
||||
// own: the script body of `sh -c`/`bash -lc`, where nested
|
||||
// whitespace, `;`, and `|` are real separators. Anything else
|
||||
// is an argument — `echo 'x & rm -rf /etc'` names no `rm`, and
|
||||
// re-parsing it both invented one and failed readable commands
|
||||
// closed once a `&` made the word reproduce itself.
|
||||
if word.contains(|ch: char| ch.is_whitespace() || matches!(ch, ';' | '&' | '|'))
|
||||
&& let Some(script) = nested_shell_script(&words, index)
|
||||
&& !collect(&script, depth + 1, quote_blind, out)
|
||||
{
|
||||
return false;
|
||||
for words in shell_word_readings(&segment) {
|
||||
for (index, word) in words.iter().enumerate() {
|
||||
let mut argv = words[index..].to_vec();
|
||||
argv[0] = command_word(word);
|
||||
out.push(argv);
|
||||
// Re-parse a word only when it can be a command line of its
|
||||
// own: the script body of `sh -c`/`bash -lc`, where nested
|
||||
// whitespace, `;`, and `|` are real separators. Anything else
|
||||
// is an argument — `echo 'x & rm -rf /etc'` names no `rm`, and
|
||||
// re-parsing it both invented one and failed readable commands
|
||||
// closed once a `&` made the word reproduce itself.
|
||||
if word.contains(|ch: char| ch.is_whitespace() || matches!(ch, ';' | '&' | '|'))
|
||||
&& let Some(script) = nested_shell_script(&words, index)
|
||||
&& !collect(&script, depth + 1, quote_blind, out)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if quote_blind && depth == 0 {
|
||||
for words in crate::shell_expand::expand_command(command).words {
|
||||
for (index, word) in words.iter().enumerate() {
|
||||
let mut argv = words[index..].to_vec();
|
||||
argv[0] = command_word(word);
|
||||
out.push(argv);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2503,31 +2534,24 @@ fn target_contains_parent_escape(target: &str) -> bool {
|
||||
|
||||
/// Check if a command is known to be safe
|
||||
fn is_safe_command(command: &str) -> bool {
|
||||
let tokens = shell_words(command);
|
||||
if let Some(start) = primary_token_index(&tokens) {
|
||||
let refs = tokens[start..]
|
||||
.iter()
|
||||
.map(String::as_str)
|
||||
.collect::<Vec<_>>();
|
||||
if contains_shell_composition(command) {
|
||||
return false;
|
||||
}
|
||||
|
||||
let normalized = normalize_windows_command_paths(command);
|
||||
let tokens = shell_words(&normalized);
|
||||
if primary_token_index(&tokens) == Some(0) {
|
||||
let refs = tokens.iter().map(String::as_str).collect::<Vec<_>>();
|
||||
if is_codewhale_readonly_invocation(&refs) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
// `starts_with` tests the WHOLE command line, so without this guard any
|
||||
// pipeline or redirection beginning with a safe word was classified Safe
|
||||
// and skipped the destructive floor entirely — `echo x | rm -rf "$HOME"`
|
||||
// reported Safe. `shell_params_are_auto_review_routine` already refuses
|
||||
// shell composition for exactly this reason ("Do not let shell composition
|
||||
// hide an unsafe second stage"); this is the same rule, applied where the
|
||||
// classification is actually made.
|
||||
if contains_shell_composition(command) {
|
||||
return false;
|
||||
}
|
||||
|
||||
let literal_words = lex_readonly_command(normalized.trim())
|
||||
.is_ok_and(|segments| segments.len() == 1 && segments[0].redirects.is_empty());
|
||||
SAFE_COMMANDS.iter().any(|entry| {
|
||||
leading_words_match(&tokens, entry).is_some_and(|words| {
|
||||
safe_command_arguments_are_read_only(entry, &tokens, &tokens[words..])
|
||||
safe_command_arguments_are_read_only(entry, &tokens, &tokens[words..], literal_words)
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -2548,7 +2572,12 @@ fn leading_words_match(tokens: &[String], entry: &str) -> Option<usize> {
|
||||
/// Arguments that turn an otherwise read-only [`SAFE_COMMANDS`] entry into a
|
||||
/// command that runs other programs or writes files. `tokens` is the whole
|
||||
/// command and `args` what follows the entry's words.
|
||||
fn safe_command_arguments_are_read_only(entry: &str, tokens: &[String], args: &[String]) -> bool {
|
||||
fn safe_command_arguments_are_read_only(
|
||||
entry: &str,
|
||||
tokens: &[String],
|
||||
args: &[String],
|
||||
literal_words: bool,
|
||||
) -> bool {
|
||||
let has = |names: &[&str]| args.iter().any(|arg| names.contains(&arg.as_str()));
|
||||
let list_mode = has(&["-l", "--list"]);
|
||||
let only_options = |allowed: &[&str], operands_ok: bool| {
|
||||
@@ -2560,7 +2589,7 @@ fn safe_command_arguments_are_read_only(entry: &str, tokens: &[String], args: &[
|
||||
}
|
||||
})
|
||||
};
|
||||
match entry {
|
||||
let read_only = match entry {
|
||||
// `env CMD` runs CMD; only the bare listing is a read.
|
||||
"env" => only_options(&["-0", "--null"], false),
|
||||
// `-exec`, `-fprint`, `-delete`, … act on every match.
|
||||
@@ -2572,15 +2601,26 @@ fn safe_command_arguments_are_read_only(entry: &str, tokens: &[String], args: &[
|
||||
"uniq" => is_agent_readonly_uniq(tokens),
|
||||
// `hostname NAME` sets the host name.
|
||||
"hostname" => args.iter().all(|arg| arg.starts_with('-')),
|
||||
// `--pre CMD` runs a preprocessor on every file searched.
|
||||
"rg" => !args.iter().any(|arg| arg.starts_with("--pre")),
|
||||
"rg" => !args
|
||||
.iter()
|
||||
.any(|arg| arg.starts_with("--pre") || arg.starts_with("--hostname-bin")),
|
||||
"ag" => !args.iter().any(|arg| arg.starts_with("--pager")),
|
||||
"info" => !args.iter().any(|arg| {
|
||||
arg == "--output"
|
||||
|| arg.starts_with("--output=")
|
||||
|| (arg.starts_with('-') && !arg.starts_with("--") && arg.contains('o'))
|
||||
}),
|
||||
// `-x` / `-X` / `--exec*` run a command per match.
|
||||
"fd" => !args.iter().any(|arg| {
|
||||
arg.starts_with("--exec")
|
||||
|| (arg.starts_with('-') && !arg.starts_with("--") && arg.contains(['x', 'X']))
|
||||
}),
|
||||
// `+CMD` runs a pager command at startup, which can reach a shell.
|
||||
"less" | "more" => !args.iter().any(|arg| arg.starts_with('+')),
|
||||
"less" | "more" => !args.iter().any(|arg| {
|
||||
let lower = arg.to_ascii_lowercase();
|
||||
arg.starts_with('+')
|
||||
|| lower.starts_with("--log-file")
|
||||
|| (arg.starts_with('-') && !arg.starts_with("--") && lower.contains('o'))
|
||||
}),
|
||||
// `-P`/`-H`/`-C` choose the pager, browser or config that man runs.
|
||||
"man" => only_options(
|
||||
&[
|
||||
@@ -2598,7 +2638,9 @@ fn safe_command_arguments_are_read_only(entry: &str, tokens: &[String], args: &[
|
||||
true,
|
||||
),
|
||||
// `--output` writes the diff or log to a file.
|
||||
"git diff" | "git log" | "git show" => !args.iter().any(|arg| arg.starts_with("--output")),
|
||||
"git diff" | "git log" | "git show" | "git stash list" => {
|
||||
!args.iter().any(|arg| arg.starts_with("--output"))
|
||||
}
|
||||
// Listing forms only: other forms delete, rename or create refs.
|
||||
"git branch" => only_options(
|
||||
&[
|
||||
@@ -2626,8 +2668,9 @@ fn safe_command_arguments_are_read_only(entry: &str, tokens: &[String], args: &[
|
||||
.all(|arg| !arg.starts_with('-') || matches!(arg.as_str(), "--push" | "--all")),
|
||||
Some(_) => false,
|
||||
},
|
||||
_ => true,
|
||||
}
|
||||
_ => return true,
|
||||
};
|
||||
read_only && literal_words
|
||||
}
|
||||
|
||||
/// A prefix grants one command's ordinary arguments. The scanner owns shell
|
||||
@@ -2657,10 +2700,10 @@ pub(crate) fn prefix_grant_is_eligible(
|
||||
// Global Git options may precede the read subcommand; --output still
|
||||
// changes its authority. Reuse the existing guard for that flag.
|
||||
(tokens[0] != "git"
|
||||
|| safe_command_arguments_are_read_only("git log", tokens, &tokens[1..]))
|
||||
|| safe_command_arguments_are_read_only("git log", tokens, &tokens[1..], true))
|
||||
&& SAFE_COMMANDS.iter().all(|entry| {
|
||||
leading_words_match(tokens, entry).is_none_or(|words| {
|
||||
safe_command_arguments_are_read_only(entry, tokens, &tokens[words..])
|
||||
safe_command_arguments_are_read_only(entry, tokens, &tokens[words..], true)
|
||||
})
|
||||
})
|
||||
};
|
||||
@@ -4316,3 +4359,203 @@ mod tests {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod shell_reading_tests {
|
||||
use super::*;
|
||||
|
||||
const HIDDEN_DESTROYERS: &[(&str, &str)] = &[
|
||||
(r#"mk""fs.ext4 -L \; /dev/sda1"#, "mkfs.ext4"),
|
||||
(r#"shr""ed /dev/sda \;"#, "shred"),
|
||||
(r#"d""d if=/dev/zero of=/dev/sda 2>\;"#, "dd"),
|
||||
(r#"wip""efs -a /dev/sda \"#, "wipefs"),
|
||||
(r#"r""m -rf /etc \;"#, "rm"),
|
||||
(r#"m\kfs.ext4 /dev/sda1 \;"#, "mkfs.ext4"),
|
||||
("mk\"\"fs.ext4 /dev/sda1\necho \"", "mkfs.ext4"),
|
||||
(r#"sh -c $'echo x\nwipefs -a /dev/sda'"#, "wipefs"),
|
||||
(r#"sh -c $'echo x\nmkfs.ext4 /dev/sda1'"#, "mkfs.ext4"),
|
||||
(r#"sh -c $'echo x\012shred /dev/sda'"#, "shred"),
|
||||
(r#"sh -c $'echo x\x0add if=/dev/zero of=/dev/sda'"#, "dd"),
|
||||
];
|
||||
|
||||
fn names(argvs: Vec<Vec<String>>) -> Vec<String> {
|
||||
argvs.into_iter().map(|argv| argv[0].clone()).collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn destroyer_walkers_read_commands_the_tokenizer_rejects() {
|
||||
let mut missed = Vec::new();
|
||||
for (command, name) in HIDDEN_DESTROYERS {
|
||||
for (label, walk) in [
|
||||
("invocations", command_invocations as fn(&str) -> _),
|
||||
("floor", destroyer_floor_invocations),
|
||||
] {
|
||||
let found = names(walk(command).expect("readable"));
|
||||
if !found.iter().any(|found| found == name) {
|
||||
missed.push(format!("{label}: {command:?} hides {name}"));
|
||||
}
|
||||
}
|
||||
}
|
||||
assert!(missed.is_empty(), "not read: {missed:#?}");
|
||||
let cr_found = names(
|
||||
destroyer_floor_invocations(r#"sh -c $'echo x\rshred /dev/sda'"#).expect("readable"),
|
||||
);
|
||||
assert!(
|
||||
cr_found.iter().any(|name| name == "shred"),
|
||||
"missing shred: {cr_found:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn destroyer_walkers_keep_quoted_text_as_data() {
|
||||
for command in [
|
||||
r#"git commit -m "mkfs.ext4 /dev/sda1""#,
|
||||
r#"echo ";mkfs.ext4 /dev/sda1""#,
|
||||
r#"echo "dd if=/dev/zero of=/dev/sda""#,
|
||||
r"dir C:\",
|
||||
r#"git commit -m $'echo x\nmkfs.ext4 /dev/sda1'"#,
|
||||
] {
|
||||
for walk in [command_invocations, destroyer_floor_invocations] {
|
||||
let found = names(walk(command).expect("readable"));
|
||||
assert!(
|
||||
!found
|
||||
.iter()
|
||||
.any(|name| name.starts_with("mkfs") || name == "dd"),
|
||||
"{command:?} was read as a command: {found:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn argument_grammar_entries_need_words_the_shell_will_not_rewrite() {
|
||||
let mut safe_shapes = Vec::new();
|
||||
for command in [
|
||||
r#"sed -n 1p {-e,"w out.txt"} in.txt"#,
|
||||
r#"sed -n 1p $'-e' "w out.txt" in.txt"#,
|
||||
"sed -n 1p $'-i' in.txt",
|
||||
"sort in.txt {-o,out.txt}",
|
||||
"sort in.txt $'-o' out.txt",
|
||||
"find . {-delete,-print}",
|
||||
"find . $'-delete'",
|
||||
"rg foo {--pre=./x.sh,}",
|
||||
"rg foo $'--pre=./x.sh' .",
|
||||
"fd . {-x,rm}",
|
||||
"git log {--output=out.txt,}",
|
||||
"uniq in.txt {out.txt,}",
|
||||
] {
|
||||
if analyze_command(command).level == SafetyLevel::Safe {
|
||||
safe_shapes.push(command);
|
||||
}
|
||||
}
|
||||
assert!(safe_shapes.is_empty(), "classified Safe: {safe_shapes:#?}");
|
||||
for command in [
|
||||
"ls *.rs",
|
||||
"cat src/*.rs",
|
||||
"echo $HOME",
|
||||
"sed -n 1,5p file.txt",
|
||||
"sort in.txt",
|
||||
"find . -name '*.rs'",
|
||||
"rg foo src",
|
||||
"git log --oneline -5",
|
||||
"git diff HEAD~1",
|
||||
] {
|
||||
assert_eq!(
|
||||
analyze_command(command).level,
|
||||
SafetyLevel::Safe,
|
||||
"{command:?} was not classified Safe"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn safe_table_rejects_prefixes_writable_options_and_windows_trailing_backslashes() {
|
||||
for command in [
|
||||
"FOO=1>/tmp/out codewhale --version",
|
||||
"FOO=1&>/tmp/out codewhale --version",
|
||||
"FOO=1>>/tmp/out codewhale --version",
|
||||
"env ->/tmp/out codewhale --version",
|
||||
"PATH=/tmp/bin codewhale --version",
|
||||
"LD_PRELOAD=/tmp/lib.so codewhale --version",
|
||||
"DYLD_INSERT_LIBRARIES=/tmp/lib.dylib codewhale --version",
|
||||
"env -P/tmp/bin codewhale --version",
|
||||
"env LD_PRELOAD=/tmp/lib.so codewhale --version",
|
||||
"env \"-S/bin/sh -c id\" codewhale --version",
|
||||
"codewhale '--version",
|
||||
"git stash list --output=/tmp/out",
|
||||
"git stash list --output /tmp/out",
|
||||
"git stash list $FLAGS",
|
||||
"ag --pager=\"sh -c id\" foo",
|
||||
"ag --pager id foo",
|
||||
"ag $FLAGS foo",
|
||||
"info -o /tmp/out",
|
||||
"info -o/tmp/out",
|
||||
"info --output=/tmp/out",
|
||||
"info --output /tmp/out",
|
||||
"info $FLAGS",
|
||||
"rg --hostname-bin=/tmp/tool foo",
|
||||
"rg --hostname-bin /tmp/tool foo",
|
||||
"less -o /tmp/out file",
|
||||
"less -O /tmp/out file",
|
||||
"less -o/tmp/out file",
|
||||
"less -O/tmp/out file",
|
||||
"less -No/tmp/out file",
|
||||
"less --log-file=/tmp/out file",
|
||||
"less --log-file /tmp/out file",
|
||||
"less --LOG-FILE=/tmp/out file",
|
||||
"more -o /tmp/out file",
|
||||
"more --log-file=/tmp/out file",
|
||||
"less $FLAGS file",
|
||||
r"find C:\ -delete",
|
||||
r"rg C:\ --pre=id foo",
|
||||
r"rg C:\ --hostname-bin=id foo",
|
||||
r"find \\?\ -delete",
|
||||
r"rg \\?\ --pre=id foo",
|
||||
r"less C:\ -o /tmp/out",
|
||||
] {
|
||||
assert!(
|
||||
!matches!(
|
||||
analyze_command(command).level,
|
||||
SafetyLevel::Safe | SafetyLevel::WorkspaceSafe
|
||||
),
|
||||
"{command:?} must not be classified Safe"
|
||||
);
|
||||
assert!(
|
||||
!is_parallel_readonly_command(command),
|
||||
"{command:?} must not be parallel read-only"
|
||||
);
|
||||
}
|
||||
for command in [
|
||||
"git stash list --output=/tmp/out",
|
||||
"ag --pager=id foo",
|
||||
"info -o /tmp/out",
|
||||
"rg --hostname-bin=id foo",
|
||||
"less -o /tmp/out file",
|
||||
"less --LOG-FILE=/tmp/out file",
|
||||
] {
|
||||
let expansion = crate::shell_expand::expand_command(command);
|
||||
assert!(
|
||||
!prefix_grant_is_eligible(command, &expansion),
|
||||
"{command:?} must not be prefix-grant eligible"
|
||||
);
|
||||
}
|
||||
for command in [
|
||||
"codewhale --version",
|
||||
"codew -V",
|
||||
"git stash list",
|
||||
"ag foo src",
|
||||
"info coreutils",
|
||||
"less -R file.txt",
|
||||
"more file.txt",
|
||||
r"dir C:\",
|
||||
r"find C:\ -name '*.rs'",
|
||||
r"rg foo C:\src",
|
||||
] {
|
||||
assert_eq!(
|
||||
analyze_command(command).level,
|
||||
SafetyLevel::Safe,
|
||||
"{command:?} should stay Safe"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -28,8 +28,10 @@
|
||||
//! Because nothing is evaluated, some command words cannot be known without
|
||||
//! running the shell: a parameter or command substitution (`$v`, `${v}`,
|
||||
//! `$(…)`), a glob or brace expansion (`r[m]`, `{rm,-f,x}`), ANSI-C escapes,
|
||||
//! and a shell that reads its script from a pipe, a here-string, or a process
|
||||
//! substitution. [`Expansion::dynamic`] reports that case so the policy engine
|
||||
//! a command word that is not a plain literal (`=rm`, `/bin/(r)m`), a builtin
|
||||
//! that rebinds names (`alias`, `hash x=…`, `setopt`), and a shell that reads
|
||||
//! its script from a pipe, a here-string, or a process substitution.
|
||||
//! [`Expansion::dynamic`] reports that case so the policy engine
|
||||
//! can fail closed instead of matching deny rules against text the shell will
|
||||
//! rewrite. [`Expansion::nested`] reports that some code runs inside another
|
||||
//! command (a substitution, `eval`, a `-c` payload, `find -exec`), which is
|
||||
@@ -800,6 +802,10 @@ const RESERVED_PREFIXES: &[&str] = &[
|
||||
"if", "then", "elif", "else", "do", "while", "until", "!", "coproc",
|
||||
];
|
||||
|
||||
const REBINDING_BUILTINS: &[&str] = &[
|
||||
"alias", "enable", "setopt", "unsetopt", "emulate", "shopt", "autoload", "zmodload",
|
||||
];
|
||||
|
||||
/// Shells whose `-c` argument is a command line to be parsed, not an operand.
|
||||
const SHELL_NAMES: &[&str] = &[
|
||||
"sh", "bash", "zsh", "dash", "ksh", "ksh93", "mksh", "ash", "fish", "csh", "tcsh", "rbash",
|
||||
@@ -811,6 +817,7 @@ const SHELL_NAMES: &[&str] = &[
|
||||
pub struct Expansion {
|
||||
/// Every command line found, as [`expanded_commands`] returns them.
|
||||
pub commands: Vec<String>,
|
||||
pub words: Vec<Vec<String>>,
|
||||
/// A command word (or a shell's script) is only known at run time, so no
|
||||
/// deny rule can be matched against it reliably.
|
||||
pub dynamic: bool,
|
||||
@@ -852,6 +859,7 @@ fn expanded_commands_for_platform(command: &str, windows: bool) -> Vec<String> {
|
||||
fn expand_for_platform(command: &str, windows: bool) -> Expansion {
|
||||
let mut expander = Expander {
|
||||
out: Vec::new(),
|
||||
out_words: Vec::new(),
|
||||
seen: HashSet::new(),
|
||||
literal_backslashes: windows,
|
||||
dynamic: false,
|
||||
@@ -870,6 +878,7 @@ fn expand_for_platform(command: &str, windows: bool) -> Expansion {
|
||||
}
|
||||
Expansion {
|
||||
commands: expander.out,
|
||||
words: expander.out_words,
|
||||
dynamic: expander.dynamic,
|
||||
nested: expander.nested,
|
||||
arguments_dynamic: expander.arguments_dynamic,
|
||||
@@ -880,6 +889,7 @@ fn expand_for_platform(command: &str, windows: bool) -> Expansion {
|
||||
|
||||
struct Expander {
|
||||
out: Vec<String>,
|
||||
out_words: Vec<Vec<String>>,
|
||||
seen: HashSet<String>,
|
||||
literal_backslashes: bool,
|
||||
dynamic: bool,
|
||||
@@ -902,6 +912,7 @@ struct WordBuf {
|
||||
brace: bool,
|
||||
/// An unquoted `[` appeared, so the word may be a bracket glob.
|
||||
bracket: bool,
|
||||
odd: bool,
|
||||
}
|
||||
|
||||
impl WordBuf {
|
||||
@@ -918,6 +929,7 @@ impl WordBuf {
|
||||
|| (self.brace && (self.text.contains(',') || self.text.contains("..")));
|
||||
line.words.push(std::mem::take(&mut self.text));
|
||||
line.dynamic.push(dynamic);
|
||||
line.odd.push(self.odd);
|
||||
}
|
||||
self.started = false;
|
||||
}
|
||||
@@ -925,6 +937,7 @@ impl WordBuf {
|
||||
self.dynamic = false;
|
||||
self.brace = false;
|
||||
self.bracket = false;
|
||||
self.odd = false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -934,6 +947,7 @@ impl WordBuf {
|
||||
struct Line {
|
||||
words: Vec<String>,
|
||||
dynamic: Vec<bool>,
|
||||
odd: Vec<bool>,
|
||||
heredoc: bool,
|
||||
}
|
||||
|
||||
@@ -965,6 +979,13 @@ impl Expander {
|
||||
}
|
||||
self.seen.insert(joined.clone());
|
||||
self.out.push(joined);
|
||||
self.out_words.push(
|
||||
tokens
|
||||
.iter()
|
||||
.filter(|token| !token.is_empty())
|
||||
.cloned()
|
||||
.collect(),
|
||||
);
|
||||
}
|
||||
|
||||
/// Word-split `input` into command lines and record each one, recursing
|
||||
@@ -1080,18 +1101,14 @@ impl Expander {
|
||||
uncertain |= i >= n;
|
||||
i = (i + 1).min(n);
|
||||
}
|
||||
// `$'…'` (ANSI-C quoting) is literal text with C escapes. The
|
||||
// escapes are not decoded here, so a word that uses them is
|
||||
// not known statically (`$'\x72m'` is `rm`).
|
||||
'$' if i + 1 < n && chars[i + 1] == '\'' => {
|
||||
word.started = true;
|
||||
word.quoted = true;
|
||||
word.dynamic = true;
|
||||
i += 2;
|
||||
while i < n && chars[i] != '\'' {
|
||||
if chars[i] == '\\' && i + 1 < n {
|
||||
word.dynamic = true;
|
||||
word.text.push(chars[i + 1]);
|
||||
i += 2;
|
||||
i = push_ansi_c_escape(&chars, i, &mut word.text);
|
||||
} else {
|
||||
word.text.push(chars[i]);
|
||||
i += 1;
|
||||
@@ -1195,6 +1212,18 @@ impl Expander {
|
||||
// command list of its own, not part of the surrounding word.
|
||||
'(' | ')' => {
|
||||
self.control = true;
|
||||
let glued = if c == '(' {
|
||||
(word.started || !word.text.is_empty())
|
||||
&& chars.get(i + 1) != Some(&')')
|
||||
&& !is_array_assignment_prefix(&word.text)
|
||||
} else {
|
||||
!(i > 0 && chars[i - 1] == '(')
|
||||
&& chars.get(i + 1).is_some_and(|next| {
|
||||
!next.is_whitespace()
|
||||
&& !matches!(next, ';' | '|' | '&' | '(' | ')' | '<' | '>')
|
||||
})
|
||||
};
|
||||
uncertain |= glued;
|
||||
word.flush(&mut line);
|
||||
end_command(&mut commands, &mut line);
|
||||
word.redirect_operand = false;
|
||||
@@ -1293,6 +1322,9 @@ impl Expander {
|
||||
'{' => word.brace = true,
|
||||
_ => {}
|
||||
}
|
||||
if !is_plain_word_char(c, !word.started && word.text.is_empty()) {
|
||||
word.odd = true;
|
||||
}
|
||||
word.text.push(c);
|
||||
word.started = true;
|
||||
i += 1;
|
||||
@@ -1355,12 +1387,22 @@ impl Expander {
|
||||
{
|
||||
self.dynamic = true;
|
||||
}
|
||||
if line.odd[head] && !matches!(tokens[head].as_str(), "[" | "[[") {
|
||||
self.dynamic = true;
|
||||
}
|
||||
let name = command_name(&tokens[head]);
|
||||
let args_dynamic = dynamic[head + 1..].iter().any(|dynamic| *dynamic);
|
||||
self.arguments_dynamic |= args_dynamic;
|
||||
match name.as_str() {
|
||||
// `eval …` takes a *command line* as data. Parse it.
|
||||
"eval" => self.code_payload(&tokens[head + 1..].join(" "), args_dynamic, depth),
|
||||
"eval" | "iex" | "invoke-expression" => {
|
||||
self.dynamic |= name != "eval" && head + 1 == tokens.len();
|
||||
self.code_payload(&tokens[head + 1..].join(" "), args_dynamic, depth);
|
||||
}
|
||||
_ if REBINDING_BUILTINS.contains(&name.as_str()) => self.dynamic = true,
|
||||
"hash" => {
|
||||
self.dynamic |= tokens[head + 1..].iter().any(|token| token != "-r");
|
||||
}
|
||||
"source" | "." => match tokens.get(head + 1) {
|
||||
None if !line.heredoc => self.dynamic = true,
|
||||
Some(_) if dynamic[head + 1] => self.dynamic = true,
|
||||
@@ -1513,6 +1555,7 @@ impl Expander {
|
||||
dynamic: (start..end)
|
||||
.map(|at| line.dynamic[at] || tokens[at].contains("{}"))
|
||||
.collect(),
|
||||
odd: line.odd[start..end].to_vec(),
|
||||
heredoc: false,
|
||||
};
|
||||
self.record(&payload, depth + 1);
|
||||
@@ -1627,13 +1670,28 @@ fn mark_replacement_operands(tokens: &[String], heads: &[usize], dynamic: &mut [
|
||||
}
|
||||
}
|
||||
|
||||
/// True when `$` followed by `next` starts a parameter expansion (`$v`, `$1`,
|
||||
/// `$@`, `$"…"`), as opposed to a literal dollar sign.
|
||||
/// True unless the `$` is a literal one: it ends the word or precedes a
|
||||
/// separator. Everything else (`$v`, `$1`, `$@`, `$=v`, `$~v`, `$^v`, `$"…"`,
|
||||
/// and any flag character a shell may add) is read as an expansion.
|
||||
fn starts_parameter(next: Option<&char>) -> bool {
|
||||
next.is_some_and(|c| {
|
||||
c.is_ascii_alphanumeric()
|
||||
|| matches!(c, '_' | '@' | '*' | '#' | '?' | '$' | '!' | '-' | '"')
|
||||
})
|
||||
next.is_some_and(|c| !c.is_whitespace() && !matches!(c, ';' | '|' | '&' | ')' | '<' | '>'))
|
||||
}
|
||||
|
||||
fn is_plain_word_char(c: char, leading: bool) -> bool {
|
||||
c.is_alphanumeric()
|
||||
|| matches!(c, '_' | '.' | '/' | '+' | ':' | '@' | ',' | '-')
|
||||
|| (leading && c == '~')
|
||||
}
|
||||
|
||||
fn is_array_assignment_prefix(text: &str) -> bool {
|
||||
let name = text
|
||||
.strip_suffix("+=")
|
||||
.or_else(|| text.strip_suffix('='))
|
||||
.unwrap_or("");
|
||||
!name.is_empty()
|
||||
&& name
|
||||
.chars()
|
||||
.all(|ch| ch.is_ascii_alphanumeric() || ch == '_')
|
||||
}
|
||||
|
||||
/// Unquoted heredocs expand substitutions, but quotes and ordinary lines are
|
||||
@@ -1708,7 +1766,7 @@ fn end_command(commands: &mut Vec<Line>, line: &mut Line) {
|
||||
heredoc: line.heredoc,
|
||||
..Line::default()
|
||||
};
|
||||
for (word, dynamic) in line.words.into_iter().zip(line.dynamic) {
|
||||
for ((word, dynamic), odd) in line.words.into_iter().zip(line.dynamic).zip(line.odd) {
|
||||
if matches!(word.as_str(), "{" | "}") {
|
||||
push_command(commands, &mut piece);
|
||||
continue;
|
||||
@@ -1718,6 +1776,7 @@ fn end_command(commands: &mut Vec<Line>, line: &mut Line) {
|
||||
}
|
||||
piece.words.push(word);
|
||||
piece.dynamic.push(dynamic);
|
||||
piece.odd.push(odd);
|
||||
}
|
||||
push_command(commands, &mut piece);
|
||||
}
|
||||
@@ -1777,6 +1836,18 @@ fn read_substitution(chars: &[char], open_at: usize) -> (String, usize, bool) {
|
||||
let c = chars[i];
|
||||
match c {
|
||||
'\\' => i += 2,
|
||||
'$' if chars.get(i + 1) == Some(&'\'') => {
|
||||
i += 2;
|
||||
while i < n && chars[i] != '\'' {
|
||||
if chars[i] == '\\' && i + 1 < n {
|
||||
i += 2;
|
||||
} else {
|
||||
i += 1;
|
||||
}
|
||||
}
|
||||
certain &= i < n;
|
||||
i += 1;
|
||||
}
|
||||
'\'' => {
|
||||
i += 1;
|
||||
while i < n && chars[i] != '\'' {
|
||||
@@ -1845,6 +1916,216 @@ fn read_substitution(chars: &[char], open_at: usize) -> (String, usize, bool) {
|
||||
(chars[start..n.max(start)].iter().collect(), n, false)
|
||||
}
|
||||
|
||||
fn push_ansi_c_escape(chars: &[char], backslash_idx: usize, out: &mut String) -> usize {
|
||||
let next = chars[backslash_idx + 1];
|
||||
match next {
|
||||
'a' => {
|
||||
out.push('\x07');
|
||||
backslash_idx + 2
|
||||
}
|
||||
'b' => {
|
||||
out.push('\x08');
|
||||
backslash_idx + 2
|
||||
}
|
||||
'e' | 'E' => {
|
||||
out.push('\x1b');
|
||||
backslash_idx + 2
|
||||
}
|
||||
'f' => {
|
||||
out.push('\x0c');
|
||||
backslash_idx + 2
|
||||
}
|
||||
'n' => {
|
||||
out.push('\n');
|
||||
backslash_idx + 2
|
||||
}
|
||||
'r' => {
|
||||
out.push('\r');
|
||||
backslash_idx + 2
|
||||
}
|
||||
't' => {
|
||||
out.push('\t');
|
||||
backslash_idx + 2
|
||||
}
|
||||
'v' => {
|
||||
out.push('\x0b');
|
||||
backslash_idx + 2
|
||||
}
|
||||
'\\' | '\'' | '"' | '?' => {
|
||||
out.push(next);
|
||||
backslash_idx + 2
|
||||
}
|
||||
'0'..='7' => {
|
||||
let mut val = 0u32;
|
||||
let mut end = backslash_idx + 1;
|
||||
while end < chars.len() && end < backslash_idx + 4 && ('0'..='7').contains(&chars[end])
|
||||
{
|
||||
val = (val << 3) | (chars[end] as u32 - '0' as u32);
|
||||
end += 1;
|
||||
}
|
||||
out.push(char::from((val & 0xff) as u8));
|
||||
end
|
||||
}
|
||||
'x' => {
|
||||
let mut val = 0u32;
|
||||
let mut end = backslash_idx + 2;
|
||||
while end < chars.len() && end < backslash_idx + 4 {
|
||||
if let Some(digit) = chars[end].to_digit(16) {
|
||||
val = (val << 4) | digit;
|
||||
end += 1;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
if end > backslash_idx + 2 {
|
||||
out.push(char::from((val & 0xff) as u8));
|
||||
end
|
||||
} else {
|
||||
out.push('\\');
|
||||
out.push('x');
|
||||
backslash_idx + 2
|
||||
}
|
||||
}
|
||||
'u' | 'U' => {
|
||||
let max_digits = if next == 'u' { 4 } else { 8 };
|
||||
let mut val = 0u32;
|
||||
let mut end = backslash_idx + 2;
|
||||
while end < chars.len() && end < backslash_idx + 2 + max_digits {
|
||||
if let Some(digit) = chars[end].to_digit(16) {
|
||||
val = (val << 4) | digit;
|
||||
end += 1;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
if end > backslash_idx + 2 {
|
||||
if let Some(ch) = char::from_u32(val) {
|
||||
out.push(ch);
|
||||
} else {
|
||||
out.extend(&chars[backslash_idx..end]);
|
||||
}
|
||||
end
|
||||
} else {
|
||||
out.push('\\');
|
||||
out.push(next);
|
||||
backslash_idx + 2
|
||||
}
|
||||
}
|
||||
'c' if backslash_idx + 2 < chars.len()
|
||||
&& chars[backslash_idx + 2].is_ascii()
|
||||
&& chars[backslash_idx + 2] != '\'' =>
|
||||
{
|
||||
out.push(char::from((chars[backslash_idx + 2] as u8) & 0x1f));
|
||||
backslash_idx + 3
|
||||
}
|
||||
_ => {
|
||||
out.push('\\');
|
||||
out.push(next);
|
||||
backslash_idx + 2
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn split_ansi_c_shell_words(segment: &str) -> Option<Vec<String>> {
|
||||
let chars: Vec<char> = segment.chars().collect();
|
||||
let n = chars.len();
|
||||
let mut i = 0;
|
||||
let mut words = Vec::new();
|
||||
let mut current = String::new();
|
||||
let mut started = false;
|
||||
while i < n {
|
||||
let ch = chars[i];
|
||||
if ch.is_whitespace() {
|
||||
if started {
|
||||
words.push(std::mem::take(&mut current));
|
||||
started = false;
|
||||
}
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
if ch == '#' && !started {
|
||||
while i < n && chars[i] != '\n' {
|
||||
i += 1;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
started = true;
|
||||
match ch {
|
||||
'$' if i + 1 < n && chars[i + 1] == '\'' => {
|
||||
i += 2;
|
||||
while i < n && chars[i] != '\'' {
|
||||
if chars[i] == '\\' && i + 1 < n {
|
||||
i = push_ansi_c_escape(&chars, i, &mut current);
|
||||
} else {
|
||||
current.push(chars[i]);
|
||||
i += 1;
|
||||
}
|
||||
}
|
||||
if i >= n {
|
||||
return None;
|
||||
}
|
||||
i += 1;
|
||||
}
|
||||
'\'' => {
|
||||
i += 1;
|
||||
while i < n && chars[i] != '\'' {
|
||||
current.push(chars[i]);
|
||||
i += 1;
|
||||
}
|
||||
if i >= n {
|
||||
return None;
|
||||
}
|
||||
i += 1;
|
||||
}
|
||||
'"' => {
|
||||
i += 1;
|
||||
while i < n && chars[i] != '"' {
|
||||
if chars[i] == '\\' {
|
||||
if i + 1 >= n {
|
||||
return None;
|
||||
}
|
||||
let next = chars[i + 1];
|
||||
if matches!(next, '"' | '\\' | '$' | '`') {
|
||||
current.push(next);
|
||||
i += 2;
|
||||
} else if next == '\n' {
|
||||
i += 2;
|
||||
} else {
|
||||
current.push('\\');
|
||||
current.push(next);
|
||||
i += 2;
|
||||
}
|
||||
} else {
|
||||
current.push(chars[i]);
|
||||
i += 1;
|
||||
}
|
||||
}
|
||||
if i >= n {
|
||||
return None;
|
||||
}
|
||||
i += 1;
|
||||
}
|
||||
'\\' => {
|
||||
if i + 1 >= n {
|
||||
return None;
|
||||
}
|
||||
if chars[i + 1] != '\n' {
|
||||
current.push(chars[i + 1]);
|
||||
}
|
||||
i += 2;
|
||||
}
|
||||
_ => {
|
||||
current.push(ch);
|
||||
i += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
if started {
|
||||
words.push(current);
|
||||
}
|
||||
Some(words)
|
||||
}
|
||||
|
||||
/// The final path component, so `/usr/bin/sudo` reads as `sudo`.
|
||||
fn basename(token: &str) -> &str {
|
||||
token
|
||||
@@ -1857,6 +2138,7 @@ fn basename(token: &str) -> &str {
|
||||
fn is_env_assignment(token: &str) -> bool {
|
||||
match token.split_once('=') {
|
||||
Some((name, _)) => {
|
||||
let name = name.strip_suffix('+').unwrap_or(name);
|
||||
!name.is_empty()
|
||||
&& !name.starts_with('-')
|
||||
&& name
|
||||
@@ -2168,6 +2450,22 @@ mod tests {
|
||||
expanded_commands_for_platform(command, false)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn native_windows_command_words_are_plain_literals() {
|
||||
for command in [
|
||||
r"C:\Windows\System32\where.exe git",
|
||||
r"C:\Git\bin\bash.exe -lc ls",
|
||||
r".\build.bat --release",
|
||||
r"\\server\share\tool.exe /q",
|
||||
] {
|
||||
assert!(
|
||||
!expand_for_platform(command, true).dynamic,
|
||||
"{command} was unresolved"
|
||||
);
|
||||
}
|
||||
assert!(expand_for_platform(r"%COMSPEC% /c dir", true).dynamic);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn windows_scan_retains_native_paths_and_posix_deny_candidates() {
|
||||
for (command, expected) in [
|
||||
@@ -2445,4 +2743,36 @@ mod tests {
|
||||
fn plain_command_expands_to_itself() {
|
||||
assert_eq!(expand("git status -s"), vec!["git status -s".to_string()]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ansi_c_escapes_decode_control_and_newline_characters() {
|
||||
for (command, expected_head) in [
|
||||
(r#"sh -c $'echo x\nwipefs -a /dev/sda'"#, "wipefs"),
|
||||
(r#"sh -c $'echo x\nmkfs.ext4 /dev/sda1'"#, "mkfs.ext4"),
|
||||
(r#"sh -c $'echo x\rwipefs -a /dev/sda'"#, "wipefs"),
|
||||
(r#"sh -c $'echo x\x0amkfs.ext4 /dev/sda1'"#, "mkfs.ext4"),
|
||||
(r#"sh -c $'echo x\012shred /dev/sda'"#, "shred"),
|
||||
(r#"sh -c $'echo x\u000add if=/dev/zero of=/dev/sda'"#, "dd"),
|
||||
(r#"sh -c $'echo x\cJwipefs -a /dev/sda'"#, "wipefs"),
|
||||
(r#"$(sh -c $'echo \'x\'\nwipefs -a /dev/sda')"#, "wipefs"),
|
||||
] {
|
||||
let expansion = expand_for_platform(command, false);
|
||||
assert!(
|
||||
expansion
|
||||
.words
|
||||
.iter()
|
||||
.any(|words| words.first().map(String::as_str) == Some(expected_head)),
|
||||
"{command}: {:?}",
|
||||
expansion.words
|
||||
);
|
||||
}
|
||||
assert!(
|
||||
expand_for_platform(r#"sh -c $'echo\ta\tb'"#, false)
|
||||
.words
|
||||
.iter()
|
||||
.any(|words| words == &["echo", "a", "b"])
|
||||
);
|
||||
assert!(expand_for_platform("$'rm' -f f", false).dynamic);
|
||||
assert!(expand_for_platform("$'\\x72m' f", false).dynamic);
|
||||
}
|
||||
}
|
||||
@@ -317,7 +317,7 @@ fn read_prefixes_do_not_inherit_write_arguments_but_preserve_read_flags_and_buil
|
||||
#[test]
|
||||
fn exact_grants_round_trip_quoted_data_and_reviewed_redirections() {
|
||||
for command in [
|
||||
r#"git log "$FLAGS""#,
|
||||
"git log '$FLAGS'",
|
||||
"cargo test 2>&1",
|
||||
"cargo test &>result.log",
|
||||
r#"grep -E "a|b" src"#,
|
||||
|
||||
@@ -135,6 +135,25 @@ const HIDDEN_RM: &[&str] = &[
|
||||
"sg wheel 'rm x'",
|
||||
"gtimeout 5 rm x",
|
||||
"gnice -n 5 rm x",
|
||||
"v=rm; $=v -f f",
|
||||
"v=rm; $~v -f f",
|
||||
"v=rm; $^v -f f",
|
||||
"$'rm' -f f",
|
||||
"/bin/(r)m -f f",
|
||||
"/bin/r(m) -f f",
|
||||
"/usr/bin/rm(N) -f f",
|
||||
"setopt equals; =rm -f f",
|
||||
"=rm -f f",
|
||||
"rm# x",
|
||||
"hash x=/bin/rm; x -f f",
|
||||
"hash -p /bin/rm x; x -f f",
|
||||
"alias x=rm\nx -f f",
|
||||
"shopt -s expand_aliases\nalias x=rm\nx -f f",
|
||||
"emulate -L sh; rm x",
|
||||
"iex 'rm f'",
|
||||
"Invoke-Expression 'rm f'",
|
||||
"iex ('r'+'m f')",
|
||||
"'rm f' | iex",
|
||||
];
|
||||
|
||||
/// Literal spellings that were already denied and must stay denied.
|
||||
@@ -243,6 +262,17 @@ fn ordinary_commands_stay_allowed_next_to_a_deny_rule() {
|
||||
"for ((i = 0; i < 3; i++)); do echo $i; done",
|
||||
"echo $(echo \")\")",
|
||||
"watch -n 5 ls",
|
||||
"arr=(a b)",
|
||||
"arr+=(c)",
|
||||
"f() { ls; }",
|
||||
"f(){ ls; }",
|
||||
"g++ x.cc",
|
||||
"set -euo pipefail",
|
||||
"hash -r",
|
||||
"grep foo$ file",
|
||||
"~/bin/tool --flag=a%b",
|
||||
"./a.out x=1",
|
||||
"FOO=bar ls",
|
||||
] {
|
||||
let decision = engine
|
||||
.check(context(command, AskForApproval::Never))
|
||||
@@ -383,3 +413,32 @@ fn typed_deny_rule_skips_global_options_before_the_subcommand() {
|
||||
};
|
||||
assert!(engine.check(elsewhere).expect("policy check").allow);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn subcommand_deny_rules_hold_against_glob_group_spellings() {
|
||||
let engines = [
|
||||
ExecPolicyEngine::new(vec![], vec!["git push".to_string()]),
|
||||
ExecPolicyEngine::with_rulesets(vec![Ruleset::user(vec![], vec![]).with_ask_rules(vec![
|
||||
ToolAskRule {
|
||||
action: PermissionAction::Deny,
|
||||
..ToolAskRule::exec_shell("git push")
|
||||
},
|
||||
])]),
|
||||
];
|
||||
for engine in &engines {
|
||||
for command in [
|
||||
"git push",
|
||||
"/usr/bin/git(N) push",
|
||||
"/usr/bin/(g)it push",
|
||||
"g=git; $=g push",
|
||||
] {
|
||||
assert!(denied(engine, command), "{command} must be denied");
|
||||
}
|
||||
for command in ["git status", "git log --oneline -3"] {
|
||||
let decision = engine
|
||||
.check(context(command, AskForApproval::Never))
|
||||
.expect("policy check");
|
||||
assert!(decision.allow, "{command:?} was denied: {decision:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -101,7 +101,7 @@
|
||||
"ExtensionsActionManage": "gestiona",
|
||||
"ExtensionsActionFocus": "focus",
|
||||
"ExtensionsActionFold": "plega",
|
||||
"ExtensionsActionTabs": "pestanyes",
|
||||
"ExtensionsActionTabs": "filtres",
|
||||
"ExtensionsCompatibilityFull": "completa",
|
||||
"ExtensionsCompatibilityPartial": "parcial",
|
||||
"ExtensionsGroupBuiltIn": "Integrat",
|
||||
@@ -132,7 +132,7 @@
|
||||
"ExtensionsMcpNotConnectedYet": "Encara no s'ha connectat en aquesta sessió. Es connecta quan cal una eina, o connecta'l ara.",
|
||||
"ExtensionsMcpRefresh": "Hi ha {count} servidors MCP configurats; obre /mcp per actualitzar aquesta instantània",
|
||||
"ExtensionsMcpSummary": "{transport} · eines: {tools} · recursos: {resources}",
|
||||
"ExtensionsNoItems": "No hi ha elements en aquesta pestanya",
|
||||
"ExtensionsNoItems": "No hi ha elements amb aquest filtre",
|
||||
"ExtensionsNoMatches": "No hi ha coincidències per a «{query}»",
|
||||
"ExtensionsPluginDetail": "{inventory} · confiança: {trust} · compatibilitat: {compatibility} · diagnòstics: {diagnostics}",
|
||||
"ExtensionsProductChromeDescription": "Inspecciona i controla una sessió local de Chrome mitjançant l'MCP oficial.",
|
||||
@@ -164,12 +164,13 @@
|
||||
"ExtensionsTabHooks": "Ganxos",
|
||||
"ExtensionsTabMarketplace": "Mercat",
|
||||
"ExtensionsTabMarketplaceCompact": "Mercat",
|
||||
"ExtensionsTabPlugins": "Connectors",
|
||||
"ExtensionsTabInstalled": "Instal·lats",
|
||||
"ExtensionsTabConnectors": "Connectors",
|
||||
"ExtensionsTierCommunity": "comunitat",
|
||||
"ExtensionsTierCurated": "seleccionat",
|
||||
"ExtensionsTierOfficial": "oficial",
|
||||
"ExtensionsTierPartner": "soci",
|
||||
"ExtensionsTitle": "Extensions",
|
||||
"ExtensionsTitle": "Plugins",
|
||||
"ExtensionsTrustCapabilitiesChanged": "capacitats modificades",
|
||||
"ExtensionsTrustContentChanged": "contingut modificat",
|
||||
"ExtensionsTrustNotReviewed": "sense revisar",
|
||||
@@ -503,7 +504,7 @@
|
||||
"CmdOverviewDescription": "Mostra el tauler inicial amb estadístiques i accions ràpides",
|
||||
"HomeBackToConversation": "Torna a la conversa",
|
||||
"HomeNavigationBusy": "Acaba o atura la feina actual abans d’obrir l’inici.",
|
||||
"CmdHooksDescription": "Gestiona els hooks del cicle de vida a Extensions",
|
||||
"CmdHooksDescription": "Gestiona els hooks del cicle de vida a Plugins",
|
||||
"CmdAgentDescription": "Obre una sessió persistent de subagent",
|
||||
"CmdGoalDescription": "Continuar un sol objectiu al llarg de diversos torns",
|
||||
"CmdInitDescription": "Genera l'AGENTS.md per al projecte",
|
||||
|
||||
@@ -101,7 +101,7 @@
|
||||
"ExtensionsActionManage": "verwalten",
|
||||
"ExtensionsActionFocus": "Fokus",
|
||||
"ExtensionsActionFold": "einklappen",
|
||||
"ExtensionsActionTabs": "Tabs",
|
||||
"ExtensionsActionTabs": "Filter",
|
||||
"ExtensionsCompatibilityFull": "vollständig",
|
||||
"ExtensionsCompatibilityPartial": "teilweise",
|
||||
"ExtensionsGroupBuiltIn": "Integriert",
|
||||
@@ -132,7 +132,7 @@
|
||||
"ExtensionsMcpNotConnectedYet": "In dieser Sitzung noch nicht verbunden. Verbindet sich, sobald ein Tool gebraucht wird, oder jetzt verbinden.",
|
||||
"ExtensionsMcpRefresh": "{count} MCP-Server sind konfiguriert; /mcp öffnen, um diesen Stand zu aktualisieren",
|
||||
"ExtensionsMcpSummary": "{transport} · Werkzeuge: {tools} · Ressourcen: {resources}",
|
||||
"ExtensionsNoItems": "Keine Einträge in diesem Tab",
|
||||
"ExtensionsNoItems": "Keine Einträge in diesem Filter",
|
||||
"ExtensionsNoMatches": "Keine Treffer für „{query}“",
|
||||
"ExtensionsPluginDetail": "{inventory} · Vertrauen: {trust} · Kompatibilität: {compatibility} · Diagnosen: {diagnostics}",
|
||||
"ExtensionsProductChromeDescription": "Eine lokale Chrome-Sitzung über das offizielle MCP untersuchen und steuern.",
|
||||
@@ -164,12 +164,13 @@
|
||||
"ExtensionsTabHooks": "Hooks",
|
||||
"ExtensionsTabMarketplace": "Marktplatz",
|
||||
"ExtensionsTabMarketplaceCompact": "Markt",
|
||||
"ExtensionsTabPlugins": "Plugins",
|
||||
"ExtensionsTabInstalled": "Installiert",
|
||||
"ExtensionsTabConnectors": "Konnektoren",
|
||||
"ExtensionsTierCommunity": "Community",
|
||||
"ExtensionsTierCurated": "kuratiert",
|
||||
"ExtensionsTierOfficial": "offiziell",
|
||||
"ExtensionsTierPartner": "Partner",
|
||||
"ExtensionsTitle": "Erweiterungen",
|
||||
"ExtensionsTitle": "Plugins",
|
||||
"ExtensionsTrustCapabilitiesChanged": "Fähigkeiten geändert",
|
||||
"ExtensionsTrustContentChanged": "Inhalt geändert",
|
||||
"ExtensionsTrustNotReviewed": "nicht geprüft",
|
||||
@@ -503,7 +504,7 @@
|
||||
"CmdOverviewDescription": "Home-Dashboard mit Statistiken und Schnellaktionen anzeigen",
|
||||
"HomeBackToConversation": "Zurück zur Unterhaltung",
|
||||
"HomeNavigationBusy": "Beende oder stoppe die aktuelle Arbeit, bevor du die Startseite öffnest.",
|
||||
"CmdHooksDescription": "Lebenszyklus-Hooks in Erweiterungen verwalten",
|
||||
"CmdHooksDescription": "Lebenszyklus-Hooks in Plugins verwalten",
|
||||
"CmdAgentDescription": "Persistente Sub-Agent-Sitzung öffnen",
|
||||
"CmdGoalDescription": "Über mehrere Turns hinweg an einem Ziel weiterarbeiten",
|
||||
"CmdInitDescription": "AGENTS.md für das Projekt erzeugen",
|
||||
|
||||
@@ -101,7 +101,7 @@
|
||||
"ExtensionsActionManage": "manage",
|
||||
"ExtensionsActionFocus": "focus",
|
||||
"ExtensionsActionFold": "fold",
|
||||
"ExtensionsActionTabs": "tabs",
|
||||
"ExtensionsActionTabs": "filters",
|
||||
"ExtensionsCompatibilityFull": "full",
|
||||
"ExtensionsCompatibilityPartial": "partial",
|
||||
"ExtensionsGroupBuiltIn": "Built-in",
|
||||
@@ -132,7 +132,7 @@
|
||||
"ExtensionsMcpNotConnectedYet": "Not connected in this session yet. Connects when a tool is needed, or connect it now.",
|
||||
"ExtensionsMcpRefresh": "{count} MCP servers set up; open /mcp to refresh",
|
||||
"ExtensionsMcpSummary": "{transport} · tools: {tools} · resources: {resources}",
|
||||
"ExtensionsNoItems": "Nothing on this tab",
|
||||
"ExtensionsNoItems": "Nothing under this filter",
|
||||
"ExtensionsNoMatches": "No matches for '{query}'",
|
||||
"ExtensionsPluginDetail": "{inventory} · trust: {trust} · compatibility: {compatibility} · diagnostics: {diagnostics}",
|
||||
"ExtensionsProductChromeDescription": "Inspect and control a local Chrome session through the official MCP.",
|
||||
@@ -164,12 +164,13 @@
|
||||
"ExtensionsTabHooks": "Hooks",
|
||||
"ExtensionsTabMarketplace": "Marketplace",
|
||||
"ExtensionsTabMarketplaceCompact": "Market",
|
||||
"ExtensionsTabPlugins": "Plugins",
|
||||
"ExtensionsTabInstalled": "Installed",
|
||||
"ExtensionsTabConnectors": "Connectors",
|
||||
"ExtensionsTierCommunity": "community",
|
||||
"ExtensionsTierCurated": "curated",
|
||||
"ExtensionsTierOfficial": "official",
|
||||
"ExtensionsTierPartner": "partner",
|
||||
"ExtensionsTitle": "Extensions",
|
||||
"ExtensionsTitle": "Plugins",
|
||||
"ExtensionsTrustCapabilitiesChanged": "capabilities changed",
|
||||
"ExtensionsTrustContentChanged": "content changed",
|
||||
"ExtensionsTrustNotReviewed": "not reviewed",
|
||||
@@ -504,7 +505,7 @@
|
||||
"CmdOverviewDescription": "Show the home dashboard",
|
||||
"HomeBackToConversation": "Back to conversation",
|
||||
"HomeNavigationBusy": "Finish or stop the current work before opening home.",
|
||||
"CmdHooksDescription": "Manage lifecycle hooks in Extensions",
|
||||
"CmdHooksDescription": "Manage lifecycle hooks in Plugins",
|
||||
"CmdAgentDescription": "Open a persistent agent session",
|
||||
"CmdGoalDescription": "Work toward one objective across turns",
|
||||
"CmdInitDescription": "Generate AGENTS.md for this project",
|
||||
|
||||
@@ -101,7 +101,7 @@
|
||||
"ExtensionsActionManage": "gestionar",
|
||||
"ExtensionsActionFocus": "foco",
|
||||
"ExtensionsActionFold": "plegar",
|
||||
"ExtensionsActionTabs": "pestañas",
|
||||
"ExtensionsActionTabs": "filtros",
|
||||
"ExtensionsCompatibilityFull": "completa",
|
||||
"ExtensionsCompatibilityPartial": "parcial",
|
||||
"ExtensionsGroupBuiltIn": "Integrado",
|
||||
@@ -132,7 +132,7 @@
|
||||
"ExtensionsMcpNotConnectedYet": "Aún sin conexión en esta sesión. Se conecta cuando se necesita una herramienta, o conéctalo ahora.",
|
||||
"ExtensionsMcpRefresh": "Hay {count} servidores MCP configurados; abre /mcp para actualizar esta instantánea",
|
||||
"ExtensionsMcpSummary": "{transport} · herramientas: {tools} · recursos: {resources}",
|
||||
"ExtensionsNoItems": "No hay elementos en esta pestaña",
|
||||
"ExtensionsNoItems": "No hay elementos con este filtro",
|
||||
"ExtensionsNoMatches": "No hay coincidencias para “{query}”",
|
||||
"ExtensionsPluginDetail": "{inventory} · confianza: {trust} · compatibilidad: {compatibility} · diagnósticos: {diagnostics}",
|
||||
"ExtensionsProductChromeDescription": "Inspecciona y controla una sesión local de Chrome mediante el MCP oficial.",
|
||||
@@ -164,12 +164,13 @@
|
||||
"ExtensionsTabHooks": "Hooks",
|
||||
"ExtensionsTabMarketplace": "Mercado",
|
||||
"ExtensionsTabMarketplaceCompact": "Mercado",
|
||||
"ExtensionsTabPlugins": "Plugins",
|
||||
"ExtensionsTabInstalled": "Instalados",
|
||||
"ExtensionsTabConnectors": "Conectores",
|
||||
"ExtensionsTierCommunity": "comunidad",
|
||||
"ExtensionsTierCurated": "seleccionado",
|
||||
"ExtensionsTierOfficial": "oficial",
|
||||
"ExtensionsTierPartner": "socio",
|
||||
"ExtensionsTitle": "Extensiones",
|
||||
"ExtensionsTitle": "Plugins",
|
||||
"ExtensionsTrustCapabilitiesChanged": "capacidades modificadas",
|
||||
"ExtensionsTrustContentChanged": "contenido modificado",
|
||||
"ExtensionsTrustNotReviewed": "sin revisar",
|
||||
@@ -504,7 +505,7 @@
|
||||
"CmdOverviewDescription": "Mostrar el panel inicial con estadísticas y acciones rápidas",
|
||||
"HomeBackToConversation": "Volver a la conversación",
|
||||
"HomeNavigationBusy": "Termina o detén el trabajo actual antes de abrir el inicio.",
|
||||
"CmdHooksDescription": "Gestionar hooks del ciclo de vida en Extensiones",
|
||||
"CmdHooksDescription": "Gestionar hooks del ciclo de vida en Plugins",
|
||||
"CmdAgentDescription": "Abrir una sesión persistente de sub-agente",
|
||||
"CmdGoalDescription": "Seguir un solo objetivo a lo largo de varios turnos",
|
||||
"CmdInitDescription": "Generar AGENTS.md para el proyecto",
|
||||
|
||||
@@ -101,7 +101,7 @@
|
||||
"ExtensionsActionManage": "gérer",
|
||||
"ExtensionsActionFocus": "focus",
|
||||
"ExtensionsActionFold": "replier",
|
||||
"ExtensionsActionTabs": "onglets",
|
||||
"ExtensionsActionTabs": "filtres",
|
||||
"ExtensionsCompatibilityFull": "complète",
|
||||
"ExtensionsCompatibilityPartial": "partielle",
|
||||
"ExtensionsGroupBuiltIn": "Intégrés",
|
||||
@@ -132,7 +132,7 @@
|
||||
"ExtensionsMcpNotConnectedYet": "Pas encore connecté dans cette session. Se connecte dès qu'un outil est nécessaire, ou connectez-le maintenant.",
|
||||
"ExtensionsMcpRefresh": "{count} serveurs MCP sont configurés ; ouvrez /mcp pour actualiser cet instantané",
|
||||
"ExtensionsMcpSummary": "{transport} · outils : {tools} · ressources : {resources}",
|
||||
"ExtensionsNoItems": "Aucun élément dans cet onglet",
|
||||
"ExtensionsNoItems": "Aucun élément pour ce filtre",
|
||||
"ExtensionsNoMatches": "Aucun résultat pour « {query} »",
|
||||
"ExtensionsPluginDetail": "{inventory} · confiance : {trust} · compatibilité : {compatibility} · diagnostics : {diagnostics}",
|
||||
"ExtensionsProductChromeDescription": "Inspectez et contrôlez une session Chrome locale via le MCP officiel.",
|
||||
@@ -164,12 +164,13 @@
|
||||
"ExtensionsTabHooks": "Hooks",
|
||||
"ExtensionsTabMarketplace": "Marché",
|
||||
"ExtensionsTabMarketplaceCompact": "Marché",
|
||||
"ExtensionsTabPlugins": "Plugins",
|
||||
"ExtensionsTabInstalled": "Installés",
|
||||
"ExtensionsTabConnectors": "Connecteurs",
|
||||
"ExtensionsTierCommunity": "communauté",
|
||||
"ExtensionsTierCurated": "sélectionné",
|
||||
"ExtensionsTierOfficial": "officiel",
|
||||
"ExtensionsTierPartner": "partenaire",
|
||||
"ExtensionsTitle": "Extensions",
|
||||
"ExtensionsTitle": "Plugins",
|
||||
"ExtensionsTrustCapabilitiesChanged": "capacités modifiées",
|
||||
"ExtensionsTrustContentChanged": "contenu modifié",
|
||||
"ExtensionsTrustNotReviewed": "non examiné",
|
||||
@@ -503,7 +504,7 @@
|
||||
"CmdOverviewDescription": "Afficher le tableau de bord avec stats et actions rapides",
|
||||
"HomeBackToConversation": "Retour à la conversation",
|
||||
"HomeNavigationBusy": "Terminez ou arrêtez le travail en cours avant d’ouvrir l’accueil.",
|
||||
"CmdHooksDescription": "Gérer les hooks du cycle de vie dans Extensions",
|
||||
"CmdHooksDescription": "Gérer les hooks du cycle de vie dans Plugins",
|
||||
"CmdAgentDescription": "Ouvrir une session de sous-agent persistante",
|
||||
"CmdGoalDescription": "Continuer vers un seul objectif sur plusieurs tours",
|
||||
"CmdInitDescription": "Générer un AGENTS.md pour le projet",
|
||||
|
||||
@@ -101,7 +101,7 @@
|
||||
"ExtensionsActionManage": "प्रबंधित करें",
|
||||
"ExtensionsActionFocus": "फ़ोकस",
|
||||
"ExtensionsActionFold": "समेटें",
|
||||
"ExtensionsActionTabs": "टैब",
|
||||
"ExtensionsActionTabs": "फ़िल्टर",
|
||||
"ExtensionsCompatibilityFull": "पूर्ण",
|
||||
"ExtensionsCompatibilityPartial": "आंशिक",
|
||||
"ExtensionsGroupBuiltIn": "अंतर्निहित",
|
||||
@@ -132,7 +132,7 @@
|
||||
"ExtensionsMcpNotConnectedYet": "इस सत्र में अभी कनेक्ट नहीं है। किसी टूल की ज़रूरत पड़ने पर कनेक्ट होता है, या अभी कनेक्ट करें।",
|
||||
"ExtensionsMcpRefresh": "{count} MCP सर्वर कॉन्फ़िगर हैं; यह स्नैपशॉट ताज़ा करने के लिए /mcp खोलें",
|
||||
"ExtensionsMcpSummary": "{transport} · टूल: {tools} · संसाधन: {resources}",
|
||||
"ExtensionsNoItems": "इस टैब पर कोई आइटम नहीं",
|
||||
"ExtensionsNoItems": "इस फ़िल्टर में कोई आइटम नहीं",
|
||||
"ExtensionsNoMatches": "“{query}” के लिए कोई मिलान नहीं",
|
||||
"ExtensionsPluginDetail": "{inventory} · भरोसा: {trust} · अनुकूलता: {compatibility} · निदान: {diagnostics}",
|
||||
"ExtensionsProductChromeDescription": "आधिकारिक MCP के माध्यम से स्थानीय Chrome सत्र की जाँच और नियंत्रण करें।",
|
||||
@@ -164,12 +164,13 @@
|
||||
"ExtensionsTabHooks": "हुक",
|
||||
"ExtensionsTabMarketplace": "मार्केटप्लेस",
|
||||
"ExtensionsTabMarketplaceCompact": "बाज़ार",
|
||||
"ExtensionsTabPlugins": "प्लगइन",
|
||||
"ExtensionsTabInstalled": "इंस्टॉल किए गए",
|
||||
"ExtensionsTabConnectors": "कनेक्टर",
|
||||
"ExtensionsTierCommunity": "समुदाय",
|
||||
"ExtensionsTierCurated": "क्यूरेटेड",
|
||||
"ExtensionsTierOfficial": "आधिकारिक",
|
||||
"ExtensionsTierPartner": "भागीदार",
|
||||
"ExtensionsTitle": "एक्सटेंशन",
|
||||
"ExtensionsTitle": "प्लगइन",
|
||||
"ExtensionsTrustCapabilitiesChanged": "क्षमताएँ बदलीं",
|
||||
"ExtensionsTrustContentChanged": "सामग्री बदली",
|
||||
"ExtensionsTrustNotReviewed": "समीक्षा नहीं हुई",
|
||||
@@ -503,7 +504,7 @@
|
||||
"CmdOverviewDescription": "आँकड़ों और त्वरित क्रियाओं सहित होम डैशबोर्ड दिखाएँ",
|
||||
"HomeBackToConversation": "बातचीत पर वापस जाएँ",
|
||||
"HomeNavigationBusy": "होम खोलने से पहले मौजूदा काम पूरा करें या रोकें।",
|
||||
"CmdHooksDescription": "Extensions में जीवनचक्र हुक प्रबंधित करें",
|
||||
"CmdHooksDescription": "प्लगइन में जीवनचक्र हुक प्रबंधित करें",
|
||||
"CmdAgentDescription": "स्थायी सब-एजेंट सत्र खोलें",
|
||||
"CmdGoalDescription": "कई टर्न तक एक ही लक्ष्य पर काम जारी रखें",
|
||||
"CmdInitDescription": "प्रोजेक्ट के लिए AGENTS.md बनाएँ",
|
||||
|
||||
@@ -101,7 +101,7 @@
|
||||
"ExtensionsActionManage": "kelola",
|
||||
"ExtensionsActionFocus": "fokus",
|
||||
"ExtensionsActionFold": "lipat",
|
||||
"ExtensionsActionTabs": "tab",
|
||||
"ExtensionsActionTabs": "filter",
|
||||
"ExtensionsCompatibilityFull": "penuh",
|
||||
"ExtensionsCompatibilityPartial": "sebagian",
|
||||
"ExtensionsGroupBuiltIn": "Bawaan",
|
||||
@@ -132,7 +132,7 @@
|
||||
"ExtensionsMcpNotConnectedYet": "Belum terhubung di sesi ini. Terhubung saat sebuah alat diperlukan, atau hubungkan sekarang.",
|
||||
"ExtensionsMcpRefresh": "{count} server MCP dikonfigurasi; buka /mcp untuk menyegarkan cuplikan ini",
|
||||
"ExtensionsMcpSummary": "{transport} · alat: {tools} · sumber daya: {resources}",
|
||||
"ExtensionsNoItems": "Tidak ada item di tab ini",
|
||||
"ExtensionsNoItems": "Tidak ada item di filter ini",
|
||||
"ExtensionsNoMatches": "Tidak ada kecocokan untuk “{query}”",
|
||||
"ExtensionsPluginDetail": "{inventory} · kepercayaan: {trust} · kompatibilitas: {compatibility} · diagnostik: {diagnostics}",
|
||||
"ExtensionsProductChromeDescription": "Periksa dan kendalikan sesi Chrome lokal melalui MCP resmi.",
|
||||
@@ -164,12 +164,13 @@
|
||||
"ExtensionsTabHooks": "Hook",
|
||||
"ExtensionsTabMarketplace": "Marketplace",
|
||||
"ExtensionsTabMarketplaceCompact": "Pasar",
|
||||
"ExtensionsTabPlugins": "Plugin",
|
||||
"ExtensionsTabInstalled": "Terpasang",
|
||||
"ExtensionsTabConnectors": "Konektor",
|
||||
"ExtensionsTierCommunity": "komunitas",
|
||||
"ExtensionsTierCurated": "terkurasi",
|
||||
"ExtensionsTierOfficial": "resmi",
|
||||
"ExtensionsTierPartner": "mitra",
|
||||
"ExtensionsTitle": "Ekstensi",
|
||||
"ExtensionsTitle": "Plugin",
|
||||
"ExtensionsTrustCapabilitiesChanged": "kemampuan berubah",
|
||||
"ExtensionsTrustContentChanged": "konten berubah",
|
||||
"ExtensionsTrustNotReviewed": "belum ditinjau",
|
||||
@@ -503,7 +504,7 @@
|
||||
"CmdOverviewDescription": "Tampilkan dasbor beranda dengan statistik dan aksi cepat",
|
||||
"HomeBackToConversation": "Kembali ke percakapan",
|
||||
"HomeNavigationBusy": "Selesaikan atau hentikan pekerjaan saat ini sebelum membuka beranda.",
|
||||
"CmdHooksDescription": "Kelola hook siklus hidup di Ekstensi",
|
||||
"CmdHooksDescription": "Kelola hook siklus hidup di Plugin",
|
||||
"CmdAgentDescription": "Buka sesi sub-agen persisten",
|
||||
"CmdGoalDescription": "Terus mengerjakan satu tujuan lintas giliran",
|
||||
"CmdInitDescription": "Buat AGENTS.md untuk proyek",
|
||||
|
||||
@@ -101,7 +101,7 @@
|
||||
"ExtensionsActionManage": "管理",
|
||||
"ExtensionsActionFocus": "フォーカス",
|
||||
"ExtensionsActionFold": "折りたたむ",
|
||||
"ExtensionsActionTabs": "タブ",
|
||||
"ExtensionsActionTabs": "フィルター",
|
||||
"ExtensionsCompatibilityFull": "完全",
|
||||
"ExtensionsCompatibilityPartial": "部分対応",
|
||||
"ExtensionsGroupBuiltIn": "組み込み",
|
||||
@@ -132,7 +132,7 @@
|
||||
"ExtensionsMcpNotConnectedYet": "このセッションではまだ未接続です。ツールが必要になると接続します。今すぐ接続することもできます。",
|
||||
"ExtensionsMcpRefresh": "MCP サーバーが {count} 件設定されています。/mcp を開いてスナップショットを更新してください",
|
||||
"ExtensionsMcpSummary": "{transport} · ツール: {tools} · リソース: {resources}",
|
||||
"ExtensionsNoItems": "このタブに項目はありません",
|
||||
"ExtensionsNoItems": "このフィルターに項目はありません",
|
||||
"ExtensionsNoMatches": "「{query}」に一致する項目はありません",
|
||||
"ExtensionsPluginDetail": "{inventory} · 信頼: {trust} · 互換性: {compatibility} · 診断: {diagnostics}",
|
||||
"ExtensionsProductChromeDescription": "公式 MCP を通じてローカルの Chrome セッションを検査・操作します。",
|
||||
@@ -164,12 +164,13 @@
|
||||
"ExtensionsTabHooks": "フック",
|
||||
"ExtensionsTabMarketplace": "マーケットプレイス",
|
||||
"ExtensionsTabMarketplaceCompact": "市場",
|
||||
"ExtensionsTabPlugins": "プラグイン",
|
||||
"ExtensionsTabInstalled": "インストール済み",
|
||||
"ExtensionsTabConnectors": "コネクタ",
|
||||
"ExtensionsTierCommunity": "コミュニティ",
|
||||
"ExtensionsTierCurated": "選定済み",
|
||||
"ExtensionsTierOfficial": "公式",
|
||||
"ExtensionsTierPartner": "パートナー",
|
||||
"ExtensionsTitle": "拡張機能",
|
||||
"ExtensionsTitle": "プラグイン",
|
||||
"ExtensionsTrustCapabilitiesChanged": "機能が変更済み",
|
||||
"ExtensionsTrustContentChanged": "内容が変更済み",
|
||||
"ExtensionsTrustNotReviewed": "未レビュー",
|
||||
@@ -504,7 +505,7 @@
|
||||
"CmdOverviewDescription": "統計とクイックアクション付きのホームダッシュボードを表示",
|
||||
"HomeBackToConversation": "会話に戻る",
|
||||
"HomeNavigationBusy": "ホームを開く前に、現在の作業を完了または停止してください。",
|
||||
"CmdHooksDescription": "拡張機能でライフサイクルフックを管理",
|
||||
"CmdHooksDescription": "プラグインでライフサイクルフックを管理",
|
||||
"CmdAgentDescription": "永続サブエージェントセッションを開く",
|
||||
"CmdGoalDescription": "複数ターンにわたって一つの目標を追い続ける",
|
||||
"CmdInitDescription": "プロジェクト用に AGENTS.md を生成",
|
||||
|
||||
@@ -101,7 +101,7 @@
|
||||
"ExtensionsActionManage": "관리",
|
||||
"ExtensionsActionFocus": "포커스",
|
||||
"ExtensionsActionFold": "접기",
|
||||
"ExtensionsActionTabs": "탭",
|
||||
"ExtensionsActionTabs": "필터",
|
||||
"ExtensionsCompatibilityFull": "완전",
|
||||
"ExtensionsCompatibilityPartial": "부분",
|
||||
"ExtensionsGroupBuiltIn": "기본 제공",
|
||||
@@ -132,7 +132,7 @@
|
||||
"ExtensionsMcpNotConnectedYet": "이 세션에서는 아직 연결되지 않았습니다. 도구가 필요해지면 연결되며, 지금 연결할 수도 있습니다.",
|
||||
"ExtensionsMcpRefresh": "MCP 서버 {count}개가 구성되었습니다. /mcp를 열어 이 스냅샷을 새로 고치세요",
|
||||
"ExtensionsMcpSummary": "{transport} · 도구: {tools} · 리소스: {resources}",
|
||||
"ExtensionsNoItems": "이 탭에 항목이 없습니다",
|
||||
"ExtensionsNoItems": "이 필터에 항목이 없습니다",
|
||||
"ExtensionsNoMatches": "‘{query}’와 일치하는 항목이 없습니다",
|
||||
"ExtensionsPluginDetail": "{inventory} · 신뢰: {trust} · 호환성: {compatibility} · 진단: {diagnostics}",
|
||||
"ExtensionsProductChromeDescription": "공식 MCP를 통해 로컬 Chrome 세션을 검사하고 제어합니다.",
|
||||
@@ -164,12 +164,13 @@
|
||||
"ExtensionsTabHooks": "훅",
|
||||
"ExtensionsTabMarketplace": "마켓플레이스",
|
||||
"ExtensionsTabMarketplaceCompact": "마켓",
|
||||
"ExtensionsTabPlugins": "플러그인",
|
||||
"ExtensionsTabInstalled": "설치됨",
|
||||
"ExtensionsTabConnectors": "커넥터",
|
||||
"ExtensionsTierCommunity": "커뮤니티",
|
||||
"ExtensionsTierCurated": "선별됨",
|
||||
"ExtensionsTierOfficial": "공식",
|
||||
"ExtensionsTierPartner": "파트너",
|
||||
"ExtensionsTitle": "확장 기능",
|
||||
"ExtensionsTitle": "플러그인",
|
||||
"ExtensionsTrustCapabilitiesChanged": "기능 변경됨",
|
||||
"ExtensionsTrustContentChanged": "내용 변경됨",
|
||||
"ExtensionsTrustNotReviewed": "검토되지 않음",
|
||||
@@ -504,7 +505,7 @@
|
||||
"CmdOverviewDescription": "통계와 빠른 작업이 있는 홈 대시보드를 표시합니다",
|
||||
"HomeBackToConversation": "대화로 돌아가기",
|
||||
"HomeNavigationBusy": "홈을 열기 전에 현재 작업을 완료하거나 중지하세요.",
|
||||
"CmdHooksDescription": "확장 기능에서 수명 주기 훅 관리",
|
||||
"CmdHooksDescription": "플러그인에서 수명 주기 훅 관리",
|
||||
"CmdAgentDescription": "지속적인 서브 에이전트 세션을 엽니다",
|
||||
"CmdGoalDescription": "여러 턴에 걸쳐 하나의 목표를 계속 진행합니다",
|
||||
"CmdInitDescription": "프로젝트용 AGENTS.md를 생성합니다",
|
||||
|
||||
@@ -101,7 +101,7 @@
|
||||
"ExtensionsActionManage": "gerenciar",
|
||||
"ExtensionsActionFocus": "foco",
|
||||
"ExtensionsActionFold": "recolher",
|
||||
"ExtensionsActionTabs": "abas",
|
||||
"ExtensionsActionTabs": "filtros",
|
||||
"ExtensionsCompatibilityFull": "total",
|
||||
"ExtensionsCompatibilityPartial": "parcial",
|
||||
"ExtensionsGroupBuiltIn": "Integrados",
|
||||
@@ -132,7 +132,7 @@
|
||||
"ExtensionsMcpNotConnectedYet": "Ainda não conectado nesta sessão. Conecta quando uma ferramenta for necessária, ou conecte agora.",
|
||||
"ExtensionsMcpRefresh": "{count} servidores MCP configurados; abra /mcp para atualizar este retrato",
|
||||
"ExtensionsMcpSummary": "{transport} · ferramentas: {tools} · recursos: {resources}",
|
||||
"ExtensionsNoItems": "Nenhum item nesta aba",
|
||||
"ExtensionsNoItems": "Nenhum item neste filtro",
|
||||
"ExtensionsNoMatches": "Nenhuma correspondência para “{query}”",
|
||||
"ExtensionsPluginDetail": "{inventory} · confiança: {trust} · compatibilidade: {compatibility} · diagnósticos: {diagnostics}",
|
||||
"ExtensionsProductChromeDescription": "Inspecione e controle uma sessão local do Chrome pelo MCP oficial.",
|
||||
@@ -164,12 +164,13 @@
|
||||
"ExtensionsTabHooks": "Ganchos",
|
||||
"ExtensionsTabMarketplace": "Marketplace",
|
||||
"ExtensionsTabMarketplaceCompact": "Mercado",
|
||||
"ExtensionsTabPlugins": "Plugins",
|
||||
"ExtensionsTabInstalled": "Instalados",
|
||||
"ExtensionsTabConnectors": "Conectores",
|
||||
"ExtensionsTierCommunity": "comunidade",
|
||||
"ExtensionsTierCurated": "selecionado",
|
||||
"ExtensionsTierOfficial": "oficial",
|
||||
"ExtensionsTierPartner": "parceiro",
|
||||
"ExtensionsTitle": "Extensões",
|
||||
"ExtensionsTitle": "Plugins",
|
||||
"ExtensionsTrustCapabilitiesChanged": "capacidades alteradas",
|
||||
"ExtensionsTrustContentChanged": "conteúdo alterado",
|
||||
"ExtensionsTrustNotReviewed": "não revisado",
|
||||
@@ -504,7 +505,7 @@
|
||||
"CmdOverviewDescription": "Exibir o painel inicial com estatísticas e ações rápidas",
|
||||
"HomeBackToConversation": "Voltar à conversa",
|
||||
"HomeNavigationBusy": "Conclua ou pare o trabalho atual antes de abrir o início.",
|
||||
"CmdHooksDescription": "Gerenciar hooks de ciclo de vida em Extensões",
|
||||
"CmdHooksDescription": "Gerenciar hooks de ciclo de vida em Plugins",
|
||||
"CmdAgentDescription": "Abrir uma sessão persistente de sub-agente",
|
||||
"CmdGoalDescription": "Continuar um único objetivo ao longo de vários turnos",
|
||||
"CmdInitDescription": "Gerar AGENTS.md para o projeto",
|
||||
|
||||
@@ -101,7 +101,7 @@
|
||||
"ExtensionsActionManage": "управлять",
|
||||
"ExtensionsActionFocus": "фокус",
|
||||
"ExtensionsActionFold": "свернуть",
|
||||
"ExtensionsActionTabs": "вкладки",
|
||||
"ExtensionsActionTabs": "фильтры",
|
||||
"ExtensionsCompatibilityFull": "полная",
|
||||
"ExtensionsCompatibilityPartial": "частичная",
|
||||
"ExtensionsGroupBuiltIn": "Встроенные",
|
||||
@@ -132,7 +132,7 @@
|
||||
"ExtensionsMcpNotConnectedYet": "В этом сеансе ещё не подключён. Подключится, когда понадобится инструмент, или подключите сейчас.",
|
||||
"ExtensionsMcpRefresh": "Настроено серверов MCP: {count}; откройте /mcp, чтобы обновить снимок",
|
||||
"ExtensionsMcpSummary": "{transport} · инструменты: {tools} · ресурсы: {resources}",
|
||||
"ExtensionsNoItems": "На этой вкладке нет элементов",
|
||||
"ExtensionsNoItems": "В этом фильтре нет элементов",
|
||||
"ExtensionsNoMatches": "Нет совпадений для «{query}»",
|
||||
"ExtensionsPluginDetail": "{inventory} · доверие: {trust} · совместимость: {compatibility} · диагностика: {diagnostics}",
|
||||
"ExtensionsProductChromeDescription": "Просмотр и управление локальным сеансом Chrome через официальный MCP.",
|
||||
@@ -164,12 +164,13 @@
|
||||
"ExtensionsTabHooks": "Хуки",
|
||||
"ExtensionsTabMarketplace": "Магазин",
|
||||
"ExtensionsTabMarketplaceCompact": "Магазин",
|
||||
"ExtensionsTabPlugins": "Плагины",
|
||||
"ExtensionsTabInstalled": "Установленные",
|
||||
"ExtensionsTabConnectors": "Коннекторы",
|
||||
"ExtensionsTierCommunity": "сообщество",
|
||||
"ExtensionsTierCurated": "отобрано",
|
||||
"ExtensionsTierOfficial": "официальный",
|
||||
"ExtensionsTierPartner": "партнёр",
|
||||
"ExtensionsTitle": "Расширения",
|
||||
"ExtensionsTitle": "Плагины",
|
||||
"ExtensionsTrustCapabilitiesChanged": "возможности изменены",
|
||||
"ExtensionsTrustContentChanged": "содержимое изменено",
|
||||
"ExtensionsTrustNotReviewed": "не проверен",
|
||||
@@ -503,7 +504,7 @@
|
||||
"CmdOverviewDescription": "Показать домашнюю панель со статистикой и быстрыми действиями",
|
||||
"HomeBackToConversation": "Вернуться к разговору",
|
||||
"HomeNavigationBusy": "Завершите или остановите текущую работу, прежде чем открыть главную.",
|
||||
"CmdHooksDescription": "Управление хуками жизненного цикла в расширениях",
|
||||
"CmdHooksDescription": "Управление хуками жизненного цикла в плагинах",
|
||||
"CmdAgentDescription": "Открыть постоянную сессию субагента",
|
||||
"CmdGoalDescription": "Продолжать одну цель на протяжении нескольких ходов",
|
||||
"CmdInitDescription": "Сгенерировать AGENTS.md для проекта",
|
||||
|
||||
@@ -101,7 +101,7 @@
|
||||
"ExtensionsActionManage": "керувати",
|
||||
"ExtensionsActionFocus": "фокус",
|
||||
"ExtensionsActionFold": "згорнути",
|
||||
"ExtensionsActionTabs": "вкладки",
|
||||
"ExtensionsActionTabs": "фільтри",
|
||||
"ExtensionsCompatibilityFull": "повна",
|
||||
"ExtensionsCompatibilityPartial": "часткова",
|
||||
"ExtensionsGroupBuiltIn": "Вбудовані",
|
||||
@@ -132,7 +132,7 @@
|
||||
"ExtensionsMcpNotConnectedYet": "У цьому сеансі ще не підключено. Підключиться, коли знадобиться інструмент, або підключіть зараз.",
|
||||
"ExtensionsMcpRefresh": "Налаштовано серверів MCP: {count}; відкрийте /mcp, щоб оновити знімок",
|
||||
"ExtensionsMcpSummary": "{transport} · інструменти: {tools} · ресурси: {resources}",
|
||||
"ExtensionsNoItems": "На цій вкладці немає елементів",
|
||||
"ExtensionsNoItems": "У цьому фільтрі немає елементів",
|
||||
"ExtensionsNoMatches": "Немає збігів для «{query}»",
|
||||
"ExtensionsPluginDetail": "{inventory} · довіра: {trust} · сумісність: {compatibility} · діагностика: {diagnostics}",
|
||||
"ExtensionsProductChromeDescription": "Перегляд і керування локальним сеансом Chrome через офіційний MCP.",
|
||||
@@ -164,12 +164,13 @@
|
||||
"ExtensionsTabHooks": "Хуки",
|
||||
"ExtensionsTabMarketplace": "Магазин",
|
||||
"ExtensionsTabMarketplaceCompact": "Магазин",
|
||||
"ExtensionsTabPlugins": "Плагіни",
|
||||
"ExtensionsTabInstalled": "Встановлені",
|
||||
"ExtensionsTabConnectors": "Конектори",
|
||||
"ExtensionsTierCommunity": "спільнота",
|
||||
"ExtensionsTierCurated": "відібрано",
|
||||
"ExtensionsTierOfficial": "офіційний",
|
||||
"ExtensionsTierPartner": "партнер",
|
||||
"ExtensionsTitle": "Розширення",
|
||||
"ExtensionsTitle": "Плагіни",
|
||||
"ExtensionsTrustCapabilitiesChanged": "можливості змінено",
|
||||
"ExtensionsTrustContentChanged": "вміст змінено",
|
||||
"ExtensionsTrustNotReviewed": "не перевірено",
|
||||
@@ -503,7 +504,7 @@
|
||||
"CmdOverviewDescription": "Показати домашню панель зі статистикою та швидкими діями",
|
||||
"HomeBackToConversation": "Повернутися до розмови",
|
||||
"HomeNavigationBusy": "Завершіть або зупиніть поточну роботу, перш ніж відкрити головну.",
|
||||
"CmdHooksDescription": "Керування хуками життєвого циклу в розширеннях",
|
||||
"CmdHooksDescription": "Керування хуками життєвого циклу в плагінах",
|
||||
"CmdAgentDescription": "Відкрити постійний сеанс субагента",
|
||||
"CmdGoalDescription": "Продовжувати одну ціль упродовж кількох ходів",
|
||||
"CmdInitDescription": "Згенерувати AGENTS.md для проєкту",
|
||||
|
||||
@@ -101,7 +101,7 @@
|
||||
"ExtensionsActionManage": "quản lý",
|
||||
"ExtensionsActionFocus": "tiêu điểm",
|
||||
"ExtensionsActionFold": "thu gọn",
|
||||
"ExtensionsActionTabs": "thẻ",
|
||||
"ExtensionsActionTabs": "bộ lọc",
|
||||
"ExtensionsCompatibilityFull": "đầy đủ",
|
||||
"ExtensionsCompatibilityPartial": "một phần",
|
||||
"ExtensionsGroupBuiltIn": "Tích hợp sẵn",
|
||||
@@ -132,7 +132,7 @@
|
||||
"ExtensionsMcpNotConnectedYet": "Chưa kết nối trong phiên này. Sẽ kết nối khi cần đến công cụ, hoặc kết nối ngay bây giờ.",
|
||||
"ExtensionsMcpRefresh": "Đã cấu hình {count} máy chủ MCP; mở /mcp để làm mới ảnh chụp này",
|
||||
"ExtensionsMcpSummary": "{transport} · công cụ: {tools} · tài nguyên: {resources}",
|
||||
"ExtensionsNoItems": "Không có mục nào trong thẻ này",
|
||||
"ExtensionsNoItems": "Không có mục nào trong bộ lọc này",
|
||||
"ExtensionsNoMatches": "Không có kết quả khớp “{query}”",
|
||||
"ExtensionsPluginDetail": "{inventory} · độ tin cậy: {trust} · tính tương thích: {compatibility} · chẩn đoán: {diagnostics}",
|
||||
"ExtensionsProductChromeDescription": "Kiểm tra và điều khiển phiên Chrome cục bộ qua MCP chính thức.",
|
||||
@@ -164,12 +164,13 @@
|
||||
"ExtensionsTabHooks": "Hook",
|
||||
"ExtensionsTabMarketplace": "Chợ",
|
||||
"ExtensionsTabMarketplaceCompact": "Chợ",
|
||||
"ExtensionsTabPlugins": "Plugin",
|
||||
"ExtensionsTabInstalled": "Đã cài",
|
||||
"ExtensionsTabConnectors": "Trình kết nối",
|
||||
"ExtensionsTierCommunity": "cộng đồng",
|
||||
"ExtensionsTierCurated": "được tuyển chọn",
|
||||
"ExtensionsTierOfficial": "chính thức",
|
||||
"ExtensionsTierPartner": "đối tác",
|
||||
"ExtensionsTitle": "Tiện ích mở rộng",
|
||||
"ExtensionsTitle": "Plugin",
|
||||
"ExtensionsTrustCapabilitiesChanged": "khả năng đã thay đổi",
|
||||
"ExtensionsTrustContentChanged": "nội dung đã thay đổi",
|
||||
"ExtensionsTrustNotReviewed": "chưa xem xét",
|
||||
@@ -504,7 +505,7 @@
|
||||
"CmdOverviewDescription": "Hiển thị bảng điều khiển trang chủ với số liệu thống kê và hành động nhanh",
|
||||
"HomeBackToConversation": "Quay lại cuộc trò chuyện",
|
||||
"HomeNavigationBusy": "Hoàn tất hoặc dừng công việc hiện tại trước khi mở trang chủ.",
|
||||
"CmdHooksDescription": "Quản lý hook vòng đời trong Tiện ích mở rộng",
|
||||
"CmdHooksDescription": "Quản lý hook vòng đời trong Plugin",
|
||||
"CmdAgentDescription": "Mở một phiên sub-agent nền",
|
||||
"CmdGoalDescription": "Tiếp tục một mục tiêu xuyên suốt nhiều lượt",
|
||||
"CmdInitDescription": "Tạo tệp AGENTS.md cho dự án",
|
||||
|
||||
@@ -101,7 +101,7 @@
|
||||
"ExtensionsActionManage": "管理",
|
||||
"ExtensionsActionFocus": "焦点",
|
||||
"ExtensionsActionFold": "折叠",
|
||||
"ExtensionsActionTabs": "标签页",
|
||||
"ExtensionsActionTabs": "筛选",
|
||||
"ExtensionsCompatibilityFull": "完全",
|
||||
"ExtensionsCompatibilityPartial": "部分",
|
||||
"ExtensionsGroupBuiltIn": "内置",
|
||||
@@ -132,7 +132,7 @@
|
||||
"ExtensionsMcpNotConnectedYet": "此会话中尚未连接。需要用到工具时会自动连接,也可以现在连接。",
|
||||
"ExtensionsMcpRefresh": "已配置 {count} 个 MCP 服务器;打开 /mcp 刷新此快照",
|
||||
"ExtensionsMcpSummary": "{transport} · 工具:{tools} · 资源:{resources}",
|
||||
"ExtensionsNoItems": "此标签页没有项目",
|
||||
"ExtensionsNoItems": "此筛选下没有项目",
|
||||
"ExtensionsNoMatches": "没有与“{query}”匹配的项目",
|
||||
"ExtensionsPluginDetail": "{inventory} · 信任:{trust} · 兼容性:{compatibility} · 诊断:{diagnostics}",
|
||||
"ExtensionsProductChromeDescription": "通过官方 MCP 检查和控制本地 Chrome 会话。",
|
||||
@@ -164,12 +164,13 @@
|
||||
"ExtensionsTabHooks": "钩子",
|
||||
"ExtensionsTabMarketplace": "插件市场",
|
||||
"ExtensionsTabMarketplaceCompact": "市场",
|
||||
"ExtensionsTabPlugins": "插件",
|
||||
"ExtensionsTabInstalled": "已安装",
|
||||
"ExtensionsTabConnectors": "连接器",
|
||||
"ExtensionsTierCommunity": "社区",
|
||||
"ExtensionsTierCurated": "精选",
|
||||
"ExtensionsTierOfficial": "官方",
|
||||
"ExtensionsTierPartner": "合作伙伴",
|
||||
"ExtensionsTitle": "扩展",
|
||||
"ExtensionsTitle": "插件",
|
||||
"ExtensionsTrustCapabilitiesChanged": "功能已更改",
|
||||
"ExtensionsTrustContentChanged": "内容已更改",
|
||||
"ExtensionsTrustNotReviewed": "未审核",
|
||||
@@ -504,7 +505,7 @@
|
||||
"CmdOverviewDescription": "显示主页面板,含统计与快捷操作",
|
||||
"HomeBackToConversation": "返回对话",
|
||||
"HomeNavigationBusy": "请先完成或停止当前工作,再打开主页。",
|
||||
"CmdHooksDescription": "在扩展中管理生命周期钩子",
|
||||
"CmdHooksDescription": "在插件中管理生命周期钩子",
|
||||
"CmdAgentDescription": "打开持久的智能体会话",
|
||||
"CmdGoalDescription": "跨多回合持续推进同一个目标",
|
||||
"CmdInitDescription": "为项目生成 AGENTS.md",
|
||||
|
||||
@@ -101,7 +101,7 @@
|
||||
"ExtensionsActionManage": "管理",
|
||||
"ExtensionsActionFocus": "焦點",
|
||||
"ExtensionsActionFold": "摺疊",
|
||||
"ExtensionsActionTabs": "分頁",
|
||||
"ExtensionsActionTabs": "篩選",
|
||||
"ExtensionsCompatibilityFull": "完整",
|
||||
"ExtensionsCompatibilityPartial": "部分",
|
||||
"ExtensionsGroupBuiltIn": "內建",
|
||||
@@ -132,7 +132,7 @@
|
||||
"ExtensionsMcpNotConnectedYet": "此工作階段中尚未連線。需要用到工具時會自動連線,也可以現在連線。",
|
||||
"ExtensionsMcpRefresh": "已設定 {count} 個 MCP 伺服器;開啟 /mcp 重新整理此快照",
|
||||
"ExtensionsMcpSummary": "{transport} · 工具:{tools} · 資源:{resources}",
|
||||
"ExtensionsNoItems": "此分頁沒有項目",
|
||||
"ExtensionsNoItems": "此篩選下沒有項目",
|
||||
"ExtensionsNoMatches": "沒有符合「{query}」的項目",
|
||||
"ExtensionsPluginDetail": "{inventory} · 信任:{trust} · 相容性:{compatibility} · 診斷:{diagnostics}",
|
||||
"ExtensionsProductChromeDescription": "透過官方 MCP 檢查並控制本機 Chrome 工作階段。",
|
||||
@@ -164,12 +164,13 @@
|
||||
"ExtensionsTabHooks": "掛鉤",
|
||||
"ExtensionsTabMarketplace": "外掛市集",
|
||||
"ExtensionsTabMarketplaceCompact": "市集",
|
||||
"ExtensionsTabPlugins": "外掛",
|
||||
"ExtensionsTabInstalled": "已安裝",
|
||||
"ExtensionsTabConnectors": "連接器",
|
||||
"ExtensionsTierCommunity": "社群",
|
||||
"ExtensionsTierCurated": "精選",
|
||||
"ExtensionsTierOfficial": "官方",
|
||||
"ExtensionsTierPartner": "合作夥伴",
|
||||
"ExtensionsTitle": "擴充功能",
|
||||
"ExtensionsTitle": "外掛",
|
||||
"ExtensionsTrustCapabilitiesChanged": "功能已變更",
|
||||
"ExtensionsTrustContentChanged": "內容已變更",
|
||||
"ExtensionsTrustNotReviewed": "未審查",
|
||||
@@ -502,7 +503,7 @@
|
||||
"CmdOverviewDescription": "顯示主頁面板,含統計與快捷操作",
|
||||
"HomeBackToConversation": "返回對話",
|
||||
"HomeNavigationBusy": "請先完成或停止目前工作,再開啟首頁。",
|
||||
"CmdHooksDescription": "在擴充功能中管理生命週期掛鉤",
|
||||
"CmdHooksDescription": "在外掛中管理生命週期掛鉤",
|
||||
"CmdAgentDescription": "開啟持久的代理工作階段",
|
||||
"CmdGoalDescription": "跨多回合持續推進同一個目標",
|
||||
"CmdInitDescription": "為專案產生 AGENTS.md",
|
||||
|
||||
@@ -711,10 +711,11 @@ pub enum MessageId {
|
||||
ExtensionsStateUnstaged,
|
||||
ExtensionsStateUnsupported,
|
||||
ExtensionsStateWarning,
|
||||
ExtensionsTabConnectors,
|
||||
ExtensionsTabHooks,
|
||||
ExtensionsTabInstalled,
|
||||
ExtensionsTabMarketplace,
|
||||
ExtensionsTabMarketplaceCompact,
|
||||
ExtensionsTabPlugins,
|
||||
ExtensionsTierCommunity,
|
||||
ExtensionsTierCurated,
|
||||
ExtensionsTierOfficial,
|
||||
@@ -3495,10 +3496,11 @@ pub const ALL_MESSAGE_IDS: &[MessageId] = &[
|
||||
MessageId::ExtensionsStateUnstaged,
|
||||
MessageId::ExtensionsStateUnsupported,
|
||||
MessageId::ExtensionsStateWarning,
|
||||
MessageId::ExtensionsTabConnectors,
|
||||
MessageId::ExtensionsTabHooks,
|
||||
MessageId::ExtensionsTabInstalled,
|
||||
MessageId::ExtensionsTabMarketplace,
|
||||
MessageId::ExtensionsTabMarketplaceCompact,
|
||||
MessageId::ExtensionsTabPlugins,
|
||||
MessageId::ExtensionsTierCommunity,
|
||||
MessageId::ExtensionsTierCurated,
|
||||
MessageId::ExtensionsTierOfficial,
|
||||
@@ -7108,7 +7110,7 @@ mod tests {
|
||||
.filter(|key| key.starts_with("Extensions"))
|
||||
.cloned()
|
||||
.collect::<Vec<_>>();
|
||||
assert_eq!(keys.len(), 98, "the complete extensions locale set changed");
|
||||
assert_eq!(keys.len(), 99, "the complete extensions locale set changed");
|
||||
|
||||
let prose_keys = [
|
||||
"ExtensionsMcpEmpty",
|
||||
|
||||
@@ -10,6 +10,9 @@ description = "Codewhale outbound network policy, guarded destinations and proxy
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
[features]
|
||||
test-support = []
|
||||
|
||||
[dependencies]
|
||||
anyhow.workspace = true
|
||||
chrono.workspace = true
|
||||
|
||||
+867
-78
File diff suppressed because it is too large.
Load diff
@@ -309,6 +309,24 @@ impl NetworkPolicy {
|
||||
self.default.into()
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn explicitly_allows(&self, host: &str) -> bool {
|
||||
let normalized = normalize_host(host);
|
||||
if normalized.is_empty() {
|
||||
return false;
|
||||
}
|
||||
if self
|
||||
.deny
|
||||
.iter()
|
||||
.any(|entry| host_matches(entry, &normalized))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
self.allow
|
||||
.iter()
|
||||
.any(|entry| host_matches(entry, &normalized))
|
||||
}
|
||||
|
||||
/// Append `host` to the allow list (de-duplicated, case-insensitive).
|
||||
/// Used by the prompt flow when the user picks "always for this host".
|
||||
pub fn add_allow(&mut self, host: &str) {
|
||||
@@ -360,10 +378,16 @@ impl NetworkPolicy {
|
||||
fn normalize_host(host: &str) -> String {
|
||||
let trimmed = host.trim().trim_end_matches('.').to_ascii_lowercase();
|
||||
if let Some(rest) = trimmed.strip_prefix("*.") {
|
||||
format!(".{rest}")
|
||||
} else {
|
||||
trimmed
|
||||
return format!(".{rest}");
|
||||
}
|
||||
let unbracketed = trimmed
|
||||
.strip_prefix('[')
|
||||
.and_then(|rest| rest.strip_suffix(']'))
|
||||
.unwrap_or(&trimmed);
|
||||
if let Ok(v6) = unbracketed.parse::<std::net::Ipv6Addr>() {
|
||||
return v6.to_string();
|
||||
}
|
||||
trimmed
|
||||
}
|
||||
|
||||
/// Match a single allow/deny entry against an already-normalized host.
|
||||
@@ -372,7 +396,7 @@ fn host_matches(entry: &str, normalized_host: &str) -> bool {
|
||||
if let Some(suffix) = entry_norm.strip_prefix('.') {
|
||||
// Wildcard subdomain rule. Match any host ending in `.suffix`, but
|
||||
// *not* the bare `suffix` itself (per spec).
|
||||
if suffix.is_empty() {
|
||||
if suffix.is_empty() || normalized_host.parse::<IpAddr>().is_ok() {
|
||||
return false;
|
||||
}
|
||||
normalized_host.ends_with(&format!(".{suffix}"))
|
||||
@@ -837,6 +861,29 @@ mod tests {
|
||||
assert_eq!(p.decide("api.example.com"), Decision::Deny);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deny_wins_across_ipv6_spellings() {
|
||||
for (allow, deny) in [
|
||||
("[fd00::1]", "fd00::1"),
|
||||
("fd00::1", "[fd00::1]"),
|
||||
("[fd00:0::1]", "fd00::1"),
|
||||
("[::1]", "0:0:0:0:0:0:0:1"),
|
||||
] {
|
||||
let p = mk(Decision::Prompt, &[allow], &[deny]);
|
||||
for probe in [allow, deny] {
|
||||
assert_eq!(
|
||||
p.decide(probe),
|
||||
Decision::Deny,
|
||||
"{allow} vs {deny} on {probe}"
|
||||
);
|
||||
assert!(
|
||||
!p.explicitly_allows(probe),
|
||||
"{allow} vs {deny} must not explicitly allow {probe}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deny_wins_with_subdomain_rules() {
|
||||
// Deny-wins applies even when the deny is a wildcard and the allow is exact.
|
||||
@@ -860,6 +907,15 @@ mod tests {
|
||||
assert_eq!(p.decide("example.com"), Decision::Deny);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wildcard_rules_never_match_ip_literals() {
|
||||
let p = mk(Decision::Prompt, &[".1", "*.0.0.1", ".0.0.1"], &[]);
|
||||
for ip in ["127.0.0.1", "10.0.0.1", "192.168.1.1", "::1"] {
|
||||
assert!(!p.explicitly_allows(ip), "{ip} must not match wildcard");
|
||||
assert_eq!(p.decide(ip), Decision::Prompt, "{ip} must not be allowed");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn host_match_is_case_insensitive() {
|
||||
let p = mk(Decision::Deny, &["API.DeepSeek.com"], &[]);
|
||||
|
||||
@@ -182,7 +182,7 @@ impl PluginDiscoveryContext {
|
||||
super::discovery::default_workspace_plugins_dir(workspace),
|
||||
];
|
||||
roots.extend(self.builtin_plugin_dirs.iter().cloned());
|
||||
super::discovery::PluginCatalogStamp::capture(roots)
|
||||
super::discovery::PluginCatalogStamp::capture(roots, &self.state_path)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -27,20 +27,40 @@ pub struct DiscoveryConfig {
|
||||
pub state_path: PathBuf,
|
||||
}
|
||||
|
||||
/// Cheap on-disk catalog fingerprint: plugin-bundle directories only.
|
||||
/// Cheap on-disk catalog fingerprint: plugin-bundle directories plus the
|
||||
/// content of the plugin state file.
|
||||
///
|
||||
/// Used to nudge `/plugin reload` when a bundle appears, disappears, or is
|
||||
/// rewritten without consulting process environment or auto-applying trust.
|
||||
/// `state.json` writes are ignored so enable/trust does not look like a
|
||||
/// catalog change.
|
||||
/// Used to apply or nudge a reload when a bundle appears, disappears, or is
|
||||
/// rewritten, or when another process installs, enables or disables a plugin.
|
||||
/// The state file is compared by content digest, never by mtime, so a rewrite
|
||||
/// with identical bytes is not a change and a same-second rewrite is.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Default)]
|
||||
pub struct PluginCatalogStamp {
|
||||
entries: BTreeMap<PathBuf, Option<SystemTime>>,
|
||||
state: Option<[u8; 32]>,
|
||||
}
|
||||
|
||||
const MAX_STAMPED_FILE_BYTES: u64 = 8 * 1024 * 1024;
|
||||
|
||||
/// SHA-256 of a small file's bytes, or `None` when it is absent, unreadable or
|
||||
/// larger than the stamp bound.
|
||||
#[must_use]
|
||||
pub fn file_content_digest(path: &Path) -> Option<[u8; 32]> {
|
||||
use std::io::Read;
|
||||
let file = fs::File::open(path).ok()?;
|
||||
let mut bytes = Vec::new();
|
||||
file.take(MAX_STAMPED_FILE_BYTES + 1)
|
||||
.read_to_end(&mut bytes)
|
||||
.ok()?;
|
||||
if bytes.len() as u64 > MAX_STAMPED_FILE_BYTES {
|
||||
return None;
|
||||
}
|
||||
Some(Sha256::digest(&bytes).into())
|
||||
}
|
||||
|
||||
impl PluginCatalogStamp {
|
||||
#[must_use]
|
||||
pub fn capture(roots: impl IntoIterator<Item = PathBuf>) -> Self {
|
||||
pub fn capture(roots: impl IntoIterator<Item = PathBuf>, state_path: &Path) -> Self {
|
||||
let mut entries = BTreeMap::new();
|
||||
for root in roots {
|
||||
let Ok(read) = fs::read_dir(&root) else {
|
||||
@@ -57,7 +77,14 @@ impl PluginCatalogStamp {
|
||||
entries.insert(path, metadata.modified().ok());
|
||||
}
|
||||
}
|
||||
Self { entries }
|
||||
Self {
|
||||
entries,
|
||||
state: file_content_digest(state_path),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn refresh_state(&mut self, state_path: &Path) {
|
||||
self.state = file_content_digest(state_path);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -2903,6 +2903,76 @@ fn valid_plugin_name(name: &str) -> bool {
|
||||
&& !name.contains("--")
|
||||
}
|
||||
|
||||
const NPM_REGISTRY: &str = "https://registry.npmjs.org";
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct NpmInstallPlan {
|
||||
pub spec: String,
|
||||
pub plugin_name: String,
|
||||
}
|
||||
|
||||
pub fn plan_npm_install(
|
||||
package: &str,
|
||||
version: &str,
|
||||
registry: Option<&str>,
|
||||
) -> Result<NpmInstallPlan, String> {
|
||||
let file = package.rsplit('/').next().unwrap_or(package);
|
||||
let tarball = |base: &str| format!("{base}/{package}/-/{file}-{version}.tgz");
|
||||
let remote = |url: String| {
|
||||
super::PluginInstallSource::Remote(
|
||||
codewhale_skill_install::install::InstallSource::DirectUrl(url),
|
||||
)
|
||||
};
|
||||
let exact = format!("npm:{package}@{version}");
|
||||
if super::PluginInstallSource::parse(&exact).ok() != Some(remote(tarball(NPM_REGISTRY))) {
|
||||
return Err(format!(
|
||||
"`{}@{}` is not an exact npm package version Codewhale can fetch: the name must be a lowercase npm name with an optional @scope, and the version exact, canonical SemVer",
|
||||
refusal_text(package),
|
||||
refusal_text(version)
|
||||
));
|
||||
}
|
||||
let plugin_name = derived_plugin_name(package).ok_or_else(|| {
|
||||
format!(
|
||||
"npm package `{}` cannot form a native plugin name (1–64 lowercase letters/digits with single dots or hyphens), so its bundle could not be imported",
|
||||
refusal_text(package)
|
||||
)
|
||||
})?;
|
||||
let Some(registry) = registry else {
|
||||
return Ok(NpmInstallPlan {
|
||||
spec: exact,
|
||||
plugin_name,
|
||||
});
|
||||
};
|
||||
let url = reqwest::Url::parse(registry)
|
||||
.map_err(|_| "the declared npm registry is not an absolute URL".to_string())?;
|
||||
let host = refusal_text(url.host_str().unwrap_or_default());
|
||||
if url.scheme() != "https"
|
||||
|| url.host_str().is_none()
|
||||
|| !url.username().is_empty()
|
||||
|| url.password().is_some()
|
||||
|| url.query().is_some()
|
||||
|| url.fragment().is_some()
|
||||
{
|
||||
return Err(format!(
|
||||
"npm registry `{host}` must be an https URL without credentials, query or fragment; Codewhale fetches a registry tarball only over https"
|
||||
));
|
||||
}
|
||||
let base = url.as_str().trim_end_matches('/');
|
||||
if base == NPM_REGISTRY {
|
||||
return Ok(NpmInstallPlan {
|
||||
spec: exact,
|
||||
plugin_name,
|
||||
});
|
||||
}
|
||||
let spec = tarball(base);
|
||||
if super::PluginInstallSource::parse(&spec).ok() != Some(remote(spec.clone())) {
|
||||
return Err(format!(
|
||||
"npm registry `{host}` does not give a tarball address the reviewed installer accepts"
|
||||
));
|
||||
}
|
||||
Ok(NpmInstallPlan { spec, plugin_name })
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Entry point
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -86,6 +86,7 @@ use codewhale_skill_install::install::{
|
||||
pub mod dsh;
|
||||
mod place;
|
||||
mod stage;
|
||||
mod summary;
|
||||
mod tarball;
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -93,6 +94,7 @@ mod tests;
|
||||
|
||||
use place::{ensure_target_within_plugins_dir, finalize_install, plugin_target_path};
|
||||
use stage::stage_local_copy;
|
||||
pub use summary::{PluginSummary, PreviousInstall, SummaryClause, SummaryInput, build_summary};
|
||||
use tarball::stage_tarball;
|
||||
|
||||
/// Default per-bundle size cap. Mirrors the skill installer; the runtime
|
||||
|
||||
@@ -0,0 +1,865 @@
|
||||
//! One-line install summary shared by every install surface.
|
||||
//!
|
||||
//! A pure builder: everything it needs arrives in [`SummaryInput`], nothing is
|
||||
//! read from disk, the registry or the environment. Each clause carries a
|
||||
//! stable message id so a surface can localize the clause while the English
|
||||
//! text here stays the fallback.
|
||||
|
||||
use std::collections::BTreeSet;
|
||||
|
||||
use super::dsh::DshConversion;
|
||||
use crate::host_services::{HostService, HostServiceReview, PluginApprover, approver_label};
|
||||
use crate::manifest::PluginInventory;
|
||||
|
||||
pub const MSG_HEAD: &str = "plugin.summary.head";
|
||||
pub const MSG_STATE_ON: &str = "plugin.summary.state_on";
|
||||
pub const MSG_STATE_OFF: &str = "plugin.summary.state_off";
|
||||
pub const MSG_APPROVER: &str = "plugin.summary.approver";
|
||||
pub const MSG_ADDS: &str = "plugin.summary.adds";
|
||||
pub const MSG_ADDS_NOTHING_NEW: &str = "plugin.summary.adds_nothing_new";
|
||||
pub const MSG_NOW_ALSO_ADDS: &str = "plugin.summary.now_also_adds";
|
||||
pub const MSG_WONT_RUN: &str = "plugin.summary.wont_run";
|
||||
pub const MSG_NEEDS_EXTENSION_HOST: &str = "plugin.summary.needs_extension_host";
|
||||
pub const MSG_NEEDS_NODE: &str = "plugin.summary.needs_node";
|
||||
pub const MSG_REPLACES: &str = "plugin.summary.replaces";
|
||||
pub const MSG_DOWNGRADED: &str = "plugin.summary.downgraded";
|
||||
pub const MSG_SERVICES: &str = "plugin.summary.services";
|
||||
pub const MSG_SERVICES_NOW_ALSO: &str = "plugin.summary.services_now_also";
|
||||
pub const MSG_SERVICES_WITHHELD: &str = "plugin.summary.services_withheld";
|
||||
pub const MSG_SERVICES_UNOFFERED: &str = "plugin.summary.services_unoffered";
|
||||
pub const MSG_SERVICES_PAUSE: &str = "plugin.summary.services_pause";
|
||||
pub const MSG_PROVIDER_NEW_SESSION: &str = "plugin.summary.provider_new_session";
|
||||
|
||||
const HOST_LIST_LIMIT: usize = 3;
|
||||
const VERSION_GATE_REASON: &str = "incompatible-version";
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct SummaryClause {
|
||||
pub id: &'static str,
|
||||
pub text: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct PluginSummary {
|
||||
pub clauses: Vec<SummaryClause>,
|
||||
}
|
||||
|
||||
impl PluginSummary {
|
||||
#[must_use]
|
||||
pub fn line(&self) -> String {
|
||||
let mut clauses = self.clauses.iter();
|
||||
let Some(head) = clauses.next() else {
|
||||
return String::new();
|
||||
};
|
||||
let rest: Vec<&str> = clauses.map(|clause| clause.text.as_str()).collect();
|
||||
if rest.is_empty() {
|
||||
head.text.clone()
|
||||
} else {
|
||||
format!("{}: {}", head.text, rest.join("; "))
|
||||
}
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn ids(&self) -> Vec<&'static str> {
|
||||
self.clauses.iter().map(|clause| clause.id).collect()
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct PreviousInstall<'a> {
|
||||
pub name: &'a str,
|
||||
pub version: &'a str,
|
||||
pub source: Option<&'a str>,
|
||||
pub inventory: &'a PluginInventory,
|
||||
pub host_services: &'a [HostService],
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct SummaryInput<'a> {
|
||||
pub name: &'a str,
|
||||
pub version: &'a str,
|
||||
pub source: Option<&'a str>,
|
||||
pub enabled: bool,
|
||||
pub approver: Option<&'a PluginApprover>,
|
||||
pub inventory: &'a PluginInventory,
|
||||
pub dsh: Option<&'a DshConversion>,
|
||||
pub host_services: Option<&'a HostServiceReview>,
|
||||
pub extension_host_on: bool,
|
||||
pub previous: Option<PreviousInstall<'a>>,
|
||||
pub downgrade_allowed: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default, PartialEq, Eq)]
|
||||
struct Capabilities {
|
||||
skills: usize,
|
||||
local_mcp: usize,
|
||||
remote_mcp: usize,
|
||||
commands: usize,
|
||||
agents: usize,
|
||||
hooks: usize,
|
||||
lsp: usize,
|
||||
native: usize,
|
||||
providers: usize,
|
||||
panels: usize,
|
||||
filesystem_roots: BTreeSet<String>,
|
||||
network_hosts: BTreeSet<String>,
|
||||
lifecycle_mutation: bool,
|
||||
}
|
||||
|
||||
impl Capabilities {
|
||||
fn from_inventory(inventory: &PluginInventory) -> Self {
|
||||
let split = inventory.stdio_mcp_servers + inventory.remote_mcp_servers;
|
||||
let (local_mcp, remote_mcp) = if split == 0 {
|
||||
(inventory.mcp_servers, 0)
|
||||
} else {
|
||||
(
|
||||
inventory.stdio_mcp_servers + inventory.mcp_servers.saturating_sub(split),
|
||||
inventory.remote_mcp_servers,
|
||||
)
|
||||
};
|
||||
Self {
|
||||
skills: inventory.skills,
|
||||
local_mcp,
|
||||
remote_mcp,
|
||||
commands: inventory.commands,
|
||||
agents: inventory.agents,
|
||||
hooks: inventory.hooks,
|
||||
lsp: inventory.lsp,
|
||||
native: inventory.native,
|
||||
providers: inventory.providers,
|
||||
panels: 0,
|
||||
filesystem_roots: inventory.filesystem_roots.iter().cloned().collect(),
|
||||
network_hosts: inventory.network_hosts.iter().cloned().collect(),
|
||||
lifecycle_mutation: inventory.lifecycle_mutation,
|
||||
}
|
||||
}
|
||||
|
||||
fn with_dsh(mut self, dsh: &DshConversion) -> Self {
|
||||
self.skills = self.skills.max(dsh.skills.len());
|
||||
self.local_mcp = self.local_mcp.max(dsh.local_servers.len());
|
||||
self.remote_mcp = self.remote_mcp.max(dsh.remote_servers.len());
|
||||
self.native = self.native.max(dsh.native_rows.len());
|
||||
if dsh.requires_native && self.native == 0 {
|
||||
self.native = 1;
|
||||
}
|
||||
self.panels = self.panels.max(dsh.client_closures.len());
|
||||
self.network_hosts.extend(dsh.network_hosts.iter().cloned());
|
||||
self
|
||||
}
|
||||
|
||||
fn since(&self, old: &Self) -> Self {
|
||||
let more = |new: usize, old: usize| new.saturating_sub(old);
|
||||
Self {
|
||||
skills: more(self.skills, old.skills),
|
||||
local_mcp: more(self.local_mcp, old.local_mcp),
|
||||
remote_mcp: more(self.remote_mcp, old.remote_mcp),
|
||||
commands: more(self.commands, old.commands),
|
||||
agents: more(self.agents, old.agents),
|
||||
hooks: more(self.hooks, old.hooks),
|
||||
lsp: more(self.lsp, old.lsp),
|
||||
native: more(self.native, old.native),
|
||||
providers: more(self.providers, old.providers),
|
||||
panels: more(self.panels, old.panels),
|
||||
filesystem_roots: self
|
||||
.filesystem_roots
|
||||
.difference(&old.filesystem_roots)
|
||||
.cloned()
|
||||
.collect(),
|
||||
network_hosts: self
|
||||
.network_hosts
|
||||
.difference(&old.network_hosts)
|
||||
.cloned()
|
||||
.collect(),
|
||||
lifecycle_mutation: self.lifecycle_mutation && !old.lifecycle_mutation,
|
||||
}
|
||||
}
|
||||
|
||||
fn phrases(&self) -> Vec<String> {
|
||||
let mut phrases = Vec::new();
|
||||
let mut count = |n: usize, one: &str, many: &str| {
|
||||
if n > 0 {
|
||||
phrases.push(format!("{n} {}", if n == 1 { one } else { many }));
|
||||
}
|
||||
};
|
||||
count(self.skills, "skill", "skills");
|
||||
count(self.local_mcp, "local MCP server", "local MCP servers");
|
||||
count(self.remote_mcp, "remote MCP server", "remote MCP servers");
|
||||
count(self.commands, "command", "commands");
|
||||
count(self.agents, "agent profile", "agent profiles");
|
||||
count(self.hooks, "hook", "hooks");
|
||||
count(self.lsp, "language server", "language servers");
|
||||
count(self.native, "native extension", "native extensions");
|
||||
count(self.providers, "provider", "providers");
|
||||
count(self.panels, "panel", "panels");
|
||||
if !self.filesystem_roots.is_empty() {
|
||||
phrases.push(format!("file access ({})", limited(&self.filesystem_roots)));
|
||||
}
|
||||
if !self.network_hosts.is_empty() {
|
||||
phrases.push(format!("network access ({})", limited(&self.network_hosts)));
|
||||
}
|
||||
if self.lifecycle_mutation {
|
||||
phrases.push("permission to change plugins".to_string());
|
||||
}
|
||||
phrases
|
||||
}
|
||||
}
|
||||
|
||||
fn limited(items: &BTreeSet<String>) -> String {
|
||||
let shown: Vec<&str> = items
|
||||
.iter()
|
||||
.take(HOST_LIST_LIMIT)
|
||||
.map(String::as_str)
|
||||
.collect();
|
||||
let mut text = shown.join(", ");
|
||||
if items.len() > HOST_LIST_LIMIT {
|
||||
text.push_str(&format!(" and {} more", items.len() - HOST_LIST_LIMIT));
|
||||
}
|
||||
text
|
||||
}
|
||||
|
||||
fn and_list(items: &[String]) -> String {
|
||||
match items {
|
||||
[] => String::new(),
|
||||
[only] => only.clone(),
|
||||
[init @ .., last] => format!("{} and {last}", init.join(", ")),
|
||||
}
|
||||
}
|
||||
|
||||
fn rows(n: usize) -> String {
|
||||
format!("{n} {}", if n == 1 { "row" } else { "rows" })
|
||||
}
|
||||
|
||||
fn quoted(peers: &[crate::host_services::ReviewPeer]) -> String {
|
||||
peers
|
||||
.iter()
|
||||
.map(|peer| format!("`{}`", peer.name))
|
||||
.collect::<Vec<_>>()
|
||||
.join(", ")
|
||||
}
|
||||
|
||||
fn push(clauses: &mut Vec<SummaryClause>, id: &'static str, text: String) {
|
||||
clauses.push(SummaryClause { id, text });
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn build_summary(input: &SummaryInput<'_>) -> PluginSummary {
|
||||
let mut clauses = Vec::new();
|
||||
|
||||
let mut head = input.name.to_string();
|
||||
if !input.version.is_empty() {
|
||||
head.push(' ');
|
||||
head.push_str(input.version);
|
||||
}
|
||||
if let Some(source) = input.source.filter(|source| !source.is_empty()) {
|
||||
head.push_str(&format!(" from {source}"));
|
||||
}
|
||||
push(&mut clauses, MSG_HEAD, head);
|
||||
|
||||
if input.enabled {
|
||||
push(&mut clauses, MSG_STATE_ON, "on".to_string());
|
||||
} else {
|
||||
push(&mut clauses, MSG_STATE_OFF, "off".to_string());
|
||||
}
|
||||
push(
|
||||
&mut clauses,
|
||||
MSG_APPROVER,
|
||||
approver_label(input.approver).to_string(),
|
||||
);
|
||||
|
||||
if let Some(previous) = &input.previous {
|
||||
if input.downgrade_allowed && previous.version != input.version {
|
||||
push(
|
||||
&mut clauses,
|
||||
MSG_DOWNGRADED,
|
||||
format!("downgraded from {}", previous.version),
|
||||
);
|
||||
}
|
||||
if previous.source != input.source {
|
||||
let from = previous
|
||||
.source
|
||||
.filter(|source| !source.is_empty())
|
||||
.map_or_else(|| "an unrecorded source".to_string(), str::to_string);
|
||||
push(
|
||||
&mut clauses,
|
||||
MSG_REPLACES,
|
||||
format!("replaces {} from {from}", previous.name),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
let mut now = Capabilities::from_inventory(input.inventory);
|
||||
if let Some(dsh) = input.dsh {
|
||||
now = now.with_dsh(dsh);
|
||||
}
|
||||
match &input.previous {
|
||||
None => {
|
||||
let phrases = now.phrases();
|
||||
if !phrases.is_empty() {
|
||||
push(
|
||||
&mut clauses,
|
||||
MSG_ADDS,
|
||||
format!("adds {}", and_list(&phrases)),
|
||||
);
|
||||
}
|
||||
}
|
||||
Some(previous) => {
|
||||
let added = now
|
||||
.since(&Capabilities::from_inventory(previous.inventory))
|
||||
.phrases();
|
||||
if added.is_empty() {
|
||||
push(
|
||||
&mut clauses,
|
||||
MSG_ADDS_NOTHING_NEW,
|
||||
"adds nothing new".to_string(),
|
||||
);
|
||||
} else {
|
||||
push(
|
||||
&mut clauses,
|
||||
MSG_NOW_ALSO_ADDS,
|
||||
format!("now also adds {}", and_list(&added)),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(dsh) = input.dsh {
|
||||
let skipped: Vec<_> = dsh
|
||||
.outcomes
|
||||
.iter()
|
||||
.filter(|outcome| outcome.needs_manual_port())
|
||||
.collect();
|
||||
if !skipped.is_empty() {
|
||||
let gated = skipped
|
||||
.iter()
|
||||
.filter(|outcome| outcome.reason.starts_with(VERSION_GATE_REASON))
|
||||
.count();
|
||||
let manual = skipped.len() - gated;
|
||||
let reason = match (gated, manual) {
|
||||
(0, _) => "each needs a manual port".to_string(),
|
||||
(_, 0) => "refused by the host version gate".to_string(),
|
||||
_ => format!(
|
||||
"{gated} refused by the host version gate, {manual} with no portable form"
|
||||
),
|
||||
};
|
||||
push(
|
||||
&mut clauses,
|
||||
MSG_WONT_RUN,
|
||||
format!("won't run: {} ({reason})", rows(skipped.len())),
|
||||
);
|
||||
}
|
||||
if dsh.requires_node {
|
||||
push(
|
||||
&mut clauses,
|
||||
MSG_NEEDS_NODE,
|
||||
"needs Node.js for its local servers".to_string(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if now.native > 0 && !input.extension_host_on {
|
||||
push(
|
||||
&mut clauses,
|
||||
MSG_NEEDS_EXTENSION_HOST,
|
||||
"needs the extension host (off)".to_string(),
|
||||
);
|
||||
}
|
||||
|
||||
if let Some(review) = input.host_services {
|
||||
let granted = review.grants();
|
||||
let previously: &[HostService] = input
|
||||
.previous
|
||||
.as_ref()
|
||||
.map_or(&[], |previous| previous.host_services);
|
||||
let names = |services: Vec<HostService>| -> Vec<String> {
|
||||
services
|
||||
.into_iter()
|
||||
.map(|service| service.name().to_string())
|
||||
.collect()
|
||||
};
|
||||
match &input.previous {
|
||||
None => {
|
||||
if !granted.is_empty() {
|
||||
let mut text =
|
||||
format!("uses host services {}", and_list(&names(granted.clone())));
|
||||
let mut peers: Vec<_> = review
|
||||
.lines
|
||||
.iter()
|
||||
.filter(|line| line.offered)
|
||||
.flat_map(|line| line.shared_with.iter())
|
||||
.collect();
|
||||
peers.sort_by(|a, b| a.id.cmp(&b.id));
|
||||
peers.dedup_by(|a, b| a.id == b.id);
|
||||
if !peers.is_empty() {
|
||||
let peers: Vec<_> = peers.into_iter().cloned().collect();
|
||||
text.push_str(&format!(" (shared with {})", quoted(&peers)));
|
||||
}
|
||||
push(&mut clauses, MSG_SERVICES, text);
|
||||
}
|
||||
}
|
||||
Some(_) => {
|
||||
let fresh: Vec<_> = granted
|
||||
.iter()
|
||||
.copied()
|
||||
.filter(|service| !previously.contains(service))
|
||||
.collect();
|
||||
if !fresh.is_empty() {
|
||||
push(
|
||||
&mut clauses,
|
||||
MSG_SERVICES_NOW_ALSO,
|
||||
format!("now also uses host services {}", and_list(&names(fresh))),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
let withheld: Vec<String> = review
|
||||
.lines
|
||||
.iter()
|
||||
.filter(|line| line.offered && !line.withheld_by.is_empty())
|
||||
.map(|line| {
|
||||
format!(
|
||||
"{} ({} is turned on without it)",
|
||||
line.service.name(),
|
||||
quoted(&line.withheld_by)
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
if !withheld.is_empty() {
|
||||
push(
|
||||
&mut clauses,
|
||||
MSG_SERVICES_WITHHELD,
|
||||
format!("off for now: {}", withheld.join(", ")),
|
||||
);
|
||||
}
|
||||
let unoffered: Vec<String> = review
|
||||
.lines
|
||||
.iter()
|
||||
.filter(|line| !line.offered)
|
||||
.map(|line| line.service.name().to_string())
|
||||
.collect();
|
||||
if !unoffered.is_empty() {
|
||||
push(
|
||||
&mut clauses,
|
||||
MSG_SERVICES_UNOFFERED,
|
||||
format!(
|
||||
"this Codewhale does not offer {} to plugins",
|
||||
and_list(&unoffered)
|
||||
),
|
||||
);
|
||||
}
|
||||
if !granted.is_empty() {
|
||||
push(
|
||||
&mut clauses,
|
||||
MSG_SERVICES_PAUSE,
|
||||
"services pause for every plugin in its host until the host is idle".to_string(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if now.providers > 0 {
|
||||
push(
|
||||
&mut clauses,
|
||||
MSG_PROVIDER_NEW_SESSION,
|
||||
"provider changes apply in a new session".to_string(),
|
||||
);
|
||||
}
|
||||
|
||||
PluginSummary { clauses }
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::host_services::{
|
||||
AgentApproval, AgentPosture, HostServiceLine, PersonVia, PluginCohort, ReviewPeer,
|
||||
};
|
||||
use crate::install::dsh::DshOutcome;
|
||||
|
||||
fn inventory() -> PluginInventory {
|
||||
PluginInventory::default()
|
||||
}
|
||||
|
||||
fn input<'a>(inventory: &'a PluginInventory, approver: &'a PluginApprover) -> SummaryInput<'a> {
|
||||
SummaryInput {
|
||||
name: "notes",
|
||||
version: "1.2.0",
|
||||
source: Some("github:acme/notes"),
|
||||
enabled: true,
|
||||
approver: Some(approver),
|
||||
inventory,
|
||||
dsh: None,
|
||||
host_services: None,
|
||||
extension_host_on: true,
|
||||
previous: None,
|
||||
downgrade_allowed: false,
|
||||
}
|
||||
}
|
||||
|
||||
fn person() -> PluginApprover {
|
||||
PluginApprover::person(PersonVia::Terminal)
|
||||
}
|
||||
|
||||
fn conversion() -> DshConversion {
|
||||
DshConversion {
|
||||
plugin_name: "dsh-whale-widget".into(),
|
||||
source_package: Some("dsh-whale-widget".into()),
|
||||
source_version: Some("0.4.2".into()),
|
||||
manifest_sha256: "0".repeat(64),
|
||||
layers: Vec::new(),
|
||||
outcomes: Vec::new(),
|
||||
diagnostics: Vec::new(),
|
||||
skills: vec!["balance".into()],
|
||||
remote_servers: vec!["account".into()],
|
||||
local_servers: vec!["bridge".into(), "helper".into()],
|
||||
network_hosts: vec!["api.example.com".into()],
|
||||
requires_node: false,
|
||||
requires_native: false,
|
||||
native_rows: Vec::new(),
|
||||
client_closures: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
fn skipped(kind: &str, reason: &str) -> DshOutcome {
|
||||
DshOutcome {
|
||||
row: Some("row".into()),
|
||||
package: Some("pkg".into()),
|
||||
kind: kind.into(),
|
||||
outcome: "skipped".into(),
|
||||
reason: reason.into(),
|
||||
layer: None,
|
||||
patch: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn text_of(summary: &PluginSummary, id: &str) -> Option<String> {
|
||||
summary
|
||||
.clauses
|
||||
.iter()
|
||||
.find(|clause| clause.id == id)
|
||||
.map(|clause| clause.text.clone())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn summary_plain_plugin_names_source_state_approver_and_adds() {
|
||||
let inventory = PluginInventory {
|
||||
skills: 2,
|
||||
mcp_servers: 1,
|
||||
stdio_mcp_servers: 1,
|
||||
hooks: 1,
|
||||
network_hosts: vec!["api.example.com".into()],
|
||||
..inventory()
|
||||
};
|
||||
let approver = person();
|
||||
let summary = build_summary(&input(&inventory, &approver));
|
||||
assert_eq!(
|
||||
summary.line(),
|
||||
"notes 1.2.0 from github:acme/notes: on; approved by you; adds 2 skills, 1 local MCP server, 1 hook and network access (api.example.com)"
|
||||
);
|
||||
assert_eq!(
|
||||
summary.ids(),
|
||||
vec![MSG_HEAD, MSG_STATE_ON, MSG_APPROVER, MSG_ADDS]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn summary_off_plugin_and_each_approver_kind_read_distinctly() {
|
||||
let inventory = inventory();
|
||||
let agent = PluginApprover::Agent(AgentApproval {
|
||||
posture: AgentPosture::AutoReview,
|
||||
workspace: "/work".into(),
|
||||
thread_id: None,
|
||||
turn_id: None,
|
||||
tool_call_id: None,
|
||||
});
|
||||
let mut request = input(&inventory, &agent);
|
||||
request.enabled = false;
|
||||
request.source = None;
|
||||
let summary = build_summary(&request);
|
||||
assert_eq!(
|
||||
summary.line(),
|
||||
"notes 1.2.0: off; approved by the agent in Auto-Review"
|
||||
);
|
||||
let mut request = input(&inventory, &agent);
|
||||
request.approver = None;
|
||||
assert!(
|
||||
build_summary(&request)
|
||||
.line()
|
||||
.contains("approver not recorded")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn summary_dsh_package_counts_conversion_fields_and_panels() {
|
||||
let mut dsh = conversion();
|
||||
dsh.requires_node = true;
|
||||
dsh.client_closures = vec![crate::install::dsh::DshClientClosure {
|
||||
package: "dsh-whale-widget".into(),
|
||||
root: String::new(),
|
||||
entry: "source/client.js".into(),
|
||||
platform: None,
|
||||
inject: Vec::new(),
|
||||
host_provided: Vec::new(),
|
||||
files: Default::default(),
|
||||
bytes: 10,
|
||||
}];
|
||||
let inventory = inventory();
|
||||
let approver = person();
|
||||
let mut request = input(&inventory, &approver);
|
||||
request.dsh = Some(&dsh);
|
||||
let summary = build_summary(&request);
|
||||
assert_eq!(
|
||||
text_of(&summary, MSG_ADDS).unwrap(),
|
||||
"adds 1 skill, 2 local MCP servers, 1 remote MCP server, 1 panel and network access (api.example.com)"
|
||||
);
|
||||
assert_eq!(
|
||||
text_of(&summary, MSG_NEEDS_NODE).unwrap(),
|
||||
"needs Node.js for its local servers"
|
||||
);
|
||||
assert!(text_of(&summary, MSG_WONT_RUN).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn summary_refused_and_version_refused_rows_are_counted_with_reasons() {
|
||||
let mut dsh = conversion();
|
||||
dsh.outcomes = vec![
|
||||
skipped("foreign", "no portable representation"),
|
||||
skipped("mcp", "needs a manual port"),
|
||||
DshOutcome {
|
||||
outcome: "converted".into(),
|
||||
..skipped("mcp", "")
|
||||
},
|
||||
];
|
||||
let inventory = inventory();
|
||||
let approver = person();
|
||||
let mut request = input(&inventory, &approver);
|
||||
request.dsh = Some(&dsh);
|
||||
assert_eq!(
|
||||
text_of(&build_summary(&request), MSG_WONT_RUN).unwrap(),
|
||||
"won't run: 2 rows (each needs a manual port)"
|
||||
);
|
||||
|
||||
dsh.outcomes = vec![skipped(
|
||||
"patch",
|
||||
"incompatible-version: this bundle requires DSH `^0.1.0-rc.6`",
|
||||
)];
|
||||
let mut request = input(&inventory, &approver);
|
||||
request.dsh = Some(&dsh);
|
||||
assert_eq!(
|
||||
text_of(&build_summary(&request), MSG_WONT_RUN).unwrap(),
|
||||
"won't run: 1 row (refused by the host version gate)"
|
||||
);
|
||||
|
||||
dsh.outcomes
|
||||
.push(skipped("foreign", "no portable representation"));
|
||||
let mut request = input(&inventory, &approver);
|
||||
request.dsh = Some(&dsh);
|
||||
assert_eq!(
|
||||
text_of(&build_summary(&request), MSG_WONT_RUN).unwrap(),
|
||||
"won't run: 2 rows (1 refused by the host version gate, 1 with no portable form)"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn summary_native_rows_need_the_extension_host_only_while_it_is_off() {
|
||||
let mut dsh = conversion();
|
||||
dsh.requires_native = true;
|
||||
dsh.native_rows = vec!["worker".into()];
|
||||
let inventory = inventory();
|
||||
let approver = person();
|
||||
let mut request = input(&inventory, &approver);
|
||||
request.dsh = Some(&dsh);
|
||||
request.extension_host_on = false;
|
||||
let off = build_summary(&request);
|
||||
assert_eq!(
|
||||
text_of(&off, MSG_NEEDS_EXTENSION_HOST).unwrap(),
|
||||
"needs the extension host (off)"
|
||||
);
|
||||
assert!(
|
||||
text_of(&off, MSG_ADDS)
|
||||
.unwrap()
|
||||
.contains("1 native extension")
|
||||
);
|
||||
request.extension_host_on = true;
|
||||
assert!(text_of(&build_summary(&request), MSG_NEEDS_EXTENSION_HOST).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn summary_update_names_only_what_changed() {
|
||||
let before = PluginInventory {
|
||||
skills: 2,
|
||||
..inventory()
|
||||
};
|
||||
let after = PluginInventory {
|
||||
skills: 2,
|
||||
hooks: 1,
|
||||
network_hosts: vec!["api.example.com".into()],
|
||||
..inventory()
|
||||
};
|
||||
let approver = person();
|
||||
let mut request = input(&after, &approver);
|
||||
request.previous = Some(PreviousInstall {
|
||||
name: "notes",
|
||||
version: "1.1.0",
|
||||
source: Some("github:acme/notes"),
|
||||
inventory: &before,
|
||||
host_services: &[],
|
||||
});
|
||||
let summary = build_summary(&request);
|
||||
assert_eq!(
|
||||
text_of(&summary, MSG_NOW_ALSO_ADDS).unwrap(),
|
||||
"now also adds 1 hook and network access (api.example.com)"
|
||||
);
|
||||
assert!(text_of(&summary, MSG_ADDS).is_none());
|
||||
assert!(text_of(&summary, MSG_REPLACES).is_none());
|
||||
assert!(text_of(&summary, MSG_DOWNGRADED).is_none());
|
||||
|
||||
request.previous = Some(PreviousInstall {
|
||||
name: "notes",
|
||||
version: "1.1.0",
|
||||
source: Some("github:acme/notes"),
|
||||
inventory: &after,
|
||||
host_services: &[],
|
||||
});
|
||||
assert_eq!(
|
||||
text_of(&build_summary(&request), MSG_ADDS_NOTHING_NEW).unwrap(),
|
||||
"adds nothing new"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn summary_source_change_and_downgrade_are_stated() {
|
||||
let inventory = inventory();
|
||||
let approver = person();
|
||||
let mut request = input(&inventory, &approver);
|
||||
request.previous = Some(PreviousInstall {
|
||||
name: "notes",
|
||||
version: "1.4.0",
|
||||
source: Some("github:old/notes"),
|
||||
inventory: &inventory,
|
||||
host_services: &[],
|
||||
});
|
||||
request.downgrade_allowed = true;
|
||||
let summary = build_summary(&request);
|
||||
assert_eq!(
|
||||
text_of(&summary, MSG_REPLACES).unwrap(),
|
||||
"replaces notes from github:old/notes"
|
||||
);
|
||||
assert_eq!(
|
||||
text_of(&summary, MSG_DOWNGRADED).unwrap(),
|
||||
"downgraded from 1.4.0"
|
||||
);
|
||||
request.downgrade_allowed = false;
|
||||
assert!(text_of(&build_summary(&request), MSG_DOWNGRADED).is_none());
|
||||
request.previous.as_mut().unwrap().source = None;
|
||||
assert_eq!(
|
||||
text_of(&build_summary(&request), MSG_REPLACES).unwrap(),
|
||||
"replaces notes from an unrecorded source"
|
||||
);
|
||||
}
|
||||
|
||||
fn review(lines: Vec<HostServiceLine>) -> HostServiceReview {
|
||||
HostServiceReview::new("user/0/notes", "c", "k", &PluginCohort::Reviewed, lines)
|
||||
}
|
||||
|
||||
fn peer(name: &str) -> ReviewPeer {
|
||||
ReviewPeer {
|
||||
id: format!("user/0/{name}"),
|
||||
name: name.into(),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn summary_host_services_state_sharing_withheld_and_the_pause() {
|
||||
let inventory = inventory();
|
||||
let approver = person();
|
||||
let review = review(vec![
|
||||
HostServiceLine {
|
||||
service: HostService::Workspace,
|
||||
offered: true,
|
||||
shared_with: vec![peer("other")],
|
||||
withheld_by: Vec::new(),
|
||||
},
|
||||
HostServiceLine {
|
||||
service: HostService::Network,
|
||||
offered: true,
|
||||
shared_with: Vec::new(),
|
||||
withheld_by: vec![peer("legacy")],
|
||||
},
|
||||
HostServiceLine {
|
||||
service: HostService::Remote,
|
||||
offered: false,
|
||||
shared_with: Vec::new(),
|
||||
withheld_by: Vec::new(),
|
||||
},
|
||||
]);
|
||||
let mut request = input(&inventory, &approver);
|
||||
request.host_services = Some(&review);
|
||||
let summary = build_summary(&request);
|
||||
assert_eq!(
|
||||
text_of(&summary, MSG_SERVICES).unwrap(),
|
||||
"uses host services workspace and network (shared with `other`)"
|
||||
);
|
||||
assert_eq!(
|
||||
text_of(&summary, MSG_SERVICES_WITHHELD).unwrap(),
|
||||
"off for now: network (`legacy` is turned on without it)"
|
||||
);
|
||||
assert_eq!(
|
||||
text_of(&summary, MSG_SERVICES_UNOFFERED).unwrap(),
|
||||
"this Codewhale does not offer remote to plugins"
|
||||
);
|
||||
assert_eq!(
|
||||
text_of(&summary, MSG_SERVICES_PAUSE).unwrap(),
|
||||
"services pause for every plugin in its host until the host is idle"
|
||||
);
|
||||
|
||||
let before = [HostService::Workspace];
|
||||
let prior = PluginInventory::default();
|
||||
request.previous = Some(PreviousInstall {
|
||||
name: "notes",
|
||||
version: "1.1.0",
|
||||
source: Some("github:acme/notes"),
|
||||
inventory: &prior,
|
||||
host_services: &before,
|
||||
});
|
||||
let update = build_summary(&request);
|
||||
assert_eq!(
|
||||
text_of(&update, MSG_SERVICES_NOW_ALSO).unwrap(),
|
||||
"now also uses host services network"
|
||||
);
|
||||
assert!(text_of(&update, MSG_SERVICES).is_none());
|
||||
|
||||
let empty = self::review(Vec::new());
|
||||
request.previous = None;
|
||||
request.host_services = Some(&empty);
|
||||
assert!(text_of(&build_summary(&request), MSG_SERVICES_PAUSE).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn summary_provider_declarations_say_they_apply_in_a_new_session() {
|
||||
let inventory = PluginInventory {
|
||||
providers: 1,
|
||||
..inventory()
|
||||
};
|
||||
let approver = person();
|
||||
let summary = build_summary(&input(&inventory, &approver));
|
||||
assert_eq!(
|
||||
text_of(&summary, MSG_PROVIDER_NEW_SESSION).unwrap(),
|
||||
"provider changes apply in a new session"
|
||||
);
|
||||
assert!(text_of(&summary, MSG_ADDS).unwrap().contains("1 provider"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn summary_long_host_lists_are_shortened() {
|
||||
let inventory = PluginInventory {
|
||||
network_hosts: ["a.io", "b.io", "c.io", "d.io", "e.io"]
|
||||
.map(String::from)
|
||||
.to_vec(),
|
||||
..inventory()
|
||||
};
|
||||
let approver = person();
|
||||
assert_eq!(
|
||||
text_of(&build_summary(&input(&inventory, &approver)), MSG_ADDS).unwrap(),
|
||||
"adds network access (a.io, b.io, c.io and 2 more)"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -27,11 +27,41 @@ pub mod test_fixture;
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
pub use context::{HostEnvironment, PluginDiscoveryContext};
|
||||
pub use discovery::PluginCatalogStamp;
|
||||
pub use registry::PluginRegistry;
|
||||
|
||||
pub const PLUGIN_RELOAD_NUDGE: &str = "Plugins changed on disk. Run /plugin reload to apply.";
|
||||
pub const PLUGIN_RELOAD_NUDGE: &str =
|
||||
"Plugins changed on disk. They apply when the session is idle, or run /plugin reload.";
|
||||
|
||||
pub const PLUGIN_PROVIDER_NEW_SESSION_NOTICE: &str =
|
||||
"Plugin provider changes apply in a new session.";
|
||||
|
||||
fn active_provider_declarations(
|
||||
registry: &PluginRegistry,
|
||||
) -> BTreeMap<String, (String, Vec<String>)> {
|
||||
registry
|
||||
.active_plugins()
|
||||
.into_iter()
|
||||
.filter(|plugin| !plugin.manifest.providers.is_empty())
|
||||
.map(|plugin| {
|
||||
(
|
||||
plugin.id.as_str().to_string(),
|
||||
(
|
||||
plugin.content_hash.clone(),
|
||||
plugin.manifest.providers.keys().cloned().collect(),
|
||||
),
|
||||
)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn provider_declarations_changed(before: &PluginRegistry, after: &PluginRegistry) -> bool {
|
||||
active_provider_declarations(before) != active_provider_declarations(after)
|
||||
}
|
||||
|
||||
/// Returns the reload nudge once per distinct on-disk catalog stamp.
|
||||
#[must_use]
|
||||
|
||||
@@ -39,9 +39,13 @@
|
||||
//! GitHub repositories (archive tarballs) rather than a package
|
||||
//! subdirectory. So:
|
||||
//!
|
||||
//! - `npm` sources parse into [`MarketplaceSourceSpec::Npm`] and stay
|
||||
//! [`MarketplaceInstallPlan::Unsupported`]: visible and truthful, never
|
||||
//! implied installable.
|
||||
//! - `npm` sources parse into [`MarketplaceSourceSpec::Npm`] and plan the
|
||||
//! reviewed installer's fetch of that exact version's registry tarball
|
||||
//! (`npm:name@version`, or the tarball under a declared https
|
||||
//! `registry`). Nothing runs npm and no dependency is installed; the
|
||||
//! bundle still lands disabled and untrusted behind the content review.
|
||||
//! A name, version or registry the installer cannot fetch exactly stays
|
||||
//! [`MarketplaceInstallPlan::Unsupported`] with the reason.
|
||||
//! - `git` sources on `github.com` without a `path` become a pinned
|
||||
//! `github:` install plan. A `path` (package inside a subdirectory) or a
|
||||
//! non-GitHub host stays unsupported.
|
||||
@@ -468,16 +472,25 @@ fn pick_version(
|
||||
));
|
||||
continue;
|
||||
}
|
||||
let registry = source.get("registry");
|
||||
if source_type == "npm" && registry.is_some_and(|value| !value.is_string()) {
|
||||
entry_diags.push(MarketplaceDiagnostic::warning(
|
||||
"INVALID_REGISTRY",
|
||||
format!(
|
||||
"`versions[{position}].source.registry` must be a URL string; the version was ignored"
|
||||
),
|
||||
None,
|
||||
None,
|
||||
));
|
||||
continue;
|
||||
}
|
||||
parsed.push((
|
||||
precedence,
|
||||
DshVersion {
|
||||
version: raw_version.to_string(),
|
||||
engines: engines.map(str::to_string),
|
||||
source_type: source_type.to_string(),
|
||||
registry: source
|
||||
.get("registry")
|
||||
.and_then(Value::as_str)
|
||||
.map(str::to_string),
|
||||
registry: registry.and_then(Value::as_str).map(str::to_string),
|
||||
git_url: source
|
||||
.get("url")
|
||||
.and_then(Value::as_str)
|
||||
@@ -630,21 +643,25 @@ fn normalize_version_source(
|
||||
}
|
||||
|
||||
if version.source_type == "npm" {
|
||||
let mut reason = format!(
|
||||
"DSH market npm source `{name}@{}` installs through npm; Codewhale does not execute npm. Install a DSH bundle from a GitHub repository, tarball, or local path",
|
||||
version.version
|
||||
);
|
||||
if let Some(registry) = &version.registry {
|
||||
reason.push_str(&format!(" (declared registry: {registry})"));
|
||||
}
|
||||
let plan = match crate::install::dsh::plan_npm_install(
|
||||
name,
|
||||
&version.version,
|
||||
version.registry.as_deref(),
|
||||
) {
|
||||
Ok(plan) => MarketplaceInstallPlan::Supported {
|
||||
spec: plan.spec,
|
||||
source_kind: "npm registry tarball".to_string(),
|
||||
},
|
||||
Err(reason) => MarketplaceInstallPlan::Unsupported {
|
||||
reason,
|
||||
raw: version.version.clone(),
|
||||
},
|
||||
};
|
||||
return (
|
||||
MarketplaceSourceSpec::Npm {
|
||||
package: name.to_string(),
|
||||
},
|
||||
MarketplaceInstallPlan::Unsupported {
|
||||
reason,
|
||||
raw: version.version.clone(),
|
||||
},
|
||||
plan,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -106,6 +106,7 @@ impl MarketplaceStore {
|
||||
// Every surface consumes this same projection. Browsing is offline
|
||||
// and read-only; installation and updates use the reviewed installer.
|
||||
// A user's local catalog or removal always takes precedence.
|
||||
replace_superseded_embedded_catalog(&mut state)?;
|
||||
if !state.first_party_removed && !state.catalogs.contains_key("codewhale") {
|
||||
state
|
||||
.catalogs
|
||||
@@ -235,6 +236,7 @@ impl MarketplaceStore {
|
||||
.write()
|
||||
.map_err(|e| format!("failed to lock marketplace state for update: {e}"))?;
|
||||
let mut next = self.load_unlocked()?;
|
||||
replace_superseded_embedded_catalog(&mut next)?;
|
||||
let result = mutate(&mut next)?;
|
||||
save_state_with_hardener(&self.path, &next, harden_plugin_state_file)?;
|
||||
Ok(result)
|
||||
@@ -313,17 +315,53 @@ fn collapse_same_source_catalogs(mut state: MarketplaceState) -> MarketplaceStat
|
||||
state
|
||||
}
|
||||
|
||||
fn first_party_catalog() -> Result<StoredMarketplaceCatalog, String> {
|
||||
#[derive(Deserialize)]
|
||||
struct Snapshot {
|
||||
repository: String,
|
||||
revision: String,
|
||||
catalog: serde_json::Value,
|
||||
}
|
||||
let snapshot: Snapshot =
|
||||
#[derive(Deserialize)]
|
||||
struct EmbeddedSnapshot {
|
||||
repository: String,
|
||||
revision: String,
|
||||
catalog: serde_json::Value,
|
||||
}
|
||||
|
||||
impl EmbeddedSnapshot {
|
||||
fn embedded() -> Result<Self, String> {
|
||||
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);
|
||||
.map_err(|error| format!("invalid bundled marketplace: {error}"))
|
||||
}
|
||||
|
||||
fn source(&self) -> String {
|
||||
format!("{}/tree/{}", self.repository, self.revision)
|
||||
}
|
||||
|
||||
fn supersedes(&self, source_path: &str) -> bool {
|
||||
source_path
|
||||
.strip_prefix(&format!("{}/tree/", self.repository))
|
||||
.is_some_and(|revision| {
|
||||
revision != self.revision
|
||||
&& revision.len() == 40
|
||||
&& revision.bytes().all(|byte| byte.is_ascii_hexdigit())
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
fn replace_superseded_embedded_catalog(state: &mut MarketplaceState) -> Result<(), String> {
|
||||
if state.first_party_removed {
|
||||
return Ok(());
|
||||
}
|
||||
let Some(stored) = state.catalogs.get("codewhale") else {
|
||||
return Ok(());
|
||||
};
|
||||
let snapshot = EmbeddedSnapshot::embedded()?;
|
||||
if snapshot.supersedes(&stored.source_path) {
|
||||
state
|
||||
.catalogs
|
||||
.insert("codewhale".into(), first_party_catalog()?);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn first_party_catalog() -> Result<StoredMarketplaceCatalog, String> {
|
||||
let snapshot = EmbeddedSnapshot::embedded()?;
|
||||
let source = snapshot.source();
|
||||
let mut catalog = super::parsers::parse_catalog(super::parsers::MarketplaceDocument {
|
||||
catalog_id: MarketplaceCatalogId::new("codewhale"),
|
||||
format: super::types::MarketplaceFormat::Codewhale,
|
||||
@@ -371,10 +409,8 @@ mod tests {
|
||||
panic!("uninstallable candidate")
|
||||
};
|
||||
if candidate.name == "computer-use" {
|
||||
assert_eq!(
|
||||
spec,
|
||||
"https://github.com/codewhale-hq/codewhale-cu-plugin/releases/download/v0.16.0/computer-use.tar.gz#sha256=11a4f5c97c32717fa5648d10c080aa0db48a4798ccdf0d17112d1ae5b9133ae4"
|
||||
);
|
||||
let (source, sha256) = snapshot_entry_pin("computer-use");
|
||||
assert_eq!(spec, format!("{source}#sha256={sha256}"));
|
||||
} else {
|
||||
assert!(spec.starts_with(
|
||||
"https://codeload.github.com/codewhale-hq/codewhale-plugin-marketplace/"
|
||||
@@ -401,6 +437,122 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
fn snapshot_plugins() -> Vec<serde_json::Value> {
|
||||
let snapshot: serde_json::Value =
|
||||
serde_json::from_str(include_str!("../../assets/first-party-marketplace.json"))
|
||||
.unwrap();
|
||||
snapshot["catalog"]["plugins"].as_array().unwrap().clone()
|
||||
}
|
||||
|
||||
fn snapshot_entry_pin(name: &str) -> (String, String) {
|
||||
let entry = snapshot_plugins()
|
||||
.into_iter()
|
||||
.find(|entry| entry["name"] == name)
|
||||
.unwrap();
|
||||
(
|
||||
entry["source"].as_str().unwrap().to_string(),
|
||||
entry["sha256"].as_str().unwrap().to_string(),
|
||||
)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_remote_snapshot_entry_carries_a_sha256() {
|
||||
let snapshot = EmbeddedSnapshot::embedded().unwrap();
|
||||
let pinned_bundle = format!(
|
||||
"https://codeload.github.com/codewhale-hq/codewhale-plugin-marketplace/tar.gz/{}#",
|
||||
snapshot.revision
|
||||
);
|
||||
let mut remote = 0;
|
||||
for entry in snapshot_plugins() {
|
||||
let source = entry["source"].as_str().unwrap();
|
||||
assert!(
|
||||
source.starts_with("https://"),
|
||||
"{source} is not a pinned remote source"
|
||||
);
|
||||
if source.starts_with(&pinned_bundle) {
|
||||
continue;
|
||||
}
|
||||
remote += 1;
|
||||
let sha256 = entry["sha256"].as_str().unwrap_or_default();
|
||||
assert!(
|
||||
sha256.len() == 64
|
||||
&& sha256
|
||||
.bytes()
|
||||
.all(|b| matches!(b, b'0'..=b'9' | b'a'..=b'f')),
|
||||
"{} has no sha256",
|
||||
entry["name"]
|
||||
);
|
||||
assert!(
|
||||
!source.contains("/latest/"),
|
||||
"{} follows a floating release",
|
||||
entry["name"]
|
||||
);
|
||||
}
|
||||
assert!(remote >= 1, "the computer-use release asset is remote");
|
||||
}
|
||||
|
||||
fn write_state(store: &MarketplaceStore, source_path: &str) {
|
||||
let mut catalog = first_party_catalog().unwrap();
|
||||
catalog.source_path = source_path.to_string();
|
||||
catalog.catalog.candidates.clear();
|
||||
let mut state = MarketplaceState::default();
|
||||
state.catalogs.insert("codewhale".into(), catalog);
|
||||
ensure_private_plugin_state_directory(store.path().parent().unwrap()).unwrap();
|
||||
fs::write(store.path(), serde_json::to_string(&state).unwrap()).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_saved_copy_of_an_older_embedded_snapshot_is_replaced() {
|
||||
let root = tempfile::tempdir().unwrap();
|
||||
let store = MarketplaceStore::open(Some(&root.path().join("plugins/state.json"))).unwrap();
|
||||
let snapshot = EmbeddedSnapshot::embedded().unwrap();
|
||||
let older = format!("{}/tree/{}", snapshot.repository, "0".repeat(40));
|
||||
write_state(&store, &older);
|
||||
|
||||
let loaded = store.load().unwrap();
|
||||
let kept = loaded.get("codewhale").unwrap();
|
||||
assert_eq!(kept.source_path, snapshot.source());
|
||||
assert!(kept.catalog.total_candidates() > 0);
|
||||
|
||||
store
|
||||
.add(
|
||||
&MarketplaceCatalogId::new("codewhale"),
|
||||
first_party_catalog().unwrap(),
|
||||
)
|
||||
.unwrap();
|
||||
let persisted: MarketplaceState =
|
||||
serde_json::from_str(&fs::read_to_string(store.path()).unwrap()).unwrap();
|
||||
assert_eq!(
|
||||
persisted.get("codewhale").unwrap().source_path,
|
||||
snapshot.source()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn desktop_bundled_and_person_added_catalogs_are_kept() {
|
||||
let snapshot = EmbeddedSnapshot::embedded().unwrap();
|
||||
let bundled_file = Path::new("resources")
|
||||
.join("marketplace")
|
||||
.join("marketplace.json")
|
||||
.display()
|
||||
.to_string();
|
||||
let moving_branch = format!("{}/tree/main", snapshot.repository);
|
||||
for source in [
|
||||
bundled_file.as_str(),
|
||||
"https://example.com/team/marketplace.json",
|
||||
moving_branch.as_str(),
|
||||
] {
|
||||
let root = tempfile::tempdir().unwrap();
|
||||
let store =
|
||||
MarketplaceStore::open(Some(&root.path().join("plugins/state.json"))).unwrap();
|
||||
write_state(&store, source);
|
||||
let loaded = store.load().unwrap();
|
||||
let kept = loaded.get("codewhale").unwrap();
|
||||
assert_eq!(kept.source_path, source);
|
||||
assert_eq!(kept.catalog.total_candidates(), 0);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn malformed_store_is_not_hidden_by_bundled_catalog() {
|
||||
let root = tempfile::tempdir().unwrap();
|
||||
|
||||
@@ -818,12 +818,14 @@ fn dsh_market_index_detects_and_keeps_the_highest_version() {
|
||||
review.source,
|
||||
MarketplaceSourceSpec::Npm { ref package } if package == "@acme/review"
|
||||
));
|
||||
match &review.install_plan {
|
||||
MarketplaceInstallPlan::Unsupported { reason, .. } => {
|
||||
assert!(reason.contains("does not execute npm"), "{reason}");
|
||||
}
|
||||
other => panic!("an npm source must stay unsupported, got {other:?}"),
|
||||
}
|
||||
assert_eq!(
|
||||
review.install_plan,
|
||||
MarketplaceInstallPlan::Supported {
|
||||
spec: "npm:@acme/[email protected]".to_string(),
|
||||
source_kind: "npm registry tarball".to_string(),
|
||||
},
|
||||
"an npm source plans the highest version's registry tarball"
|
||||
);
|
||||
|
||||
let badge = catalog.candidate_by_name("acme-badge").unwrap();
|
||||
assert_eq!(badge.display_name.as_deref(), Some("Badge"));
|
||||
@@ -943,3 +945,119 @@ fn dsh_index_unknown_fields_and_undeclared_category_are_reported_not_guessed() {
|
||||
assert!(plugin.categories.is_empty());
|
||||
assert_eq!(plugin.keywords, vec!["k".to_string()]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dsh_index_npm_sources_plan_an_exact_registry_tarball_or_say_why_not() {
|
||||
use super::document::{CatalogInstallResolution, resolve_candidate_install};
|
||||
use super::store::StoredMarketplaceCatalog;
|
||||
use crate::install::PluginInstallSource;
|
||||
use codewhale_skill_install::install::InstallSource;
|
||||
|
||||
let body = r#"{
|
||||
"format": 1,
|
||||
"title": "t",
|
||||
"plugins": [
|
||||
{"name": "@acme/review", "versions": [{"version": "1.10.0", "publishTimestamp": 1,
|
||||
"source": {"type": "npm", "registry": "https://registry.npmjs.org/"}}]},
|
||||
{"name": "@acme/mirrored", "versions": [{"version": "2.0.0-rc.1", "publishTimestamp": 1,
|
||||
"source": {"type": "npm", "registry": "https://registry.example.com/npm/"}}]},
|
||||
{"name": "plain-http", "versions": [{"version": "1.0.0", "publishTimestamp": 1,
|
||||
"source": {"type": "npm", "registry": "http://registry.example.com"}}]},
|
||||
{"name": "with-credentials", "versions": [{"version": "1.0.0", "publishTimestamp": 1,
|
||||
"source": {"type": "npm", "registry": "https://user:[email protected]"}}]},
|
||||
{"name": "tilde~name", "versions": [{"version": "1.0.0", "publishTimestamp": 1,
|
||||
"source": {"type": "npm"}}]},
|
||||
{"name": "typed-registry", "versions": [
|
||||
{"version": "2.0.0", "publishTimestamp": 2, "source": {"type": "npm", "registry": 7}},
|
||||
{"version": "1.0.0", "publishTimestamp": 1, "source": {"type": "npm"}}]}
|
||||
]
|
||||
}"#;
|
||||
let catalog = parse_dsh("dsh", body, None);
|
||||
assert_eq!(catalog.total_candidates(), 6);
|
||||
let plan = |name: &str| {
|
||||
catalog
|
||||
.candidate_by_name(name)
|
||||
.unwrap()
|
||||
.install_plan
|
||||
.clone()
|
||||
};
|
||||
let supported = |name: &str| match plan(name) {
|
||||
MarketplaceInstallPlan::Supported { spec, source_kind } => {
|
||||
assert_eq!(source_kind, "npm registry tarball", "{name}");
|
||||
spec
|
||||
}
|
||||
other => panic!("`{name}` must be installable, got {other:?}"),
|
||||
};
|
||||
let refused = |name: &str| match plan(name) {
|
||||
MarketplaceInstallPlan::Unsupported { reason, .. } => reason,
|
||||
other => panic!("`{name}` must not be installable, got {other:?}"),
|
||||
};
|
||||
|
||||
let review = supported("@acme/review");
|
||||
assert_eq!(
|
||||
review, "npm:@acme/[email protected]",
|
||||
"the default registry, named or not, is the installer's own npm spec"
|
||||
);
|
||||
assert_eq!(
|
||||
PluginInstallSource::parse(&review).unwrap(),
|
||||
PluginInstallSource::Remote(InstallSource::DirectUrl(
|
||||
"https://registry.npmjs.org/@acme/review/-/review-1.10.0.tgz".into()
|
||||
))
|
||||
);
|
||||
let mirrored = supported("@acme/mirrored");
|
||||
assert_eq!(
|
||||
mirrored, "https://registry.example.com/npm/@acme/mirrored/-/mirrored-2.0.0-rc.1.tgz",
|
||||
"a declared https registry is the one fetched, never the default in its place"
|
||||
);
|
||||
|
||||
let http = refused("plain-http");
|
||||
assert!(http.contains("only over https"), "{http}");
|
||||
let credentials = refused("with-credentials");
|
||||
assert!(credentials.contains("without credentials"), "{credentials}");
|
||||
assert!(
|
||||
!credentials.contains("s3cret"),
|
||||
"a refusal never repeats a credential: {credentials}"
|
||||
);
|
||||
let name = refused("tilde~name");
|
||||
assert!(name.contains("not an exact npm package version"), "{name}");
|
||||
|
||||
let typed = catalog.candidate_by_name("typed-registry").unwrap();
|
||||
assert_eq!(
|
||||
typed.version.as_deref(),
|
||||
Some("1.0.0"),
|
||||
"a version whose registry is not a string is ignored, not fetched from the default registry"
|
||||
);
|
||||
assert!(
|
||||
typed
|
||||
.diagnostics
|
||||
.iter()
|
||||
.any(|d| d.code == "INVALID_REGISTRY")
|
||||
);
|
||||
assert_eq!(supported("typed-registry"), "npm:[email protected]");
|
||||
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let registry = crate::PluginRegistry::empty(tmp.path());
|
||||
let entry = StoredMarketplaceCatalog {
|
||||
added_at: String::new(),
|
||||
source_path: tmp.path().join("index.json").display().to_string(),
|
||||
catalog: catalog.clone(),
|
||||
};
|
||||
for (name, expected) in [("@acme/review", &review), ("@acme/mirrored", &mirrored)] {
|
||||
let candidate = entry.catalog.candidate_by_name(name).unwrap();
|
||||
let CatalogInstallResolution::Supported { spec, .. } =
|
||||
resolve_candidate_install(&entry, candidate, ®istry)
|
||||
else {
|
||||
panic!("`{name}` resolves to the reviewed installer")
|
||||
};
|
||||
assert_eq!(&spec, expected);
|
||||
assert!(matches!(
|
||||
PluginInstallSource::parse(&spec).unwrap(),
|
||||
PluginInstallSource::Remote(InstallSource::DirectUrl(_))
|
||||
));
|
||||
}
|
||||
let candidate = entry.catalog.candidate_by_name("plain-http").unwrap();
|
||||
assert!(matches!(
|
||||
resolve_candidate_install(&entry, candidate, ®istry),
|
||||
CatalogInstallResolution::Unsupported { .. }
|
||||
));
|
||||
}
|
||||
@@ -218,8 +218,9 @@ pub enum MarketplaceSourceSpec {
|
||||
/// Non-GitHub git URL. The installer cannot fetch these yet; the plan
|
||||
/// stays [`MarketplaceInstallPlan::Unsupported`].
|
||||
GitUrl { url: String },
|
||||
/// npm package declaration. Codewhale does not execute npm; the plan
|
||||
/// stays unsupported.
|
||||
/// npm package declaration. Codewhale never executes npm: a DSH market
|
||||
/// entry plans a fetch of its exact version's registry tarball, and the
|
||||
/// unversioned Claude and Codex declarations stay unsupported.
|
||||
Npm { package: String },
|
||||
/// A source form the catalog format defines but Codewhale refuses to
|
||||
/// execute (e.g. Claude `command` sources), with the reason.
|
||||
|
||||
@@ -95,11 +95,21 @@ pub async fn execute(
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn install_from_cli(
|
||||
pub fn check_network_outcome(outcome: &PluginMutationOutcome) -> Result<()> {
|
||||
match outcome {
|
||||
PluginMutationOutcome::NeedsApproval(host) => {
|
||||
bail!("Network approval required for {host}; use /network allow {host}, then retry")
|
||||
}
|
||||
PluginMutationOutcome::NetworkDenied(host) => bail!("Network access denied for {host}"),
|
||||
_ => Ok(()),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn install_from_cli_receipt(
|
||||
source: String,
|
||||
config: codewhale_runtime::config::Config,
|
||||
mut registry: PluginRegistry,
|
||||
) -> Result<()> {
|
||||
) -> Result<PluginMutationReceipt> {
|
||||
let runtime = tokio::runtime::Handle::current();
|
||||
let policy = crate::activation::extension_host_policy_enabled();
|
||||
let receipt = tokio::task::spawn_blocking(move || {
|
||||
@@ -119,6 +129,16 @@ pub async fn install_from_cli(
|
||||
))
|
||||
})
|
||||
.await??;
|
||||
check_network_outcome(&receipt.outcome)?;
|
||||
Ok(receipt)
|
||||
}
|
||||
|
||||
pub async fn install_from_cli(
|
||||
source: String,
|
||||
config: codewhale_runtime::config::Config,
|
||||
registry: PluginRegistry,
|
||||
) -> Result<()> {
|
||||
let receipt = install_from_cli_receipt(source, config, registry).await?;
|
||||
match receipt.outcome {
|
||||
PluginMutationOutcome::Installed => {
|
||||
println!("Installed {} (disabled, untrusted).", receipt.name);
|
||||
@@ -128,10 +148,6 @@ pub async fn install_from_cli(
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
PluginMutationOutcome::NeedsApproval(host) => {
|
||||
bail!("Network approval required for {host}; use /network allow {host}, then retry")
|
||||
}
|
||||
PluginMutationOutcome::NetworkDenied(host) => bail!("Network access denied for {host}"),
|
||||
_ => bail!("Unexpected plugin install outcome"),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1326,6 +1326,7 @@ impl PluginRegistry {
|
||||
let mut next = load_state_unlocked(path)?;
|
||||
mutate(&mut next)?;
|
||||
save_state(path, &next)?;
|
||||
self.catalog_stamp.refresh_state(path);
|
||||
self.state = next;
|
||||
// Runtime selection rechecks the persisted review through its own
|
||||
// read lock. Publish the completed state transaction before deriving
|
||||
|
||||
@@ -35,6 +35,26 @@ fn write_named_plugin(config: &DiscoveryConfig, name: &str, extra: &str) -> Path
|
||||
plugin
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn catalog_stamp_changes_when_only_state_bytes_change() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let config = config(tmp.path());
|
||||
write_plugin(&config, "");
|
||||
let registry = discover_with_config(&config);
|
||||
assert!(!registry.on_disk_catalog_changed());
|
||||
|
||||
std::fs::create_dir_all(config.state_path.parent().unwrap()).unwrap();
|
||||
std::fs::write(&config.state_path, "{\"version\":1}").unwrap();
|
||||
assert!(registry.on_disk_catalog_changed());
|
||||
|
||||
let reloaded = registry.rediscover_for_workspace(&config.workspace);
|
||||
assert!(!reloaded.on_disk_catalog_changed());
|
||||
std::fs::write(&config.state_path, "{\"version\":1}").unwrap();
|
||||
assert!(!reloaded.on_disk_catalog_changed());
|
||||
std::fs::write(&config.state_path, "{\"version\":2}").unwrap();
|
||||
assert!(reloaded.on_disk_catalog_changed());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn on_disk_catalog_change_nudges_reload_once_until_rediscover() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
|
||||
+1568
-87
File diff suppressed because it is too large.
Load diff
@@ -11,7 +11,7 @@ description = "Codewhale community-skill installer, package digests and skill fr
|
||||
workspace = true
|
||||
|
||||
[features]
|
||||
test-support = []
|
||||
test-support = ["codewhale-net/test-support"]
|
||||
|
||||
[dependencies]
|
||||
anyhow.workspace = true
|
||||
@@ -31,4 +31,5 @@ tokio.workspace = true
|
||||
yaml-rust2.workspace = true
|
||||
|
||||
[dev-dependencies]
|
||||
codewhale-net = { path = "../net", version = "0.10.3", features = ["test-support"] }
|
||||
tempfile.workspace = true
|
||||
@@ -51,10 +51,8 @@ use serde::{Deserialize, Serialize};
|
||||
use sha2::{Digest, Sha256};
|
||||
use thiserror::Error;
|
||||
|
||||
use codewhale_net::guard::{
|
||||
DnsPin, fetch_target_host, guarded_reqwest_client_builder, validate_fetch_destination,
|
||||
};
|
||||
use codewhale_net::network_policy::{Decision, NetworkPolicy, NetworkPolicyDecider, host_from_url};
|
||||
use codewhale_net::guard::{GuardedGet, PrivateTargets};
|
||||
use codewhale_net::network_policy::{Decision, NetworkPolicy, host_from_url};
|
||||
|
||||
const MAX_INSTALL_REDIRECTS: usize = 5;
|
||||
|
||||
@@ -75,81 +73,24 @@ fn install_http_timeouts() -> (std::time::Duration, std::time::Duration) {
|
||||
}
|
||||
}
|
||||
|
||||
fn reqwest_client(proxy: Option<reqwest::Proxy>, pin: DnsPin) -> reqwest::Client {
|
||||
let (connect_timeout, total_timeout) = install_http_timeouts();
|
||||
let mut builder = guarded_reqwest_client_builder()
|
||||
.redirect(reqwest::redirect::Policy::none())
|
||||
// The shared platform builder sets no timeouts; without a bound, a
|
||||
// connection that opens but stalls (dead proxy, black-holed route)
|
||||
// hangs installs and registry sync forever. Connect is bounded tightly;
|
||||
// the total budget is generous for 5 MiB tarballs on slow links
|
||||
// (registry sync fans out `SYNC_REGISTRY_CONCURRENCY` of these in
|
||||
// parallel).
|
||||
.connect_timeout(connect_timeout)
|
||||
.timeout(total_timeout);
|
||||
if let Some(proxy) = proxy {
|
||||
builder = builder.proxy(proxy);
|
||||
} else if let Some((host, ip)) = pin {
|
||||
builder = builder.resolve(&host, std::net::SocketAddr::new(ip, 0));
|
||||
}
|
||||
builder.build().expect("build platform HTTP client")
|
||||
}
|
||||
|
||||
async fn guarded_get(
|
||||
url: &str,
|
||||
network: &NetworkPolicy,
|
||||
if_none_match: Option<&str>,
|
||||
) -> Result<reqwest::Response> {
|
||||
let decider = NetworkPolicyDecider::new(network.clone(), None);
|
||||
let mut current = reqwest::Url::parse(url).with_context(|| format!("invalid url: {url}"))?;
|
||||
let mut initial_pin: Option<DnsPin> = (cfg!(any(test, feature = "test-support"))
|
||||
&& current.host_str() == Some("127.0.0.1"))
|
||||
.then_some(None);
|
||||
for _ in 0..=MAX_INSTALL_REDIRECTS {
|
||||
let host = fetch_target_host(¤t)?;
|
||||
if matches!(network.decide(&host), Decision::Deny) {
|
||||
bail!("network policy denies {host}");
|
||||
}
|
||||
let proxy = if initial_pin.is_some() {
|
||||
None
|
||||
let (connect_timeout, total_timeout) = install_http_timeouts();
|
||||
let request = GuardedGet::new(url, network, "skill_install", PrivateTargets::Refuse)
|
||||
.if_none_match(if_none_match)
|
||||
.timeouts(connect_timeout, total_timeout)
|
||||
.max_redirects(MAX_INSTALL_REDIRECTS);
|
||||
#[cfg(any(test, feature = "test-support"))]
|
||||
let request =
|
||||
if reqwest::Url::parse(url).is_ok_and(|parsed| parsed.host_str() == Some("127.0.0.1")) {
|
||||
request.trust_first_hop()
|
||||
} else {
|
||||
codewhale_net::proxy::configured_mcp_proxy(¤t, false, |key| std::env::var(key))?
|
||||
request
|
||||
};
|
||||
let pin = match initial_pin.take() {
|
||||
Some(pin) => pin,
|
||||
None => {
|
||||
validate_fetch_destination(host, Some(&decider), "skill_install", proxy.is_none())
|
||||
.await?
|
||||
}
|
||||
};
|
||||
let mut request = reqwest_client(proxy, pin).get(current.clone());
|
||||
if let Some(etag) = if_none_match {
|
||||
request = request.header(reqwest::header::IF_NONE_MATCH, etag);
|
||||
}
|
||||
let response = request
|
||||
.send()
|
||||
.await
|
||||
.with_context(|| format!("failed to GET {current}"))?;
|
||||
if !response.status().is_redirection() {
|
||||
return Ok(response);
|
||||
}
|
||||
let Some(location) = response
|
||||
.headers()
|
||||
.get(reqwest::header::LOCATION)
|
||||
.and_then(|value| value.to_str().ok())
|
||||
else {
|
||||
return Ok(response);
|
||||
};
|
||||
let next = response
|
||||
.url()
|
||||
.join(location)
|
||||
.with_context(|| format!("invalid redirect location from {current}"))?;
|
||||
if current.scheme() == "https" && next.scheme() != "https" {
|
||||
bail!("redirect from {current} would downgrade HTTPS");
|
||||
}
|
||||
current = next;
|
||||
}
|
||||
bail!("{url} exceeded {MAX_INSTALL_REDIRECTS} redirects")
|
||||
request.send().await
|
||||
}
|
||||
|
||||
/// Cache directory for registry-synced skills.
|
||||
@@ -2133,6 +2074,61 @@ mod tests {
|
||||
assert!(err.contains("timed out"), "{err}");
|
||||
}
|
||||
|
||||
async fn counting_server(
|
||||
response: String,
|
||||
) -> (
|
||||
std::net::SocketAddr,
|
||||
std::sync::Arc<std::sync::atomic::AtomicUsize>,
|
||||
) {
|
||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
||||
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||
let addr = listener.local_addr().unwrap();
|
||||
let hits = std::sync::Arc::new(AtomicUsize::new(0));
|
||||
let counter = std::sync::Arc::clone(&hits);
|
||||
tokio::spawn(async move {
|
||||
while let Ok((mut socket, _)) = listener.accept().await {
|
||||
counter.fetch_add(1, Ordering::SeqCst);
|
||||
let response = response.clone();
|
||||
tokio::spawn(async move {
|
||||
let mut buf = [0u8; 1024];
|
||||
let _ = socket.read(&mut buf).await;
|
||||
let _ = socket.write_all(response.as_bytes()).await;
|
||||
});
|
||||
}
|
||||
});
|
||||
(addr, hits)
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn an_allow_listed_hop_to_a_local_address_is_refused() {
|
||||
use std::sync::atomic::Ordering;
|
||||
let (internal, internal_hits) = counting_server(
|
||||
"HTTP/1.1 200 OK\r\nContent-Length: 13\r\nConnection: close\r\n\r\ninternal-only"
|
||||
.to_string(),
|
||||
)
|
||||
.await;
|
||||
let location = format!("http://127.0.0.1:{}/secret", internal.port());
|
||||
let (entry, _) = counting_server(format!(
|
||||
"HTTP/1.1 302 Found\r\nLocation: {location}\r\nContent-Length: 0\r\nConnection: close\r\n\r\n"
|
||||
))
|
||||
.await;
|
||||
let policy = NetworkPolicy {
|
||||
default: Decision::Prompt.into(),
|
||||
allow: vec!["127.0.0.1".to_string()],
|
||||
deny: Vec::new(),
|
||||
proxy: Vec::new(),
|
||||
proxy_fake_ip_cidrs: Vec::new(),
|
||||
audit: false,
|
||||
};
|
||||
let outcome = guarded_get(&format!("http://{entry}/start"), &policy, None).await;
|
||||
assert!(
|
||||
outcome.is_err(),
|
||||
"the hop to an allow-listed local name must be refused"
|
||||
);
|
||||
assert_eq!(internal_hits.load(Ordering::SeqCst), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_github_source() {
|
||||
let s = InstallSource::parse("github:Hmbown/test-skill").unwrap();
|
||||
|
||||
@@ -28,9 +28,11 @@ codewhale-plugins = { path = "../plugins", version = "0.10.3" }
|
||||
codewhale-runtime = { path = "../runtime", version = "0.10.3" }
|
||||
codewhale-skill-install = { path = "../skill-install", version = "0.10.3" }
|
||||
codewhale-util = { path = "../util", version = "0.10.3" }
|
||||
flate2 = "1.1"
|
||||
serde.workspace = true
|
||||
serde_json = { workspace = true, features = ["preserve_order", "raw_value"] }
|
||||
sha2.workspace = true
|
||||
tar = "0.4"
|
||||
tempfile.workspace = true
|
||||
tokio.workspace = true
|
||||
toml.workspace = true
|
||||
|
||||
+993
-164
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
+16
-2
@@ -7,6 +7,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
- **Agent `cwd` in sibling worktrees:** spawning an agent with `cwd` pointing to an existing linked worktree (or a subdirectory inside it) under the repository's `.codewhale-worktrees/<repo>/` root is now accepted when both share the same repository common git directory; outside directories and foreign or plain directories under `.codewhale-worktrees/` stay refused. (CW-8215)
|
||||
- **DSH market npm installs and extension catalog service:** DSH market `npm` entries with an exact semantic version (and optional `https` registry) now resolve to reviewed registry-tarball install plans (`npm:<pkg>@<ver>` or exact `.tgz` URL) without executing `npm` or bypassing plugin trust review. Inside the extension host, the `dsh/catalog` broker arm now plans reviewed installs without writing to disk, returns only verified approved provider routes scoped to the calling plugin, and serves account balance read-only while withholding credentials and account mutations (CW-7988).
|
||||
- **Plugin settings service backend (0.10.3):** adds the internal `settings` host-service handler (`describe`, `update`, `replace`, `mutate`) and extension-host adapter helper so plugins can inspect and atomically edit only their own reviewed `[plugins."<name>".config]` keys in `config.toml` through the session permission gate and live write posture, leaving comments, unrelated tables and other plugins' configuration untouched (writes return `applies: "reload"` and take effect at the next reload). This backend is not yet wired into live host-service dispatch or plugin grants, so no active plugin exposes it yet. (CW-7983)
|
||||
- **Runtime thread completion wakes:** idle non-ACP runtime threads automatically coalesce finished background shell jobs and completed child sub-agents into a bounded runtime continuation turn without dropping completions during busy turns or triggering wakes on cancelled, archived, or already-drained work. (CW-8215)
|
||||
- **Plugin install summary builder (crate API):** `codewhale-plugins` adds `install::build_summary`, a pure one-line install and update summary builder with stable message IDs for plugins and DSH packages; no CLI, TUI or desktop caller uses it yet. (CW-8182)
|
||||
- **Live reload for external plugin and MCP changes:** when another process installs, enables or disables a plugin or updates the configured MCP file, an idle TUI session now applies the change automatically and records any added or removed skills in the transcript (provider plugin changes still note they apply in a new session); while a turn is running, a toast notifies you and applies the change once idle. (CW-8183)
|
||||
- **First-party plugin catalog updates:** saved `codewhale` marketplace catalogs that came from an earlier embedded snapshot revision now update to the shipped snapshot on load while leaving custom, moving-branch, desktop-bundled, and removed catalogs untouched; release-candidate checks also verify that the embedded snapshot matches the marketplace files it installs from. (CW-8185)
|
||||
- **Network guard and portable config bundles:** `/network allow` now admits a local or private address only for `fetch_url`, `web.run`, and `config import` when the host is a `localhost` name (resolving to loopback only) or a local/private IP literal you typed and explicitly allowed (with IPv6 `deny` rules matching across bracketed and uncompressed spellings and wildcard rules never matching IP literals); domain names resolving to private addresses, skill and plugin installs, extension-host requests, and all cloud metadata forms (including `64:ff9b:1::/48` and `::ffff:0:0:0/96` embedded addresses) plus `0.0.0.0/8` stay refused on every hop. `codewhale config import <url>` now validates every redirect hop and refuses cross-host redirects to visibly local hosts, and portable config bundles reject on import and omit on export visibly local `network.allow` entries (including `localhost` names, local-domain wildcards such as `.localdomain` and `*.localhost`, and local IP literals).
|
||||
- **Skill install and update controller:** installing an already installed skill now updates it in place (reporting any replaced source and preserving `skills_state.toml` enabled/disabled state), local folders and `.tar.gz`/`.tgz` archives install and update as managed skills with path-safety and archive-bound checks, person-started global installs are trusted automatically without marking project skills, and mutations emit a one-line outcome receipt. (CW-8184)
|
||||
- **Removed (crate API):** the unwired `codewhale-hooks` dispatcher and its stdout, JSONL and Unix-socket sinks; `codewhale-core`'s `session`, `journal` and `ids` modules and the `hooks` argument of `Runtime::new`; `codewhale-runtime::resource_telemetry`; ten unreferenced public functions; and the duplicate `codewhale-agent` crate. `hook_sinks.unix_socket_path` still parses and is ignored. CLI, TUI and app-server behavior is unchanged.
|
||||
- **First-run setup:** installer downloads retry up to three times and remove partial files after a failed attempt. Folder trust uses 1 to trust, 2 to continue untrusted, and 3 or Esc to quit; letters do nothing. Provider pickers consistently say "needs key", and the unconfigured custom placeholder says "needs endpoint" while configured-route errors remain visible.
|
||||
- **Quota-window reset text:** a rate-limited turn now gives the reset time in plain words such as "19h29m" instead of Rust debug output like `Some(70168s)`, and leaves the reset clause out when the provider gives no reset time. An OpenCode Go message points at that provider's own key with `codewhale auth set --provider opencode-go`, without repeating a hint the message already carries. (CW-8209)
|
||||
@@ -23,6 +32,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
- **Charts in the transcript:** the model can now show numbers as a small chart with the new read-only `show_data` tool: a one-row sparkline, up to 24 labelled bars, or a multi-row trend. The TUI paints it in the transcript with the terminal kit, including after a session is restored, and every value is also in words, so the model and other clients get the same facts as text. Input the chart cannot show honestly (a non-finite number, more than 1024 points or 24 bars, a label over 120 characters) is refused with the limit it broke.
|
||||
- **Charts in the transcript:** the model can now show numbers as a small chart with the new read-only `show_data` tool: a one-row sparkline, up to 24 labelled bars, or a multi-row trend. The TUI paints it in the transcript with the terminal kit, including after a session is restored, and a plain-text summary accompanies each chart for the model and other clients. Input the chart cannot show honestly (a non-finite number, more than 1024 points or 24 bars, a label over 120 characters) is refused with the limit it broke.
|
||||
- **Terminal widgets:** narrow action hints wrap, scrollbars reach the final row, progress bars show fractional cells, focused tabs and panels are clearer, and the shared terminal kit supports measured ocean themes and reduced-motion-aware water drift.
|
||||
- **Plugin lifecycle from the shell:** `codewhale plugins` now has `install <spec>`, `show <name>`, `update <name>` and `uninstall <name>` (each with `--json`) alongside `status`, `restage`, `trust`, `enable`, `disable` and `revoke`. `plugins install` uses the same reviewed installer as `codewhale install`, `show` prints the capability review without prompting or trusting, `update` refreshes from the recorded source and reports either the new version or `already current`, and `uninstall` disables an enabled bundle first and asks in a terminal unless `--yes` is passed (non-interactive and `--json` runs require `--yes`). (CW-8161)
|
||||
|
||||
- **Renaming sessions with Chinese titles:** the session picker no longer rejects a Chinese, Japanese, Korean or emoji title of more than about 33 characters as "Title must be 1–100 characters". It now counts characters, as the message says and as `/rename` and the Runtime API already did, so any title up to 100 characters is accepted. Thanks [@luochen211](https://github.com/luochen211) (PR 7322).
|
||||
- **Non-ASCII file names:** `/diff`, the `@git` and `@diff` mentions and the Runtime API workspace diff now show paths with Chinese, emoji and other non-ASCII characters as written instead of as quoted octal escapes, and the turn context no longer lists the workspace root as an empty "Active paths" entry. Thanks [@luochen211](https://github.com/luochen211) (PR 7317).
|
||||
@@ -39,7 +49,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
- **Website maintainer and cron secrets:** the manual cron trigger is rate limited, admin sign-in and the cron secret require a 32 to 512 character secret with at least 8 distinct characters that is not a repeated pattern, and changing the maintainer token signs out existing admin sessions.
|
||||
- **Chat bridges thread ownership:** Chat bridges now list and resume only the threads the chat itself created: `/threads` shows owned threads, `/resume` refuses any other thread id with one fixed message, and `/new` always starts a new thread. In an existing thread map each chat keeps only the thread it is bound to, so threads it created earlier and left are no longer listed or resumable; a thread that several chats already pointed at is usable from none of them (send `/new`). Review `thread-map.json` after upgrading.
|
||||
- **Skill and plugin installs:** skill and plugin installs now enforce the uncompressed size cap on the whole decompressed archive, and reject archive entries with names longer than 4096 bytes.
|
||||
|
||||
- **Built-in execution rules:** a `Bash` deny rule now also blocks built-in test and verifier runs (`Run` / `run_tests` / `run_verifiers`), `workflow` runs with `verify: true`, and delayed-message scheduling (`send_later` `schedule`) while keeping task reads, status checks, management actions, and the bounded verifier's own default verification runs working.
|
||||
- **README and contributor credit:** the repository front page now opens the way the website does: the line "The model can be anyone's. The agent should be yours.", one sentence on what Codewhale is, the install command and one picture, with reference detail moved into the linked guides; the 18 translated READMEs follow the same structure and say they are machine-drafted. The contributor wall and the website now count the same 433 people (407 GitHub accounts and the commit authors whose email is not linked to an account), where the website had counted accounts only. `DEPENDENCY_SECURITY.md` moved into `docs/dependency-maintenance.md`, and `.github/SUPPORT.md` says where to ask for help.
|
||||
- **Website Plugins page:** The website's Plugins page now describes what the released version does, and a labelled section says what 0.10.3 adds: one Plugins place, Standard and Creator mode, and the Permissions control.
|
||||
- **Website `/app` captures:** the `/app` page's desktop build screenshots (`conversation.webp`, `approval.webp`, `model-picker.webp`) and their alt and caption text have been refreshed from the current 0.10.3 desktop UI.
|
||||
@@ -123,7 +133,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Credential redaction:** text, JSON, and workflow-disclosure redaction now masks multi-line private-key blocks across PGP armor headers (including `Comment:` URLs), longer preambles, and 16-character wrapped lines; masks credential values inside nested or escaped JSON assignments, escaped quotes, `\u003c`/`\u003e` delimiters, and spaced or nested JSON objects and arrays; and treats plural token keys (`API_TOKENS`, `ACCESS_TOKENS`) and credential-shaped `tokens` values as credentials while keeping model and tokenizer metric names (`max_tokens`, `tokenizer`, `token_limit`, `tokens=5`) visible.
|
||||
- Child processes now inherit only the standard locale variables (`LC_ALL`, `LC_CTYPE` and the other locale categories, plus `LC_TERMINAL` and `LC_TERMINAL_VERSION`) and a listed set of `DOTNET_` host, SDK and runtime settings. Other `DOTNET_` variables, including application configuration keys, are no longer passed to child processes or MCP servers.
|
||||
- **Deny rules and command safety:** a command whose command word is not a plain literal, or that redefines a command name earlier in the same call, is now refused while a deny rule is configured; the background destroyer hold reads commands the shell tokenizer rejects and decodes ANSI-C escapes inside shell payloads; and `sed`, `find`, `sort`, `rg`, `fd`, `uniq`, `env`, `less`, `more`, `ag`, `info` and `git` count as read-only only when their words are literal and use read-only flags, with `codewhale --version` and Windows trailing-backslash paths normalized before checking.
|
||||
- **Starting after a hard kill:** the Engine now starts when its process was killed and its data directory was then deleted. A socket owner record whose process is dead and whose data directory, the one this start selected, no longer exists is discarded instead of failing every start with "Failed to resolve ... tasks/runtime". A home reached through a symbolic link, such as one under macOS /tmp, is not covered yet.
|
||||
- The install guide's note for a first message that gets no answer now describes v0.10.2: connect a model with `/provider`, and restart if messages stay saved for later after you connect. It described v0.10.0's key screen. The Chinese guide carries the same correction.
|
||||
- Project activity on the site now comes from a server-side snapshot with a freshness record, the browser no longer calls GitHub, and count sentences use the right plural form in every language.
|
||||
@@ -475,6 +487,8 @@ work is declared complete here. Items moved to 0.10.4 by name are marked below.
|
||||
|
||||
### Changed
|
||||
|
||||
- **Terminal Plugins screen:** The terminal's Extensions screen is now called Plugins; its tabs filter by kind (Installed, Skills, Connectors, Hooks, Marketplace), and `/plugin` is listed with the main commands. `/skills`, `/mcp` and `/hooks` work as before.
|
||||
|
||||
- **`read` pages in lines only (breaking).** One `read` call returns at
|
||||
most 2,000 lines — the default and the maximum `limit` — inside a 50 KiB
|
||||
output cap, and a line longer than 2,000 chars comes back truncated with
|
||||
@@ -710,7 +724,7 @@ work is declared complete here. Items moved to 0.10.4 by name are marked below.
|
||||
- **`codewhale exec --add-dir <path>`** (repeatable) and `addDirs` on `POST /v1/threads` let the file tools read and write a sibling directory, a second checkout or a shared scratch root without turning on Full Access.
|
||||
- **Unsettled usage is persisted in production, not only in tests.** The interactive sink now records a legacy usage receipt when a provider response has no settlement, so children and turns stop reporting settlement failure.
|
||||
- **A full usage journal no longer fails a turn before dispatch.** The request runs with cost unknown instead of stopping on “native usage journal capacity reached”.
|
||||
- **Reverting a file works in any posture.** Restoring a file the thread's own turns changed no longer requires `/trust` or Full Access; the exact snapshot and reviewed-hash checks still apply. **`fetch_url` can reach a local or private host you allowed** with `/network allow <host>`; cloud metadata stays blocked. Both refusals now name the setting.
|
||||
- **Reverting a file works in any posture.** Restoring a file the thread's own turns changed no longer requires `/trust` or Full Access; the exact snapshot and reviewed-hash checks still apply. **`fetch_url` can reach a typed `localhost` name or local IP literal you allowed** with `/network allow <host>` (domain names resolving to private addresses and cloud metadata stay blocked). Both refusals now name the setting.
|
||||
- **`codewhale exec` offers tools by default**, still under the configured sandbox and approval posture. `--no-tools` is the one-shot. A pipe on stdin is the prompt when no arguments are given, or is appended to them.
|
||||
- **A child no longer dies when usage settlement is not acknowledged.** The turn continues and cost stays unknown, matching the parent path.
|
||||
- **Workspace memory works without a git origin.** A plain folder or a repo with no `origin` remote gets a stable workspace id. `codewhale plugin list` is an alias of `plugins status`. A heavy command runs after 30 seconds instead of waiting forever for an admission slot. An MCP registration 4xx names the status and the manual client-id path.
|
||||
|
||||
@@ -278,6 +278,42 @@ fn report(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn block_on_mutation<F: std::future::Future>(future: F) -> Result<F::Output> {
|
||||
match tokio::runtime::Handle::try_current() {
|
||||
Ok(handle) => Ok(handle.block_on(future)),
|
||||
Err(_) => Ok(tokio::runtime::Builder::new_current_thread()
|
||||
.enable_all()
|
||||
.build()?
|
||||
.block_on(future)),
|
||||
}
|
||||
}
|
||||
|
||||
fn manifest_version_at(path: &Path) -> Option<String> {
|
||||
let manifest_path = codewhale_plugins::agent_plugin::resolve_manifest_path(path)?;
|
||||
codewhale_plugins::manifest::PluginManifest::validate_from_path(&manifest_path)
|
||||
.ok()
|
||||
.map(|validated| validated.manifest.plugin.version)
|
||||
}
|
||||
|
||||
pub(crate) fn show(
|
||||
registry: &PluginRegistry,
|
||||
locale: Locale,
|
||||
name: &str,
|
||||
json: bool,
|
||||
console: &mut dyn Console,
|
||||
) -> Result<()> {
|
||||
let marked = console.agent_marked();
|
||||
let plugin = find(registry, locale, name)?;
|
||||
if json {
|
||||
console.print(&serde_json::to_string_pretty(&review_json(
|
||||
registry, plugin, locale, marked,
|
||||
))?);
|
||||
} else {
|
||||
console.print(&review_text(registry, plugin, locale, marked));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) struct TrustRequest {
|
||||
pub name: String,
|
||||
pub review_token: Option<String>,
|
||||
@@ -315,13 +351,10 @@ pub(crate) fn trust(
|
||||
"`--yes` only skips the confirmation prompt. Trust also needs --review-token, the bundle review token that names the exact content to trust; to see it, {review_again}"
|
||||
);
|
||||
}
|
||||
show(registry, locale, &name, json, console)?;
|
||||
if json {
|
||||
console.print(&serde_json::to_string_pretty(&review_json(
|
||||
registry, plugin, locale, marked,
|
||||
))?);
|
||||
return Ok(());
|
||||
}
|
||||
console.print(&review_text(registry, plugin, locale, marked));
|
||||
if !console.interactive() {
|
||||
console.print(&format!(
|
||||
"\nNot trusted. The bundle review token ties trust to exactly the content shown above; it does not record that anyone read this review. A trust made with it is recorded as {}. To trust, run:\n {}",
|
||||
@@ -358,7 +391,7 @@ pub(crate) fn trust(
|
||||
"Trust needs confirmation: pass --yes with the bundle review token, or run in a terminal to be shown the review and asked"
|
||||
);
|
||||
}
|
||||
console.print(&review_text(registry, plugin, locale, marked));
|
||||
show(registry, locale, &name, false, console)?;
|
||||
if !console.confirm("\nTrust this bundle with exactly the capabilities above?") {
|
||||
console.print("Not trusted.");
|
||||
return Ok(());
|
||||
@@ -370,6 +403,150 @@ pub(crate) fn trust(
|
||||
report(registry, locale, &name, "trusted", json, console)
|
||||
}
|
||||
|
||||
pub(crate) fn update(
|
||||
registry: &mut Arc<PluginRegistry>,
|
||||
locale: Locale,
|
||||
name: &str,
|
||||
network: &codewhale_net::network_policy::NetworkPolicy,
|
||||
json: bool,
|
||||
console: &mut dyn Console,
|
||||
) -> Result<()> {
|
||||
use codewhale_plugins::mutation::{
|
||||
PluginMutationContext, PluginMutationOutcome, PluginMutationRequest, check_network_outcome,
|
||||
execute,
|
||||
};
|
||||
let before = find(registry, locale, name)?.clone();
|
||||
let selector = before.name().to_string();
|
||||
let ctx = PluginMutationContext {
|
||||
network,
|
||||
max_size: codewhale_plugins::install::DEFAULT_MAX_SIZE_BYTES,
|
||||
};
|
||||
let receipt = block_on_mutation(execute(
|
||||
PluginMutationRequest::Update { selector },
|
||||
&ctx,
|
||||
Arc::make_mut(registry),
|
||||
))?
|
||||
.map_err(|error| failed(locale, &format!("{error:#}")))?;
|
||||
check_network_outcome(&receipt.outcome)?;
|
||||
match receipt.outcome {
|
||||
PluginMutationOutcome::Updated => {
|
||||
*registry = registry.rediscover_for_workspace(registry.workspace());
|
||||
let version = registry
|
||||
.get(&receipt.name)
|
||||
.map(|plugin| plugin.manifest.plugin.version.clone())
|
||||
.or_else(|| receipt.path.as_deref().and_then(manifest_version_at))
|
||||
.unwrap_or_default();
|
||||
if json {
|
||||
let mut record = registry
|
||||
.get(&receipt.name)
|
||||
.map(crate::plugin_cli_record)
|
||||
.unwrap_or_else(|| {
|
||||
serde_json::json!({
|
||||
"name": text(&receipt.name),
|
||||
"version": text(&version),
|
||||
"path": receipt.path.as_ref().map(|p| text(&p.to_string_lossy())),
|
||||
"content_hash": receipt.installed_content_hash.or(receipt.content_hash),
|
||||
})
|
||||
});
|
||||
if let Some(object) = record.as_object_mut() {
|
||||
object.insert("action".to_string(), "updated".into());
|
||||
}
|
||||
console.print(&serde_json::to_string_pretty(&record)?);
|
||||
} else {
|
||||
let action = if version.is_empty() {
|
||||
"updated".to_string()
|
||||
} else {
|
||||
format!("updated to {}", text(&version))
|
||||
};
|
||||
console.print(
|
||||
&tr(locale, MessageId::CmdPluginBundleMutationSuccess)
|
||||
.replace("{name}", &text(&receipt.name))
|
||||
.replace("{action}", &action),
|
||||
);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
PluginMutationOutcome::NoChange => {
|
||||
if json {
|
||||
let mut record = crate::plugin_cli_record(&before);
|
||||
if let Some(object) = record.as_object_mut() {
|
||||
object.insert("action".to_string(), "already_current".into());
|
||||
}
|
||||
console.print(&serde_json::to_string_pretty(&record)?);
|
||||
} else {
|
||||
console.print(
|
||||
&tr(locale, MessageId::CmdPluginBundleMutationSuccess)
|
||||
.replace("{name}", &text(&receipt.name))
|
||||
.replace("{action}", "already current"),
|
||||
);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
_ => bail!("Unexpected plugin update outcome"),
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn uninstall(
|
||||
registry: &mut Arc<PluginRegistry>,
|
||||
locale: Locale,
|
||||
name: &str,
|
||||
yes: bool,
|
||||
json: bool,
|
||||
console: &mut dyn Console,
|
||||
) -> Result<()> {
|
||||
use codewhale_plugins::mutation::{PluginMutationContext, PluginMutationRequest, execute};
|
||||
use codewhale_plugins::types::PluginScope;
|
||||
let plugin = find(registry, locale, name)?.clone();
|
||||
let plugin_name = plugin.name().to_string();
|
||||
let version = plugin.manifest.plugin.version.clone();
|
||||
if !yes {
|
||||
if json || !console.interactive() {
|
||||
bail!("Uninstall needs confirmation: pass --yes, or run in a terminal to be asked");
|
||||
}
|
||||
let prompt = if version.is_empty() {
|
||||
format!("Remove {}?", text(&plugin_name))
|
||||
} else {
|
||||
format!("Remove {} {}?", text(&plugin_name), text(&version))
|
||||
};
|
||||
if !console.confirm(&prompt) {
|
||||
console.print("Not removed.");
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
if plugin.scope == PluginScope::User && plugin.enabled {
|
||||
Arc::make_mut(registry)
|
||||
.disable(&plugin_name)
|
||||
.map_err(|error| failed(locale, &error))?;
|
||||
}
|
||||
let network = codewhale_net::network_policy::NetworkPolicy::default();
|
||||
let ctx = PluginMutationContext {
|
||||
network: &network,
|
||||
max_size: codewhale_plugins::install::DEFAULT_MAX_SIZE_BYTES,
|
||||
};
|
||||
let receipt = block_on_mutation(execute(
|
||||
PluginMutationRequest::Uninstall {
|
||||
selector: plugin_name,
|
||||
},
|
||||
&ctx,
|
||||
Arc::make_mut(registry),
|
||||
))?
|
||||
.map_err(|error| failed(locale, &format!("{error:#}")))?;
|
||||
if json {
|
||||
console.print(&serde_json::to_string_pretty(&serde_json::json!({
|
||||
"name": text(&receipt.name),
|
||||
"version": text(&version),
|
||||
"action": "uninstalled",
|
||||
}))?);
|
||||
} else {
|
||||
console.print(
|
||||
&tr(locale, MessageId::CmdPluginBundleMutationSuccess)
|
||||
.replace("{name}", &text(&receipt.name))
|
||||
.replace("{action}", "uninstalled"),
|
||||
);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) fn enable(
|
||||
registry: &mut Arc<PluginRegistry>,
|
||||
locale: Locale,
|
||||
|
||||
@@ -1507,3 +1507,258 @@ fn headless_marked_trust_needs_an_install_from_a_folder_in_the_project() {
|
||||
Some(PluginApprover::command_line(Some(registry.workspace())))
|
||||
);
|
||||
}
|
||||
|
||||
fn install_temp_home_plugin(
|
||||
root: &Path,
|
||||
name: &str,
|
||||
version: &str,
|
||||
) -> (
|
||||
std::path::PathBuf,
|
||||
std::sync::Arc<codewhale_plugins::PluginRegistry>,
|
||||
) {
|
||||
let source = root.join("src").join(name);
|
||||
fs::create_dir_all(source.join("skills/hello")).unwrap();
|
||||
fs::write(
|
||||
source.join("plugin.toml"),
|
||||
format!(
|
||||
"schema_version = 1\n[plugin]\nname = {name:?}\nversion = {version:?}\n[skills]\npath = \"skills\"\n"
|
||||
),
|
||||
)
|
||||
.unwrap();
|
||||
fs::write(
|
||||
source.join("skills/hello/SKILL.md"),
|
||||
"---\nname: hello\ndescription: hi\n---\nbody\n",
|
||||
)
|
||||
.unwrap();
|
||||
let (app, _temp) = create_test_app(root);
|
||||
let mut registry = app.plugin_registry.clone();
|
||||
let network = codewhale_net::network_policy::NetworkPolicy::default();
|
||||
let ctx = codewhale_plugins::mutation::PluginMutationContext {
|
||||
network: &network,
|
||||
max_size: codewhale_plugins::install::DEFAULT_MAX_SIZE_BYTES,
|
||||
};
|
||||
let parsed = codewhale_plugins::install::PluginInstallSource::parse(
|
||||
source.to_str().expect("utf-8 path"),
|
||||
)
|
||||
.unwrap();
|
||||
tokio::runtime::Builder::new_current_thread()
|
||||
.enable_all()
|
||||
.build()
|
||||
.unwrap()
|
||||
.block_on(codewhale_plugins::mutation::execute(
|
||||
codewhale_plugins::mutation::PluginMutationRequest::Install { source: parsed },
|
||||
&ctx,
|
||||
std::sync::Arc::make_mut(&mut registry),
|
||||
))
|
||||
.unwrap();
|
||||
let registry = registry.rediscover_for_workspace(registry.workspace());
|
||||
(source, registry)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn headless_show_prints_review_without_prompting_or_trusting() {
|
||||
let _lock = crate::test_support::lock_test_env();
|
||||
let root = TempDir::new().unwrap();
|
||||
let _home = crate::test_support::EnvVarGuard::set("CODEWHALE_HOME", root.path().join("home"));
|
||||
write_mcp_review_bundle(root.path());
|
||||
let (mut app, _temp) = create_test_app(root.path());
|
||||
let Some(AppAction::OpenCommandReview { content, .. }) =
|
||||
plugins_with_kimi_home_override(&mut app, Some("trust review-mcp"), None).action
|
||||
else {
|
||||
panic!("trust opens a confirmation control");
|
||||
};
|
||||
let registry = app.plugin_registry.clone();
|
||||
let token = registry.get("review-mcp").unwrap().review_token();
|
||||
|
||||
let mut interactive = ScriptedConsole {
|
||||
interactive: true,
|
||||
answer: true,
|
||||
..ScriptedConsole::default()
|
||||
};
|
||||
headless::show(®istry, Locale::En, "review-mcp", false, &mut interactive).unwrap();
|
||||
assert!(interactive.prompts.is_empty());
|
||||
assert!(interactive.printed[0].starts_with(&content));
|
||||
assert!(interactive.printed[0].ends_with(&format!("Bundle review token: {token}")));
|
||||
assert!(!registry.get("review-mcp").unwrap().trusted());
|
||||
|
||||
let mut json_console = ScriptedConsole::default();
|
||||
headless::show(®istry, Locale::En, "review-mcp", true, &mut json_console).unwrap();
|
||||
let parsed: serde_json::Value = serde_json::from_str(&json_console.printed[0]).unwrap();
|
||||
assert_eq!(parsed["action"], "review");
|
||||
assert_eq!(parsed["trusted_by_this_command"], false);
|
||||
assert_eq!(parsed["bundle_review_token"], token);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn headless_update_reports_current_or_new_version_and_network_approval() {
|
||||
let _lock = crate::test_support::lock_test_env();
|
||||
let root = TempDir::new().unwrap();
|
||||
let home = root.path().join("home");
|
||||
let _home = crate::test_support::EnvVarGuard::set("CODEWHALE_HOME", &home);
|
||||
let (source, mut registry) = install_temp_home_plugin(root.path(), "demo", "1.0.0");
|
||||
let network = codewhale_net::network_policy::NetworkPolicy::default();
|
||||
|
||||
let mut console = ScriptedConsole::default();
|
||||
headless::update(
|
||||
&mut registry,
|
||||
Locale::En,
|
||||
"demo",
|
||||
&network,
|
||||
false,
|
||||
&mut console,
|
||||
)
|
||||
.unwrap();
|
||||
assert!(
|
||||
console.printed.last().unwrap().contains("already current"),
|
||||
"{:?}",
|
||||
console.printed
|
||||
);
|
||||
|
||||
fs::write(
|
||||
source.join("plugin.toml"),
|
||||
"schema_version = 1\n[plugin]\nname = \"demo\"\nversion = \"1.1.0\"\n[skills]\npath = \"skills\"\n",
|
||||
)
|
||||
.unwrap();
|
||||
headless::update(
|
||||
&mut registry,
|
||||
Locale::En,
|
||||
"demo",
|
||||
&network,
|
||||
false,
|
||||
&mut console,
|
||||
)
|
||||
.unwrap();
|
||||
assert!(
|
||||
console.printed.last().unwrap().contains("updated to 1.1.0"),
|
||||
"{:?}",
|
||||
console.printed
|
||||
);
|
||||
assert_eq!(
|
||||
registry.get("demo").unwrap().manifest.plugin.version,
|
||||
"1.1.0"
|
||||
);
|
||||
|
||||
fs::write(
|
||||
home.join("plugins/demo/.installed-from"),
|
||||
serde_json::json!({
|
||||
"version": 2,
|
||||
"spec": "https://example.com/demo.tar.gz",
|
||||
"source_checksum": "",
|
||||
"content_hash": "",
|
||||
"plugin_name": "demo"
|
||||
})
|
||||
.to_string(),
|
||||
)
|
||||
.unwrap();
|
||||
let err = headless::update(
|
||||
&mut registry,
|
||||
Locale::En,
|
||||
"demo",
|
||||
&network,
|
||||
false,
|
||||
&mut console,
|
||||
)
|
||||
.unwrap_err()
|
||||
.to_string();
|
||||
assert!(
|
||||
err.contains(
|
||||
"Network approval required for example.com; use /network allow example.com, then retry"
|
||||
),
|
||||
"{err}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn headless_uninstall_disables_enabled_plugin_first_and_removes_it() {
|
||||
let _lock = crate::test_support::lock_test_env();
|
||||
let root = TempDir::new().unwrap();
|
||||
let home = root.path().join("home");
|
||||
let _home = crate::test_support::EnvVarGuard::set("CODEWHALE_HOME", &home);
|
||||
let (_source, mut registry) = install_temp_home_plugin(root.path(), "demo", "1.0.0");
|
||||
let token = registry.get("demo").unwrap().review_token();
|
||||
let mut console = ScriptedConsole::default();
|
||||
headless_trust_named(
|
||||
&mut registry,
|
||||
"demo",
|
||||
Some(&token),
|
||||
true,
|
||||
false,
|
||||
&mut console,
|
||||
)
|
||||
.unwrap();
|
||||
headless::enable(&mut registry, Locale::En, "demo", false, &mut console).unwrap();
|
||||
assert!(registry.get("demo").unwrap().enabled);
|
||||
let installed_dir = home.join("plugins/demo");
|
||||
assert!(installed_dir.exists());
|
||||
|
||||
let piped_err = headless::uninstall(
|
||||
&mut registry,
|
||||
Locale::En,
|
||||
"demo",
|
||||
false,
|
||||
false,
|
||||
&mut console,
|
||||
)
|
||||
.unwrap_err()
|
||||
.to_string();
|
||||
assert!(piped_err.contains("--yes"), "{piped_err}");
|
||||
assert!(installed_dir.exists());
|
||||
|
||||
let mut json_interactive = ScriptedConsole {
|
||||
interactive: true,
|
||||
answer: true,
|
||||
..ScriptedConsole::default()
|
||||
};
|
||||
let json_err = headless::uninstall(
|
||||
&mut registry,
|
||||
Locale::En,
|
||||
"demo",
|
||||
false,
|
||||
true,
|
||||
&mut json_interactive,
|
||||
)
|
||||
.unwrap_err()
|
||||
.to_string();
|
||||
assert!(json_err.contains("--yes"), "{json_err}");
|
||||
assert!(json_interactive.prompts.is_empty());
|
||||
assert!(installed_dir.exists());
|
||||
|
||||
let mut declined = ScriptedConsole {
|
||||
interactive: true,
|
||||
answer: false,
|
||||
..ScriptedConsole::default()
|
||||
};
|
||||
headless::uninstall(
|
||||
&mut registry,
|
||||
Locale::En,
|
||||
"demo",
|
||||
false,
|
||||
false,
|
||||
&mut declined,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(declined.prompts.len(), 1);
|
||||
assert_eq!(declined.prompts[0].0, "Remove demo 1.0.0?");
|
||||
assert!(installed_dir.exists());
|
||||
assert!(registry.get("demo").unwrap().enabled);
|
||||
|
||||
let mut accepted = ScriptedConsole {
|
||||
interactive: true,
|
||||
answer: true,
|
||||
..ScriptedConsole::default()
|
||||
};
|
||||
headless::uninstall(
|
||||
&mut registry,
|
||||
Locale::En,
|
||||
"demo",
|
||||
false,
|
||||
false,
|
||||
&mut accepted,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(accepted.prompts.len(), 1);
|
||||
assert_eq!(accepted.prompts[0].0, "Remove demo 1.0.0?");
|
||||
assert!(!installed_dir.exists());
|
||||
let rediscovered = registry.rediscover_for_workspace(registry.workspace());
|
||||
assert!(rediscovered.get("demo").is_none());
|
||||
}
|
||||
@@ -243,7 +243,7 @@ fn parse_scope_args(args: &str) -> Result<(Option<SkillTargetScope>, &str), Stri
|
||||
pub(in crate::commands) const SKILLS_INFO: CommandInfo = CommandInfo {
|
||||
name: "skills",
|
||||
aliases: &["jinengliebiao"],
|
||||
usage: "/skills [manage|--remote|sync|inspect|suggest <task>|<prefix>] (bare opens Extensions)",
|
||||
usage: "/skills [manage|--remote|sync|inspect|suggest <task>|<prefix>] (bare opens Plugins)",
|
||||
description_key: "cmd_skills_description",
|
||||
};
|
||||
|
||||
|
||||
@@ -1362,6 +1362,25 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn plugin_command_is_primary_discovery() {
|
||||
use codewhale_engine::command_catalog::CommandCatalog;
|
||||
|
||||
assert!(!traits::ADVANCED_DISCOVERY_COMMANDS.contains(&"plugin"));
|
||||
let info = registry().get_info("plugin").expect("/plugin registered");
|
||||
assert!(info.show_in_empty_discovery());
|
||||
let row = CommandTable
|
||||
.builtins()
|
||||
.into_iter()
|
||||
.find(|row| row.name == "plugin")
|
||||
.expect("/plugin in the built-in catalog");
|
||||
assert_eq!(
|
||||
row.discovery,
|
||||
codewhale_command_contract::CommandDiscovery::Primary
|
||||
);
|
||||
assert!(row.show_in_empty_discovery);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn command_discovery_tier_lists_use_canonical_registered_names() {
|
||||
for (tier_name, names) in [
|
||||
@@ -2992,7 +3011,7 @@ mod tests {
|
||||
std::fs::create_dir_all(tmp.path().join("skills")).unwrap();
|
||||
feat022_write_skill(&tmp.path().join("skills"), "demo");
|
||||
|
||||
// Bare /skills opens Extensions; explicit manage retains the mutation surface.
|
||||
// Bare /skills opens Plugins; explicit manage retains the mutation surface.
|
||||
let result = execute("/skills", &mut app);
|
||||
assert!(!result.is_error, "{result:?}");
|
||||
assert!(
|
||||
|
||||
@@ -31,7 +31,6 @@ pub(crate) const ADVANCED_DISCOVERY_COMMANDS: &[&str] = &[
|
||||
"modeldb",
|
||||
"models",
|
||||
"network",
|
||||
"plugin",
|
||||
"preview-request",
|
||||
"profile",
|
||||
"purge",
|
||||
|
||||
+206
-11
@@ -147,7 +147,9 @@ enum Commands {
|
||||
SessionDiagnostics(SessionDiagnosticsArgs),
|
||||
#[command(about = "Install a local, GitHub or version-pinned npm plugin bundle")]
|
||||
Install { source: String },
|
||||
#[command(about = "Inspect, review, trust, enable, disable or revoke plugin bundles")]
|
||||
#[command(
|
||||
about = "Install, inspect, review, trust, enable, disable, update, uninstall or revoke plugin bundles"
|
||||
)]
|
||||
Plugins {
|
||||
#[command(subcommand)]
|
||||
command: PluginsCommand,
|
||||
@@ -278,6 +280,40 @@ enum PluginsCommand {
|
||||
#[arg(long)]
|
||||
json: bool,
|
||||
},
|
||||
#[command(about = "Show a plugin's capability review without prompting or trusting")]
|
||||
Show {
|
||||
#[arg(value_name = "NAME", help = "Plugin bundle name")]
|
||||
name: String,
|
||||
#[arg(long, help = "Print the review as JSON")]
|
||||
json: bool,
|
||||
},
|
||||
#[command(
|
||||
about = "Install a local, GitHub or version-pinned npm plugin bundle without trusting or enabling it"
|
||||
)]
|
||||
Install {
|
||||
#[arg(value_name = "SPEC")]
|
||||
spec: String,
|
||||
#[arg(long, help = "Print the result as JSON")]
|
||||
json: bool,
|
||||
},
|
||||
#[command(about = "Update an installed user-scope plugin bundle from its recorded source")]
|
||||
Update {
|
||||
#[arg(value_name = "NAME", help = "Plugin bundle name")]
|
||||
name: String,
|
||||
#[arg(long, help = "Print the result as JSON")]
|
||||
json: bool,
|
||||
},
|
||||
#[command(
|
||||
about = "Uninstall an installed user-scope plugin bundle (disables it first if enabled)"
|
||||
)]
|
||||
Uninstall {
|
||||
#[arg(value_name = "NAME", help = "Plugin bundle name")]
|
||||
name: String,
|
||||
#[arg(long, short = 'y', help = "Skip the confirmation prompt")]
|
||||
yes: bool,
|
||||
#[arg(long, help = "Print the result as JSON; requires --yes")]
|
||||
json: bool,
|
||||
},
|
||||
#[command(about = "Restore an eligible reviewed plugin snapshot")]
|
||||
Restage {
|
||||
#[arg(value_name = "NAME")]
|
||||
@@ -2468,7 +2504,7 @@ fn telemetry_command_is_read_only(command: Option<&Commands>) -> bool {
|
||||
|| matches!(
|
||||
command,
|
||||
Some(Commands::Plugins {
|
||||
command: PluginsCommand::Status { .. }
|
||||
command: PluginsCommand::Status { .. } | PluginsCommand::Show { .. }
|
||||
})
|
||||
)
|
||||
}
|
||||
@@ -2803,15 +2839,37 @@ async fn run_async_main_dispatch(
|
||||
)
|
||||
.await
|
||||
}
|
||||
Commands::Plugins { command } => tokio::task::spawn_blocking(move || {
|
||||
let _config = load_structural_config_from_cli(&cli)?;
|
||||
let workspace = crate::commands::groups::plugins::headless::agent_project()
|
||||
.unwrap_or_else(|| resolve_workspace(&cli));
|
||||
let registry = plugin_discovery.inspect_registry_for_workspace(&workspace);
|
||||
run_plugins_cli(command, registry)
|
||||
})
|
||||
.await
|
||||
.context("Plugin command failed to finish")?,
|
||||
Commands::Plugins { command } => match command {
|
||||
PluginsCommand::Install { spec, json } => {
|
||||
let config = load_config_from_cli(&cli)?;
|
||||
let default_workspace = resolve_workspace(&cli);
|
||||
let workspace = crate::commands::groups::plugins::headless::agent_project()
|
||||
.unwrap_or_else(|| default_workspace.clone());
|
||||
let registry = if workspace == default_workspace {
|
||||
(*plugin_registry).clone()
|
||||
} else {
|
||||
(*plugin_discovery.inspect_registry_for_workspace(&workspace)).clone()
|
||||
};
|
||||
run_plugins_install_cli(
|
||||
spec,
|
||||
json,
|
||||
config,
|
||||
registry,
|
||||
&plugin_discovery,
|
||||
&workspace,
|
||||
)
|
||||
.await
|
||||
}
|
||||
command => tokio::task::spawn_blocking(move || {
|
||||
let config = load_structural_config_from_cli(&cli)?;
|
||||
let workspace = crate::commands::groups::plugins::headless::agent_project()
|
||||
.unwrap_or_else(|| resolve_workspace(&cli));
|
||||
let registry = plugin_discovery.inspect_registry_for_workspace(&workspace);
|
||||
run_plugins_cli(command, registry, &config)
|
||||
})
|
||||
.await
|
||||
.context("Plugin command failed to finish")?,
|
||||
},
|
||||
Commands::Setup(args) => {
|
||||
let config = load_config_from_cli(&cli)?;
|
||||
let workspace = resolve_workspace(&cli);
|
||||
@@ -9433,9 +9491,47 @@ fn print_plugin_cli_diagnostic(diagnostic: &codewhale_plugins::types::PluginDiag
|
||||
);
|
||||
}
|
||||
|
||||
async fn run_plugins_install_cli(
|
||||
spec: String,
|
||||
json: bool,
|
||||
config: Config,
|
||||
registry: codewhale_plugins::PluginRegistry,
|
||||
plugin_discovery: &Arc<codewhale_plugins::PluginDiscoveryContext>,
|
||||
workspace: &Path,
|
||||
) -> Result<()> {
|
||||
if !json {
|
||||
return codewhale_plugins::mutation::install_from_cli(spec, config, registry).await;
|
||||
}
|
||||
let receipt =
|
||||
codewhale_plugins::mutation::install_from_cli_receipt(spec, config, registry).await?;
|
||||
let refreshed = plugin_discovery.inspect_registry_for_workspace(workspace);
|
||||
let record = refreshed
|
||||
.get(&receipt.name)
|
||||
.map(|plugin| {
|
||||
let mut value = plugin_cli_record(plugin);
|
||||
if let Some(object) = value.as_object_mut() {
|
||||
object.insert("action".to_string(), "installed".into());
|
||||
}
|
||||
value
|
||||
})
|
||||
.unwrap_or_else(|| {
|
||||
serde_json::json!({
|
||||
"name": plugin_cli_text(&receipt.name),
|
||||
"action": "installed",
|
||||
"path": receipt.path.as_ref().map(|p| plugin_cli_text(&p.to_string_lossy())),
|
||||
"content_hash": receipt.installed_content_hash.or(receipt.content_hash),
|
||||
"trust": "untrusted",
|
||||
"enabled": false,
|
||||
})
|
||||
});
|
||||
println!("{}", serde_json::to_string_pretty(&record)?);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn run_plugins_cli(
|
||||
command: PluginsCommand,
|
||||
mut registry: Arc<codewhale_plugins::PluginRegistry>,
|
||||
config: &Config,
|
||||
) -> Result<()> {
|
||||
use crate::commands::groups::plugins::headless;
|
||||
use codewhale_localization::{MessageId, resolve_locale, tr};
|
||||
@@ -9525,6 +9621,39 @@ fn run_plugins_cli(
|
||||
}
|
||||
}
|
||||
}
|
||||
PluginsCommand::Show { name, json } => headless::show(
|
||||
®istry,
|
||||
locale,
|
||||
&name,
|
||||
json,
|
||||
&mut headless::ProcessConsole,
|
||||
)?,
|
||||
PluginsCommand::Install { .. } => {
|
||||
unreachable!("plugins install is dispatched before spawn_blocking")
|
||||
}
|
||||
PluginsCommand::Update { name, json } => {
|
||||
let network = config
|
||||
.network
|
||||
.clone()
|
||||
.map(codewhale_net::network_policy::NetworkPolicy::from)
|
||||
.unwrap_or_default();
|
||||
headless::update(
|
||||
&mut registry,
|
||||
locale,
|
||||
&name,
|
||||
&network,
|
||||
json,
|
||||
&mut headless::ProcessConsole,
|
||||
)?;
|
||||
}
|
||||
PluginsCommand::Uninstall { name, yes, json } => headless::uninstall(
|
||||
&mut registry,
|
||||
locale,
|
||||
&name,
|
||||
yes,
|
||||
json,
|
||||
&mut headless::ProcessConsole,
|
||||
)?,
|
||||
PluginsCommand::Trust {
|
||||
name,
|
||||
review_token,
|
||||
@@ -21017,6 +21146,72 @@ mod mcp_add_arg_tests {
|
||||
#[cfg(test)]
|
||||
mod config_stay_tests;
|
||||
|
||||
#[cfg(test)]
|
||||
mod plugins_cli_tests {
|
||||
use super::*;
|
||||
use clap::Parser;
|
||||
|
||||
#[test]
|
||||
fn plugins_cli_parses_install_uninstall_update_and_show() {
|
||||
let install = Cli::try_parse_from([
|
||||
"codewhale",
|
||||
"plugins",
|
||||
"install",
|
||||
"github:example/plugin",
|
||||
"--json",
|
||||
])
|
||||
.expect("parse plugins install");
|
||||
assert!(matches!(
|
||||
install.command,
|
||||
Some(Commands::Plugins {
|
||||
command: PluginsCommand::Install {
|
||||
ref spec,
|
||||
json: true,
|
||||
}
|
||||
}) if spec == "github:example/plugin"
|
||||
));
|
||||
|
||||
let uninstall =
|
||||
Cli::try_parse_from(["codewhale", "plugins", "uninstall", "demo", "-y", "--json"])
|
||||
.expect("parse plugins uninstall");
|
||||
assert!(matches!(
|
||||
uninstall.command,
|
||||
Some(Commands::Plugins {
|
||||
command: PluginsCommand::Uninstall {
|
||||
ref name,
|
||||
yes: true,
|
||||
json: true,
|
||||
}
|
||||
}) if name == "demo"
|
||||
));
|
||||
|
||||
let update = Cli::try_parse_from(["codewhale", "plugins", "update", "demo", "--json"])
|
||||
.expect("parse plugins update");
|
||||
assert!(matches!(
|
||||
update.command,
|
||||
Some(Commands::Plugins {
|
||||
command: PluginsCommand::Update {
|
||||
ref name,
|
||||
json: true,
|
||||
}
|
||||
}) if name == "demo"
|
||||
));
|
||||
|
||||
let show = Cli::try_parse_from(["codewhale", "plugins", "show", "demo", "--json"])
|
||||
.expect("parse plugins show");
|
||||
assert!(matches!(
|
||||
show.command,
|
||||
Some(Commands::Plugins {
|
||||
command: PluginsCommand::Show {
|
||||
ref name,
|
||||
json: true,
|
||||
}
|
||||
}) if name == "demo"
|
||||
));
|
||||
assert!(telemetry_command_is_read_only(show.command.as_ref()));
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod doctor_secret_scan_tests {
|
||||
use std::path::PathBuf;
|
||||
|
||||
@@ -1757,6 +1757,7 @@ pub struct App {
|
||||
pub plugin_reload_nudge_stamp: Option<codewhale_plugins::PluginCatalogStamp>,
|
||||
/// Last idle catalog fingerprint poll, so disk changes can surface between turns.
|
||||
pub last_plugin_catalog_poll: Option<Instant>,
|
||||
pub mcp_config_watch: Option<crate::tui::plugin_suggestions::McpConfigWatch>,
|
||||
/// Live composer plugin CTA (debounce + one match, never auto-install).
|
||||
pub plugin_cta: crate::tui::plugin_suggestions::PluginCtaState,
|
||||
pub model: String,
|
||||
|
||||
@@ -858,6 +858,7 @@ impl App {
|
||||
context_pressure_warning_dismissed: None,
|
||||
plugin_reload_nudge_stamp: None,
|
||||
last_plugin_catalog_poll: None,
|
||||
mcp_config_watch: None,
|
||||
plugin_cta: crate::tui::plugin_suggestions::PluginCtaState::from_settings(&settings),
|
||||
model,
|
||||
provider_models,
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
use crate::tui::app::App;
|
||||
use crate::tui::history::HistoryCell;
|
||||
|
||||
/// `/mcp` and every MCP action land on Extensions → MCP, the one MCP
|
||||
/// `/mcp` and every MCP action land on Plugins → Connectors, the one MCP
|
||||
/// surface, rebuilt from the snapshot just stored on `app`. A pager full of
|
||||
/// text was the 0.9.12 "terrible menu" (defects #17–#20). An Extensions view
|
||||
/// already on top is replaced so it reads the fresh snapshot.
|
||||
|
||||
@@ -31,6 +31,22 @@ use codewhale_plugins::recommend::{
|
||||
|
||||
const CATALOG_POLL_INTERVAL: Duration = Duration::from_secs(2);
|
||||
|
||||
pub const MCP_CONFIG_NUDGE: &str =
|
||||
"MCP servers changed on disk. They apply when the session is idle, or run /mcp reload.";
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct ExternalCatalogChange {
|
||||
pub plugins: bool,
|
||||
pub mcp: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct McpConfigWatch {
|
||||
pub digest: Option<[u8; 32]>,
|
||||
pub generation: u64,
|
||||
pub notified: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum PluginCtaPhase {
|
||||
Hidden,
|
||||
@@ -124,24 +140,63 @@ impl App {
|
||||
true
|
||||
}
|
||||
|
||||
/// Cheap idle poll so on-disk plugin changes can surface between turns,
|
||||
/// not only on send. Fingerprints directories; never auto-reloads.
|
||||
pub fn maybe_poll_plugin_catalog_idle(&mut self) {
|
||||
pub fn poll_external_catalog_changes(
|
||||
&mut self,
|
||||
engine_busy: bool,
|
||||
) -> Option<ExternalCatalogChange> {
|
||||
let now = Instant::now();
|
||||
if self
|
||||
.last_plugin_catalog_poll
|
||||
.is_some_and(|seen| now.duration_since(seen) < CATALOG_POLL_INTERVAL)
|
||||
{
|
||||
return;
|
||||
return None;
|
||||
}
|
||||
self.last_plugin_catalog_poll = Some(now);
|
||||
if let Some(message) = codewhale_plugins::plugin_reload_nudge(
|
||||
self.plugin_registry.as_ref(),
|
||||
&mut self.plugin_reload_nudge_stamp,
|
||||
) {
|
||||
self.push_status_toast(message, StatusToastLevel::Warning, Some(8_000));
|
||||
self.needs_redraw = true;
|
||||
let digest = codewhale_plugins::discovery::file_content_digest(&self.mcp_config_path);
|
||||
let generation = self.mcp_snapshot_generation;
|
||||
let watch = self.mcp_config_watch.get_or_insert(McpConfigWatch {
|
||||
digest,
|
||||
generation,
|
||||
notified: false,
|
||||
});
|
||||
if watch.generation != generation {
|
||||
*watch = McpConfigWatch {
|
||||
digest,
|
||||
generation,
|
||||
notified: false,
|
||||
};
|
||||
}
|
||||
let mcp = watch.digest != digest;
|
||||
let mcp_unannounced = mcp && !std::mem::replace(&mut watch.notified, true);
|
||||
let plugins = self.plugin_registry.on_disk_catalog_changed();
|
||||
if !plugins && !mcp {
|
||||
return None;
|
||||
}
|
||||
if engine_busy || self.pending_fresh_session.is_some() {
|
||||
if plugins
|
||||
&& let Some(message) = codewhale_plugins::plugin_reload_nudge(
|
||||
self.plugin_registry.as_ref(),
|
||||
&mut self.plugin_reload_nudge_stamp,
|
||||
)
|
||||
{
|
||||
self.push_status_toast(message, StatusToastLevel::Warning, Some(8_000));
|
||||
self.needs_redraw = true;
|
||||
}
|
||||
if mcp_unannounced {
|
||||
self.push_status_toast(MCP_CONFIG_NUDGE, StatusToastLevel::Warning, Some(8_000));
|
||||
self.needs_redraw = true;
|
||||
}
|
||||
return None;
|
||||
}
|
||||
Some(ExternalCatalogChange { plugins, mcp })
|
||||
}
|
||||
|
||||
pub fn mark_external_catalog_applied(&mut self) {
|
||||
self.mcp_config_watch = Some(McpConfigWatch {
|
||||
digest: codewhale_plugins::discovery::file_content_digest(&self.mcp_config_path),
|
||||
generation: self.mcp_snapshot_generation,
|
||||
notified: false,
|
||||
});
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
@@ -318,13 +373,14 @@ mod tests {
|
||||
"schema_version = 1\n[plugin]\nname = \"supabase\"\nversion = \"1.0.0\"\ndescription = \"Hosted Postgres and auth\"\nkeywords = [\"supabase\"]\n",
|
||||
)
|
||||
.unwrap();
|
||||
let temp = TempDir::new().unwrap();
|
||||
let app_dir = root.path().join("app");
|
||||
fs::create_dir_all(&app_dir).unwrap();
|
||||
let options = TuiOptions {
|
||||
config_path: Some(temp.path().join("config.toml")),
|
||||
skills_dir: temp.path().join("skills"),
|
||||
memory_path: temp.path().join("memory.md"),
|
||||
notes_path: temp.path().join("notes.txt"),
|
||||
mcp_config_path: temp.path().join("mcp.json"),
|
||||
config_path: Some(app_dir.join("config.toml")),
|
||||
skills_dir: app_dir.join("skills"),
|
||||
memory_path: app_dir.join("memory.md"),
|
||||
notes_path: app_dir.join("notes.txt"),
|
||||
mcp_config_path: app_dir.join("mcp.json"),
|
||||
..crate::tui::test_support::test_tui_options(root.path())
|
||||
};
|
||||
let discovery = codewhale_plugins::PluginDiscoveryContext::capture_pre_dotenv();
|
||||
@@ -417,12 +473,53 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn external_state_and_mcp_writes_apply_when_idle_and_toast_while_busy() {
|
||||
let _lock = crate::test_support::lock_test_env();
|
||||
let (mut app, _root, _home) = app_with_supabase_plugin();
|
||||
assert_eq!(app.poll_external_catalog_changes(false), None);
|
||||
|
||||
let state_path = app.plugin_registry.state_path().unwrap().to_path_buf();
|
||||
fs::create_dir_all(state_path.parent().unwrap()).unwrap();
|
||||
fs::write(&state_path, "{\"version\":1,\"plugins\":{}}").unwrap();
|
||||
fs::write(&app.mcp_config_path, "{\"servers\":{}}").unwrap();
|
||||
|
||||
app.last_plugin_catalog_poll = None;
|
||||
assert_eq!(app.poll_external_catalog_changes(true), None);
|
||||
let toasts = app
|
||||
.status_toasts
|
||||
.iter()
|
||||
.map(|toast| toast.text.to_string())
|
||||
.collect::<Vec<_>>();
|
||||
assert!(toasts.iter().any(|text| text.contains("Plugins changed")));
|
||||
assert!(
|
||||
toasts
|
||||
.iter()
|
||||
.any(|text| text.contains("MCP servers changed"))
|
||||
);
|
||||
|
||||
app.last_plugin_catalog_poll = None;
|
||||
assert_eq!(
|
||||
app.poll_external_catalog_changes(false),
|
||||
Some(ExternalCatalogChange {
|
||||
plugins: true,
|
||||
mcp: true
|
||||
})
|
||||
);
|
||||
app.mark_external_catalog_applied();
|
||||
app.plugin_registry = app
|
||||
.plugin_registry
|
||||
.rediscover_for_workspace(&app.workspace.clone());
|
||||
app.last_plugin_catalog_poll = None;
|
||||
assert_eq!(app.poll_external_catalog_changes(false), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn typing_a_matching_draft_never_shows_a_row() {
|
||||
let _lock = crate::test_support::lock_test_env();
|
||||
let (mut app, _root, _home) = app_with_supabase_plugin();
|
||||
app.input = "add supabase auth to login".to_string();
|
||||
app.maybe_poll_plugin_catalog_idle();
|
||||
let _ = app.poll_external_catalog_changes(false);
|
||||
assert!(!app.plugin_cta.phase.is_visible());
|
||||
assert_eq!(app.plugin_cta_row_height(), 0);
|
||||
}
|
||||
|
||||
@@ -980,7 +980,9 @@ pub(crate) use compaction_flow::*;
|
||||
pub(crate) use provider_setup::*;
|
||||
mod dispatch;
|
||||
mod dispatch_prepare;
|
||||
mod external_catalog;
|
||||
pub(crate) use dispatch_prepare::*;
|
||||
pub(crate) use external_catalog::*;
|
||||
pub(crate) mod fatal_signal_guard;
|
||||
// #6169: runtime half of the foreground-ownership contract — restore on stop,
|
||||
// rebuild on continue. Sits next to the fatal guard because both write the same
|
||||
|
||||
@@ -2640,7 +2640,17 @@ pub(crate) async fn run_event_loop(
|
||||
// #1830/#2317: service any already-arrived terminal keys before a
|
||||
// potentially long engine batch so composer/modal input stays live.
|
||||
collect_pending_terminal_events(&terminal_input, &mut pending_terminal_events)?;
|
||||
app.maybe_poll_plugin_catalog_idle();
|
||||
if let Some(change) = app.poll_external_catalog_changes(mcp_engine_busy(app)) {
|
||||
apply_external_catalog_change(
|
||||
terminal,
|
||||
app,
|
||||
&mut engine_handle,
|
||||
&task_manager,
|
||||
config,
|
||||
change,
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
|
||||
if drain_remote_control_events(app, config, &engine_handle).await? {
|
||||
app.needs_redraw = true;
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
use super::*;
|
||||
use crate::tui::plugin_suggestions::ExternalCatalogChange;
|
||||
|
||||
fn skill_delta(before: &[(String, String)], after: &[(String, String)]) -> String {
|
||||
let names = |skills: &[(String, String)]| {
|
||||
skills
|
||||
.iter()
|
||||
.map(|(name, _)| name.clone())
|
||||
.collect::<std::collections::BTreeSet<_>>()
|
||||
};
|
||||
let (before, after) = (names(before), names(after));
|
||||
let added = after.difference(&before).cloned().collect::<Vec<_>>();
|
||||
let removed = before.difference(&after).cloned().collect::<Vec<_>>();
|
||||
let mut parts = Vec::new();
|
||||
if !added.is_empty() {
|
||||
parts.push(format!("skills added: {}", added.join(", ")));
|
||||
}
|
||||
if !removed.is_empty() {
|
||||
parts.push(format!("skills removed: {}", removed.join(", ")));
|
||||
}
|
||||
parts.join("; ")
|
||||
}
|
||||
|
||||
pub(crate) async fn apply_external_catalog_change(
|
||||
terminal: &mut AppTerminal,
|
||||
app: &mut App,
|
||||
engine_handle: &mut EngineHandle,
|
||||
task_manager: &SharedTaskManager,
|
||||
config: &mut Config,
|
||||
change: ExternalCatalogChange,
|
||||
) -> Result<()> {
|
||||
app.mark_external_catalog_applied();
|
||||
if change.plugins {
|
||||
let before = app.plugin_registry.clone();
|
||||
let skills_before = app.cached_skills.clone();
|
||||
let workspace = app.workspace.clone();
|
||||
app.plugin_registry = before.rediscover_for_workspace(&workspace);
|
||||
app.refresh_skill_cache();
|
||||
let mut message = String::from("Plugins changed outside this session and were applied");
|
||||
let delta = skill_delta(&skills_before, &app.cached_skills);
|
||||
if !delta.is_empty() {
|
||||
message.push_str(&format!(" ({delta})"));
|
||||
}
|
||||
message.push('.');
|
||||
if codewhale_plugins::provider_declarations_changed(
|
||||
before.as_ref(),
|
||||
app.plugin_registry.as_ref(),
|
||||
) {
|
||||
message.push(' ');
|
||||
message.push_str(codewhale_plugins::PLUGIN_PROVIDER_NEW_SESSION_NOTICE);
|
||||
}
|
||||
let result = commands::CommandResult::with_message_and_action(
|
||||
message,
|
||||
AppAction::PluginRegistryChanged,
|
||||
);
|
||||
apply_command_result(terminal, app, engine_handle, task_manager, config, result).await?;
|
||||
}
|
||||
if change.mcp {
|
||||
let extensions_was_open = app.view_stack.extensions_is_top();
|
||||
handle_mcp_ui_action(
|
||||
app,
|
||||
engine_handle,
|
||||
config,
|
||||
crate::tui::app::McpUiAction::Reload,
|
||||
)
|
||||
.await;
|
||||
if !extensions_was_open && app.view_stack.extensions_is_top() {
|
||||
app.view_stack.pop();
|
||||
}
|
||||
}
|
||||
app.needs_redraw = true;
|
||||
Ok(())
|
||||
}
|
||||
@@ -740,7 +740,7 @@ fn mcp_scoped_config_path(
|
||||
|
||||
/// Whether a turn (or its compaction work) owns the engine. The engine
|
||||
/// services ops only between turns, so an op sent now waits for that turn.
|
||||
fn mcp_engine_busy(app: &App) -> bool {
|
||||
pub(super) fn mcp_engine_busy(app: &App) -> bool {
|
||||
app.is_loading
|
||||
|| app.dispatch_in_flight
|
||||
|| matches!(app.runtime_turn_status.as_deref(), Some("in_progress"))
|
||||
|
||||
@@ -31971,6 +31971,7 @@ fn queued_terminal_events_keep_receipt_gap_for_late_unbracketed_submit() {
|
||||
);
|
||||
}
|
||||
|
||||
#[allow(clippy::await_holding_lock)]
|
||||
#[tokio::test]
|
||||
async fn terminal_input_child_pause_drains_codewhale_events_before_editor_handoff() {
|
||||
let _serialized = CHILD_TERMINAL_GATE_TEST_LOCK
|
||||
@@ -32028,6 +32029,7 @@ async fn terminal_input_child_pause_drains_codewhale_events_before_editor_handof
|
||||
assert!(!input.paused_ack.load(std::sync::atomic::Ordering::Acquire));
|
||||
}
|
||||
|
||||
#[allow(clippy::await_holding_lock)]
|
||||
#[tokio::test]
|
||||
async fn terminal_input_handoff_preserves_pending_cancellation_keys() {
|
||||
let _serialized = CHILD_TERMINAL_GATE_TEST_LOCK
|
||||
@@ -36173,6 +36175,7 @@ fn issue_7123_reasoning_after_prose_stays_in_the_thinking_cell() {
|
||||
);
|
||||
}
|
||||
|
||||
#[allow(clippy::await_holding_lock)]
|
||||
#[tokio::test]
|
||||
async fn nested_editor_handoff_closes_every_child_window() {
|
||||
let _serialized = CHILD_TERMINAL_GATE_TEST_LOCK
|
||||
@@ -36217,6 +36220,7 @@ async fn nested_editor_handoff_closes_every_child_window() {
|
||||
);
|
||||
}
|
||||
|
||||
#[allow(clippy::await_holding_lock)]
|
||||
#[tokio::test]
|
||||
async fn single_pump_handoff_closes_its_child_window() {
|
||||
let _serialized = CHILD_TERMINAL_GATE_TEST_LOCK
|
||||
|
||||
@@ -63,10 +63,10 @@ impl ExtensionsTab {
|
||||
fn label(self, locale: Locale) -> String {
|
||||
match self {
|
||||
Self::Hooks => tr(locale, MessageId::ExtensionsTabHooks),
|
||||
Self::Plugins => tr(locale, MessageId::ExtensionsTabPlugins),
|
||||
Self::Plugins => tr(locale, MessageId::ExtensionsTabInstalled),
|
||||
Self::Marketplace => tr(locale, MessageId::ExtensionsTabMarketplace),
|
||||
Self::Skills => tr(locale, MessageId::HelpSkills),
|
||||
Self::Mcp => tr(locale, MessageId::ConfigSectionMcp),
|
||||
Self::Mcp => tr(locale, MessageId::ExtensionsTabConnectors),
|
||||
}
|
||||
.into_owned()
|
||||
}
|
||||
@@ -2290,20 +2290,41 @@ impl ModalView for ExtensionsView {
|
||||
let mut hits = HitAreas::default();
|
||||
let mut x = rows[0].x;
|
||||
let available = rows[0].right();
|
||||
for tab in ExtensionsTab::ALL {
|
||||
let label = if body.width < 58 && tab == ExtensionsTab::Marketplace {
|
||||
tr(self.locale, MessageId::ExtensionsTabMarketplaceCompact).into_owned()
|
||||
} else {
|
||||
tab.label(self.locale)
|
||||
};
|
||||
// Display cells, not chars: a CJK label is two cells per char,
|
||||
// and the painted tab and its hit rect share this one width.
|
||||
let label_cells =
|
||||
u16::try_from(UnicodeWidthStr::width(label.as_str())).unwrap_or(u16::MAX);
|
||||
let width = label_cells
|
||||
let cells = |label: &str| {
|
||||
u16::try_from(UnicodeWidthStr::width(label))
|
||||
.unwrap_or(u16::MAX)
|
||||
.saturating_add(2)
|
||||
.min(available.saturating_sub(x));
|
||||
if width == 0 {
|
||||
};
|
||||
let mut labels: Vec<(ExtensionsTab, String)> = ExtensionsTab::ALL
|
||||
.iter()
|
||||
.map(|tab| (*tab, tab.label(self.locale)))
|
||||
.collect();
|
||||
let row_cells = available.saturating_sub(x);
|
||||
let total = labels
|
||||
.iter()
|
||||
.fold(0u16, |sum, (_, label)| sum.saturating_add(cells(label)));
|
||||
if total > row_cells {
|
||||
for (tab, label) in &mut labels {
|
||||
if *tab == ExtensionsTab::Marketplace {
|
||||
*label =
|
||||
tr(self.locale, MessageId::ExtensionsTabMarketplaceCompact).into_owned();
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut first = 0;
|
||||
if let Some(active_index) = labels.iter().position(|(tab, _)| *tab == self.active_tab) {
|
||||
while first < active_index
|
||||
&& labels[first..=active_index]
|
||||
.iter()
|
||||
.fold(0u16, |sum, (_, label)| sum.saturating_add(cells(label)))
|
||||
> row_cells
|
||||
{
|
||||
first += 1;
|
||||
}
|
||||
}
|
||||
for (tab, label) in labels.into_iter().skip(first) {
|
||||
let width = cells(&label);
|
||||
if width > available.saturating_sub(x) {
|
||||
break;
|
||||
}
|
||||
let tab_area = Rect::new(x, rows[0].y, width, 1);
|
||||
@@ -2820,6 +2841,88 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
fn painted_text(buf: &Buffer, area: Rect) -> String {
|
||||
let mut text = String::new();
|
||||
for y in area.y..area.bottom() {
|
||||
for x in area.x..area.right() {
|
||||
text.push_str(buf[(x, y)].symbol());
|
||||
}
|
||||
text.push('\n');
|
||||
}
|
||||
text
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extensions_screen_is_titled_plugins_with_filter_labels() {
|
||||
for (width, height) in [(80, 24), (120, 32)] {
|
||||
let view = ExtensionsView::from_snapshot_with_locale(
|
||||
ExtensionsSnapshot::default(),
|
||||
ExtensionsTab::Plugins,
|
||||
Locale::En,
|
||||
);
|
||||
let area = Rect::new(0, 0, width, height);
|
||||
let mut buf = Buffer::empty(area);
|
||||
view.render(area, &mut buf);
|
||||
let text = painted_text(&buf, area);
|
||||
assert!(text.contains("Plugins"), "{width}x{height}: {text}");
|
||||
for label in ["Hooks", "Installed", "Marketplace", "Skills", "Connectors"] {
|
||||
assert!(
|
||||
text.contains(label),
|
||||
"{width}x{height} is missing the {label} filter: {text}"
|
||||
);
|
||||
}
|
||||
assert!(!text.contains("Extensions"), "{width}x{height}: {text}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_pack_keeps_every_painted_filter_whole_and_the_active_one_in_view() {
|
||||
for locale in Locale::shipped_complete() {
|
||||
let compact = format!(
|
||||
" {} ",
|
||||
tr(*locale, MessageId::ExtensionsTabMarketplaceCompact)
|
||||
);
|
||||
for width in [40u16, 60, 80] {
|
||||
for active in ExtensionsTab::ALL {
|
||||
let view = ExtensionsView::from_snapshot_with_locale(
|
||||
ExtensionsSnapshot::default(),
|
||||
active,
|
||||
*locale,
|
||||
);
|
||||
let area = Rect::new(0, 0, width, 24);
|
||||
let mut buf = Buffer::empty(area);
|
||||
view.render(area, &mut buf);
|
||||
let tabs = view.hits.borrow().tabs.clone();
|
||||
assert!(
|
||||
tabs.iter().any(|(_, tab)| *tab == active),
|
||||
"{} at {width}: the active {active:?} filter is not painted",
|
||||
locale.tag()
|
||||
);
|
||||
if width == 80 {
|
||||
assert_eq!(tabs.len(), ExtensionsTab::ALL.len(), "{}", locale.tag());
|
||||
}
|
||||
for (rect, tab) in tabs {
|
||||
let full = format!(" {} ", tab.label(*locale));
|
||||
let mut painted = String::new();
|
||||
let mut x = rect.x;
|
||||
while x < rect.right() {
|
||||
let symbol = buf[(x, rect.y)].symbol();
|
||||
painted.push_str(symbol);
|
||||
x += u16::try_from(UnicodeWidthStr::width(symbol).max(1)).unwrap_or(1);
|
||||
}
|
||||
assert!(
|
||||
painted == full
|
||||
|| (tab == ExtensionsTab::Marketplace && painted == compact),
|
||||
"{} at {width}: {tab:?} is clipped: {painted:?}",
|
||||
locale.tag()
|
||||
);
|
||||
assert!(rect.right() < area.right(), "{} at {width}", locale.tag());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn workbench_extension_hover_preserves_selection_and_small_resize_clears_targets() {
|
||||
let mut view = view_on_item(ExtensionAction::Command {
|
||||
|
||||
@@ -604,6 +604,18 @@ fn wait(tui: &mut Harness, text: &str) {
|
||||
}
|
||||
}
|
||||
|
||||
fn wait_for_plugins_screen(tui: &mut Harness) {
|
||||
if let Err(error) = tui.wait_for(
|
||||
|frame| frame.contains("Plugins") && frame.contains("Connectors"),
|
||||
WAIT,
|
||||
) {
|
||||
panic!(
|
||||
"waiting for the Plugins screen: {error}\n{}",
|
||||
tui.diagnostics()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
fn click_text(tui: &mut Harness, text: &str) {
|
||||
tui.pump();
|
||||
let (row, col) = tui
|
||||
@@ -617,13 +629,13 @@ fn click_text(tui: &mut Harness, text: &str) {
|
||||
fn local_slash_navigation_does_not_create_rewindable_user_turns() {
|
||||
let (_workspace, mut tui) = start_with_titles(24, 80, false, &[]);
|
||||
// The first command leaves home; the others use the active-session path.
|
||||
for (command, title) in [
|
||||
("/settings", "Settings"),
|
||||
("/skills", "Extensions"),
|
||||
("/mcp", "Extensions"),
|
||||
] {
|
||||
for (command, title) in [("/settings", "Settings"), ("/skills", ""), ("/mcp", "")] {
|
||||
tui.type_line(command).unwrap();
|
||||
wait(&mut tui, title);
|
||||
if title.is_empty() {
|
||||
wait_for_plugins_screen(&mut tui);
|
||||
} else {
|
||||
wait(&mut tui, title);
|
||||
}
|
||||
tui.send(keys::key::esc()).unwrap();
|
||||
tui.wait_for_idle(Duration::from_millis(200), WAIT).unwrap();
|
||||
assert!(
|
||||
@@ -665,7 +677,7 @@ fn raw_slash_input_keeps_a_steady_submit_cue_and_runs_on_enter_without_another_k
|
||||
tui.diagnostics()
|
||||
);
|
||||
tui.send(keys::key::enter()).unwrap();
|
||||
wait(&mut tui, "Extensions");
|
||||
wait_for_plugins_screen(&mut tui);
|
||||
tui.shutdown();
|
||||
}
|
||||
|
||||
@@ -704,7 +716,7 @@ fn launch_mcp_summary_opens_manager_by_click_and_keyboard() {
|
||||
wait(&mut tui, "MCP");
|
||||
capture(&mut tui, "home-mcp");
|
||||
click_text(&mut tui, "MCP");
|
||||
wait(&mut tui, "Extensions");
|
||||
wait_for_plugins_screen(&mut tui);
|
||||
wait(&mut tui, "launch-proof");
|
||||
capture(&mut tui, "mcp");
|
||||
tui.send(keys::key::esc()).unwrap();
|
||||
@@ -714,7 +726,7 @@ fn launch_mcp_summary_opens_manager_by_click_and_keyboard() {
|
||||
tui.send(keys::key::down()).unwrap();
|
||||
}
|
||||
tui.send(keys::key::enter()).unwrap();
|
||||
wait(&mut tui, "Extensions");
|
||||
wait_for_plugins_screen(&mut tui);
|
||||
wait(&mut tui, "launch-proof");
|
||||
tui.shutdown();
|
||||
}
|
||||
@@ -765,6 +777,33 @@ pub(super) fn capture(tui: &mut Harness, name: &str) {
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn plugin_command_opens_plugins_screen_with_kind_filters() {
|
||||
for (rows, cols) in [(24, 80), (32, 120)] {
|
||||
let (_workspace, mut tui) = start(rows, cols, true);
|
||||
tui.paste("/plugin").unwrap();
|
||||
tui.wait_for_idle(Duration::from_millis(300), WAIT).unwrap();
|
||||
tui.send(keys::key::enter()).unwrap();
|
||||
wait_for_plugins_screen(&mut tui);
|
||||
tui.wait_for_idle(Duration::from_millis(300), WAIT).unwrap();
|
||||
let frame = tui.frame();
|
||||
println!("--- /plugin at {cols}x{rows} ---\n{}", frame.text());
|
||||
for label in ["Hooks", "Installed", "Skills", "Connectors"] {
|
||||
assert!(
|
||||
frame.contains(label),
|
||||
"missing filter {label:?} at {cols}x{rows}\n{}",
|
||||
frame.text()
|
||||
);
|
||||
}
|
||||
assert!(
|
||||
!frame.contains("Extensions"),
|
||||
"the screen still says Extensions at {cols}x{rows}\n{}",
|
||||
frame.text()
|
||||
);
|
||||
tui.shutdown();
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "opt-in visual evidence; writes only with QA_LAUNCH_CAPTURE_DIR"]
|
||||
fn workbench_settings_visual_evidence() {
|
||||
@@ -773,7 +812,7 @@ fn workbench_settings_visual_evidence() {
|
||||
("/model", "route ·", "models"),
|
||||
("/provider", "Provider", "providers"),
|
||||
("/fleet", "Coordinator", "fleet"),
|
||||
("/plugin", "Extensions", "plugins"),
|
||||
("/plugin", "Installed", "plugins"),
|
||||
("/config", "Settings", "settings"),
|
||||
("/statusline", "Status", "statusline"),
|
||||
] {
|
||||
|
||||
@@ -12,6 +12,7 @@ workspace = true
|
||||
|
||||
[dependencies]
|
||||
codewhale-protocol = { path = "../protocol", version = "0.10.3" }
|
||||
codewhale-sanitize = { path = "../sanitize", version = "0.10.3" }
|
||||
serde.workspace = true
|
||||
serde_json.workspace = true
|
||||
sha2.workspace = true
|
||||
|
||||
+933
-111
File diff suppressed because it is too large.
Load diff
@@ -75,6 +75,16 @@ same-layer deny.
|
||||
For chained shell commands, a trusted prefix never approves the whole chain. A
|
||||
typed deny that wins for any individual segment blocks the full invocation.
|
||||
|
||||
Deny rules match commands by name against a static reading of the command
|
||||
line. While any deny rule is configured, a command whose command word cannot be
|
||||
read statically is refused (or prompts, where a person always sees the prompt):
|
||||
a variable or substitution, a glob or brace list, a parenthesis glued to the
|
||||
word, a word that is not a plain literal, or a call that rebinds names first
|
||||
(`alias`, `hash`, `setopt`, `shopt`). Deny rules do not cover a script file, a
|
||||
copy of a program under another name, or another program that does the same
|
||||
job; the limits are listed at the top of `crates/execpolicy/src/shell_expand.rs`.
|
||||
Containment is the sandbox's job, not the deny list's.
|
||||
|
||||
## Approval posture and missing prompts
|
||||
|
||||
The execution-policy result is combined with the registered-tool baseline; the
|
||||
|
||||
@@ -479,7 +479,14 @@ deterministic plan — added / changed / skipped / conflicting / rejected —
|
||||
then asks for consent unless `--yes` is given; headless use requires it.
|
||||
Credential-shaped entries are rejected by key name and by value shape;
|
||||
rejections name the field, never a value. Remote bundles come from HTTPS
|
||||
only (loopback http excepted) with a 5 MiB cap. Application backs up the
|
||||
only (loopback http excepted) with a 5 MiB cap. The first request and every
|
||||
redirect honor the `[network]` policy and refuse loopback, private, link-local
|
||||
and cloud-metadata destinations, including a host name that resolves to one;
|
||||
the only exceptions are a loopback URL you type yourself and a `localhost` name
|
||||
or private IP address you listed in `network.allow` (never on a redirect to a
|
||||
different host). When `HTTPS_PROXY` (or `HTTP_PROXY`, unless matched by
|
||||
`NO_PROXY`) is set, non-local hops delegate resolution to the proxy and skip
|
||||
the local DNS resolution check. Application backs up the
|
||||
target document to `<config>.bundle-backup-<timestamp>-<random>`, rolls back on any
|
||||
failure, and re-importing an applied bundle changes nothing.
|
||||
|
||||
@@ -2793,7 +2800,7 @@ reasoning contract, and all four membership ids omit generic sampling fields.
|
||||
default pack. Only
|
||||
Codewhale-owned roots (`<workspace>/.codewhale/skills` and
|
||||
`~/.codewhale/skills`) are writable install/import targets; compatible harness
|
||||
roots stay read-only. Bare `/skills` opens Extensions → Skills (owned-only,
|
||||
roots stay read-only. Bare `/skills` opens Plugins → Skills (owned-only,
|
||||
zero network); `/skills manage` opens the manager. See [SKILLS.md](SKILLS.md)
|
||||
for the manager, audit statuses, provenance markers, and mutation rules, and
|
||||
[CLAUDE_PLUGIN_COMPAT.md](CLAUDE_PLUGIN_COMPAT.md) for the supported boundary
|
||||
|
||||
+1
-1
@@ -681,7 +681,7 @@ Use skills when a task has a repeatable process:
|
||||
- Using a project-specific memory or wiki workflow.
|
||||
|
||||
Inside the TUI, `/skill <name>` activates a skill when one is available, and
|
||||
bare `/skills` opens Extensions → Skills (owned-only inventory, no network).
|
||||
bare `/skills` opens Plugins → Skills (owned-only inventory, no network).
|
||||
Use `/skills manage` for the manager and
|
||||
`/skills <prefix>`, `/skills inspect`, `/skills --remote`, `/skills suggest <task>`,
|
||||
or `/skills sync` for the text/registry paths. Suggestions rank the remote
|
||||
|
||||
+1
-1
@@ -3,7 +3,7 @@
|
||||
|
||||
Everything you add to Codewhale is a plugin. A hook in `config.toml` or a
|
||||
project `hooks.toml` is a plugin of the kind **Hook**, and a plugin bundle can
|
||||
also carry hooks. Hooks are still managed under Extensions → Hooks in the
|
||||
also carry hooks. Hooks are still managed under Plugins → Hooks in the
|
||||
terminal and Customize › Hooks on the desktop; one Plugins list that shows every
|
||||
kind together is planned for 0.10.3 and is not in this build. Configuration and
|
||||
the `/hooks` commands are unchanged.
|
||||
|
||||
+3
-3
@@ -1,13 +1,13 @@
|
||||
# MCP (External Tool Servers)
|
||||
|
||||
In the terminal, `/mcp` (also `/mcps`) opens **Extensions → MCP**. Enter opens the selected server’s recovery action or read-only details. An empty inventory offers server suggestions; browsing them installs nothing. Explicit subcommands such as `/mcp status`, `/mcp doctor`, `/mcp login`, and `/mcp add` retain their existing behavior.
|
||||
In the terminal, `/mcp` (also `/mcps`) opens **Plugins → Connectors**. Enter opens the selected server’s recovery action or read-only details. An empty inventory offers server suggestions; browsing them installs nothing. Explicit subcommands such as `/mcp status`, `/mcp doctor`, `/mcp login`, and `/mcp add` retain their existing behavior.
|
||||
|
||||
|
||||
> 阅读简体中文版:[zh_hans/MCP.md](zh_hans/MCP.md)
|
||||
|
||||
Everything you add to Codewhale is a plugin. An MCP server you configure is a
|
||||
plugin of the kind **Connector**, and a plugin bundle can also carry connectors.
|
||||
Connectors are still managed under Extensions → MCP in the terminal and
|
||||
Connectors are still managed under Plugins → Connectors in the terminal and
|
||||
Customize › Connectors on the desktop; one Plugins list that shows every kind
|
||||
together is planned for 0.10.3 and is not in this build. The configuration
|
||||
files, sign-in and the `/mcp` commands are unchanged. "Connection" on this page
|
||||
@@ -249,7 +249,7 @@ bundle and marketplace semantics, see [Plugin bundles](PLUGIN_BUNDLES.md).
|
||||
|
||||
`/mcp validate` (alias `/mcp doctor`) with no name reports each server's state
|
||||
from the last MCP snapshot the TUI recorded, in a pager over
|
||||
**Extensions → MCP**. That snapshot is usually the engine pool's latest report,
|
||||
**Plugins → Connectors**. That snapshot is usually the engine pool's latest report,
|
||||
but some actions, such as `/mcp import`, replace it with a separate UI
|
||||
discovery pass that also connects `lazy` servers the engine has not started.
|
||||
Validate also re-reads the MCP config files without connecting: it
|
||||
|
||||
+39
-7
@@ -16,8 +16,8 @@ Everything you add to Codewhale is a plugin. A skill, an MCP connector and a hoo
|
||||
are each a plugin, or part of one, and each has a kind: Skill, Connector, Hook,
|
||||
Command. A skill you set up by hand is a plugin of the kind Skill, a server you
|
||||
configure is a plugin of the kind Connector, and a hook you configure is a plugin
|
||||
of the kind Hook. They are still managed where they are today: Extensions →
|
||||
Skills, MCP and Hooks in the terminal, and Customize › Skills, Connectors and
|
||||
of the kind Hook. They are still managed where they are today: Plugins →
|
||||
Skills, Connectors and Hooks in the terminal, and Customize › Skills, Connectors and
|
||||
Hooks on the desktop. Their files, trust rules and commands (`/skills`, `/mcp`,
|
||||
`/hooks`) do not change. One Plugins list that shows every kind together is
|
||||
planned for 0.10.3 and is not in this build; today the kind labels appear on the
|
||||
@@ -102,7 +102,7 @@ Codewhale is helpful about plugins, not pushy. The rules:
|
||||
task needs; a second call fails. Exec, ACP, and runtime-API sessions do not
|
||||
get the tool.
|
||||
- **Built-ins are never advertised.** Inspect existing bundles in `/plugin list`
|
||||
and Extensions before installing another copy. The published v0.10.2 CLI
|
||||
and Plugins before installing another copy. The published v0.10.2 CLI
|
||||
includes Computer Use; its layout differs from the current source candidate.
|
||||
See [Computer Use across versions](PLUGIN_MARKETPLACE.md#computer-use-across-versions).
|
||||
- **Specific terms only.** Generic words (accessibility, browser, chrome,
|
||||
@@ -114,7 +114,7 @@ Codewhale is helpful about plugins, not pushy. The rules:
|
||||
- **Only what runs here.** Plugins whose `when.os` excludes this OS are not
|
||||
offered.
|
||||
- **Review before acting.** A model-requested row offers Review. Its button
|
||||
opens the existing Extensions inventory: Plugins for an installed bundle,
|
||||
opens the existing Plugins inventory: Installed for an installed bundle,
|
||||
Marketplace for a suggestion that is not installed. Nothing is installed,
|
||||
trusted, or enabled by that click. Choose the inventory's explicit action;
|
||||
trust still reviews the exact installed content before activation.
|
||||
@@ -123,7 +123,7 @@ Codewhale is helpful about plugins, not pushy. The rules:
|
||||
persisted choice. `/plugin dismissals` lists both kinds, and
|
||||
`/plugin dismissals reset [<name>]` lets suggestions offer a plugin again.
|
||||
- **Discovery is passive.** Find new plugins in these docs,
|
||||
`/plugin marketplace list`, Extensions, and the browser guide below.
|
||||
`/plugin marketplace list`, Plugins, and the browser guide below.
|
||||
|
||||
Codewhale does not invent a remote plugin URL; missing plugins are suggested
|
||||
only from available catalogs. On-disk bundle changes still toast
|
||||
@@ -145,7 +145,8 @@ None of these is offered to you proactively. Add the one that fits the job.
|
||||
|
||||
## Sources
|
||||
|
||||
`codewhale install <spec>` and `/plugin install <spec>` use the same installer.
|
||||
`codewhale install <spec>`, `codewhale plugins install <spec>`, and
|
||||
`/plugin install <spec>` use the same installer.
|
||||
The shell entry does not start a model or require model credentials. Both keep
|
||||
bundles disabled and untrusted until you explicitly review and enable them.
|
||||
|
||||
@@ -176,7 +177,10 @@ There is no registry index and no `git clone` in v1 — tarball-only fetching
|
||||
keeps the size cap and no-symlink guarantees of the installer. Downloads are
|
||||
gated by the per-domain network policy: an unknown host returns a
|
||||
"needs approval" error naming the host (`/network allow <host>`, then retry),
|
||||
a denied host aborts without touching disk.
|
||||
a denied host aborts without touching disk. Allowing a host never lets a
|
||||
download reach a local or private address: every request and redirect refuses
|
||||
loopback, private, link-local and cloud-metadata destinations, including a host
|
||||
name that resolves to one.
|
||||
|
||||
The fetched tree must contain **exactly one** bundle root — a directory
|
||||
holding a `plugin.json`, compatible `kimi.plugin.json`,
|
||||
@@ -248,6 +252,34 @@ matching and the plugin goes inactive until you review again.
|
||||
- `uninstall` refuses enabled plugins (disable first), deletes the bundle
|
||||
directory, and removes its persisted trust/enablement entry.
|
||||
|
||||
## Shell CLI
|
||||
|
||||
Run the full plugin lifecycle from a shell without starting a session:
|
||||
|
||||
```sh
|
||||
codewhale plugins install <spec> [--json]
|
||||
codewhale plugins show <name> [--json]
|
||||
codewhale plugins status [name] [--json]
|
||||
codewhale plugins trust <name> [--review-token TOKEN --yes] [--json]
|
||||
codewhale plugins enable <name> [--json]
|
||||
codewhale plugins disable <name> [--json]
|
||||
codewhale plugins update <name> [--json]
|
||||
codewhale plugins uninstall <name> [--yes] [--json]
|
||||
codewhale plugins revoke <name> [--json]
|
||||
codewhale plugins restage <name> [--json]
|
||||
```
|
||||
|
||||
- `install` uses the same installer as `codewhale install <spec>` and leaves the
|
||||
bundle disabled and untrusted.
|
||||
- `show` prints the same capability review as `trust` without prompting or
|
||||
changing trust state.
|
||||
- `update` re-reads the recorded source and reports `updated to <version>` or
|
||||
`already current` (or the network-approval message when a host needs
|
||||
approval).
|
||||
- `uninstall` disables an enabled plugin first, then removes it. In a terminal
|
||||
it asks `Remove <name> <version>? [y/N]` unless `--yes` is given; without a
|
||||
terminal or with `--json`, `--yes` is required.
|
||||
|
||||
## Safety rules
|
||||
|
||||
- Every install carries an `.installed-from` provenance marker. The installer
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# First-party plugin marketplace
|
||||
|
||||
Codewhale includes an offline snapshot of the `codewhale` catalog in the same
|
||||
marketplace store consumed by the terminal, Extensions, recommendations, and
|
||||
marketplace store consumed by the terminal screen, recommendations, and
|
||||
Runtime API. Browse the [public first-party marketplace](https://github.com/codewhale-hq/codewhale-plugin-marketplace)
|
||||
for current entries and their prerequisites. Its contents can be newer than
|
||||
the snapshot in your installed CLI. Browsing a catalog does not fetch or
|
||||
|
||||
+3
-3
@@ -1,13 +1,13 @@
|
||||
# Skills Manager
|
||||
|
||||
In the terminal, `/skills` opens **Extensions → Skills**. Enter on a skill opens the dedicated manager, also available directly through `/skills manage`, for existing install, update, remove, and trust operations. `/skill <name>` still activates a skill, and the explicit inspection, remote, sync, and suggestion subcommands remain available.
|
||||
In the terminal, `/skills` opens **Plugins → Skills**. Enter on a skill opens the dedicated manager, also available directly through `/skills manage`, for existing install, update, remove, and trust operations. `/skill <name>` still activates a skill, and the explicit inspection, remote, sync, and suggestion subcommands remain available.
|
||||
|
||||
|
||||
> 阅读简体中文版:[zh_hans/SKILLS.md](zh_hans/SKILLS.md)
|
||||
|
||||
Everything you add to Codewhale is a plugin: a skill you set up by hand is a
|
||||
plugin of the kind **Skill**, and a skill inside a plugin bundle belongs to that
|
||||
plugin. Skills are still managed here, under Extensions → Skills and Customize ›
|
||||
plugin. Skills are still managed here, under Plugins → Skills and Customize ›
|
||||
Skills; one Plugins list that shows every kind together is planned for 0.10.3 and
|
||||
is not in this build. This page keeps the word "skill" for the kind; files, trust
|
||||
and the `/skills` commands are unchanged.
|
||||
@@ -73,7 +73,7 @@ warning, and the session's skills directory falls back to the global one.
|
||||
|
||||
| Command | Behavior |
|
||||
| --- | --- |
|
||||
| `/skills` | Opens Extensions → Skills (owned-only scan, **no network**). |
|
||||
| `/skills` | Opens Plugins → Skills (owned-only scan, **no network**). |
|
||||
| `/skills manage` | Opens the dedicated Skills Manager for audit and mutation. |
|
||||
| `/skills <prefix>` | Text list filtered by name prefix. |
|
||||
| `/skills inspect` | Text discovery mode, searched directories, and source paths. |
|
||||
|
||||
@@ -255,7 +255,7 @@ codewhale account keys remove deepseek # remove the account's saved key
|
||||
|
||||
`codewhale config export --portable [--project] [--out FILE]` 写出一份可移植、不含机密的配置包:排序后的 TOML,其中凭据和机器特有的键(API key、base URL、socket 路径)会被丢弃,绝不会用脱敏占位符顶替。不带 `--out` 时,配置包输出到 stdout。类型化的表、数组、数字、布尔值和日期时间保持其类型。与机器绑定的权限刻意设为不可移植:项目信任覆盖、凭据读取器、会自动运行的 hooks、可执行的 LSP 定义和本地路径绑定都会被省略,而不是复制到新主机。信任相关设置(`yolo`、`allow_shell`、`approval_policy`、`sandbox_mode`、`sandbox_network_access` 以及沙箱读取拒绝列表路径)以及本地端点或可执行文件(`[lifecycle_outbox]`、`[extension_host]`、`[control_socket]`)同样如此。带有凭据的 URL——含 userinfo、含名称表明它携带令牌的查询参数,或 Slack/Discord/Teams webhook 路径——按凭据处理。
|
||||
|
||||
`codewhale config import <FILE|HTTPS_URL|-> [--dry-run] [--yes] [--project]` 应用一份配置包。信封是严格的(`schema_version = 1`,kind 为 `codewhale.portable-config`;未知字段会失败)。导入会打印一份确定性的计划——新增 / 更改 / 跳过 / 冲突 / 拒绝——然后征求同意,除非给了 `--yes`;无头使用则必须带它。形似凭据的条目会按键名和值的形态被拒绝;拒绝信息会指出字段,绝不会指出值。远程配置包只允许来自 HTTPS(loopback 的 http 除外),上限 5 MiB。应用时会把目标文档备份到 `<config>.bundle-backup-<timestamp>-<random>`,任何失败都会回滚,并且重新导入已应用过的配置包不会改变任何东西。
|
||||
`codewhale config import <FILE|HTTPS_URL|-> [--dry-run] [--yes] [--project]` 应用一份配置包。信封是严格的(`schema_version = 1`,kind 为 `codewhale.portable-config`;未知字段会失败)。导入会打印一份确定性的计划——新增 / 更改 / 跳过 / 冲突 / 拒绝——然后征求同意,除非给了 `--yes`;无头使用则必须带它。形似凭据的条目会按键名和值的形态被拒绝;拒绝信息会指出字段,绝不会指出值。远程配置包只允许来自 HTTPS(loopback 的 http 除外),上限 5 MiB。首次请求和每一次重定向都遵守 `[network]` 策略,并拒绝 loopback、私有、链路本地和云元数据地址(包括解析到这些地址的主机名);仅有的例外是你自己输入的 loopback URL,以及你在 `network.allow` 中列出的 `localhost` 名称或私有 IP 地址(重定向到其他主机时不适用)。当设置了 `HTTPS_PROXY`(或 `HTTP_PROXY`,且未被 `NO_PROXY` 命中)时,非本地跳转会将解析交给代理服务器,并跳过本地 DNS 解析检查。应用时会把目标文档备份到 `<config>.bundle-backup-<timestamp>-<random>`,任何失败都会回滚,并且重新导入已应用过的配置包不会改变任何东西。
|
||||
|
||||
导入还会拒绝导出所省略的那些不可移植的权限类别,包括嵌套/驼峰/点号形式的凭据键和 cookie 头。这样,手工编写的或远程的配置包就无法重新引入机器信任、本地凭据访问,或本地导出会拒绝携带的可自动执行命令。结构化的表会被深度合并:可移植的模型或偏好更新不会抹掉目标本地的 provider 凭据、端点、hooks 或可执行定义——这些是刻意从配置包中省略的。数组和标量值仍会替换对应的可移植值。
|
||||
|
||||
@@ -1367,7 +1367,7 @@ DeepSeek V4 前缀缓存让 token 标签变得重要。这些数量保持分离
|
||||
|
||||
`/config subagents status` 打印全局值和活动 provider 的解析 profile,这样速率限制调整在 TUI 中可见。`[subagents.models]` 接受小写的 Fleet 角色键,如 `worker`、`scout`、`planner`、`reviewer`、`builder` 和 `verifier`;旧类型键在 v0.9.x 期间仍被接受。值在派生时对照活动 provider 验证;直接 DeepSeek 需要 DeepSeek ID,而 OpenAI 兼容/自定义 provider 路由把显式模型 ID 透传给该 provider。要把子智能体路由到不同于父会话的 provider,保存带显式 `provider` 和 `model` 字段的 Fleet/AgentProfile(包括 `lm-studio` 这样的用户命名自定义 provider),并调用 `agent(profile: "...")`;见 [SUBAGENTS.md](SUBAGENTS.md)。
|
||||
|
||||
- `skills_dir`(字符串,可选):默认 `~/.codewhale/skills`(每个技能是包含 `SKILL.md` 的目录)。工作区获得信任后,项目技能优先于全局技能,其中 `.codewhale/skills` 优先于 `.agents/skills` 等兼容目录。顶层 `./skills` 需要显式设置 `[skills] flat_workspace_root = true` 或 `skills_dir` 才会加载。运行时还发现全局 agentskills.io 兼容的 `~/.agents/skills` 和更广的 Claude 生态系统 `~/.claude/skills`。在默认捆绑策略且未设置 `skills_dir` 时,首次启动为常见工作流安装带版本的捆绑技能,包括技能创建、委派、MCP/插件脚手架、文档、演示文稿、电子表格及 PDF。飞书/Lark 不属于默认安装包。只有 CodeWhale 自有的根(`<workspace>/.codewhale/skills` 和 `~/.codewhale/skills`)是可写安装/导入目标;兼容 harness 根保持只读。裸 `/skills` 打开 Extensions → Skills(仅自有,零网络);`/skills manage` 打开技能管理器。管理器、审计状态、来源标记和变更规则见 [SKILLS.md](SKILLS.md),可移植 `SKILL.md` 包与 Claude Code 插件运行时之间的受支持边界见 [CLAUDE_PLUGIN_COMPAT.md](CLAUDE_PLUGIN_COMPAT.md)。
|
||||
- `skills_dir`(字符串,可选):默认 `~/.codewhale/skills`(每个技能是包含 `SKILL.md` 的目录)。工作区获得信任后,项目技能优先于全局技能,其中 `.codewhale/skills` 优先于 `.agents/skills` 等兼容目录。顶层 `./skills` 需要显式设置 `[skills] flat_workspace_root = true` 或 `skills_dir` 才会加载。运行时还发现全局 agentskills.io 兼容的 `~/.agents/skills` 和更广的 Claude 生态系统 `~/.claude/skills`。在默认捆绑策略且未设置 `skills_dir` 时,首次启动为常见工作流安装带版本的捆绑技能,包括技能创建、委派、MCP/插件脚手架、文档、演示文稿、电子表格及 PDF。飞书/Lark 不属于默认安装包。只有 CodeWhale 自有的根(`<workspace>/.codewhale/skills` 和 `~/.codewhale/skills`)是可写安装/导入目标;兼容 harness 根保持只读。裸 `/skills` 打开 Plugins → Skills(仅自有,零网络);`/skills manage` 打开技能管理器。管理器、审计状态、来源标记和变更规则见 [SKILLS.md](SKILLS.md),可移植 `SKILL.md` 包与 Claude Code 插件运行时之间的受支持边界见 [CLAUDE_PLUGIN_COMPAT.md](CLAUDE_PLUGIN_COMPAT.md)。
|
||||
|
||||
- `[skills.bundled].install`(默认 `"auto"`,另可设 `"all"` 或 `"none"`):独立控制捆绑安装。`auto` 保留删除即退出规则,设置 `skills_dir` 时停止安装;`all` 补装已启用的缺失技能,只写入 `$CODEWHALE_HOME/skills`(通常为 `~/.codewhale/skills`),不受发现目录覆盖影响;`none` 不写文件或版本标记。修改版和托管正文始终保留。
|
||||
- `[skills.bundled].selected` / `disabled` / `explicit_only`(名称数组,默认 `[]`):空选择列表启用整个安装包,非空列表限制安装和发现。禁用优先,并允许低优先级目录的同名副本生效。显式调用保留用户调用,移除模型索引及模型加载权限。别名沿用规范名称策略;仅影响全局自有目录中可识别的发行副本,不删除文件。配置档逐字段合并,显式空列表清空继承值。`doctor` 报告未知及忽略条目,`/skills inspect` 显示路径和原因。详见 [SKILLS.md](SKILLS.md#捆绑技能)。
|
||||
|
||||
Loaded 100 of 108 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user