Compare commits

..
Author SHA1 Message Date
dependabot[bot] 4b377be965 build(deps): bump actions/setup-java from 6.1.0 to 6.2.0
Bumps [actions/setup-java](https://github.com/actions/setup-java) from 6.1.0 to 6.2.0.
- [Release notes](https://github.com/actions/setup-java/releases)
- [Commits](https://github.com/actions/setup-java/compare/v6.1.0...v6.2.0)

---
updated-dependencies:
- dependency-name: actions/setup-java
  dependency-version: 6.2.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <[email protected]>
2026-11-13 21:15:05 +00:00
119 changed files with 1146 additions and 16134 deletions

No files matched your search

+1 -1
View File
@@ -86,7 +86,7 @@ jobs:
timeout-minutes: 25
steps:
- uses: actions/checkout@v7
- uses: actions/setup-java@v6.1.0
- uses: actions/setup-java@v6.2.0
with:
distribution: temurin
java-version: '17'
+1 -1
View File
@@ -286,7 +286,7 @@ jobs:
mirror: true
- name: Install Java for the OpenHarmony binary signer
if: matrix.platform == 'ohos-arm64'
uses: actions/setup-java@b843ebf9b913146b38da2eaa7aeddf76074ae1ad # v4
uses: actions/setup-java@77dd5b2ce225ea11469928ac317850245a74ae78 # v4
with:
distribution: temurin
java-version: '17'
-2
View File
@@ -68,8 +68,6 @@ 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
+2 -17
View File
@@ -7,16 +7,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
- **DeepSeek Tools ASR, TTS, OCR, and uploaded runtime voice endpoints:** integrates DeepSeek Tools `/v1/tools/asr`, `/v1/tools/tts` (`stella` / `tide`, `wav` / `pcm`), and opt-in `/v1/tools/ocr` fallback (`CODEWHALE_DEEPSEEK_OCR=1`); adds recorder-free `POST /v1/voice/transcribe` and `POST /v1/voice/tts` runtime HTTP routes plus `upload_available`, `tts_available`, `max_upload_bytes`, and `tts_voices` capability flags on `GET /v1/voice`, and enables DeepSeek in `SpeechTool` and CLI `speech`. (CW-8218)
- **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)
@@ -33,7 +23,6 @@ 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).
@@ -50,7 +39,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.
@@ -134,9 +123,7 @@ 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.
@@ -488,8 +475,6 @@ 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
@@ -725,7 +710,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 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.
- **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.
- **`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
View File
@@ -1037,7 +1037,6 @@ dependencies = [
"codewhale-engine",
"codewhale-execpolicy",
"codewhale-lane",
"codewhale-net",
"codewhale-paths",
"codewhale-release",
"codewhale-runtime",
@@ -1717,11 +1716,9 @@ 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",
@@ -1943,7 +1940,6 @@ name = "codewhale-workflow"
version = "0.10.3"
dependencies = [
"codewhale-protocol",
"codewhale-sanitize",
"serde",
"serde_json",
"sha2 0.11.1",
-8
View File
@@ -3208,14 +3208,6 @@ pub fn build_router(state: RuntimeApiState) -> Router {
.route("/v1/voice/dictate", post(voice::voice_dictate))
.route("/v1/voice/send", post(voice::voice_send))
.route("/v1/voice/control", post(voice::voice_control))
.route(
"/v1/voice/transcribe",
post(voice::voice_transcribe).layer(DefaultBodyLimit::max(12 * 1024 * 1024)),
)
.route(
"/v1/voice/tts",
post(voice::voice_tts).layer(DefaultBodyLimit::max(64 * 1024)),
)
.route("/v1/threads/{id}/resume", post(resume_thread))
.route("/v1/threads/{id}/fork", post(fork_thread))
.route("/v1/threads/{id}/undo", post(undo_thread_turn))
-155
View File
@@ -23638,161 +23638,6 @@ async fn voice_routes_report_capability_and_fail_closed_without_a_recorder() ->
assert_eq!(outcome["ok"], false);
assert_eq!(outcome["reason"], "no_recorder");
for path in ["/v1/voice/transcribe", "/v1/voice/tts"] {
let unauthorized = client.post(format!("{base}{path}")).send().await?.status();
assert_eq!(unauthorized, StatusCode::UNAUTHORIZED, "POST {path}");
}
assert!(status["upload_available"].is_boolean());
assert!(status["tts_available"].is_boolean());
assert_eq!(
status["max_upload_bytes"],
codewhale_engine::voice::UPLOAD_AUDIO_MAX_BYTES
);
handle.abort();
Ok(())
}
#[tokio::test]
async fn voice_upload_transcribe_and_tts_use_deepseek_even_when_host_mic_disabled() -> Result<()> {
use base64::Engine as _;
use wiremock::matchers::{method, path};
use wiremock::{Mock, MockServer, ResponseTemplate};
let upstream = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/v1/tools/asr"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"request_id": "req-rt-asr",
"text": "ship the branch send it",
"duration_ms": 880
})))
.mount(&upstream)
.await;
Mock::given(method("POST"))
.and(path("/v1/tools/tts"))
.respond_with(ResponseTemplate::new(200).set_body_bytes(b"RIFF_RT_WAV".to_vec()))
.mount(&upstream)
.await;
let tmp = tempfile::tempdir()?;
let workspace = tmp.path().join("workspace");
fs::create_dir_all(&workspace)?;
let _lock = lock_test_env();
let _voice_off = EnvVarGuard::set("CODEWHALE_DISABLE_VOICE", "1");
let _asr_env = EnvVarGuard::remove("CODEWHALE_ASR_MODEL");
let _groq_env = EnvVarGuard::remove("GROQ_API_KEY");
let _path = EnvVarGuard::set("PATH", "/nonexistent-voice-path");
let _base_env = EnvVarGuard::set("DEEPSEEK_BASE_URL", format!("{}/v1", upstream.uri()));
let _key_env = EnvVarGuard::set("DEEPSEEK_API_KEY", "ds-runtime-voice-key");
let mut config = Config::default().with_legacy_root(
Some("ds-runtime-voice-key".to_string()),
Some(format!("{}/v1", upstream.uri())),
);
config.provider = Some("deepseek".to_string());
let (addr, _runtime_threads, handle) =
spawn_test_server_with_root_token_mobile_workspace_and_overrides(
tmp.path().join("runtime"),
tmp.path().join("sessions"),
Some("voice-ds-token".to_string()),
false,
workspace,
TestServerOverrides {
config: Some(config),
..Default::default()
},
)
.await?
.context("deepseek voice test requires a loopback listener")?;
let client = codewhale_release::tls::reqwest_client();
let base = format!("http://{addr}");
let status: Value = client
.get(format!("{base}/v1/voice"))
.bearer_auth("voice-ds-token")
.send()
.await?
.error_for_status()?
.json()
.await?;
assert_eq!(status["available"], false);
assert_eq!(status["upload_available"], true);
assert_eq!(status["tts_available"], true);
assert_eq!(status["asr"]["kind"], "deepseek");
let mut wav = Vec::with_capacity(44 + 640);
wav.extend_from_slice(b"RIFF");
wav.extend_from_slice(&(36u32 + 640u32).to_le_bytes());
wav.extend_from_slice(b"WAVEfmt ");
wav.extend_from_slice(&16u32.to_le_bytes());
wav.extend_from_slice(&1u16.to_le_bytes());
wav.extend_from_slice(&1u16.to_le_bytes());
wav.extend_from_slice(&16000u32.to_le_bytes());
wav.extend_from_slice(&32000u32.to_le_bytes());
wav.extend_from_slice(&2u16.to_le_bytes());
wav.extend_from_slice(&16u16.to_le_bytes());
wav.extend_from_slice(b"data");
wav.extend_from_slice(&640u32.to_le_bytes());
wav.resize(44 + 640, 0);
let transcribed: Value = client
.post(format!("{base}/v1/voice/transcribe"))
.bearer_auth("voice-ds-token")
.json(&json!({
"audio_base64": base64::engine::general_purpose::STANDARD.encode(wav),
"mime_type": "audio/wav",
"mode": "send"
}))
.send()
.await?
.error_for_status()?
.json()
.await?;
assert_eq!(transcribed["ok"], true);
assert_eq!(transcribed["text"], "ship the branch");
assert_eq!(transcribed["send"], true);
assert_eq!(transcribed["asr"]["kind"], "deepseek");
let tts_json: Value = client
.post(format!("{base}/v1/voice/tts"))
.bearer_auth("voice-ds-token")
.json(&json!({
"text": "Ready for review",
"voice": "tide",
"format": "wav",
"filter_markdown": true
}))
.send()
.await?
.error_for_status()?
.json()
.await?;
assert_eq!(tts_json["ok"], true);
assert_eq!(tts_json["mime_type"], "audio/wav");
assert_eq!(tts_json["provider"], "deepseek");
assert_eq!(tts_json["model"], "deepseek-tts");
assert_eq!(tts_json["voice_id"], "tide");
assert_eq!(tts_json["format"], "wav");
assert_eq!(
base64::engine::general_purpose::STANDARD
.decode(tts_json["audio_base64"].as_str().context("audio_base64")?)?,
b"RIFF_RT_WAV"
);
let tts_empty: Value = client
.post(format!("{base}/v1/voice/tts"))
.bearer_auth("voice-ds-token")
.json(&json!({ "text": " " }))
.send()
.await?
.error_for_status()?
.json()
.await?;
assert_eq!(tts_empty["ok"], false);
assert_eq!(tts_empty["reason"], "invalid_input");
handle.abort();
Ok(())
}
+43 -154
View File
@@ -1,37 +1,51 @@
//! Voice/dictation HTTP for native clients (APPS-98).
//!
//! The runtime owns the host microphone and the ASR dispatch — recording and
//! transcription are the same implementation the TUI's `/voice` commands run,
//! exposed headlessly so a desktop client gets text back instead of driving a
//! terminal. There is deliberately no second speech stack and no audio upload
//! path: dictate means "record on the host this runtime runs on".
//!
//! Fail-closed as data: no recorder, no speech, missing provider auth, or an
//! ASR failure all answer `200` with `ok: false` + a machine-readable
//! `reason` — a desktop client reads `GET /v1/voice` first and disables its
//! dictation affordance when `available` is false.
//!
//! Routes:
//! GET /v1/voice — capability: recorder, ASR selection, send phrases
//! POST /v1/voice/dictate — record + transcribe → insert text
//! POST /v1/voice/send — record + transcribe + send-suffix detection
//! POST /v1/voice/control — record + assisted dictation; body {"composer"}
use axum::Json;
use axum::extract::State;
use base64::Engine as _;
use serde::Deserialize;
use serde_json::{Value, json};
use super::{ApiError, RuntimeApiState};
use codewhale_engine::deepseek_media::{DEEPSEEK_TTS_MAX_CHARS, DEEPSEEK_TTS_VOICES};
use codewhale_engine::voice as voice_core;
use voice_core::{DictateError, DictateMode, UPLOAD_AUDIO_MAX_BYTES};
use voice_core::{DictateError, DictateMode};
/// One mic per host — serialize captures so concurrent dictate requests get
/// an honest "no speech" for the loser rather than fighting over the device.
static DICTATE_LOCK: tokio::sync::Mutex<()> = tokio::sync::Mutex::const_new(());
/// `GET /v1/voice` — what this host can do: whether a recorder exists, which
/// ASR backend the live config resolves to, and the send-suffix contract.
pub(super) async fn voice_status(
State(state): State<RuntimeApiState>,
) -> Result<Json<Value>, ApiError> {
let (asr_kind, asr_model, upload_available, tts_available) = {
let (asr_kind, asr_model) = {
let config = state.config.read();
let (asr_kind, asr_model) = voice_core::asr_choice(&config);
let upload_available = voice_core::upload_available(&config);
let tts_available = voice_core::tts_available(&config);
(asr_kind, asr_model, upload_available, tts_available)
voice_core::asr_choice(&config)
};
Ok(Json(json!({
"available": voice_core::is_available(),
"upload_available": upload_available,
"tts_available": tts_available,
"recorder": voice_core::recorder_command(),
"asr": { "kind": asr_kind, "model": asr_model },
"modes": ["insert", "send", "control"],
"send_phrases": voice_core::SEND_PHRASES,
"max_record_seconds": voice_core::MAX_RECORD_SECS,
"max_upload_bytes": UPLOAD_AUDIO_MAX_BYTES,
"tts_voices": DEEPSEEK_TTS_VOICES,
})))
}
@@ -43,31 +57,32 @@ fn dictate_failure(error: DictateError) -> Json<Value> {
}))
}
fn dictate_success(outcome: voice_core::DictationOutcome) -> Json<Value> {
Json(json!({
"ok": true,
"text": outcome.text,
"send": outcome.send,
"assisted": outcome.assisted,
"asr": { "kind": outcome.asr_kind, "model": outcome.asr_model },
}))
}
async fn dictate(state: &RuntimeApiState, mode: DictateMode) -> Result<Json<Value>, ApiError> {
// Clone before recording: holding the config lock across a ~10s capture
// would stall unrelated config writes for the duration.
let config = state.config.read().clone();
let _permit = DICTATE_LOCK.lock().await;
match voice_core::dictate_once(&config, mode).await {
Ok(outcome) => Ok(dictate_success(outcome)),
Ok(outcome) => Ok(Json(json!({
"ok": true,
"text": outcome.text,
"send": outcome.send,
"assisted": outcome.assisted,
"asr": { "kind": outcome.asr_kind, "model": outcome.asr_model },
}))),
Err(error) => Ok(dictate_failure(error)),
}
}
/// `POST /v1/voice/dictate` — record + transcribe → text to insert.
pub(super) async fn voice_dictate(
State(state): State<RuntimeApiState>,
) -> Result<Json<Value>, ApiError> {
dictate(&state, DictateMode::Insert).await
}
/// `POST /v1/voice/send` — dictate with send-suffix detection; the client
/// submits when `send` is true (empty `text` = submit the current draft).
pub(super) async fn voice_send(
State(state): State<RuntimeApiState>,
) -> Result<Json<Value>, ApiError> {
@@ -77,10 +92,15 @@ pub(super) async fn voice_send(
#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
pub(super) struct VoiceControlRequest {
/// The client's current composer text — the model sees it for
/// AI-assisted dictation (the `/voice-control` pipeline).
#[serde(default)]
composer: String,
}
/// `POST /v1/voice/control` — assisted dictation with composer context.
/// `assisted: false` in the response means a free ASR kind handled the audio
/// and the composer text was never seen.
pub(super) async fn voice_control(
State(state): State<RuntimeApiState>,
body: Option<Json<VoiceControlRequest>>,
@@ -90,134 +110,3 @@ pub(super) async fn voice_control(
.unwrap_or_default();
dictate(&state, DictateMode::Control(composer)).await
}
#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
pub(super) struct VoiceTranscribeRequest {
audio_base64: String,
#[serde(default)]
mime_type: Option<String>,
#[serde(default)]
mode: Option<String>,
#[serde(default)]
composer: Option<String>,
#[serde(default)]
language: Option<String>,
}
pub(super) async fn voice_transcribe(
State(state): State<RuntimeApiState>,
Json(request): Json<VoiceTranscribeRequest>,
) -> Result<Json<Value>, ApiError> {
let max_encoded_len = (UPLOAD_AUDIO_MAX_BYTES / 3 + 2) * 4 + 16;
if request.audio_base64.len() > max_encoded_len {
return Ok(dictate_failure(DictateError::InvalidInput(format!(
"encoded audio exceeds maximum size for {UPLOAD_AUDIO_MAX_BYTES} byte upload"
))));
}
let Ok(audio_bytes) =
base64::engine::general_purpose::STANDARD.decode(request.audio_base64.trim())
else {
return Ok(dictate_failure(DictateError::InvalidInput(
"audio_base64 is not valid base64".to_string(),
)));
};
let mode = match request.mode.as_deref().unwrap_or("insert").trim() {
"" | "insert" => DictateMode::Insert,
"send" => DictateMode::Send,
"control" => DictateMode::Control(request.composer.unwrap_or_default()),
other => {
return Ok(dictate_failure(DictateError::InvalidInput(format!(
"unsupported voice mode '{other}'"
))));
}
};
let mime_type = request.mime_type.as_deref().unwrap_or("audio/wav");
let config = state.config.read().clone();
match voice_core::transcribe_uploaded_audio(
&config,
&audio_bytes,
mime_type,
mode,
request.language.as_deref(),
)
.await
{
Ok(outcome) => Ok(dictate_success(outcome)),
Err(error) => Ok(dictate_failure(error)),
}
}
#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
pub(super) struct VoiceTtsRequest {
text: String,
#[serde(default)]
voice: Option<String>,
#[serde(default)]
voice_id: Option<String>,
#[serde(default)]
format: Option<String>,
#[serde(default)]
filter_markdown: Option<bool>,
}
fn tts_failure(reason: &str, message: impl Into<String>) -> Json<Value> {
Json(json!({
"ok": false,
"reason": reason,
"message": message.into(),
}))
}
pub(super) async fn voice_tts(
State(state): State<RuntimeApiState>,
Json(request): Json<VoiceTtsRequest>,
) -> Result<Json<Value>, ApiError> {
if request.text.trim().is_empty() {
return Ok(tts_failure("invalid_input", "speech text cannot be empty"));
}
if request.text.chars().count() > DEEPSEEK_TTS_MAX_CHARS {
return Ok(tts_failure(
"invalid_input",
format!("speech text exceeds maximum of {DEEPSEEK_TTS_MAX_CHARS} characters"),
));
}
let voice = request.voice.as_deref().or(request.voice_id.as_deref());
let filter_markdown = request.filter_markdown.unwrap_or(true);
let config = state.config.read().clone();
match voice_core::synthesize_voice_tts_with_options(
&config,
&request.text,
voice,
request.format.as_deref(),
filter_markdown,
)
.await
{
Ok(outcome) => Ok(Json(json!({
"ok": true,
"mime_type": outcome.content_type,
"audio_base64": base64::engine::general_purpose::STANDARD.encode(&outcome.audio_bytes),
"voice_id": outcome.voice,
"provider": outcome.provider,
"model": outcome.model,
"format": outcome.format,
}))),
Err(err) => {
let reason = if err.contains("requires a configured") {
"no_provider_auth"
} else if err.contains("unsupported")
|| err.contains("cannot be empty")
|| err.contains("exceeds maximum")
|| err.contains("does not support")
|| err.contains("must be one of")
{
"invalid_input"
} else {
"synthesis_failed"
};
Ok(tts_failure(reason, err))
}
}
}
-1
View File
@@ -34,7 +34,6 @@ 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" }
+72 -333
View File
@@ -15,9 +15,6 @@
//! - 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.
@@ -576,29 +573,13 @@ 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";
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
(top_level_telemetry && matches!(value, toml::Value::Boolean(true)))
.then_some("would port telemetry opt-in consent between machines")
}
// ---------------------------------------------------------------------------
@@ -817,85 +798,67 @@ 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. 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();
/// 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(&current_url)?;
let initial_scheme = current_url.scheme().to_string();
let mut policy = network.clone();
if is_loopback_bundle_host(&initial_host) {
policy.add_allow(&initial_host);
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 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);
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"))?
})
// 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)
}
fn validate_bundle_url(url: &reqwest::Url) -> Result<()> {
@@ -914,37 +877,19 @@ fn validate_bundle_url(url: &reqwest::Url) -> Result<()> {
}
}
fn validate_bundle_redirect(
initial_scheme: &str,
initial_host: &str,
next_url: &reqwest::Url,
) -> Result<()> {
fn validate_bundle_redirect(initial_scheme: &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)
.trim_end_matches('.');
.unwrap_or(host);
normalized.eq_ignore_ascii_case("localhost")
|| normalized.to_ascii_lowercase().ends_with(".localhost")
|| normalized
@@ -1626,13 +1571,7 @@ pub fn run_import(
}
buffer
} else if remote_source {
let network = store
.config
.network
.clone()
.map(codewhale_net::network_policy::NetworkPolicy::from)
.unwrap_or_default();
fetch_bundle(&args.source, &network)?
fetch_bundle(&args.source)?
} else {
let path = PathBuf::from(&args.source);
let metadata = std::fs::metadata(&path)
@@ -1733,7 +1672,6 @@ 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};
@@ -4245,7 +4183,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", &NetworkPolicy::default())
let err = fetch_bundle("http://example.com/bundle.toml")
.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:#}");
@@ -4258,8 +4196,7 @@ 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, &NetworkPolicy::default())
.expect_err("redirect target must be revalidated");
let error = fetch_bundle(&url).expect_err("redirect target must be revalidated");
let rendered = format!("{error:#}");
assert!(rendered.contains("loopback"), "{rendered}");
assert!(!rendered.contains("example.com"), "{rendered}");
@@ -4267,149 +4204,13 @@ 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", "example.com", &target)
let error = validate_bundle_redirect("https", &target)
.expect_err("HTTPS redirect must not downgrade to loopback HTTP");
let rendered = format!("{error:#}");
assert!(rendered.contains("scheme"), "{rendered}");
@@ -4417,59 +4218,6 @@ allow = ["docs.example.com", ".localdomain", "*.localdomain", ".localhost", "*.l
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"");
@@ -4477,8 +4225,7 @@ allow = ["docs.example.com", ".localdomain", "*.localdomain", ".localhost", "*.l
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, &NetworkPolicy::default())
.expect("relative redirect remains allowed");
let fetched = fetch_bundle(&url).expect("relative redirect remains allowed");
assert_eq!(fetched, body);
assert_eq!(server.join().expect("server joins"), 2);
}
@@ -4493,23 +4240,16 @@ allow = ["docs.example.com", ".localdomain", "*.localdomain", ".localhost", "*.l
.collect();
let (url, server) = spawn_bundle_http_server(responses);
let error = fetch_bundle(&url, &NetworkPolicy::default())
.expect_err("sixth redirect must be refused");
assert!(
error.to_string().contains("too many redirects"),
"{error:#}"
);
let error = fetch_bundle(&url).expect_err("sixth redirect must be refused");
assert!(error.to_string().contains("five-redirect"), "{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"),
&NetworkPolicy::default(),
)
.expect_err("URL userinfo must be refused");
let error = fetch_bundle(&format!("https://user:{secret}@example.com/bundle.toml"))
.expect_err("URL userinfo must be refused");
let rendered = format!("{error:#}");
assert!(rendered.contains("credentials"), "{rendered}");
assert!(!rendered.contains(secret), "{rendered}");
@@ -4518,8 +4258,7 @@ allow = ["docs.example.com", ".localdomain", "*.localdomain", ".localhost", "*.l
#[test]
fn unsupported_schemes_are_refused() {
let err = fetch_bundle("file:///etc/passwd", &NetworkPolicy::default())
.expect_err("file scheme refused");
let err = fetch_bundle("file:///etc/passwd").expect_err("file scheme refused");
assert!(err.to_string().contains("scheme"), "{err:#}");
}
+2 -2
View File
@@ -234,10 +234,10 @@ enum Commands {
#[command(about = "Install a plugin bundle without trusting or enabling it")]
Install(TuiPassthroughArgs),
#[command(
about = "Install, inspect, review, trust, enable, disable, update, uninstall or revoke plugin bundles",
about = "Inspect, review, trust, enable, disable or revoke plugin bundles",
alias = "plugin",
override_usage = "codewhale plugins <COMMAND>",
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."
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."
)]
Plugins(TuiPassthroughArgs),
/// Bootstrap MCP config and/or skills directories.
@@ -1,193 +0,0 @@
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
},
}
@@ -1,91 +0,0 @@
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
},
}
@@ -1,158 +0,0 @@
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/)
})
@@ -1,83 +0,0 @@
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/)
})
+1 -14
View File
@@ -4570,22 +4570,9 @@ impl CodewhaleClient {
) -> Result<SpeechSynthesisResponse> {
let _inference = self.acquire_remote_control_inference_permit().await;
let _permit = self.acquire_provider_request_permit().await;
if matches!(
self.api_provider,
codewhale_config::ProviderKind::Deepseek
| codewhale_config::ProviderKind::DeepseekAnthropic
) {
return crate::deepseek_media::synthesize_deepseek_with_client(
&self.http_client,
&self.base_url,
&self.api_key,
request,
)
.await;
}
if self.api_provider != codewhale_config::ProviderKind::XiaomiMimo {
anyhow::bail!(
"speech synthesis requires provider 'xiaomi-mimo' or 'deepseek' (current: {})",
"speech synthesis requires provider 'xiaomi-mimo' (current: {})",
self.api_provider.as_str()
);
}
@@ -2889,17 +2889,6 @@ 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",
@@ -2911,41 +2900,6 @@ 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_denies_verification_actions() {
fn shell_denial_preserves_task_reads_and_bounded_verification_actions() {
let mut tasks = catalog_tool("tasks");
tasks.input_schema =
json!({"type":"object", "properties":{"action":{"enum":["list", "read", "gate_run"]}}});
@@ -737,293 +737,21 @@ fn shell_denial_preserves_task_reads_and_denies_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!({})));
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":"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"})
));
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;
+3 -12
View File
@@ -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::{holds_bounded_verification, tool_call_denied_for};
use crate::tools::execution_envelope::tool_call_denied;
use codewhale_tools::names::{
LEGACY_TOOL_SEARCH_BM25_NAME, LEGACY_TOOL_SEARCH_REGEX_NAME, TOOL_SEARCH_NAME,
is_tool_search_tool,
@@ -625,7 +625,6 @@ 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.
@@ -646,7 +645,6 @@ 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);
@@ -671,11 +669,10 @@ impl ToolSurfacePolicy {
.and_then(Value::as_array_mut)
{
actions.retain(|action| {
!tool_call_denied_for(
!tool_call_denied(
disallowed_tools.as_deref(),
&tool.name,
&json!({"action": action}),
holds_bounded_verification,
)
});
}
@@ -700,7 +697,6 @@ impl ToolSurfacePolicy {
strict_tool_mode,
allowed_tools,
disallowed_tools,
holds_bounded_verification,
max_tool_calls,
}
}
@@ -742,12 +738,7 @@ impl ToolSurfacePolicy {
}
pub(super) fn denies_call(&self, name: &str, input: &Value) -> bool {
tool_call_denied_for(
self.disallowed_tools.as_deref(),
name,
input,
self.holds_bounded_verification,
)
tool_call_denied(self.disallowed_tools.as_deref(), name, input)
}
}
-825
View File
@@ -1,825 +0,0 @@
use std::path::Path;
use std::time::Duration;
use serde::{Deserialize, Serialize};
use serde_json::Value;
use codewhale_config::ProviderKind;
use codewhale_config::descriptors::defaults::DEFAULT_DEEPSEEK_BASE_URL;
use codewhale_runtime::config::Config;
pub const DEEPSEEK_ASR_MODEL: &str = "deepseek-asr";
pub const DEEPSEEK_TTS_MODEL: &str = "deepseek-tts";
pub const DEEPSEEK_OCR_MODEL: &str = "deepseek-ocr";
pub const DEEPSEEK_ASR_MAX_BYTES: usize = 8 * 1024 * 1024;
pub const DEEPSEEK_OCR_MAX_BYTES: usize = 10 * 1024 * 1024;
pub const DEEPSEEK_TTS_MAX_CHARS: usize = 10_000;
pub const DEEPSEEK_TTS_VOICES: &[&str] = &["stella", "tide"];
#[derive(Debug, Clone, Deserialize, PartialEq, Eq)]
pub struct DeepseekAsrResponse {
#[serde(default)]
pub request_id: Option<String>,
pub text: String,
#[serde(default)]
pub duration_ms: Option<u64>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct DeepseekTtsResponse {
pub audio_bytes: Vec<u8>,
pub content_type: String,
pub format: String,
pub model: String,
pub voice: String,
}
#[derive(Debug, Deserialize)]
struct DeepseekOcrPage {
index: usize,
#[serde(default)]
text: String,
#[serde(default)]
finish_reason: Option<String>,
}
#[derive(Debug, Deserialize)]
struct DeepseekOcrPayload {
#[serde(default)]
id: Option<String>,
#[serde(default)]
object: Option<String>,
#[serde(default)]
page_count: Option<usize>,
#[serde(default)]
pages: Vec<DeepseekOcrPage>,
}
#[derive(Serialize)]
struct DeepseekTtsPayload<'a> {
text: &'a str,
voice_id: &'a str,
format: &'a str,
filter_markdown: bool,
}
fn strip_root_suffix(url: &str) -> String {
let mut trimmed = url.trim().trim_end_matches('/').to_string();
for suffix in ["/anthropic", "/beta", "/v1"] {
if let Some(prefix) = trimmed.strip_suffix(suffix) {
trimmed = prefix.trim_end_matches('/').to_string();
break;
}
}
trimmed
}
pub fn deepseek_root_url(base_url: &str) -> String {
let stripped = strip_root_suffix(base_url);
if stripped.is_empty() {
strip_root_suffix(DEFAULT_DEEPSEEK_BASE_URL)
} else {
stripped
}
}
pub fn deepseek_tools_url(base_url: &str, tool: &str) -> String {
format!(
"{}/v1/tools/{}",
deepseek_root_url(base_url),
tool.trim().trim_start_matches('/')
)
}
pub fn normalize_deepseek_voice_id(voice: Option<&str>) -> Result<&'static str, String> {
let normalized = voice.unwrap_or("").trim().to_ascii_lowercase();
match normalized.as_str() {
"" | "default" | "mimo_default" | "female_calm" | "stella" => Ok("stella"),
"tide" => Ok("tide"),
_ => Err(format!(
"unsupported DeepSeek TTS voice '{}' (allowed: {})",
voice.unwrap_or_default().trim(),
DEEPSEEK_TTS_VOICES.join(", ")
)),
}
}
pub fn normalize_deepseek_tts_format(format: &str) -> Result<(&'static str, &'static str), String> {
match format.trim().to_ascii_lowercase().as_str() {
"" | "wav" => Ok(("wav", "audio/wav")),
"pcm" | "pcm16" => Ok(("pcm", "audio/pcm")),
other => Err(format!(
"unsupported DeepSeek TTS format '{other}' (allowed: wav, pcm)"
)),
}
}
pub fn normalize_deepseek_asr_mime(mime_type: &str) -> Result<&'static str, String> {
let base = mime_type
.split(';')
.next()
.unwrap_or("")
.trim()
.to_ascii_lowercase();
match base.as_str() {
"" | "audio/wav" | "audio/x-wav" | "audio/wave" => Ok("audio/wav"),
"audio/ogg" => Ok("audio/ogg"),
other => Err(format!(
"unsupported DeepSeek ASR mime type '{other}' (allowed: audio/wav, audio/ogg)"
)),
}
}
pub fn normalize_deepseek_ocr_mime(mime_type: &str) -> Result<&'static str, String> {
let base = mime_type
.split(';')
.next()
.unwrap_or("")
.trim()
.to_ascii_lowercase();
match base.as_str() {
"image/png" => Ok("image/png"),
"image/jpeg" | "image/jpg" => Ok("image/jpeg"),
"image/tiff" | "image/tif" => Ok("image/tiff"),
"application/pdf" => Ok("application/pdf"),
other => Err(format!(
"unsupported DeepSeek OCR mime type '{other}' (allowed: image/png, image/jpeg, image/tiff, application/pdf)"
)),
}
}
pub fn resolve_deepseek_config_auth(config: &Config) -> Option<(String, String)> {
if let Ok(identity) = config.active_provider_identity()
&& matches!(
identity.provider,
ProviderKind::Deepseek | ProviderKind::DeepseekAnthropic
)
{
let api_key = config
.active_route_api_key_read_only()
.ok()
.map(|value| value.trim().to_string())
.filter(|value| !value.is_empty())?;
return Some((deepseek_root_url(&config.active_route_base_url()), api_key));
}
let mut scoped = config.clone();
scoped.provider = Some(ProviderKind::Deepseek.as_str().to_string());
let identity = scoped.active_provider_identity().ok()?;
let api_key = scoped
.active_route_api_key_read_only()
.ok()
.map(|value| value.trim().to_string())
.filter(|value| !value.is_empty())?;
let base_url = scoped.base_url_for_route(&identity);
Some((deepseek_root_url(&base_url), api_key))
}
pub fn deepseek_ocr_explicitly_enabled() -> bool {
std::env::var("CODEWHALE_DEEPSEEK_OCR")
.ok()
.is_some_and(|value| {
matches!(
value.trim().to_ascii_lowercase().as_str(),
"1" | "true" | "yes" | "on"
)
})
}
pub fn resolve_deepseek_ocr_auth() -> Option<(String, String)> {
let read = || {
if !deepseek_ocr_explicitly_enabled() {
return None;
}
let api_key = std::env::var("DEEPSEEK_API_KEY")
.ok()
.map(|value| codewhale_secrets::normalize_api_key(&value))
.filter(|value| !value.trim().is_empty())?;
let base_url = std::env::var("DEEPSEEK_BASE_URL")
.ok()
.map(|value| value.trim().to_string())
.filter(|value| !value.is_empty())
.unwrap_or_else(|| DEFAULT_DEEPSEEK_BASE_URL.to_string());
Some((deepseek_root_url(&base_url), api_key))
};
#[cfg(any(test, feature = "test-support"))]
{
crate::test_support::with_test_env_lock(read)
}
#[cfg(not(any(test, feature = "test-support")))]
{
read()
}
}
fn sanitize_multipart_header_value(raw: &str, fallback: &str) -> String {
let cleaned: String = raw
.chars()
.map(|ch| match ch {
'\r' | '\n' | '"' | '\\' | '/' => '_',
_ => ch,
})
.collect();
let trimmed = cleaned.trim();
if trimmed.is_empty() {
fallback.to_string()
} else {
trimmed.to_string()
}
}
fn build_multipart_body(
fields: &[(&str, &str)],
file_field: &str,
file_name: &str,
file_mime: &str,
file_bytes: &[u8],
) -> (String, Vec<u8>) {
let boundary = format!("----codewhale-boundary-{}", uuid::Uuid::new_v4().simple());
let safe_name = sanitize_multipart_header_value(file_name, "upload.bin");
let mut body = Vec::with_capacity(file_bytes.len() + fields.len() * 128 + 256);
for (name, value) in fields {
let safe_field = sanitize_multipart_header_value(name, "field");
body.extend_from_slice(b"--");
body.extend_from_slice(boundary.as_bytes());
body.extend_from_slice(b"\r\nContent-Disposition: form-data; name=\"");
body.extend_from_slice(safe_field.as_bytes());
body.extend_from_slice(b"\"\r\n\r\n");
body.extend_from_slice(value.as_bytes());
body.extend_from_slice(b"\r\n");
}
body.extend_from_slice(b"--");
body.extend_from_slice(boundary.as_bytes());
body.extend_from_slice(b"\r\nContent-Disposition: form-data; name=\"");
body.extend_from_slice(file_field.as_bytes());
body.extend_from_slice(b"\"; filename=\"");
body.extend_from_slice(safe_name.as_bytes());
body.extend_from_slice(b"\"\r\nContent-Type: ");
body.extend_from_slice(file_mime.as_bytes());
body.extend_from_slice(b"\r\n\r\n");
body.extend_from_slice(file_bytes);
body.extend_from_slice(b"\r\n--");
body.extend_from_slice(boundary.as_bytes());
body.extend_from_slice(b"--\r\n");
(format!("multipart/form-data; boundary={boundary}"), body)
}
fn standalone_http_client() -> Result<reqwest::Client, String> {
crate::platform_client_builder()
.timeout(Duration::from_secs(60))
.build()
.map_err(|err| format!("failed to build DeepSeek HTTP client: {err}"))
}
fn redact_secret(text: &str, api_key: &str) -> String {
if api_key.trim().is_empty() {
text.to_string()
} else {
text.replace(api_key, "[redacted]")
}
}
fn format_error_response(status: reqwest::StatusCode, body: &str, api_key: &str) -> String {
let detail = serde_json::from_str::<Value>(body)
.ok()
.and_then(|value| {
value
.pointer("/error/message")
.and_then(Value::as_str)
.or_else(|| value.get("message").and_then(Value::as_str))
.map(str::to_string)
})
.unwrap_or_else(|| {
let trimmed = body.trim();
if trimmed.len() > 512 {
format!("{}...", &trimmed[..512])
} else {
trimmed.to_string()
}
});
let redacted = redact_secret(detail.trim(), api_key);
if redacted.is_empty() {
format!("DeepSeek Tools API returned HTTP {}", status.as_u16())
} else {
format!(
"DeepSeek Tools API returned HTTP {}: {redacted}",
status.as_u16()
)
}
}
pub fn validate_deepseek_tts_inputs(
model: Option<&str>,
text: &str,
instruction: Option<&str>,
voice: Option<&str>,
format: &str,
) -> Result<(String, &'static str, &'static str, &'static str), String> {
let trimmed_text = text.trim();
if trimmed_text.is_empty() {
return Err("speech text cannot be empty".to_string());
}
if text.chars().count() > DEEPSEEK_TTS_MAX_CHARS {
return Err(format!(
"DeepSeek TTS text exceeds maximum of {DEEPSEEK_TTS_MAX_CHARS} characters"
));
}
if instruction.is_some_and(|value| !value.trim().is_empty()) {
return Err(
"DeepSeek TTS does not support voice instructions or voice design prompts".to_string(),
);
}
if let Some(raw_voice) = voice
&& raw_voice.trim_start().starts_with("data:")
{
return Err("DeepSeek TTS does not support voice cloning data URIs".to_string());
}
let normalized_model = match model.unwrap_or("").trim().to_ascii_lowercase().as_str() {
"" | "auto" | "deepseek-tts" | "mimo-v2.5-tts" | "mimo-v2-tts" => DEEPSEEK_TTS_MODEL,
other if other.contains("voiceclone") || other.contains("voicedesign") => {
return Err(format!(
"DeepSeek TTS does not support model '{other}'; use '{DEEPSEEK_TTS_MODEL}'"
));
}
other => {
return Err(format!(
"unsupported DeepSeek TTS model '{other}'; use '{DEEPSEEK_TTS_MODEL}'"
));
}
};
let voice_id = normalize_deepseek_voice_id(voice)?;
let (wire_format, content_type) = normalize_deepseek_tts_format(format)?;
Ok((
normalized_model.to_string(),
voice_id,
wire_format,
content_type,
))
}
pub async fn transcribe_deepseek_bytes(
base_url: &str,
api_key: &str,
audio_bytes: &[u8],
file_name: &str,
mime_type: &str,
language: Option<&str>,
) -> Result<DeepseekAsrResponse, String> {
if api_key.trim().is_empty() {
return Err("DeepSeek ASR requires a configured API key".to_string());
}
if audio_bytes.is_empty() {
return Err("DeepSeek ASR audio payload cannot be empty".to_string());
}
if audio_bytes.len() > DEEPSEEK_ASR_MAX_BYTES {
return Err(format!(
"DeepSeek ASR audio payload exceeds maximum of {DEEPSEEK_ASR_MAX_BYTES} bytes"
));
}
let canonical_mime = normalize_deepseek_asr_mime(mime_type)?;
let default_name = if canonical_mime == "audio/ogg" {
"voice.ogg"
} else {
"voice.wav"
};
let chosen_name = if file_name.trim().is_empty() {
default_name
} else {
file_name
};
let mut fields = Vec::new();
let cleaned_language = language
.map(str::trim)
.filter(|value| !value.is_empty())
.map(str::to_string);
if let Some(ref lang) = cleaned_language {
fields.push(("language", lang.as_str()));
}
let (content_type, body) =
build_multipart_body(&fields, "file", chosen_name, canonical_mime, audio_bytes);
let url = deepseek_tools_url(base_url, "asr");
let client = standalone_http_client()?;
let response = client
.post(&url)
.bearer_auth(api_key)
.header(reqwest::header::CONTENT_TYPE, content_type)
.body(body)
.send()
.await
.map_err(|err| redact_secret(&format!("DeepSeek ASR request failed: {err}"), api_key))?;
let status = response.status();
let response_text = response.text().await.map_err(|err| {
redact_secret(
&format!("failed to read DeepSeek ASR response: {err}"),
api_key,
)
})?;
if !status.is_success() {
return Err(format_error_response(status, &response_text, api_key));
}
serde_json::from_str::<DeepseekAsrResponse>(&response_text)
.map_err(|err| format!("failed to parse DeepSeek ASR response JSON: {err}"))
}
pub async fn synthesize_deepseek_with_client(
http_client: &reqwest::Client,
base_url: &str,
api_key: &str,
request: crate::client::SpeechSynthesisRequest,
) -> anyhow::Result<crate::client::SpeechSynthesisResponse> {
synthesize_deepseek_with_client_and_options(http_client, base_url, api_key, request, true).await
}
pub async fn synthesize_deepseek_with_client_and_options(
http_client: &reqwest::Client,
base_url: &str,
api_key: &str,
request: crate::client::SpeechSynthesisRequest,
filter_markdown: bool,
) -> anyhow::Result<crate::client::SpeechSynthesisResponse> {
if api_key.trim().is_empty() {
anyhow::bail!("DeepSeek TTS requires a configured API key");
}
let (model, voice_id, wire_format, _content_type) = validate_deepseek_tts_inputs(
Some(request.model.as_str()),
&request.text,
request.instruction.as_deref(),
request.voice.as_deref(),
&request.audio_format,
)
.map_err(anyhow::Error::msg)?;
let url = deepseek_tools_url(base_url, "tts");
let payload = DeepseekTtsPayload {
text: &request.text,
voice_id,
format: wire_format,
filter_markdown,
};
let response = http_client
.post(&url)
.bearer_auth(api_key)
.json(&payload)
.send()
.await
.map_err(|err| {
anyhow::anyhow!(redact_secret(
&format!("DeepSeek TTS request failed: {err}"),
api_key
))
})?;
let status = response.status();
if !status.is_success() {
let body = response.text().await.unwrap_or_default();
anyhow::bail!("{}", format_error_response(status, &body, api_key));
}
let audio_bytes = response
.bytes()
.await
.map_err(|err| {
anyhow::anyhow!(redact_secret(
&format!("failed to read DeepSeek TTS audio bytes: {err}"),
api_key
))
})?
.to_vec();
if audio_bytes.is_empty() {
anyhow::bail!("DeepSeek TTS response audio body was empty");
}
Ok(crate::client::SpeechSynthesisResponse {
model,
audio_format: wire_format.to_string(),
audio_bytes,
transcript: None,
voice: Some(voice_id.to_string()),
})
}
pub async fn synthesize_deepseek_bytes(
base_url: &str,
api_key: &str,
text: &str,
voice: Option<&str>,
format: &str,
) -> Result<DeepseekTtsResponse, String> {
synthesize_deepseek_bytes_with_options(base_url, api_key, text, voice, format, true).await
}
pub async fn synthesize_deepseek_bytes_with_options(
base_url: &str,
api_key: &str,
text: &str,
voice: Option<&str>,
format: &str,
filter_markdown: bool,
) -> Result<DeepseekTtsResponse, String> {
let client = standalone_http_client()?;
let (model, voice_id, wire_format, content_type) =
validate_deepseek_tts_inputs(Some(DEEPSEEK_TTS_MODEL), text, None, voice, format)?;
let response = synthesize_deepseek_with_client_and_options(
&client,
base_url,
api_key,
crate::client::SpeechSynthesisRequest {
model,
text: text.to_string(),
instruction: None,
audio_format: wire_format.to_string(),
voice: Some(voice_id.to_string()),
},
filter_markdown,
)
.await
.map_err(|err| err.to_string())?;
Ok(DeepseekTtsResponse {
audio_bytes: response.audio_bytes,
content_type: content_type.to_string(),
format: response.audio_format,
model: response.model,
voice: response.voice.unwrap_or_else(|| voice_id.to_string()),
})
}
pub fn ocr_mime_for_path(path: &Path, bytes: &[u8]) -> Result<&'static str, String> {
if bytes.starts_with(b"\x89PNG\r\n\x1a\n") {
return Ok("image/png");
}
if bytes.starts_with(&[0xFF, 0xD8, 0xFF]) {
return Ok("image/jpeg");
}
if bytes.starts_with(b"II*\x00") || bytes.starts_with(b"MM\x00*") {
return Ok("image/tiff");
}
if bytes.starts_with(b"%PDF") {
return Ok("application/pdf");
}
let ext = path
.extension()
.and_then(|value| value.to_str())
.map(str::to_ascii_lowercase)
.unwrap_or_default();
match ext.as_str() {
"png" => Ok("image/png"),
"jpg" | "jpeg" => Ok("image/jpeg"),
"tif" | "tiff" => Ok("image/tiff"),
"pdf" => Ok("application/pdf"),
other => Err(format!(
"unsupported file type '{other}' for DeepSeek OCR (allowed: png, jpeg, tiff, pdf)"
)),
}
}
pub async fn ocr_deepseek_bytes(
base_url: &str,
api_key: &str,
file_bytes: &[u8],
file_name: &str,
mime_type: &str,
) -> Result<String, String> {
if api_key.trim().is_empty() {
return Err("DeepSeek OCR requires a configured API key".to_string());
}
if file_bytes.is_empty() {
return Err("DeepSeek OCR input file cannot be empty".to_string());
}
if file_bytes.len() > DEEPSEEK_OCR_MAX_BYTES {
return Err(format!(
"DeepSeek OCR input exceeds maximum of {DEEPSEEK_OCR_MAX_BYTES} bytes"
));
}
let canonical_mime = normalize_deepseek_ocr_mime(mime_type)?;
let fields = [("purpose", "text_recognition")];
let (content_type, body) = build_multipart_body(
&fields,
"file",
if file_name.trim().is_empty() {
"document"
} else {
file_name
},
canonical_mime,
file_bytes,
);
let url = deepseek_tools_url(base_url, "ocr");
let client = standalone_http_client()?;
let response = client
.post(&url)
.bearer_auth(api_key)
.header(reqwest::header::CONTENT_TYPE, content_type)
.body(body)
.send()
.await
.map_err(|err| redact_secret(&format!("DeepSeek OCR request failed: {err}"), api_key))?;
let status = response.status();
let raw = response.text().await.map_err(|err| {
redact_secret(
&format!("failed to read DeepSeek OCR response: {err}"),
api_key,
)
})?;
if !status.is_success() {
return Err(format_error_response(status, &raw, api_key));
}
let mut payload = serde_json::from_str::<DeepseekOcrPayload>(raw.trim())
.map_err(|err| format!("failed to parse DeepSeek OCR response JSON: {err}"))?;
let _ = (&payload.id, &payload.object, payload.page_count);
payload.pages.sort_by_key(|page| page.index);
let text = payload
.pages
.into_iter()
.map(|page| {
let _ = page.finish_reason;
page.text.trim().to_string()
})
.filter(|text| !text.is_empty())
.collect::<Vec<_>>()
.join("\n\n");
Ok(text)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::test_support::{EnvVarGuard, lock_test_env};
use wiremock::matchers::{header, method, path};
use wiremock::{Mock, MockServer, ResponseTemplate};
#[test]
fn root_url_strips_suffixes_and_falls_back_to_default() {
assert_eq!(
deepseek_root_url("https://api.deepseek.com/v1/"),
"https://api.deepseek.com"
);
assert_eq!(
deepseek_root_url("https://api.deepseek.com/anthropic"),
"https://api.deepseek.com"
);
assert_eq!(
deepseek_root_url("https://api.deepseek.com/beta"),
"https://api.deepseek.com"
);
assert_eq!(deepseek_root_url(" "), "https://api.deepseek.com");
assert_eq!(
deepseek_tools_url("https://api.deepseek.com/v1", "asr"),
"https://api.deepseek.com/v1/tools/asr"
);
}
#[test]
fn voice_and_format_normalization() {
assert_eq!(normalize_deepseek_voice_id(None).unwrap(), "stella");
assert_eq!(
normalize_deepseek_voice_id(Some("mimo_default")).unwrap(),
"stella"
);
assert_eq!(
normalize_deepseek_voice_id(Some("female_calm")).unwrap(),
"stella"
);
assert_eq!(normalize_deepseek_voice_id(Some("tide")).unwrap(), "tide");
assert!(normalize_deepseek_voice_id(Some("unknown")).is_err());
assert_eq!(
normalize_deepseek_tts_format("wav").unwrap(),
("wav", "audio/wav")
);
assert_eq!(
normalize_deepseek_tts_format("pcm16").unwrap(),
("pcm", "audio/pcm")
);
assert!(normalize_deepseek_tts_format("mp3").is_err());
}
#[tokio::test]
async fn asr_wiremock_sends_multipart_and_parses_response() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/v1/tools/asr"))
.and(header("authorization", "Bearer ds-secret"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"request_id": "req-1",
"text": "hello deepseek",
"duration_ms": 1240
})))
.mount(&server)
.await;
let res = transcribe_deepseek_bytes(
&format!("{}/v1", server.uri()),
"ds-secret",
b"RIFFdata",
"voice.wav",
"audio/x-wav",
Some("en"),
)
.await
.expect("asr succeeds");
assert_eq!(res.text, "hello deepseek");
assert_eq!(res.duration_ms, Some(1240));
let received = server.received_requests().await.expect("requests");
let raw = String::from_utf8_lossy(&received[0].body);
assert!(raw.contains("name=\"language\"\r\n\r\nen\r\n"));
assert!(raw.contains("name=\"file\"; filename=\"voice.wav\""));
assert!(raw.contains("Content-Type: audio/wav"));
}
#[tokio::test]
async fn tts_wiremock_sends_json_and_rejects_clone_or_overflow() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/v1/tools/tts"))
.and(header("authorization", "Bearer ds-secret"))
.respond_with(ResponseTemplate::new(200).set_body_bytes(b"RIFF_WAV".to_vec()))
.mount(&server)
.await;
let out = synthesize_deepseek_bytes(
&format!("{}/anthropic", server.uri()),
"ds-secret",
"Read **this** aloud",
Some("tide"),
"wav",
)
.await
.expect("tts succeeds");
assert_eq!(out.audio_bytes, b"RIFF_WAV");
assert_eq!(out.content_type, "audio/wav");
assert_eq!(out.voice, "tide");
let received = server.received_requests().await.expect("requests");
let sent: Value = serde_json::from_slice(&received[0].body).unwrap();
assert_eq!(
sent,
serde_json::json!({
"text": "Read **this** aloud",
"voice_id": "tide",
"format": "wav",
"filter_markdown": true
})
);
assert!(
validate_deepseek_tts_inputs(
Some("deepseek-tts"),
"hi",
None,
Some("data:audio/wav;base64,AA=="),
"wav"
)
.is_err()
);
let too_long = "a".repeat(DEEPSEEK_TTS_MAX_CHARS + 1);
assert!(validate_deepseek_tts_inputs(None, &too_long, None, None, "wav").is_err());
}
#[tokio::test]
async fn ocr_multipart_puts_purpose_before_file_and_strips_keepalives() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/v1/tools/ocr"))
.and(header("authorization", "Bearer ds-secret"))
.respond_with(ResponseTemplate::new(200).set_body_string(
"\n\n\n{\"id\":\"ocr-1\",\"object\":\"ocr\",\"page_count\":2,\"pages\":[{\"index\":1,\"text\":\"Page two\",\"finish_reason\":\"stop\"},{\"index\":0,\"text\":\"Page one\",\"finish_reason\":\"stop\"}]}",
))
.mount(&server)
.await;
let text = ocr_deepseek_bytes(
&server.uri(),
"ds-secret",
b"\x89PNG\r\n\x1a\npayload",
"shot.png",
"image/png",
)
.await
.expect("ocr succeeds");
assert_eq!(text, "Page one\n\nPage two");
let received = server.received_requests().await.expect("requests");
let raw = String::from_utf8_lossy(&received[0].body);
let purpose_pos = raw
.find("name=\"purpose\"\r\n\r\ntext_recognition")
.unwrap();
let file_pos = raw.find("name=\"file\"; filename=\"shot.png\"").unwrap();
assert!(purpose_pos < file_pos);
}
#[test]
fn ocr_opt_in_requires_flag_and_api_key() {
let _lock = lock_test_env();
let _flag_off = EnvVarGuard::remove("CODEWHALE_DEEPSEEK_OCR");
let _key = EnvVarGuard::set("DEEPSEEK_API_KEY", "ds-ocr-key");
let _base = EnvVarGuard::set("DEEPSEEK_BASE_URL", "https://ds.example/v1");
assert!(resolve_deepseek_ocr_auth().is_none());
let _flag_on = EnvVarGuard::set("CODEWHALE_DEEPSEEK_OCR", "1");
assert_eq!(
resolve_deepseek_ocr_auth(),
Some(("https://ds.example".to_string(), "ds-ocr-key".to_string()))
);
}
}
@@ -1866,11 +1866,7 @@ 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::Catalog
DshService::Network | DshService::Remote | DshService::Terminal | DshService::Workspace
)
}) {
let ticket = &tickets_a[service.name()];
@@ -2149,11 +2145,7 @@ 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::Catalog
DshService::Network | DshService::Remote | DshService::Terminal | DshService::Workspace
) {
let result = f
.broker(
@@ -4237,347 +4229,3 @@ 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);
}
+3 -334
View File
@@ -1,337 +1,6 @@
use std::sync::Arc;
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::dsh_broker::{BrokerCall, BrokerFuture, not_available};
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" }),
}
pub(super) fn serve(_call: BrokerCall) -> BrokerFuture {
not_available(DshService::Catalog)
}
+15 -79
View File
@@ -12,7 +12,6 @@ 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;
@@ -93,8 +92,6 @@ 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 {
@@ -103,8 +100,6 @@ impl NetworkAuthority {
hosts: BTreeSet::new(),
tool: None,
vault: None,
#[cfg(test)]
test_dns_pin: None,
}
}
}
@@ -133,8 +128,6 @@ 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
@@ -339,7 +332,6 @@ 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"))?;
@@ -401,26 +393,9 @@ async fn fetch(
let mut substituted: Option<Substituted> = None;
let response = loop {
egress_host(&url, hosts)?;
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 pin = validate_fetch_target(&url, tool, AUDIT_LABEL)
.await
.map_err(|error| invalid(format!("egress refused: {error}")))?;
let substitution = match substituted.take() {
Some(done) => done,
None => substitute_headers(plugin, vault, &input.headers)?,
@@ -581,22 +556,6 @@ 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)
}
}
@@ -706,16 +665,11 @@ mod tests {
"a",
"fetch",
json!({
"url": format!("http://api.example.com:{}/v1/models", server.address().port()),
"url": format!("{}/v1/models", server.uri()),
"headers": [["authorization", "Bearer codewhale-credential:KEY"]],
}),
),
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();
@@ -771,16 +725,6 @@ 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",
@@ -871,18 +815,16 @@ mod tests {
let vault = vault();
vault.set(&vault_slot("a", "KEY"), SECRET).unwrap();
let headers = json!([["authorization", "codewhale-credential:KEY"]]);
let base = format!("http://api.example.com:{}", server.address().port());
let both = authority_with_pin(
&["api.example.com", "localhost"],
policy(&["api.example.com", "localhost"], &[]),
let both = authority(
&["127.0.0.1", "localhost"],
policy(&["127.0.0.1", "localhost"], &[]),
&vault,
"api.example.com",
);
let away = serve_with(
call(
"a",
"fetch",
json!({"url": format!("{base}/away"), "headers": headers}),
json!({"url": format!("{}/away", server.uri()), "headers": headers}),
),
both,
)
@@ -892,13 +834,12 @@ mod tests {
call(
"a",
"fetch",
json!({"url": format!("{base}/rename"), "headers": headers}),
json!({"url": format!("{}/rename", server.uri()), "headers": headers}),
),
authority_with_pin(
&["api.example.com", "localhost"],
policy(&["api.example.com", "localhost"], &[]),
authority(
&["127.0.0.1", "localhost"],
policy(&["127.0.0.1", "localhost"], &[]),
&vault,
"api.example.com",
),
)
.await;
@@ -907,14 +848,9 @@ mod tests {
call(
"a",
"fetch",
json!({"url": format!("{base}/hop"), "headers": headers}),
),
authority_with_pin(
&["api.example.com"],
policy(&["api.example.com"], &[]),
&vault,
"api.example.com",
json!({"url": format!("{}/hop", server.uri()), "headers": headers}),
),
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).
pub(super) const TOML_DATETIME_KEY: &str = "$__toml_private_datetime";
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.
pub(super) fn convert(name: &str, settings: &PluginSettings) -> Result<PluginConfig, String> {
fn convert(name: &str, settings: &PluginSettings) -> Result<PluginConfig, String> {
let Some(table) = &settings.config else {
return Ok(PluginConfig::empty());
};
-1
View File
@@ -18,7 +18,6 @@ pub mod compaction_archive;
pub mod conversation_modes;
pub mod core;
pub mod cost_status;
pub mod deepseek_media;
pub mod diagnostics_messages;
pub mod exec_events;
pub mod extension_host;
+2 -775
View File
@@ -1835,7 +1835,6 @@ pub(super) enum RuntimeTurnInputSource {
Scheduled {
task_id: String,
},
AsyncCompletionWake,
}
impl RuntimeTurnInputSource {
@@ -1851,7 +1850,7 @@ impl RuntimeTurnInputSource {
Self::GoalContinuation {
continuation_index, ..
} if *continuation_index > 0 => crate::core::ops::UserInputProvenance::GoalContinuation,
Self::GoalContinuation { .. } | Self::Scheduled { .. } | Self::AsyncCompletionWake => {
Self::GoalContinuation { .. } | Self::Scheduled { .. } => {
crate::core::ops::UserInputProvenance::Runtime
}
}
@@ -1864,9 +1863,7 @@ impl RuntimeTurnInputSource {
| Self::ExtensionCommand { .. }
| Self::WatchEvent(_) => None,
Self::AgentMail { message_id, .. } => Some(message_id),
Self::GoalContinuation { .. } | Self::Scheduled { .. } | Self::AsyncCompletionWake => {
None
}
Self::GoalContinuation { .. } | Self::Scheduled { .. } => None,
}
}
@@ -1877,7 +1874,6 @@ 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.
@@ -1942,9 +1938,6 @@ impl RuntimeTurnInputSource {
"input_provenance": "scheduled",
"task_id": task_id,
})),
Self::AsyncCompletionWake => Some(json!({
"input_provenance": "async_completion_wake",
})),
}
}
}
@@ -6606,695 +6599,6 @@ 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(
@@ -15702,9 +15006,6 @@ 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 {
@@ -15714,51 +15015,6 @@ 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 { .. }
@@ -15801,30 +15057,6 @@ 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;
}
@@ -18913,11 +18145,6 @@ 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(())
-616
View File
@@ -26146,619 +26146,3 @@ 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(())
}
+7 -52
View File
@@ -61,7 +61,6 @@
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
@@ -478,15 +477,6 @@ 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)
@@ -496,40 +486,12 @@ pub fn tool_call_denied_for(
.and_then(Value::as_str)
.is_some_and(|url| !url.trim().is_empty())
&& codewhale_execpolicy::tool_rules::tool_denied(rules, "fetch_url"))
|| (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
})
|| (matches!(
classify_verification(action, input),
Some(VerificationBound::Unbounded)
) && rules.is_some_and(|rules| {
codewhale_execpolicy::tool_rules::tool_matches_any_rule(rules, "Bash")
}))
}
/// Repeat the command ceiling at native dispatch and direct delegation sinks.
@@ -545,14 +507,7 @@ 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_for(
Some(&context.disallowed_tools),
name,
input,
holds_bounded_verification(context),
)
{
if !bounded_child_read && tool_call_denied(Some(&context.disallowed_tools), name, input) {
return Err(crate::tools::spec::ToolError::permission_denied(format!(
"Tool '{name}' or its execution dependency is in the disallowed-tools list"
)));
+13 -173
View File
@@ -72,22 +72,9 @@ impl ToolSpec for ImageOcrTool {
}
}
fn local_ocr_disabled() -> bool {
let read = || std::env::var_os("CODEWHALE_LOCAL_OCR_UNAVAILABLE").is_some();
#[cfg(any(test, feature = "test-support"))]
{
crate::test_support::with_test_env_lock(read)
}
#[cfg(not(any(test, feature = "test-support")))]
{
read()
}
}
pub fn ocr_available() -> bool {
(!local_ocr_disabled()
&& (codewhale_dependencies::resolve_tesseract().is_some() || native_ocr_available()))
|| crate::deepseek_media::resolve_deepseek_ocr_auth().is_some()
std::env::var_os("CODEWHALE_LOCAL_OCR_UNAVAILABLE").is_none()
&& (codewhale_dependencies::resolve_tesseract().is_some() || native_ocr_available())
}
const OCR_TIMEOUT: Duration = Duration::from_secs(120);
@@ -207,21 +194,17 @@ impl CapturedOcr {
let fallback = if matches!(result, Ok(Some(_))) {
None
} else {
let resolve = || {
(!local_ocr_disabled())
.then(codewhale_dependencies::resolve_tesseract)
.flatten()
.map(OsString::from)
};
#[cfg(all(test, unix))]
let supplied = self.overrides.as_ref().map(|value| value.tesseract.clone());
#[cfg(all(test, unix))]
{
supplied.unwrap_or_else(resolve)
supplied.unwrap_or_else(|| {
codewhale_dependencies::resolve_tesseract().map(OsString::from)
})
}
#[cfg(not(all(test, unix)))]
{
resolve()
codewhale_dependencies::resolve_tesseract().map(OsString::from)
}
};
OcrNativeStep {
@@ -260,9 +243,6 @@ impl OcrNativeStep {
pub fn finish(self) -> OcrOutcome {
self.outcome
}
pub(crate) fn finish_with_input(self) -> (CapturedOcr, OcrOutcome) {
(self.input, self.outcome)
}
pub async fn tesseract(
self,
cancel: Option<&CancellationToken>,
@@ -429,20 +409,12 @@ pub async fn ocr_image_path(image_path: &Path, context: &ToolContext) -> Result<
()=tokio::time::sleep_until(deadline)=>return Err(ToolError::Timeout {seconds:OCR_TIMEOUT.as_secs()}),
result=native=>result.map_err(|error|ToolError::execution_failed(format!("Image OCR task: {error}")))?,
};
if step.needs_tesseract() {
let output = step
.tesseract(context.cancel_token.as_ref(), deadline)
.await;
if context
.cancel_token
.as_ref()
.is_some_and(CancellationToken::is_cancelled)
{
return Err(ToolError::cancelled("Image OCR was cancelled"));
}
return output.into_rust_text();
}
let (captured, output) = step.finish_with_input();
let output = if step.needs_tesseract() {
step.tesseract(context.cancel_token.as_ref(), deadline)
.await
} else {
step.finish()
};
if context
.cancel_token
.as_ref()
@@ -450,96 +422,8 @@ pub async fn ocr_image_path(image_path: &Path, context: &ToolContext) -> Result<
{
return Err(ToolError::cancelled("Image OCR was cancelled"));
}
if matches!(output, OcrOutcome::Native(Ok(None)))
&& let Some((base_url, api_key)) = crate::deepseek_media::resolve_deepseek_ocr_auth()
{
return run_deepseek_ocr(&captured.path, &base_url, &api_key, context, deadline).await;
}
output.into_rust_text()
}
fn check_ocr_network_policy(context: &ToolContext, base_url: &str) -> Result<(), ToolError> {
let Some(decider) = context.network_policy.as_ref() else {
return Ok(());
};
let Some(host) = codewhale_net::network_policy::host_from_url(base_url) else {
return Ok(());
};
match decider.evaluate(&host, "image_ocr") {
codewhale_net::network_policy::Decision::Allow => Ok(()),
codewhale_net::network_policy::Decision::Deny => Err(ToolError::permission_denied(
format!("image_ocr network call to '{host}' blocked by network policy"),
)),
codewhale_net::network_policy::Decision::Prompt => {
Err(ToolError::permission_denied(format!(
"image_ocr network call to '{host}' requires approval; re-run after `/network allow {host}` or set network.default = \"allow\" in config"
)))
}
}
}
async fn run_deepseek_ocr(
path: &Path,
base_url: &str,
api_key: &str,
context: &ToolContext,
deadline: tokio::time::Instant,
) -> Result<String, ToolError> {
check_ocr_network_policy(context, base_url)?;
if context
.cancel_token
.as_ref()
.is_some_and(CancellationToken::is_cancelled)
{
return Err(ToolError::cancelled("Image OCR was cancelled"));
}
if tokio::time::Instant::now() >= deadline {
return Err(ToolError::Timeout {
seconds: OCR_TIMEOUT.as_secs(),
});
}
let bytes = tokio::fs::read(path).await.map_err(|err| {
ToolError::execution_failed(format!("failed to read image for OCR: {err}"))
})?;
if bytes.len() > crate::deepseek_media::DEEPSEEK_OCR_MAX_BYTES {
return Err(ToolError::invalid_input(format!(
"image exceeds DeepSeek OCR limit of {} bytes",
crate::deepseek_media::DEEPSEEK_OCR_MAX_BYTES
)));
}
let mime =
crate::deepseek_media::ocr_mime_for_path(path, &bytes).map_err(ToolError::invalid_input)?;
let file_name = path
.file_name()
.and_then(|value| value.to_str())
.unwrap_or("image");
let text = tokio::select! {
biased;
() = wait_cancel(context.cancel_token.as_ref()) => {
return Err(ToolError::cancelled("Image OCR was cancelled"));
}
() = tokio::time::sleep_until(deadline) => {
return Err(ToolError::Timeout {
seconds: OCR_TIMEOUT.as_secs(),
});
}
result = crate::deepseek_media::ocr_deepseek_bytes(
base_url,
api_key,
&bytes,
file_name,
mime,
) => {
result.map_err(ToolError::execution_failed)?
}
};
if text.len() > MAX_OCR_TEXT {
return Err(ToolError::execution_failed(
"DeepSeek OCR output exceeded size limit",
));
}
Ok(text)
}
async fn wait_cancel(cancel: Option<&CancellationToken>) {
match cancel {
Some(cancel) => cancel.cancelled().await,
@@ -564,7 +448,7 @@ thread_local! {static TEST_OVERRIDES:std::cell::RefCell<Option<TestOverrides>>=c
fn native_ocr_available() -> bool {
// Classes can exist at link time while runtime Vision is unusable
// (restricted CI hosts); probe the ObjC class table once to match real use.
!local_ocr_disabled() && macos_vision::vision_runtime_available()
macos_vision::vision_runtime_available()
}
#[cfg(not(target_os = "macos"))]
@@ -859,50 +743,6 @@ mod tests {
result.content
);
}
#[tokio::test]
async fn deepseek_ocr_fallback_runs_when_opted_in_and_respects_network_policy() {
use crate::test_support::{EnvVarGuard, lock_test_env};
use codewhale_net::network_policy::{NetworkPolicy, NetworkPolicyDecider};
use wiremock::matchers::{method, path};
use wiremock::{Mock, MockServer, ResponseTemplate};
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/v1/tools/ocr"))
.respond_with(ResponseTemplate::new(200).set_body_string(
"\n{\"id\":\"ocr-test\",\"object\":\"ocr\",\"page_count\":1,\"pages\":[{\"index\":0,\"text\":\"INVOICE 42\",\"finish_reason\":\"stop\"}]}",
))
.mount(&server)
.await;
let tmp = tempfile::tempdir().expect("tempdir");
let tmp_root = tmp.path().canonicalize().expect("canonical tempdir");
let image_path = tmp_root.join("sample.png");
fs::write(&image_path, b"\x89PNG\r\n\x1a\nfake-png").unwrap();
let _lock = lock_test_env();
let _local_off = EnvVarGuard::set("CODEWHALE_LOCAL_OCR_UNAVAILABLE", "1");
let _opt_in = EnvVarGuard::set("CODEWHALE_DEEPSEEK_OCR", "1");
let _key = EnvVarGuard::set("DEEPSEEK_API_KEY", "ds-ocr-test-key");
let _base = EnvVarGuard::set("DEEPSEEK_BASE_URL", server.uri());
assert!(ocr_available());
let blocked_ctx = ToolContext::new(tmp_root.clone())
.with_network_policy(NetworkPolicyDecider::new(NetworkPolicy::default(), None));
let blocked = ocr_image_path(&image_path, &blocked_ctx).await;
assert!(
matches!(blocked, Err(ToolError::PermissionDenied { .. })),
"{blocked:?}"
);
let allow_ctx = ToolContext::new(tmp_root);
let recovered = ocr_image_path(&image_path, &allow_ctx)
.await
.expect("deepseek ocr fallback succeeds");
assert_eq!(recovered, "INVOICE 42");
}
}
#[cfg(all(test, unix))]
+37 -154
View File
@@ -164,13 +164,9 @@ impl ToolSpec for SpeechTool {
let client = self.client.clone().ok_or_else(|| {
ToolError::not_available(
"speech tool requires an active Xiaomi MiMo or DeepSeek API client; configure provider = \"xiaomi-mimo\" or \"deepseek\" and an API key first",
"speech tool requires an active Xiaomi MiMo API client; configure provider = \"xiaomi-mimo\" and an API key first",
)
})?;
let is_deepseek = matches!(
client.api_provider(),
ProviderKind::Deepseek | ProviderKind::DeepseekAnthropic
);
let requested_format_raw = optional_str(&input, "format")?
.map(str::trim)
@@ -178,14 +174,6 @@ impl ToolSpec for SpeechTool {
.unwrap_or(DEFAULT_FORMAT);
let requested_format =
prepare_speech_format(requested_format_raw, SpeechSurface::Tool, context).await?;
let requested_format = if is_deepseek {
let (wire_format, _) =
crate::deepseek_media::normalize_deepseek_tts_format(&requested_format)
.map_err(ToolError::invalid_input)?;
wire_format.to_string()
} else {
requested_format
};
let output_raw = optional_str(&input, "output")?
.map(str::trim)
.filter(|value| !value.is_empty());
@@ -221,69 +209,35 @@ impl ToolSpec for SpeechTool {
SpeechPreparation {
model: optional_str(&input, "model")?,
voice: raw_voice.as_deref(),
instruction: raw_instruction.clone(),
voice_prompt: voice_prompt.clone(),
instruction: raw_instruction,
voice_prompt,
has_clone_path: clone_voice.is_some(),
surface: SpeechSurface::Tool,
},
context,
)
.await?;
let mut model = plan.model;
let model = plan.model;
let instruction = plan.instruction;
let voice = if is_deepseek {
if clone_voice.is_some()
|| raw_instruction.is_some()
|| voice_prompt.is_some()
|| instruction.is_some()
|| model.contains("voiceclone")
|| model.contains("voicedesign")
{
return Err(ToolError::invalid_input(
"DeepSeek TTS does not support voice cloning, voice design, or style instructions",
));
}
if model == "mimo-v2.5-tts" || model == "mimo-v2-tts" {
model = crate::deepseek_media::DEEPSEEK_TTS_MODEL.to_string();
}
let selected_voice = match plan.voice {
SpeechVoice::Clone => {
return Err(ToolError::invalid_input(
"DeepSeek TTS does not support voice cloning",
));
}
SpeechVoice::Raw => raw_voice.as_deref(),
SpeechVoice::Default | SpeechVoice::Omit => None,
};
let normalized_voice =
crate::deepseek_media::normalize_deepseek_voice_id(selected_voice)
.map_err(ToolError::invalid_input)?;
Some(normalized_voice.to_string())
} else {
match plan.voice {
SpeechVoice::Clone => {
let clone_path = clone_voice.as_deref().ok_or_else(|| {
ToolError::execution_failed(
"speech adapter requested an uncaptured clone sample",
)
})?;
let clone_path = context.resolve_path(clone_path)?;
Some(encode_voice_clone_data_uri(&clone_path).await?)
}
SpeechVoice::Omit => None,
SpeechVoice::Raw => Some(raw_voice.ok_or_else(|| {
ToolError::execution_failed("speech adapter requested an uncaptured voice")
})?),
SpeechVoice::Default => Some(DEFAULT_VOICE.to_string()),
// Raw sample bytes/data URIs are never part of the host snapshot.
let voice = match plan.voice {
SpeechVoice::Clone => {
let clone_path = clone_voice.as_deref().ok_or_else(|| {
ToolError::execution_failed(
"speech adapter requested an uncaptured clone sample",
)
})?;
let clone_path = context.resolve_path(clone_path)?;
Some(encode_voice_clone_data_uri(&clone_path).await?)
}
SpeechVoice::Omit => None,
SpeechVoice::Raw => Some(raw_voice.ok_or_else(|| {
ToolError::execution_failed("speech adapter requested an uncaptured voice")
})?),
SpeechVoice::Default => Some(DEFAULT_VOICE.to_string()),
};
let policy_base_url = if is_deepseek {
crate::deepseek_media::deepseek_root_url(client.base_url())
} else {
client.base_url().to_string()
};
check_network_policy(context, &policy_base_url)?;
check_network_policy(context, client.base_url())?;
let response = client
.synthesize_speech(SpeechSynthesisRequest {
@@ -318,39 +272,21 @@ impl ToolSpec for SpeechTool {
))
})?;
let result = if is_deepseek {
json!({
"mode": "speech",
"success": true,
"api": "DeepSeek Tools TTS (/v1/tools/tts)",
"base_url": crate::deepseek_media::deepseek_root_url(client.base_url()),
"model": response.model,
"format": response.audio_format,
"output": output_label,
"absolute_output": output_path.display().to_string(),
"bytes": response.audio_bytes.len(),
"voice": response.voice.as_deref().map(describe_speech_voice),
"transcript": response.transcript,
"supported_formats": ["wav", "pcm"],
"supported_deepseek_voices": crate::deepseek_media::DEEPSEEK_TTS_VOICES,
})
} else {
json!({
"mode": "speech",
"success": true,
"api": "Xiaomi MiMo OpenAI-compatible chat/completions speech synthesis",
"base_url": openai_compatible_base_url(client.base_url()),
"model": response.model,
"format": response.audio_format,
"output": output_label,
"absolute_output": output_path.display().to_string(),
"bytes": response.audio_bytes.len(),
"voice": response.voice.as_deref().map(describe_speech_voice),
"transcript": response.transcript,
"supported_formats": SUPPORTED_SPEECH_FORMATS,
"supported_xiaomi_mimo_models": SUPPORTED_XIAOMI_MIMO_SPEECH_MODELS,
})
};
let result = json!({
"mode": "speech",
"success": true,
"api": "Xiaomi MiMo OpenAI-compatible chat/completions speech synthesis",
"base_url": openai_compatible_base_url(client.base_url()),
"model": response.model,
"format": response.audio_format,
"output": output_label,
"absolute_output": output_path.display().to_string(),
"bytes": response.audio_bytes.len(),
"voice": response.voice.as_deref().map(describe_speech_voice),
"transcript": response.transcript,
"supported_formats": SUPPORTED_SPEECH_FORMATS,
"supported_xiaomi_mimo_models": SUPPORTED_XIAOMI_MIMO_SPEECH_MODELS,
});
ToolResult::json(&result).map_err(|err| {
ToolError::execution_failed(format!("failed to serialize result: {err}"))
})
@@ -833,63 +769,10 @@ mod tests {
let schema = tool.input_schema();
assert!(schema.to_string().contains("mimo-v2.5-tts-voiceclone"));
assert!(schema.to_string().contains("pcm16"));
// #6516: the tool writes complete audio files only, so it no longer
// advertises a `stream` parameter it could only reject.
assert!(schema["properties"].get("stream").is_none());
}
#[tokio::test]
async fn speech_tool_supports_deepseek_tts_and_rejects_clone_or_mp3() {
use crate::test_support::{EnvVarGuard, lock_test_env};
use wiremock::matchers::{method, path};
use wiremock::{Mock, MockServer, ResponseTemplate};
let _lock = lock_test_env();
let _cli_source = EnvVarGuard::remove(codewhale_config::CLI_API_KEY_SOURCE_ENV);
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/v1/tools/tts"))
.respond_with(ResponseTemplate::new(200).set_body_bytes(b"RIFF_DEEPSEEK".to_vec()))
.mount(&server)
.await;
let config: codewhale_runtime::config::Config = toml::from_str(&format!(
"provider = \"deepseek\"\n\n[providers.deepseek]\napi_key = \"ds-speech-key\"\nbase_url = \"{}/v1\"\nmodel = \"deepseek-v4\"\n",
server.uri()
))
.expect("deepseek config parses");
let client = CodewhaleClient::new(&config).expect("client");
let tmp = tempfile::tempdir().expect("tempdir");
let ctx = ToolContext::new(tmp.path().to_path_buf());
let tool = SpeechTool::new("speech", Some(client), None);
let result = tool
.execute(
json!({"text": "Hello from DeepSeek", "voice": "tide"}),
&ctx,
)
.await
.expect("deepseek speech tool succeeds");
assert!(result.success);
let parsed: Value = serde_json::from_str(&result.content).expect("json");
assert_eq!(parsed["model"], "deepseek-tts");
assert_eq!(parsed["voice"], "tide");
assert_eq!(parsed["format"], "wav");
assert_eq!(parsed["base_url"], server.uri());
assert_eq!(
std::fs::read(tmp.path().join("speech.wav")).unwrap(),
b"RIFF_DEEPSEEK"
);
assert!(
tool.execute(json!({"text": "Hello", "format": "mp3"}), &ctx)
.await
.is_err()
);
assert!(
tool.execute(json!({"text": "Hello", "instruction": "whisper"}), &ctx)
.await
.is_err()
);
}
}
#[cfg(test)]
-74
View File
@@ -22735,80 +22735,6 @@ 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] {
+18 -253
View File
@@ -29,7 +29,9 @@ 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 {
canonicalize_or_self(parent_workspace)
parent_workspace
.canonicalize()
.unwrap_or_else(|_| parent_workspace.to_path_buf())
};
if let Some(worktree) = worktree {
@@ -44,10 +46,6 @@ 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,
@@ -57,7 +55,9 @@ fn validate_existing_child_cwd(
} else {
parent_workspace.join(requested_cwd)
};
let workspace_canonical = canonicalize_or_self(parent_workspace);
let workspace_canonical = parent_workspace
.canonicalize()
.unwrap_or_else(|_| parent_workspace.to_path_buf());
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,9 +65,7 @@ fn validate_existing_child_cwd(
workspace_canonical.display()
))
})?;
if !canonical.starts_with(&workspace_canonical)
&& !is_sibling_worktree_path(parent_workspace, &canonical)
{
if !canonical.starts_with(&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(),
@@ -78,29 +76,6 @@ 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,
@@ -208,7 +183,9 @@ 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 = canonicalize_or_self(workspace);
let start = workspace
.canonicalize()
.unwrap_or_else(|_| workspace.to_path_buf());
let mut paths_tried = Vec::new();
let mut current = Some(start.as_path());
let mut levels = 0usize;
@@ -283,31 +260,19 @@ fn path_looks_like_git_checkout(path: &Path) -> bool {
git_path.is_dir() || git_path.is_file()
}
fn git_rev_parse_path(args: &[&str], path: &Path) -> Option<PathBuf> {
let output = Git::output(args, path).ok()?;
fn try_git_toplevel(path: &Path) -> Option<PathBuf> {
let output = Git::output(&["rev-parse", "--show-toplevel"], path).ok()?;
if !output.status.success() {
return None;
}
let raw = String::from_utf8_lossy(&output.stdout).trim().to_string();
if raw.is_empty() {
let root = String::from_utf8_lossy(&output.stdout).trim().to_string();
if root.is_empty() {
None
} else {
Some(PathBuf::from(raw))
Some(PathBuf::from(root))
}
}
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() {
@@ -385,7 +350,9 @@ fn resolve_worktree_path(
None => default_root.join(branch_worktree_slug(branch)),
};
let normalized = normalize_path_lexically(&path);
let repo_canonical = canonicalize_or_self(repo_root);
let repo_canonical = repo_root
.canonicalize()
.unwrap_or_else(|_| repo_root.to_path_buf());
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 {}",
@@ -718,206 +685,4 @@ 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}"
);
}
}
-1
View File
@@ -515,7 +515,6 @@ 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,
+8 -26
View File
@@ -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, PrivateTargets, guarded_reqwest_client_builder};
use codewhale_net::guard::{DnsPin, guarded_reqwest_client_builder};
use codewhale_runtime::features::Feature;
pub const DEFAULT_TIMEOUT: Duration = Duration::from_secs(15);
@@ -535,15 +535,7 @@ 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,
PrivateTargets::TypedLocalHosts,
)
.await?
}
None => validate_fetch_target(&initial_url, context, tool_label).await?,
};
if let Some(cached) = (!cache_mode.is_revalidate())
@@ -711,24 +703,14 @@ async fn fetch_attempt(
let dns_pin = if redirects == 0 {
match initial_pin.take() {
Some(pin) => Some(pin),
None => validate_fetch_target(
&current_url,
context,
tool_label,
PrivateTargets::TypedLocalHosts,
)
.await
.map_err(AttemptError::Fatal)?,
None => validate_fetch_target(&current_url, context, tool_label)
.await
.map_err(AttemptError::Fatal)?,
}
} else {
validate_fetch_target(
&current_url,
context,
tool_label,
PrivateTargets::TypedLocalHosts,
)
.await
.map_err(AttemptError::Fatal)?
validate_fetch_target(&current_url, context, tool_label)
.await
.map_err(AttemptError::Fatal)?
};
let remaining = deadline.saturating_duration_since(Instant::now());
+12 -13
View File
@@ -7,7 +7,7 @@
//! re-invoke [`validate_fetch_target`] on every new Location.
use crate::tools::spec::{ToolContext, ToolError};
use codewhale_net::guard::{DnsPin, PrivateTargets, fetch_target_host, validate_fetch_destination};
use codewhale_net::guard::{DnsPin, 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,11 +20,10 @@ 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, private).await
validate_fetch_destination(host, context.network_policy.as_ref(), tool, true).await
}
pub fn validate_network_policy(
@@ -61,7 +60,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", PrivateTargets::TypedLocalHosts)
let err = validate_fetch_target(&url, &ctx(), "fetch_url")
.await
.unwrap_err();
assert!(format!("{err}").contains("localhost"));
@@ -70,7 +69,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", PrivateTargets::TypedLocalHosts)
let err = validate_fetch_target(&url, &ctx(), "fetch_url")
.await
.unwrap_err();
assert!(format!("{err}").contains("restricted address"));
@@ -80,7 +79,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", PrivateTargets::TypedLocalHosts)
let err = validate_fetch_target(&url, &ctx(), "fetch_url")
.await
.expect_err("[::1] must be rejected as restricted");
assert!(format!("{err}").contains("restricted"));
@@ -89,7 +88,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", PrivateTargets::TypedLocalHosts)
let err = validate_fetch_target(&url, &ctx(), "fetch_url")
.await
.expect_err("[fc00::1] must be rejected as restricted");
assert!(format!("{err}").contains("restricted"));
@@ -98,7 +97,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", PrivateTargets::TypedLocalHosts)
let err = validate_fetch_target(&url, &ctx(), "fetch_url")
.await
.expect_err("[fe80::1] must be rejected as restricted");
assert!(format!("{err}").contains("restricted"));
@@ -107,7 +106,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", PrivateTargets::TypedLocalHosts)
let err = validate_fetch_target(&url, &ctx(), "fetch_url")
.await
.expect_err("[::ffff:127.0.0.1] must be rejected as restricted");
assert!(format!("{err}").contains("restricted"));
@@ -116,7 +115,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", PrivateTargets::TypedLocalHosts)
let err = validate_fetch_target(&url, &ctx(), "fetch_url")
.await
.expect_err("[::] must be rejected as restricted");
assert!(format!("{err}").contains("restricted"));
@@ -136,7 +135,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", PrivateTargets::TypedLocalHosts)
let err = validate_fetch_target(&url, &ctx, "fetch_url")
.await
.unwrap_err();
assert!(format!("{err}").contains("blocked"));
@@ -147,7 +146,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", PrivateTargets::TypedLocalHosts)
let err = validate_fetch_target(&url, &ctx(), "fetch_url")
.await
.expect_err("unresolved host must fail preflight");
let message = format!("{err}");
@@ -162,7 +161,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", PrivateTargets::TypedLocalHosts)
let err = validate_fetch_target(&url, &ctx(), "web_run")
.await
.expect_err("unresolved host must fail preflight");
let message = format!("{err}");
-71
View File
@@ -1381,7 +1381,6 @@ 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 {
@@ -6294,76 +6293,6 @@ 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 {
+26 -394
View File
@@ -23,7 +23,6 @@ use codewhale_runtime::config::Config;
/// Transcription model requested from the provider's chat-completions API.
const ASR_MODEL: &str = "mimo-v2.5-asr";
const GROQ_ASR_MODEL: &str = "whisper-large-v3-turbo";
pub const UPLOAD_AUDIO_MAX_BYTES: usize = crate::deepseek_media::DEEPSEEK_ASR_MAX_BYTES;
/// Local whisper binary names to probe (whisper.cpp, faster-whisper, OpenAI whisper).
const LOCAL_WHISPER_BINS: &[&str] = &["whisper", "whisper.cpp", "whisper-cpp", "faster-whisper"];
/// Model used for the AI-assisted voice-control pipeline.
@@ -473,38 +472,28 @@ pub async fn transcribe_groq(audio_samples: &[i16]) -> Result<String, String> {
transcribe_with_model(&api_key, base_url, audio_samples, GROQ_ASR_MODEL, None).await
}
pub fn resolve_asr_choice(config: &Config) -> (String, String) {
let read = || {
let explicit = std::env::var("CODEWHALE_ASR_MODEL")
.ok()
.and_then(|value| explicit_asr_choice(&value));
let (kind, model) = explicit.unwrap_or_else(|| match detect_free_asr() {
"local-whisper" => ("local-whisper", "tiny"),
"groq" => ("groq", GROQ_ASR_MODEL),
_ if config.active_provider_identity().is_ok_and(|identity| {
matches!(
identity.provider,
codewhale_config::ProviderKind::Deepseek
| codewhale_config::ProviderKind::DeepseekAnthropic
)
}) =>
{
("deepseek", crate::deepseek_media::DEEPSEEK_ASR_MODEL)
}
_ => ("provider", ASR_MODEL),
});
(kind.to_string(), model.to_string())
};
#[cfg(any(test, feature = "test-support"))]
{
crate::test_support::with_test_env_lock(read)
}
#[cfg(not(any(test, feature = "test-support")))]
{
read()
}
/// Resolve ASR model/provider preference.
/// Priority: env `CODEWHALE_ASR_MODEL` > auto-detect (local-whisper > groq >
/// provider). Known limitation (C01-10): there is no config-file ASR setting
/// yet — `config` is accepted for the day one exists and is not read, so the
/// env var is the only explicit override.
pub fn resolve_asr_choice(_config: &Config) -> (String, String) {
let explicit = std::env::var("CODEWHALE_ASR_MODEL")
.ok()
.and_then(|value| explicit_asr_choice(&value));
// Auto-detect best free: local whisper (offline, no key) > Groq free
// tier > provider ASR (needs key).
let (kind, model) = explicit.unwrap_or_else(|| match detect_free_asr() {
"local-whisper" => ("local-whisper", "tiny"),
"groq" => ("groq", GROQ_ASR_MODEL),
_ => ("provider", ASR_MODEL),
});
(kind.to_string(), model.to_string())
}
/// Map an explicit `CODEWHALE_ASR_MODEL` value to (kind, model). Local
/// spellings are tested first: `local-whisper` and `whisper.cpp` both contain
/// "whisper", which on its own names Groq's hosted Whisper (C01-10).
fn explicit_asr_choice(value: &str) -> Option<(&'static str, &'static str)> {
let m = value.trim().to_ascii_lowercase();
if m.contains("local") || m.contains("whisper.cpp") || m.contains("whisper-cpp") {
@@ -513,9 +502,6 @@ fn explicit_asr_choice(value: &str) -> Option<(&'static str, &'static str)> {
if m.contains("groq") || m.contains("whisper") {
return Some(("groq", GROQ_ASR_MODEL));
}
if m.contains("deepseek") {
return Some(("deepseek", crate::deepseek_media::DEEPSEEK_ASR_MODEL));
}
if m.contains("mimo") || m.contains("xiaomi") {
return Some(("provider", ASR_MODEL));
}
@@ -543,10 +529,13 @@ pub enum DictateMode {
/// localize by `reason` rather than parsing message text.
#[derive(Debug)]
pub enum DictateError {
/// No supported recorder binary on this host.
NoRecorder,
/// Recording produced no usable speech segment.
NoSpeech,
/// The selected provider ASR needs an API key that is not configured.
NoProviderAuth,
InvalidInput(String),
/// ASR request or transcription failed.
Transcription(String),
}
@@ -556,7 +545,6 @@ impl DictateError {
Self::NoRecorder => "no_recorder",
Self::NoSpeech => "no_speech",
Self::NoProviderAuth => "no_provider_auth",
Self::InvalidInput(_) => "invalid_input",
Self::Transcription(_) => "transcription_failed",
}
}
@@ -570,7 +558,7 @@ impl std::fmt::Display for DictateError {
Self::NoProviderAuth => {
write!(f, "provider ASR requires a configured API key")
}
Self::InvalidInput(e) | Self::Transcription(e) => write!(f, "{e}"),
Self::Transcription(e) => write!(f, "{e}"),
}
}
}
@@ -655,21 +643,6 @@ pub async fn transcribe_selected(
let text = match kind {
"local-whisper" => transcribe_local_whisper(samples).await,
"groq" => transcribe_groq(samples).await,
"deepseek" => {
let (base_url, api_key) = crate::deepseek_media::resolve_deepseek_config_auth(config)
.ok_or(DictateError::NoProviderAuth)?;
let wav = encode_wav(samples);
crate::deepseek_media::transcribe_deepseek_bytes(
&base_url,
&api_key,
&wav,
"voice.wav",
"audio/wav",
None,
)
.await
.map(|response| response.text)
}
_ => {
let key = config
.active_route_api_key()
@@ -689,269 +662,6 @@ pub async fn transcribe_selected(
text.map_err(DictateError::Transcription)
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct VoiceTtsOutcome {
pub audio_bytes: Vec<u8>,
pub content_type: String,
pub format: String,
pub provider: String,
pub model: String,
pub voice: String,
}
pub fn decode_wav_pcm16_mono(wav_bytes: &[u8]) -> Result<Vec<i16>, String> {
if wav_bytes.len() < 12 || &wav_bytes[0..4] != b"RIFF" || &wav_bytes[8..12] != b"WAVE" {
return Err("invalid WAV header: expected RIFF/WAVE".to_string());
}
let mut fmt_valid = false;
let mut data_chunk: Option<&[u8]> = None;
let mut offset = 12usize;
while offset + 8 <= wav_bytes.len() {
let chunk_id = &wav_bytes[offset..offset + 4];
let chunk_size = u32::from_le_bytes([
wav_bytes[offset + 4],
wav_bytes[offset + 5],
wav_bytes[offset + 6],
wav_bytes[offset + 7],
]) as usize;
let data_start = offset + 8;
let Some(data_end) = data_start.checked_add(chunk_size) else {
return Err("invalid WAV chunk length".to_string());
};
if data_end > wav_bytes.len() {
return Err("truncated WAV chunk".to_string());
}
let chunk = &wav_bytes[data_start..data_end];
if chunk_id == b"fmt " {
if chunk.len() < 16 {
return Err("truncated WAV fmt chunk".to_string());
}
let audio_format = u16::from_le_bytes([chunk[0], chunk[1]]);
let channels = u16::from_le_bytes([chunk[2], chunk[3]]);
let bits_per_sample = u16::from_le_bytes([chunk[14], chunk[15]]);
if audio_format != 1 || channels != 1 || bits_per_sample != 16 {
return Err(format!(
"unsupported WAV encoding: expected PCM16 mono, got format={audio_format} channels={channels} bits={bits_per_sample}"
));
}
fmt_valid = true;
} else if chunk_id == b"data" {
data_chunk = Some(chunk);
}
offset = data_end.saturating_add(chunk_size & 1);
}
if !fmt_valid {
return Err("missing WAV fmt chunk".to_string());
}
let Some(data) = data_chunk else {
return Err("missing WAV data chunk".to_string());
};
if data.len() % 2 != 0 {
return Err("WAV PCM16 data length must be even".to_string());
}
Ok(data
.as_chunks::<2>()
.0
.iter()
.copied()
.map(i16::from_le_bytes)
.collect())
}
pub fn upload_available(config: &Config) -> bool {
let (asr_kind, _) = resolve_asr_choice(config);
match asr_kind.as_str() {
"local-whisper" => true,
"groq" => {
let has_groq_key =
|| std::env::var("GROQ_API_KEY").is_ok_and(|value| !value.trim().is_empty());
#[cfg(any(test, feature = "test-support"))]
{
crate::test_support::with_test_env_lock(has_groq_key)
}
#[cfg(not(any(test, feature = "test-support")))]
{
has_groq_key()
}
}
"deepseek" => crate::deepseek_media::resolve_deepseek_config_auth(config).is_some(),
_ => config
.active_route_api_key_read_only()
.is_ok_and(|value| !value.trim().is_empty()),
}
}
pub fn tts_available(config: &Config) -> bool {
let active_mimo = config
.active_provider_identity()
.is_ok_and(|identity| identity.provider == codewhale_config::ProviderKind::XiaomiMimo)
&& config
.active_route_api_key_read_only()
.is_ok_and(|value| !value.trim().is_empty());
active_mimo || crate::deepseek_media::resolve_deepseek_config_auth(config).is_some()
}
pub async fn transcribe_uploaded_audio(
config: &Config,
audio_bytes: &[u8],
mime_type: &str,
mode: DictateMode,
language: Option<&str>,
) -> Result<DictationOutcome, DictateError> {
if audio_bytes.is_empty() {
return Err(DictateError::InvalidInput(
"audio payload cannot be empty".to_string(),
));
}
if audio_bytes.len() > UPLOAD_AUDIO_MAX_BYTES {
return Err(DictateError::InvalidInput(format!(
"audio payload exceeds maximum of {UPLOAD_AUDIO_MAX_BYTES} bytes"
)));
}
let canonical_mime = crate::deepseek_media::normalize_deepseek_asr_mime(mime_type)
.map_err(DictateError::InvalidInput)?;
let (asr_kind, asr_model) = resolve_asr_choice(config);
let assisted = asr_kind == "provider" && matches!(&mode, DictateMode::Control(_));
let text = if asr_kind == "deepseek" {
let (base_url, api_key) = crate::deepseek_media::resolve_deepseek_config_auth(config)
.ok_or(DictateError::NoProviderAuth)?;
let file_name = if canonical_mime == "audio/ogg" {
"voice.ogg"
} else {
"voice.wav"
};
crate::deepseek_media::transcribe_deepseek_bytes(
&base_url,
&api_key,
audio_bytes,
file_name,
canonical_mime,
language,
)
.await
.map(|response| response.text)
.map_err(DictateError::Transcription)?
} else {
if canonical_mime != "audio/wav" {
return Err(DictateError::InvalidInput(format!(
"mime type '{canonical_mime}' requires DeepSeek ASR; send audio/wav for '{asr_kind}'"
)));
}
let samples = decode_wav_pcm16_mono(audio_bytes).map_err(DictateError::InvalidInput)?;
if samples.is_empty() {
return Err(DictateError::NoSpeech);
}
let composer = match &mode {
DictateMode::Control(composer) => Some(composer.as_str()),
_ => None,
};
transcribe_selected(config, &asr_kind, &samples, composer).await?
};
let clean = text.trim().to_string();
let (text, send) = match mode {
DictateMode::Send => {
let (remainder, wants_send) = split_send_suffix(&clean);
(remainder.to_string(), wants_send)
}
_ => (clean, false),
};
Ok(DictationOutcome {
text,
send,
assisted,
asr_kind,
asr_model,
})
}
pub async fn synthesize_voice_tts(
config: &Config,
text: &str,
voice: Option<&str>,
format: Option<&str>,
) -> Result<VoiceTtsOutcome, String> {
synthesize_voice_tts_with_options(config, text, voice, format, true).await
}
pub async fn synthesize_voice_tts_with_options(
config: &Config,
text: &str,
voice: Option<&str>,
format: Option<&str>,
filter_markdown: bool,
) -> Result<VoiceTtsOutcome, String> {
if text.trim().is_empty() {
return Err("speech text cannot be empty".to_string());
}
let active_mimo = config
.active_provider_identity()
.is_ok_and(|identity| identity.provider == codewhale_config::ProviderKind::XiaomiMimo)
&& config
.active_route_api_key_read_only()
.is_ok_and(|value| !value.trim().is_empty());
if active_mimo {
let client = crate::client::CodewhaleClient::new(config).map_err(|err| err.to_string())?;
let req_format = format.unwrap_or("wav").trim();
let format_value = if req_format.is_empty() {
"wav"
} else {
req_format
};
let requested_voice = voice
.map(str::trim)
.filter(|value| !value.is_empty())
.unwrap_or("mimo_default");
let response = client
.synthesize_speech(crate::client::SpeechSynthesisRequest {
model: "mimo-v2.5-tts".to_string(),
text: text.to_string(),
instruction: None,
audio_format: format_value.to_string(),
voice: Some(requested_voice.to_string()),
})
.await
.map_err(|err| err.to_string())?;
let content_type = match response.audio_format.as_str() {
"mp3" => "audio/mpeg",
"pcm" | "pcm16" => "audio/pcm",
_ => "audio/wav",
};
return Ok(VoiceTtsOutcome {
audio_bytes: response.audio_bytes,
content_type: content_type.to_string(),
format: response.audio_format,
provider: "xiaomi-mimo".to_string(),
model: response.model,
voice: response
.voice
.unwrap_or_else(|| requested_voice.to_string()),
});
}
if let Some((base_url, api_key)) = crate::deepseek_media::resolve_deepseek_config_auth(config) {
let outcome = crate::deepseek_media::synthesize_deepseek_bytes_with_options(
&base_url,
&api_key,
text,
voice,
format.unwrap_or("wav"),
filter_markdown,
)
.await?;
return Ok(VoiceTtsOutcome {
audio_bytes: outcome.audio_bytes,
content_type: outcome.content_type,
format: outcome.format,
provider: "deepseek".to_string(),
model: outcome.model,
voice: outcome.voice,
});
}
Err("voice TTS requires a configured DeepSeek or Xiaomi MiMo API key".to_string())
}
#[cfg(test)]
mod tests {
mod selected_asr;
@@ -1079,85 +789,7 @@ mod tests {
#[test]
fn recorder_detection_does_not_crash() {
// Just verify the function runs without panicking
let _ = is_available();
}
#[test]
fn explicit_deepseek_asr_choice_resolves_model() {
assert_eq!(
explicit_asr_choice("deepseek"),
Some(("deepseek", crate::deepseek_media::DEEPSEEK_ASR_MODEL))
);
assert_eq!(
explicit_asr_choice("DeepSeek-ASR"),
Some(("deepseek", crate::deepseek_media::DEEPSEEK_ASR_MODEL))
);
}
#[test]
fn wav_decoder_roundtrips_pcm16_mono_and_rejects_bad_headers() {
let wav = encode_wav(&[10, -250, 1234, -4096]);
let decoded = decode_wav_pcm16_mono(&wav).expect("valid wav");
assert_eq!(decoded, vec![10, -250, 1234, -4096]);
assert!(decode_wav_pcm16_mono(b"not-wav").is_err());
}
#[tokio::test]
async fn deepseek_uploaded_audio_and_tts_work_with_deepseek_config() {
use crate::test_support::{EnvVarGuard, lock_test_env};
use wiremock::matchers::{method, path};
use wiremock::{Mock, MockServer, ResponseTemplate};
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/v1/tools/asr"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"request_id": "req-upload",
"text": "commit the slice send it",
"duration_ms": 950
})))
.mount(&server)
.await;
Mock::given(method("POST"))
.and(path("/v1/tools/tts"))
.respond_with(ResponseTemplate::new(200).set_body_bytes(b"RIFF_TTS".to_vec()))
.mount(&server)
.await;
let _lock = lock_test_env();
let _asr_env = EnvVarGuard::remove("CODEWHALE_ASR_MODEL");
let _groq_env = EnvVarGuard::remove("GROQ_API_KEY");
let _path = EnvVarGuard::set("PATH", "/nonexistent-voice-path");
let config: Config = toml::from_str(&format!(
"provider = \"deepseek\"\n\n[providers.deepseek]\napi_key = \"ds-voice-key\"\nbase_url = \"{}/v1\"\n",
server.uri()
))
.expect("deepseek config parses");
assert_eq!(
resolve_asr_choice(&config),
("deepseek".to_string(), "deepseek-asr".to_string())
);
assert!(upload_available(&config));
assert!(tts_available(&config));
let wav = encode_wav(&[0; 320]);
let outcome =
transcribe_uploaded_audio(&config, &wav, "audio/wav", DictateMode::Send, None)
.await
.expect("upload transcription");
assert_eq!(outcome.text, "commit the slice");
assert!(outcome.send);
assert!(!outcome.assisted);
assert_eq!(outcome.asr_kind, "deepseek");
assert_eq!(outcome.asr_model, "deepseek-asr");
let tts = synthesize_voice_tts(&config, "All tests passed", Some("tide"), Some("wav"))
.await
.expect("voice tts");
assert_eq!(tts.audio_bytes, b"RIFF_TTS");
assert_eq!(tts.provider, "deepseek");
assert_eq!(tts.voice, "tide");
}
}
+52 -295
View File
@@ -564,9 +564,7 @@ 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 Some(tokens) = shlex::split(trimmed) else {
return false;
};
let tokens = shell_words(trimmed);
let Some(start) = primary_token_index(&tokens) else {
return false;
};
@@ -2089,31 +2087,12 @@ fn split_command_segments(command: &str) -> Vec<String> {
}
fn shell_words(segment: &str) -> Vec<String> {
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)],
}
shlex::split(segment).unwrap_or_else(|| {
segment
.split_whitespace()
.map(|token| token.trim_matches(['"', '\'']).to_string())
.collect()
})
}
/// Whether this is one direct, literal rm invocation. Only that shape may
@@ -2182,32 +2161,22 @@ fn walk_invocations(command: &str, quote_blind: bool) -> Option<Vec<Vec<String>>
return false;
}
for segment in split_command_segments(command) {
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);
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;
}
}
}
@@ -2534,24 +2503,31 @@ fn target_contains_parent_escape(target: &str) -> bool {
/// Check if a command is known to be safe
fn is_safe_command(command: &str) -> bool {
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<_>>();
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 is_codewhale_readonly_invocation(&refs) {
return true;
}
}
let literal_words = lex_readonly_command(normalized.trim())
.is_ok_and(|segments| segments.len() == 1 && segments[0].redirects.is_empty());
// `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;
}
SAFE_COMMANDS.iter().any(|entry| {
leading_words_match(&tokens, entry).is_some_and(|words| {
safe_command_arguments_are_read_only(entry, &tokens, &tokens[words..], literal_words)
safe_command_arguments_are_read_only(entry, &tokens, &tokens[words..])
})
})
}
@@ -2572,12 +2548,7 @@ 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],
literal_words: bool,
) -> bool {
fn safe_command_arguments_are_read_only(entry: &str, tokens: &[String], args: &[String]) -> 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| {
@@ -2589,7 +2560,7 @@ fn safe_command_arguments_are_read_only(
}
})
};
let read_only = match entry {
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.
@@ -2601,26 +2572,15 @@ fn safe_command_arguments_are_read_only(
"uniq" => is_agent_readonly_uniq(tokens),
// `hostname NAME` sets the host name.
"hostname" => args.iter().all(|arg| arg.starts_with('-')),
"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'))
}),
// `--pre CMD` runs a preprocessor on every file searched.
"rg" => !args.iter().any(|arg| arg.starts_with("--pre")),
// `-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']))
}),
"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'))
}),
// `+CMD` runs a pager command at startup, which can reach a shell.
"less" | "more" => !args.iter().any(|arg| arg.starts_with('+')),
// `-P`/`-H`/`-C` choose the pager, browser or config that man runs.
"man" => only_options(
&[
@@ -2638,9 +2598,7 @@ fn safe_command_arguments_are_read_only(
true,
),
// `--output` writes the diff or log to a file.
"git diff" | "git log" | "git show" | "git stash list" => {
!args.iter().any(|arg| arg.starts_with("--output"))
}
"git diff" | "git log" | "git show" => !args.iter().any(|arg| arg.starts_with("--output")),
// Listing forms only: other forms delete, rename or create refs.
"git branch" => only_options(
&[
@@ -2668,9 +2626,8 @@ fn safe_command_arguments_are_read_only(
.all(|arg| !arg.starts_with('-') || matches!(arg.as_str(), "--push" | "--all")),
Some(_) => false,
},
_ => return true,
};
read_only && literal_words
_ => true,
}
}
/// A prefix grants one command's ordinary arguments. The scanner owns shell
@@ -2700,10 +2657,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..], true))
|| safe_command_arguments_are_read_only("git log", tokens, &tokens[1..]))
&& SAFE_COMMANDS.iter().all(|entry| {
leading_words_match(tokens, entry).is_none_or(|words| {
safe_command_arguments_are_read_only(entry, tokens, &tokens[words..], true)
safe_command_arguments_are_read_only(entry, tokens, &tokens[words..])
})
})
};
@@ -4359,203 +4316,3 @@ 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"
);
}
}
}
+16 -346
View File
@@ -28,10 +28,8 @@
//! 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,
//! 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
//! 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
@@ -802,10 +800,6 @@ 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",
@@ -817,7 +811,6 @@ 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,
@@ -859,7 +852,6 @@ 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,
@@ -878,7 +870,6 @@ 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,
@@ -889,7 +880,6 @@ 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,
@@ -912,7 +902,6 @@ struct WordBuf {
brace: bool,
/// An unquoted `[` appeared, so the word may be a bracket glob.
bracket: bool,
odd: bool,
}
impl WordBuf {
@@ -929,7 +918,6 @@ 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;
}
@@ -937,7 +925,6 @@ impl WordBuf {
self.dynamic = false;
self.brace = false;
self.bracket = false;
self.odd = false;
}
}
@@ -947,7 +934,6 @@ impl WordBuf {
struct Line {
words: Vec<String>,
dynamic: Vec<bool>,
odd: Vec<bool>,
heredoc: bool,
}
@@ -979,13 +965,6 @@ 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
@@ -1101,14 +1080,18 @@ 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 {
i = push_ansi_c_escape(&chars, i, &mut word.text);
word.dynamic = true;
word.text.push(chars[i + 1]);
i += 2;
} else {
word.text.push(chars[i]);
i += 1;
@@ -1212,18 +1195,6 @@ 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;
@@ -1322,9 +1293,6 @@ 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;
@@ -1387,22 +1355,12 @@ 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" | "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");
}
"eval" => self.code_payload(&tokens[head + 1..].join(" "), args_dynamic, depth),
"source" | "." => match tokens.get(head + 1) {
None if !line.heredoc => self.dynamic = true,
Some(_) if dynamic[head + 1] => self.dynamic = true,
@@ -1555,7 +1513,6 @@ 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);
@@ -1670,28 +1627,13 @@ fn mark_replacement_operands(tokens: &[String], heads: &[usize], dynamic: &mut [
}
}
/// 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.
/// True when `$` followed by `next` starts a parameter expansion (`$v`, `$1`,
/// `$@`, `$"…"`), as opposed to a literal dollar sign.
fn starts_parameter(next: Option<&char>) -> bool {
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 == '_')
next.is_some_and(|c| {
c.is_ascii_alphanumeric()
|| matches!(c, '_' | '@' | '*' | '#' | '?' | '$' | '!' | '-' | '"')
})
}
/// Unquoted heredocs expand substitutions, but quotes and ordinary lines are
@@ -1766,7 +1708,7 @@ fn end_command(commands: &mut Vec<Line>, line: &mut Line) {
heredoc: line.heredoc,
..Line::default()
};
for ((word, dynamic), odd) in line.words.into_iter().zip(line.dynamic).zip(line.odd) {
for (word, dynamic) in line.words.into_iter().zip(line.dynamic) {
if matches!(word.as_str(), "{" | "}") {
push_command(commands, &mut piece);
continue;
@@ -1776,7 +1718,6 @@ 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);
}
@@ -1836,18 +1777,6 @@ 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] != '\'' {
@@ -1916,216 +1845,6 @@ 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
@@ -2138,7 +1857,6 @@ 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
@@ -2450,22 +2168,6 @@ 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 [
@@ -2743,36 +2445,4 @@ 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 [
"git log '$FLAGS'",
r#"git log "$FLAGS""#,
"cargo test 2>&1",
"cargo test &>result.log",
r#"grep -E "a|b" src"#,
@@ -135,25 +135,6 @@ 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.
@@ -262,17 +243,6 @@ 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))
@@ -413,32 +383,3 @@ 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:?}");
}
}
}
+5 -6
View File
@@ -101,7 +101,7 @@
"ExtensionsActionManage": "gestiona",
"ExtensionsActionFocus": "focus",
"ExtensionsActionFold": "plega",
"ExtensionsActionTabs": "filtres",
"ExtensionsActionTabs": "pestanyes",
"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 amb aquest filtre",
"ExtensionsNoItems": "No hi ha elements en aquesta pestanya",
"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,13 +164,12 @@
"ExtensionsTabHooks": "Ganxos",
"ExtensionsTabMarketplace": "Mercat",
"ExtensionsTabMarketplaceCompact": "Mercat",
"ExtensionsTabInstalled": "Instal·lats",
"ExtensionsTabConnectors": "Connectors",
"ExtensionsTabPlugins": "Connectors",
"ExtensionsTierCommunity": "comunitat",
"ExtensionsTierCurated": "seleccionat",
"ExtensionsTierOfficial": "oficial",
"ExtensionsTierPartner": "soci",
"ExtensionsTitle": "Plugins",
"ExtensionsTitle": "Extensions",
"ExtensionsTrustCapabilitiesChanged": "capacitats modificades",
"ExtensionsTrustContentChanged": "contingut modificat",
"ExtensionsTrustNotReviewed": "sense revisar",
@@ -504,7 +503,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 Plugins",
"CmdHooksDescription": "Gestiona els hooks del cicle de vida a Extensions",
"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",
+5 -6
View File
@@ -101,7 +101,7 @@
"ExtensionsActionManage": "verwalten",
"ExtensionsActionFocus": "Fokus",
"ExtensionsActionFold": "einklappen",
"ExtensionsActionTabs": "Filter",
"ExtensionsActionTabs": "Tabs",
"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 Filter",
"ExtensionsNoItems": "Keine Einträge in diesem Tab",
"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,13 +164,12 @@
"ExtensionsTabHooks": "Hooks",
"ExtensionsTabMarketplace": "Marktplatz",
"ExtensionsTabMarketplaceCompact": "Markt",
"ExtensionsTabInstalled": "Installiert",
"ExtensionsTabConnectors": "Konnektoren",
"ExtensionsTabPlugins": "Plugins",
"ExtensionsTierCommunity": "Community",
"ExtensionsTierCurated": "kuratiert",
"ExtensionsTierOfficial": "offiziell",
"ExtensionsTierPartner": "Partner",
"ExtensionsTitle": "Plugins",
"ExtensionsTitle": "Erweiterungen",
"ExtensionsTrustCapabilitiesChanged": "Fähigkeiten geändert",
"ExtensionsTrustContentChanged": "Inhalt geändert",
"ExtensionsTrustNotReviewed": "nicht geprüft",
@@ -504,7 +503,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 Plugins verwalten",
"CmdHooksDescription": "Lebenszyklus-Hooks in Erweiterungen verwalten",
"CmdAgentDescription": "Persistente Sub-Agent-Sitzung öffnen",
"CmdGoalDescription": "Über mehrere Turns hinweg an einem Ziel weiterarbeiten",
"CmdInitDescription": "AGENTS.md für das Projekt erzeugen",
+5 -6
View File
@@ -101,7 +101,7 @@
"ExtensionsActionManage": "manage",
"ExtensionsActionFocus": "focus",
"ExtensionsActionFold": "fold",
"ExtensionsActionTabs": "filters",
"ExtensionsActionTabs": "tabs",
"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 under this filter",
"ExtensionsNoItems": "Nothing on this tab",
"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,13 +164,12 @@
"ExtensionsTabHooks": "Hooks",
"ExtensionsTabMarketplace": "Marketplace",
"ExtensionsTabMarketplaceCompact": "Market",
"ExtensionsTabInstalled": "Installed",
"ExtensionsTabConnectors": "Connectors",
"ExtensionsTabPlugins": "Plugins",
"ExtensionsTierCommunity": "community",
"ExtensionsTierCurated": "curated",
"ExtensionsTierOfficial": "official",
"ExtensionsTierPartner": "partner",
"ExtensionsTitle": "Plugins",
"ExtensionsTitle": "Extensions",
"ExtensionsTrustCapabilitiesChanged": "capabilities changed",
"ExtensionsTrustContentChanged": "content changed",
"ExtensionsTrustNotReviewed": "not reviewed",
@@ -505,7 +504,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 Plugins",
"CmdHooksDescription": "Manage lifecycle hooks in Extensions",
"CmdAgentDescription": "Open a persistent agent session",
"CmdGoalDescription": "Work toward one objective across turns",
"CmdInitDescription": "Generate AGENTS.md for this project",
+5 -6
View File
@@ -101,7 +101,7 @@
"ExtensionsActionManage": "gestionar",
"ExtensionsActionFocus": "foco",
"ExtensionsActionFold": "plegar",
"ExtensionsActionTabs": "filtros",
"ExtensionsActionTabs": "pestañas",
"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 con este filtro",
"ExtensionsNoItems": "No hay elementos en esta pestaña",
"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,13 +164,12 @@
"ExtensionsTabHooks": "Hooks",
"ExtensionsTabMarketplace": "Mercado",
"ExtensionsTabMarketplaceCompact": "Mercado",
"ExtensionsTabInstalled": "Instalados",
"ExtensionsTabConnectors": "Conectores",
"ExtensionsTabPlugins": "Plugins",
"ExtensionsTierCommunity": "comunidad",
"ExtensionsTierCurated": "seleccionado",
"ExtensionsTierOfficial": "oficial",
"ExtensionsTierPartner": "socio",
"ExtensionsTitle": "Plugins",
"ExtensionsTitle": "Extensiones",
"ExtensionsTrustCapabilitiesChanged": "capacidades modificadas",
"ExtensionsTrustContentChanged": "contenido modificado",
"ExtensionsTrustNotReviewed": "sin revisar",
@@ -505,7 +504,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 Plugins",
"CmdHooksDescription": "Gestionar hooks del ciclo de vida en Extensiones",
"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",
+5 -6
View File
@@ -101,7 +101,7 @@
"ExtensionsActionManage": "gérer",
"ExtensionsActionFocus": "focus",
"ExtensionsActionFold": "replier",
"ExtensionsActionTabs": "filtres",
"ExtensionsActionTabs": "onglets",
"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 pour ce filtre",
"ExtensionsNoItems": "Aucun élément dans cet onglet",
"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,13 +164,12 @@
"ExtensionsTabHooks": "Hooks",
"ExtensionsTabMarketplace": "Marché",
"ExtensionsTabMarketplaceCompact": "Marché",
"ExtensionsTabInstalled": "Installés",
"ExtensionsTabConnectors": "Connecteurs",
"ExtensionsTabPlugins": "Plugins",
"ExtensionsTierCommunity": "communauté",
"ExtensionsTierCurated": "sélectionné",
"ExtensionsTierOfficial": "officiel",
"ExtensionsTierPartner": "partenaire",
"ExtensionsTitle": "Plugins",
"ExtensionsTitle": "Extensions",
"ExtensionsTrustCapabilitiesChanged": "capacités modifiées",
"ExtensionsTrustContentChanged": "contenu modifié",
"ExtensionsTrustNotReviewed": "non examiné",
@@ -504,7 +503,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 Plugins",
"CmdHooksDescription": "Gérer les hooks du cycle de vie dans Extensions",
"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",
+5 -6
View File
@@ -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,13 +164,12 @@
"ExtensionsTabHooks": "हुक",
"ExtensionsTabMarketplace": "मार्केटप्लेस",
"ExtensionsTabMarketplaceCompact": "बाज़ार",
"ExtensionsTabInstalled": "इंस्टॉल किए गए",
"ExtensionsTabConnectors": "कनेक्टर",
"ExtensionsTabPlugins": "प्लगइन",
"ExtensionsTierCommunity": "समुदाय",
"ExtensionsTierCurated": "क्यूरेटेड",
"ExtensionsTierOfficial": "आधिकारिक",
"ExtensionsTierPartner": "भागीदार",
"ExtensionsTitle": "प्लगइन",
"ExtensionsTitle": "एक्सटेंशन",
"ExtensionsTrustCapabilitiesChanged": "क्षमताएँ बदलीं",
"ExtensionsTrustContentChanged": "सामग्री बदली",
"ExtensionsTrustNotReviewed": "समीक्षा नहीं हुई",
@@ -504,7 +503,7 @@
"CmdOverviewDescription": "आँकड़ों और त्वरित क्रियाओं सहित होम डैशबोर्ड दिखाएँ",
"HomeBackToConversation": "बातचीत पर वापस जाएँ",
"HomeNavigationBusy": "होम खोलने से पहले मौजूदा काम पूरा करें या रोकें।",
"CmdHooksDescription": "प्लगइन में जीवनचक्र हुक प्रबंधित करें",
"CmdHooksDescription": "Extensions में जीवनचक्र हुक प्रबंधित करें",
"CmdAgentDescription": "स्थायी सब-एजेंट सत्र खोलें",
"CmdGoalDescription": "कई टर्न तक एक ही लक्ष्य पर काम जारी रखें",
"CmdInitDescription": "प्रोजेक्ट के लिए AGENTS.md बनाएँ",
+5 -6
View File
@@ -101,7 +101,7 @@
"ExtensionsActionManage": "kelola",
"ExtensionsActionFocus": "fokus",
"ExtensionsActionFold": "lipat",
"ExtensionsActionTabs": "filter",
"ExtensionsActionTabs": "tab",
"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 filter ini",
"ExtensionsNoItems": "Tidak ada item di tab 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,13 +164,12 @@
"ExtensionsTabHooks": "Hook",
"ExtensionsTabMarketplace": "Marketplace",
"ExtensionsTabMarketplaceCompact": "Pasar",
"ExtensionsTabInstalled": "Terpasang",
"ExtensionsTabConnectors": "Konektor",
"ExtensionsTabPlugins": "Plugin",
"ExtensionsTierCommunity": "komunitas",
"ExtensionsTierCurated": "terkurasi",
"ExtensionsTierOfficial": "resmi",
"ExtensionsTierPartner": "mitra",
"ExtensionsTitle": "Plugin",
"ExtensionsTitle": "Ekstensi",
"ExtensionsTrustCapabilitiesChanged": "kemampuan berubah",
"ExtensionsTrustContentChanged": "konten berubah",
"ExtensionsTrustNotReviewed": "belum ditinjau",
@@ -504,7 +503,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 Plugin",
"CmdHooksDescription": "Kelola hook siklus hidup di Ekstensi",
"CmdAgentDescription": "Buka sesi sub-agen persisten",
"CmdGoalDescription": "Terus mengerjakan satu tujuan lintas giliran",
"CmdInitDescription": "Buat AGENTS.md untuk proyek",
+5 -6
View File
@@ -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,13 +164,12 @@
"ExtensionsTabHooks": "フック",
"ExtensionsTabMarketplace": "マーケットプレイス",
"ExtensionsTabMarketplaceCompact": "市場",
"ExtensionsTabInstalled": "インストール済み",
"ExtensionsTabConnectors": "コネクタ",
"ExtensionsTabPlugins": "プラグイン",
"ExtensionsTierCommunity": "コミュニティ",
"ExtensionsTierCurated": "選定済み",
"ExtensionsTierOfficial": "公式",
"ExtensionsTierPartner": "パートナー",
"ExtensionsTitle": "プラグイン",
"ExtensionsTitle": "拡張機能",
"ExtensionsTrustCapabilitiesChanged": "機能が変更済み",
"ExtensionsTrustContentChanged": "内容が変更済み",
"ExtensionsTrustNotReviewed": "未レビュー",
@@ -505,7 +504,7 @@
"CmdOverviewDescription": "統計とクイックアクション付きのホームダッシュボードを表示",
"HomeBackToConversation": "会話に戻る",
"HomeNavigationBusy": "ホームを開く前に、現在の作業を完了または停止してください。",
"CmdHooksDescription": "プラグインでライフサイクルフックを管理",
"CmdHooksDescription": "拡張機能でライフサイクルフックを管理",
"CmdAgentDescription": "永続サブエージェントセッションを開く",
"CmdGoalDescription": "複数ターンにわたって一つの目標を追い続ける",
"CmdInitDescription": "プロジェクト用に AGENTS.md を生成",
+5 -6
View File
@@ -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,13 +164,12 @@
"ExtensionsTabHooks": "훅",
"ExtensionsTabMarketplace": "마켓플레이스",
"ExtensionsTabMarketplaceCompact": "마켓",
"ExtensionsTabInstalled": "설치됨",
"ExtensionsTabConnectors": "커넥터",
"ExtensionsTabPlugins": "플러그인",
"ExtensionsTierCommunity": "커뮤니티",
"ExtensionsTierCurated": "선별됨",
"ExtensionsTierOfficial": "공식",
"ExtensionsTierPartner": "파트너",
"ExtensionsTitle": "플러그인",
"ExtensionsTitle": "확장 기능",
"ExtensionsTrustCapabilitiesChanged": "기능 변경됨",
"ExtensionsTrustContentChanged": "내용 변경됨",
"ExtensionsTrustNotReviewed": "검토되지 않음",
@@ -505,7 +504,7 @@
"CmdOverviewDescription": "통계와 빠른 작업이 있는 홈 대시보드를 표시합니다",
"HomeBackToConversation": "대화로 돌아가기",
"HomeNavigationBusy": "홈을 열기 전에 현재 작업을 완료하거나 중지하세요.",
"CmdHooksDescription": "플러그인에서 수명 주기 훅 관리",
"CmdHooksDescription": "확장 기능에서 수명 주기 훅 관리",
"CmdAgentDescription": "지속적인 서브 에이전트 세션을 엽니다",
"CmdGoalDescription": "여러 턴에 걸쳐 하나의 목표를 계속 진행합니다",
"CmdInitDescription": "프로젝트용 AGENTS.md를 생성합니다",
+5 -6
View File
@@ -101,7 +101,7 @@
"ExtensionsActionManage": "gerenciar",
"ExtensionsActionFocus": "foco",
"ExtensionsActionFold": "recolher",
"ExtensionsActionTabs": "filtros",
"ExtensionsActionTabs": "abas",
"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 neste filtro",
"ExtensionsNoItems": "Nenhum item nesta aba",
"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,13 +164,12 @@
"ExtensionsTabHooks": "Ganchos",
"ExtensionsTabMarketplace": "Marketplace",
"ExtensionsTabMarketplaceCompact": "Mercado",
"ExtensionsTabInstalled": "Instalados",
"ExtensionsTabConnectors": "Conectores",
"ExtensionsTabPlugins": "Plugins",
"ExtensionsTierCommunity": "comunidade",
"ExtensionsTierCurated": "selecionado",
"ExtensionsTierOfficial": "oficial",
"ExtensionsTierPartner": "parceiro",
"ExtensionsTitle": "Plugins",
"ExtensionsTitle": "Extensões",
"ExtensionsTrustCapabilitiesChanged": "capacidades alteradas",
"ExtensionsTrustContentChanged": "conteúdo alterado",
"ExtensionsTrustNotReviewed": "não revisado",
@@ -505,7 +504,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 Plugins",
"CmdHooksDescription": "Gerenciar hooks de ciclo de vida em Extensões",
"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",
+5 -6
View File
@@ -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,13 +164,12 @@
"ExtensionsTabHooks": "Хуки",
"ExtensionsTabMarketplace": "Магазин",
"ExtensionsTabMarketplaceCompact": "Магазин",
"ExtensionsTabInstalled": "Установленные",
"ExtensionsTabConnectors": "Коннекторы",
"ExtensionsTabPlugins": "Плагины",
"ExtensionsTierCommunity": "сообщество",
"ExtensionsTierCurated": "отобрано",
"ExtensionsTierOfficial": "официальный",
"ExtensionsTierPartner": "партнёр",
"ExtensionsTitle": "Плагины",
"ExtensionsTitle": "Расширения",
"ExtensionsTrustCapabilitiesChanged": "возможности изменены",
"ExtensionsTrustContentChanged": "содержимое изменено",
"ExtensionsTrustNotReviewed": "не проверен",
@@ -504,7 +503,7 @@
"CmdOverviewDescription": "Показать домашнюю панель со статистикой и быстрыми действиями",
"HomeBackToConversation": "Вернуться к разговору",
"HomeNavigationBusy": "Завершите или остановите текущую работу, прежде чем открыть главную.",
"CmdHooksDescription": "Управление хуками жизненного цикла в плагинах",
"CmdHooksDescription": "Управление хуками жизненного цикла в расширениях",
"CmdAgentDescription": "Открыть постоянную сессию субагента",
"CmdGoalDescription": "Продолжать одну цель на протяжении нескольких ходов",
"CmdInitDescription": "Сгенерировать AGENTS.md для проекта",
+5 -6
View File
@@ -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,13 +164,12 @@
"ExtensionsTabHooks": "Хуки",
"ExtensionsTabMarketplace": "Магазин",
"ExtensionsTabMarketplaceCompact": "Магазин",
"ExtensionsTabInstalled": "Встановлені",
"ExtensionsTabConnectors": "Конектори",
"ExtensionsTabPlugins": "Плагіни",
"ExtensionsTierCommunity": "спільнота",
"ExtensionsTierCurated": "відібрано",
"ExtensionsTierOfficial": "офіційний",
"ExtensionsTierPartner": "партнер",
"ExtensionsTitle": "Плагіни",
"ExtensionsTitle": "Розширення",
"ExtensionsTrustCapabilitiesChanged": "можливості змінено",
"ExtensionsTrustContentChanged": "вміст змінено",
"ExtensionsTrustNotReviewed": "не перевірено",
@@ -504,7 +503,7 @@
"CmdOverviewDescription": "Показати домашню панель зі статистикою та швидкими діями",
"HomeBackToConversation": "Повернутися до розмови",
"HomeNavigationBusy": "Завершіть або зупиніть поточну роботу, перш ніж відкрити головну.",
"CmdHooksDescription": "Керування хуками життєвого циклу в плагінах",
"CmdHooksDescription": "Керування хуками життєвого циклу в розширеннях",
"CmdAgentDescription": "Відкрити постійний сеанс субагента",
"CmdGoalDescription": "Продовжувати одну ціль упродовж кількох ходів",
"CmdInitDescription": "Згенерувати AGENTS.md для проєкту",
+5 -6
View File
@@ -101,7 +101,7 @@
"ExtensionsActionManage": "quản lý",
"ExtensionsActionFocus": "tiêu điểm",
"ExtensionsActionFold": "thu gọn",
"ExtensionsActionTabs": "bộ lọc",
"ExtensionsActionTabs": "thẻ",
"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 bộ lọc này",
"ExtensionsNoItems": "Không có mục nào trong thẻ 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,13 +164,12 @@
"ExtensionsTabHooks": "Hook",
"ExtensionsTabMarketplace": "Chợ",
"ExtensionsTabMarketplaceCompact": "Chợ",
"ExtensionsTabInstalled": "Đã cài",
"ExtensionsTabConnectors": "Trình kết nối",
"ExtensionsTabPlugins": "Plugin",
"ExtensionsTierCommunity": "cộng đồng",
"ExtensionsTierCurated": "được tuyển chọn",
"ExtensionsTierOfficial": "chính thức",
"ExtensionsTierPartner": "đối tác",
"ExtensionsTitle": "Plugin",
"ExtensionsTitle": "Tiện ích mở rộng",
"ExtensionsTrustCapabilitiesChanged": "khả năng đã thay đổi",
"ExtensionsTrustContentChanged": "nội dung đã thay đổi",
"ExtensionsTrustNotReviewed": "chưa xem xét",
@@ -505,7 +504,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 Plugin",
"CmdHooksDescription": "Quản lý hook vòng đời trong Tiện ích mở rộng",
"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",
+5 -6
View File
@@ -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,13 +164,12 @@
"ExtensionsTabHooks": "钩子",
"ExtensionsTabMarketplace": "插件市场",
"ExtensionsTabMarketplaceCompact": "市场",
"ExtensionsTabInstalled": "已安装",
"ExtensionsTabConnectors": "连接器",
"ExtensionsTabPlugins": "插件",
"ExtensionsTierCommunity": "社区",
"ExtensionsTierCurated": "精选",
"ExtensionsTierOfficial": "官方",
"ExtensionsTierPartner": "合作伙伴",
"ExtensionsTitle": "插件",
"ExtensionsTitle": "扩展",
"ExtensionsTrustCapabilitiesChanged": "功能已更改",
"ExtensionsTrustContentChanged": "内容已更改",
"ExtensionsTrustNotReviewed": "未审核",
@@ -505,7 +504,7 @@
"CmdOverviewDescription": "显示主页面板,含统计与快捷操作",
"HomeBackToConversation": "返回对话",
"HomeNavigationBusy": "请先完成或停止当前工作,再打开主页。",
"CmdHooksDescription": "在插件中管理生命周期钩子",
"CmdHooksDescription": "在扩展中管理生命周期钩子",
"CmdAgentDescription": "打开持久的智能体会话",
"CmdGoalDescription": "跨多回合持续推进同一个目标",
"CmdInitDescription": "为项目生成 AGENTS.md",
+5 -6
View File
@@ -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,13 +164,12 @@
"ExtensionsTabHooks": "掛鉤",
"ExtensionsTabMarketplace": "外掛市集",
"ExtensionsTabMarketplaceCompact": "市集",
"ExtensionsTabInstalled": "已安裝",
"ExtensionsTabConnectors": "連接器",
"ExtensionsTabPlugins": "外掛",
"ExtensionsTierCommunity": "社群",
"ExtensionsTierCurated": "精選",
"ExtensionsTierOfficial": "官方",
"ExtensionsTierPartner": "合作夥伴",
"ExtensionsTitle": "外掛",
"ExtensionsTitle": "擴充功能",
"ExtensionsTrustCapabilitiesChanged": "功能已變更",
"ExtensionsTrustContentChanged": "內容已變更",
"ExtensionsTrustNotReviewed": "未審查",
@@ -503,7 +502,7 @@
"CmdOverviewDescription": "顯示主頁面板,含統計與快捷操作",
"HomeBackToConversation": "返回對話",
"HomeNavigationBusy": "請先完成或停止目前工作,再開啟首頁。",
"CmdHooksDescription": "在外掛中管理生命週期掛鉤",
"CmdHooksDescription": "在擴充功能中管理生命週期掛鉤",
"CmdAgentDescription": "開啟持久的代理工作階段",
"CmdGoalDescription": "跨多回合持續推進同一個目標",
"CmdInitDescription": "為專案產生 AGENTS.md",
+3 -5
View File
@@ -711,11 +711,10 @@ pub enum MessageId {
ExtensionsStateUnstaged,
ExtensionsStateUnsupported,
ExtensionsStateWarning,
ExtensionsTabConnectors,
ExtensionsTabHooks,
ExtensionsTabInstalled,
ExtensionsTabMarketplace,
ExtensionsTabMarketplaceCompact,
ExtensionsTabPlugins,
ExtensionsTierCommunity,
ExtensionsTierCurated,
ExtensionsTierOfficial,
@@ -3496,11 +3495,10 @@ 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,
@@ -7110,7 +7108,7 @@ mod tests {
.filter(|key| key.starts_with("Extensions"))
.cloned()
.collect::<Vec<_>>();
assert_eq!(keys.len(), 99, "the complete extensions locale set changed");
assert_eq!(keys.len(), 98, "the complete extensions locale set changed");
let prose_keys = [
"ExtensionsMcpEmpty",
-3
View File
@@ -10,9 +10,6 @@ description = "Codewhale outbound network policy, guarded destinations and proxy
[lints]
workspace = true
[features]
test-support = []
[dependencies]
anyhow.workspace = true
chrono.workspace = true
+77 -866
View File
File diff suppressed because it is too large. Load diff
+4 -60
View File
@@ -309,24 +309,6 @@ 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) {
@@ -378,16 +360,10 @@ 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("*.") {
return format!(".{rest}");
format!(".{rest}")
} else {
trimmed
}
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.
@@ -396,7 +372,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() || normalized_host.parse::<IpAddr>().is_ok() {
if suffix.is_empty() {
return false;
}
normalized_host.ends_with(&format!(".{suffix}"))
@@ -861,29 +837,6 @@ 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.
@@ -907,15 +860,6 @@ 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"], &[]);
+1 -1
View File
@@ -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, &self.state_path)
super::discovery::PluginCatalogStamp::capture(roots)
}
}
+7 -34
View File
@@ -27,40 +27,20 @@ pub struct DiscoveryConfig {
pub state_path: PathBuf,
}
/// Cheap on-disk catalog fingerprint: plugin-bundle directories plus the
/// content of the plugin state file.
/// Cheap on-disk catalog fingerprint: plugin-bundle directories only.
///
/// 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.
/// 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.
#[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>, state_path: &Path) -> Self {
pub fn capture(roots: impl IntoIterator<Item = PathBuf>) -> Self {
let mut entries = BTreeMap::new();
for root in roots {
let Ok(read) = fs::read_dir(&root) else {
@@ -77,14 +57,7 @@ impl PluginCatalogStamp {
entries.insert(path, metadata.modified().ok());
}
}
Self {
entries,
state: file_content_digest(state_path),
}
}
pub fn refresh_state(&mut self, state_path: &Path) {
self.state = file_content_digest(state_path);
Self { entries }
}
}
-70
View File
@@ -2903,76 +2903,6 @@ 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
// ─────────────────────────────────────────────────────────────────────────────
-2
View File
@@ -86,7 +86,6 @@ use codewhale_skill_install::install::{
pub mod dsh;
mod place;
mod stage;
mod summary;
mod tarball;
#[cfg(test)]
@@ -94,7 +93,6 @@ 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
-865
View File
@@ -1,865 +0,0 @@
//! 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)"
);
}
}
+1 -31
View File
@@ -27,41 +27,11 @@ 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. 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)
}
pub const PLUGIN_RELOAD_NUDGE: &str = "Plugins changed on disk. Run /plugin reload to apply.";
/// Returns the reload nudge once per distinct on-disk catalog stamp.
#[must_use]
+18 -35
View File
@@ -39,13 +39,9 @@
//! GitHub repositories (archive tarballs) rather than a package
//! subdirectory. So:
//!
//! - `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.
//! - `npm` sources parse into [`MarketplaceSourceSpec::Npm`] and stay
//! [`MarketplaceInstallPlan::Unsupported`]: visible and truthful, never
//! implied installable.
//! - `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.
@@ -472,25 +468,16 @@ 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: registry.and_then(Value::as_str).map(str::to_string),
registry: source
.get("registry")
.and_then(Value::as_str)
.map(str::to_string),
git_url: source
.get("url")
.and_then(Value::as_str)
@@ -643,25 +630,21 @@ fn normalize_version_source(
}
if version.source_type == "npm" {
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(),
},
};
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})"));
}
return (
MarketplaceSourceSpec::Npm {
package: name.to_string(),
},
plan,
MarketplaceInstallPlan::Unsupported {
reason,
raw: version.version.clone(),
},
);
}
+14 -166
View File
@@ -106,7 +106,6 @@ 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
@@ -236,7 +235,6 @@ 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)
@@ -315,53 +313,17 @@ fn collapse_same_source_catalogs(mut state: MarketplaceState) -> MarketplaceStat
state
}
#[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}"))
}
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();
#[derive(Deserialize)]
struct Snapshot {
repository: String,
revision: String,
catalog: serde_json::Value,
}
let snapshot: Snapshot =
serde_json::from_str(include_str!("../../assets/first-party-marketplace.json"))
.map_err(|error| format!("invalid bundled marketplace: {error}"))?;
let source = format!("{}/tree/{}", snapshot.repository, snapshot.revision);
let mut catalog = super::parsers::parse_catalog(super::parsers::MarketplaceDocument {
catalog_id: MarketplaceCatalogId::new("codewhale"),
format: super::types::MarketplaceFormat::Codewhale,
@@ -409,8 +371,10 @@ mod tests {
panic!("uninstallable candidate")
};
if candidate.name == "computer-use" {
let (source, sha256) = snapshot_entry_pin("computer-use");
assert_eq!(spec, format!("{source}#sha256={sha256}"));
assert_eq!(
spec,
"https://github.com/codewhale-hq/codewhale-cu-plugin/releases/download/v0.16.0/computer-use.tar.gz#sha256=11a4f5c97c32717fa5648d10c080aa0db48a4798ccdf0d17112d1ae5b9133ae4"
);
} else {
assert!(spec.starts_with(
"https://codeload.github.com/codewhale-hq/codewhale-plugin-marketplace/"
@@ -437,122 +401,6 @@ 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();
+6 -124
View File
@@ -818,14 +818,12 @@ fn dsh_market_index_detects_and_keeps_the_highest_version() {
review.source,
MarketplaceSourceSpec::Npm { ref package } if package == "@acme/review"
));
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"
);
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:?}"),
}
let badge = catalog.candidate_by_name("acme-badge").unwrap();
assert_eq!(badge.display_name.as_deref(), Some("Badge"));
@@ -945,119 +943,3 @@ 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, &registry)
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, &registry),
CatalogInstallResolution::Unsupported { .. }
));
}
+2 -3
View File
@@ -218,9 +218,8 @@ 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 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 declaration. Codewhale does not execute npm; the plan
/// stays unsupported.
Npm { package: String },
/// A source form the catalog format defines but Codewhale refuses to
/// execute (e.g. Claude `command` sources), with the reason.
+6 -22
View File
@@ -95,21 +95,11 @@ pub async fn execute(
}
}
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(
pub async fn install_from_cli(
source: String,
config: codewhale_runtime::config::Config,
mut registry: PluginRegistry,
) -> Result<PluginMutationReceipt> {
) -> Result<()> {
let runtime = tokio::runtime::Handle::current();
let policy = crate::activation::extension_host_policy_enabled();
let receipt = tokio::task::spawn_blocking(move || {
@@ -129,16 +119,6 @@ pub async fn install_from_cli_receipt(
))
})
.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);
@@ -148,6 +128,10 @@ 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"),
}
}
-1
View File
@@ -1326,7 +1326,6 @@ 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
-20
View File
@@ -35,26 +35,6 @@ 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();
File diff suppressed because it is too large. Load diff
+1 -2
View File
@@ -11,7 +11,7 @@ description = "Codewhale community-skill installer, package digests and skill fr
workspace = true
[features]
test-support = ["codewhale-net/test-support"]
test-support = []
[dependencies]
anyhow.workspace = true
@@ -31,5 +31,4 @@ tokio.workspace = true
yaml-rust2.workspace = true
[dev-dependencies]
codewhale-net = { path = "../net", version = "0.10.3", features = ["test-support"] }
tempfile.workspace = true
+72 -68
View File
@@ -51,8 +51,10 @@ use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use thiserror::Error;
use codewhale_net::guard::{GuardedGet, PrivateTargets};
use codewhale_net::network_policy::{Decision, NetworkPolicy, host_from_url};
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};
const MAX_INSTALL_REDIRECTS: usize = 5;
@@ -73,24 +75,81 @@ 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 (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()
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(&current)?;
if matches!(network.decide(&host), Decision::Deny) {
bail!("network policy denies {host}");
}
let proxy = if initial_pin.is_some() {
None
} else {
request
codewhale_net::proxy::configured_mcp_proxy(&current, false, |key| std::env::var(key))?
};
request.send().await
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")
}
/// Cache directory for registry-synced skills.
@@ -2074,61 +2133,6 @@ 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();
-2
View File
@@ -28,11 +28,9 @@ 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
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
+2 -17
View File
@@ -7,16 +7,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
- **DeepSeek Tools ASR, TTS, OCR, and uploaded runtime voice endpoints:** integrates DeepSeek Tools `/v1/tools/asr`, `/v1/tools/tts` (`stella` / `tide`, `wav` / `pcm`), and opt-in `/v1/tools/ocr` fallback (`CODEWHALE_DEEPSEEK_OCR=1`); adds recorder-free `POST /v1/voice/transcribe` and `POST /v1/voice/tts` runtime HTTP routes plus `upload_available`, `tts_available`, `max_upload_bytes`, and `tts_voices` capability flags on `GET /v1/voice`, and enables DeepSeek in `SpeechTool` and CLI `speech`. (CW-8218)
- **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)
@@ -33,7 +23,6 @@ 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).
@@ -50,7 +39,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.
@@ -134,9 +123,7 @@ 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.
@@ -488,8 +475,6 @@ 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
@@ -725,7 +710,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 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.
- **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.
- **`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.
@@ -66,10 +66,6 @@ pub async fn capture_and_transcribe(
let (asr_kind, _asr_model) = resolve_asr_choice(config);
if asr_kind == "provider" {
provider_key()?;
} else if asr_kind == "deepseek"
&& codewhale_engine::deepseek_media::resolve_deepseek_config_auth(config).is_none()
{
return Err(tr(locale, MessageId::VoiceErrNoAuth).to_string());
}
// Show the localized recording status plus the live interim in the composer.
let original_input = app.composer.input.clone();
@@ -278,42 +278,6 @@ 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>,
@@ -351,10 +315,13 @@ 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 {}",
@@ -391,7 +358,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"
);
}
show(registry, locale, &name, false, console)?;
console.print(&review_text(registry, plugin, locale, marked));
if !console.confirm("\nTrust this bundle with exactly the capabilities above?") {
console.print("Not trusted.");
return Ok(());
@@ -403,150 +370,6 @@ 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,258 +1507,3 @@ 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(&registry, 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(&registry, 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 Plugins)",
usage: "/skills [manage|--remote|sync|inspect|suggest <task>|<prefix>] (bare opens Extensions)",
description_key: "cmd_skills_description",
};
+1 -20
View File
@@ -1362,25 +1362,6 @@ 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 [
@@ -3011,7 +2992,7 @@ mod tests {
std::fs::create_dir_all(tmp.path().join("skills")).unwrap();
feat022_write_skill(&tmp.path().join("skills"), "demo");
// Bare /skills opens Plugins; explicit manage retains the mutation surface.
// Bare /skills opens Extensions; explicit manage retains the mutation surface.
let result = execute("/skills", &mut app);
assert!(!result.is_error, "{result:?}");
assert!(
+1
View File
@@ -31,6 +31,7 @@ pub(crate) const ADVANCED_DISCOVERY_COMMANDS: &[&str] = &[
"modeldb",
"models",
"network",
"plugin",
"preview-request",
"profile",
"purge",
+13 -211
View File
@@ -147,9 +147,7 @@ enum Commands {
SessionDiagnostics(SessionDiagnosticsArgs),
#[command(about = "Install a local, GitHub or version-pinned npm plugin bundle")]
Install { source: String },
#[command(
about = "Install, inspect, review, trust, enable, disable, update, uninstall or revoke plugin bundles"
)]
#[command(about = "Inspect, review, trust, enable, disable or revoke plugin bundles")]
Plugins {
#[command(subcommand)]
command: PluginsCommand,
@@ -280,40 +278,6 @@ 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")]
@@ -2504,7 +2468,7 @@ fn telemetry_command_is_read_only(command: Option<&Commands>) -> bool {
|| matches!(
command,
Some(Commands::Plugins {
command: PluginsCommand::Status { .. } | PluginsCommand::Show { .. }
command: PluginsCommand::Status { .. }
})
)
}
@@ -2839,37 +2803,15 @@ async fn run_async_main_dispatch(
)
.await
}
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::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::Setup(args) => {
let config = load_config_from_cli(&cli)?;
let workspace = resolve_workspace(&cli);
@@ -9491,47 +9433,9 @@ 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};
@@ -9621,39 +9525,6 @@ fn run_plugins_cli(
}
}
}
PluginsCommand::Show { name, json } => headless::show(
&registry,
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,
@@ -9791,12 +9662,9 @@ async fn run_speech(config: &Config, args: SpeechArgs) -> Result<()> {
let identity = config
.active_provider_identity()
.map_err(anyhow::Error::msg)?;
if !matches!(
identity.provider,
ProviderKind::XiaomiMimo | ProviderKind::Deepseek | ProviderKind::DeepseekAnthropic
) {
if identity.provider != ProviderKind::XiaomiMimo {
bail!(
"`speech` requires provider = \"xiaomi-mimo\" or \"deepseek\" (current: {}). Run with `--provider xiaomi-mimo` or `--provider deepseek` or set it in config.",
"`speech` requires provider = \"xiaomi-mimo\" (current: {}). Run with `--provider xiaomi-mimo` or set it in config.",
identity.key.as_str()
);
}
@@ -21149,72 +21017,6 @@ 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;
-1
View File
@@ -1757,7 +1757,6 @@ 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,
-1
View File
@@ -858,7 +858,6 @@ 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,
+1 -1
View File
@@ -4,7 +4,7 @@
use crate::tui::app::App;
use crate::tui::history::HistoryCell;
/// `/mcp` and every MCP action land on Plugins → Connectors, the one MCP
/// `/mcp` and every MCP action land on Extensions → MCP, 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.
+17 -114
View File
@@ -31,22 +31,6 @@ 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,
@@ -140,63 +124,24 @@ impl App {
true
}
pub fn poll_external_catalog_changes(
&mut self,
engine_busy: bool,
) -> Option<ExternalCatalogChange> {
/// 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) {
let now = Instant::now();
if self
.last_plugin_catalog_poll
.is_some_and(|seen| now.duration_since(seen) < CATALOG_POLL_INTERVAL)
{
return None;
return;
}
self.last_plugin_catalog_poll = Some(now);
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,
};
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 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]
@@ -373,14 +318,13 @@ mod tests {
"schema_version = 1\n[plugin]\nname = \"supabase\"\nversion = \"1.0.0\"\ndescription = \"Hosted Postgres and auth\"\nkeywords = [\"supabase\"]\n",
)
.unwrap();
let app_dir = root.path().join("app");
fs::create_dir_all(&app_dir).unwrap();
let temp = TempDir::new().unwrap();
let options = TuiOptions {
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"),
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"),
..crate::tui::test_support::test_tui_options(root.path())
};
let discovery = codewhale_plugins::PluginDiscoveryContext::capture_pre_dotenv();
@@ -473,53 +417,12 @@ 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();
let _ = app.poll_external_catalog_changes(false);
app.maybe_poll_plugin_catalog_idle();
assert!(!app.plugin_cta.phase.is_visible());
assert_eq!(app.plugin_cta_row_height(), 0);
}
-2
View File
@@ -980,9 +980,7 @@ 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
+1 -11
View File
@@ -2640,17 +2640,7 @@ 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)?;
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?;
}
app.maybe_poll_plugin_catalog_idle();
if drain_remote_control_events(app, config, &engine_handle).await? {
app.needs_redraw = true;
-73
View File
@@ -1,73 +0,0 @@
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(())
}
+1 -1
View File
@@ -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.
pub(super) fn mcp_engine_busy(app: &App) -> bool {
fn mcp_engine_busy(app: &App) -> bool {
app.is_loading
|| app.dispatch_in_flight
|| matches!(app.runtime_turn_status.as_deref(), Some("in_progress"))
-4
View File
@@ -31971,7 +31971,6 @@ 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
@@ -32029,7 +32028,6 @@ 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
@@ -36175,7 +36173,6 @@ 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
@@ -36220,7 +36217,6 @@ 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
+15 -118
View File
@@ -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::ExtensionsTabInstalled),
Self::Plugins => tr(locale, MessageId::ExtensionsTabPlugins),
Self::Marketplace => tr(locale, MessageId::ExtensionsTabMarketplace),
Self::Skills => tr(locale, MessageId::HelpSkills),
Self::Mcp => tr(locale, MessageId::ExtensionsTabConnectors),
Self::Mcp => tr(locale, MessageId::ConfigSectionMcp),
}
.into_owned()
}
@@ -2290,41 +2290,20 @@ impl ModalView for ExtensionsView {
let mut hits = HitAreas::default();
let mut x = rows[0].x;
let available = rows[0].right();
let cells = |label: &str| {
u16::try_from(UnicodeWidthStr::width(label))
.unwrap_or(u16::MAX)
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
.saturating_add(2)
};
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) {
.min(available.saturating_sub(x));
if width == 0 {
break;
}
let tab_area = Rect::new(x, rows[0].y, width, 1);
@@ -2841,88 +2820,6 @@ 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 {
+10 -49
View File
@@ -604,18 +604,6 @@ 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
@@ -629,13 +617,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", ""), ("/mcp", "")] {
for (command, title) in [
("/settings", "Settings"),
("/skills", "Extensions"),
("/mcp", "Extensions"),
] {
tui.type_line(command).unwrap();
if title.is_empty() {
wait_for_plugins_screen(&mut tui);
} else {
wait(&mut tui, title);
}
wait(&mut tui, title);
tui.send(keys::key::esc()).unwrap();
tui.wait_for_idle(Duration::from_millis(200), WAIT).unwrap();
assert!(
@@ -677,7 +665,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_for_plugins_screen(&mut tui);
wait(&mut tui, "Extensions");
tui.shutdown();
}
@@ -716,7 +704,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_for_plugins_screen(&mut tui);
wait(&mut tui, "Extensions");
wait(&mut tui, "launch-proof");
capture(&mut tui, "mcp");
tui.send(keys::key::esc()).unwrap();
@@ -726,7 +714,7 @@ fn launch_mcp_summary_opens_manager_by_click_and_keyboard() {
tui.send(keys::key::down()).unwrap();
}
tui.send(keys::key::enter()).unwrap();
wait_for_plugins_screen(&mut tui);
wait(&mut tui, "Extensions");
wait(&mut tui, "launch-proof");
tui.shutdown();
}
@@ -777,33 +765,6 @@ 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() {
@@ -812,7 +773,7 @@ fn workbench_settings_visual_evidence() {
("/model", "route ·", "models"),
("/provider", "Provider", "providers"),
("/fleet", "Coordinator", "fleet"),
("/plugin", "Installed", "plugins"),
("/plugin", "Extensions", "plugins"),
("/config", "Settings", "settings"),
("/statusline", "Status", "statusline"),
] {
-1
View File
@@ -12,7 +12,6 @@ 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
Loaded 100 of 119 files, more files were not shown because too many files have changed in this diff. Show more