mirror of
https://github.com/codewhale-hq/Codewhale.git
synced 2026-11-13 20:08:04 -10:00
Compare commits
11
Commits
ci/CW-8218
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8028167bdf | ||
|
|
059529678f | ||
|
|
46ea35dbb4 | ||
|
|
1961db94a3 | ||
|
|
a6277af49c | ||
|
|
ad540d8b38 | ||
|
|
f6f42681f3 | ||
|
|
07a5f2a1bc | ||
|
|
18dcb1515a | ||
|
|
1a7ce6a95e | ||
|
|
fa0c649edf |
No files matched your search
+6
-1
@@ -7,7 +7,7 @@ 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)
|
||||
- **Plugin service hardening (terminals, remote pairing, network):** plugin background terminal sessions enforce the per-plugin cap inside the shared session registry after approval waits and close when the plugin owner is revoked or disabled (CW-8051); plugin remote pairing and status serve reviewed plugins only, refuse child-agent and stale requests, validate pairing labels and one-time loopback redemption through host secrets, and keep `remote` in the refused Cordis service and inspect catalogs (CW-8000); plugin network credentials are bound to a single `https` origin per plugin build in a private credential file, check live session posture and destination guards before egress, serialize first-send confirmation per site through `plugin_network_send`, and scrub returned URLs, headers and bodies before returning to plugins (CW-7979).
|
||||
- **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)
|
||||
@@ -17,17 +17,22 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
- **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)
|
||||
- **Tool deny rules across scheduled work:** tasks, automations, delayed messages and the threads that run them now keep the tool deny rules of the session that created them; older versions refuse such records instead of running them without those rules.
|
||||
- **Sub-minute delayed messages and plan replanning triggers:** `send_later` now accepts `delay_seconds` alongside `delay_minutes` and `fire_at` (requiring exactly one of the three), and the automation scheduler wakes immediately on trigger creation or cancellation so sub-minute continuations fire promptly. Plans also support `replan_if` conditions across updates, snapshots, plan cards, and session relay handoff output.
|
||||
- **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)
|
||||
- **Usage-reporting collector:** accepts runtime schema 6 alongside existing versions and keeps schema 6 reports first-party only.
|
||||
- **Project MCP approvals:** Project `.codewhale/mcp.json` servers now load only after you approve the file's exact contents with `/mcp review` and `/mcp approve <digest>`, as project hooks do. A changed file loads none of its servers until it is approved again, and existing project files must be approved once after upgrading.
|
||||
|
||||
- **Source-build toolchain:** Rust 1.100.0 is pinned consistently across local source builds, CI, Docker, Dev Containers and CNB recipes. The minimum supported Rust version remains 1.89.
|
||||
- **Terminal input recovery:** retains a wake event from a replaced reader, suppresses retired events observed during a child-terminal handoff, and closes only the handoff window owned by that scope.
|
||||
|
||||
- **Plugin index hooks:** hooks with the same name and event now have distinct row IDs, so clients can keep every configured hook visible.
|
||||
- **Extension service approvals:** plugin service acts use the session permission gate and retain their approved or refused act records when a tool call fails. Closing a call seals its broker-act record so a late approval cannot authorize an unrecorded act. Ordinary nested-call receipts may still show an in-flight outcome when cancellation ends the call early.
|
||||
- **Extension workspace confinement:** plugin workspace file reads, listings, and overlay writes now verify pinned ancestors and non-linked targets at the point of use without letting session trust flags escape the workspace, reject Windows alternate data streams and 8.3 short-name aliases for protected metadata directories, and check caller deny rules for git review queries before requesting approval. (CW-8056)
|
||||
- **Delayed-message workspaces and shell stdin approvals:** `send_later` `schedule` now keeps its workspace inside the session's reachable paths instead of accepting an arbitrary directory, and `send_later` approval prompts show the requested workspace; session shell grants now match exact inputs when `stdin` (`stdin`, `input`, `data`) is supplied instead of reusing a command-only grant across different `stdin`.
|
||||
- **Sub-agent approvals and worktree isolation:** a child sub-agent's built-in verification calls now ask the same approval as the main session, a read-only agent started with `workspace_policy: "worktree"` shows the approval card, and a sub-agent worktree is refused when its root or nested adoption path passes through a symlink.
|
||||
|
||||
- **Installers no longer say Computer Use comes bundled:** the macOS install script and the install guide no longer print or state that Computer Use ships with the binary or needs Node.js 20. Computer Use is released separately and stays off until you turn it on.
|
||||
- **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.
|
||||
|
||||
@@ -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))
|
||||
@@ -7224,28 +7216,14 @@ fn mcp_management_config(
|
||||
.map_err(|e| ApiError::internal(format!("Failed to load MCP config: {e}")))?;
|
||||
let global = codewhale_engine::mcp::load_config(&global_path)
|
||||
.map_err(|e| ApiError::internal(format!("Failed to load MCP config: {e}")))?;
|
||||
let project_path = codewhale_engine::mcp::workspace_mcp_config_path(&state.workspace);
|
||||
let same_source = project_path == global_path
|
||||
|| project_path
|
||||
.canonicalize()
|
||||
.ok()
|
||||
.zip(global_path.canonicalize().ok())
|
||||
.is_some_and(|(project, global)| project == global);
|
||||
let project = if !same_source
|
||||
&& codewhale_runtime::config::is_workspace_trusted(&state.workspace)
|
||||
{
|
||||
codewhale_engine::mcp::load_config(&project_path)
|
||||
.map_err(|e| ApiError::internal(format!("Failed to load project MCP config: {e}")))?
|
||||
} else {
|
||||
codewhale_mcp::config::McpConfig::default()
|
||||
};
|
||||
let project = codewhale_engine::mcp::project_server_names(&global_path, &state.workspace);
|
||||
let origins = config
|
||||
.servers
|
||||
.iter()
|
||||
.map(|(name, server)| {
|
||||
let origin = if server.reviewed_plugin.is_some() {
|
||||
"plugin"
|
||||
} else if project.servers.contains_key(name) {
|
||||
} else if project.contains(name) {
|
||||
"project"
|
||||
} else if global.servers.contains_key(name) {
|
||||
"global"
|
||||
@@ -8485,6 +8463,7 @@ async fn create_trigger(
|
||||
owner_session_id: Some(owner_session_id),
|
||||
parent_trigger_id: None,
|
||||
destination_thread_id: destination,
|
||||
disallowed_tools: Vec::new(),
|
||||
};
|
||||
automation_mutation(
|
||||
&state,
|
||||
|
||||
@@ -568,6 +568,7 @@ fn messages_from_thread_detail_batches_tool_results() {
|
||||
executor: None,
|
||||
environment: None,
|
||||
agent_preset: None,
|
||||
disallowed_tools: Vec::new(),
|
||||
};
|
||||
let turn = TurnRecord {
|
||||
source_usage_origin_turn_id: None,
|
||||
@@ -801,6 +802,7 @@ fn legacy_exact_thread_export_normalizes_provider_kind_and_id() {
|
||||
executor: None,
|
||||
environment: None,
|
||||
agent_preset: None,
|
||||
disallowed_tools: Vec::new(),
|
||||
},
|
||||
turns: Vec::new(),
|
||||
items: Vec::new(),
|
||||
@@ -23638,161 +23640,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(())
|
||||
}
|
||||
@@ -25250,6 +25097,10 @@ async fn mcp_management_project_ownership_blocks_global_shadow_mutations() -> Re
|
||||
let project = json!({"servers":{"shared":{"command":root.path().join("missing-project-executable")},"project":{"command":root.path().join("missing-project-executable")}}}).to_string();
|
||||
fs::write(root.path().join("mcp.json"), &global)?;
|
||||
fs::write(workspace.join(".codewhale/mcp.json"), &project)?;
|
||||
let reviewed = codewhale_engine::mcp::project_authority::review_project_mcp(&workspace)
|
||||
.map_err(anyhow::Error::msg)?;
|
||||
codewhale_engine::mcp::project_authority::approve_project_mcp(&workspace, &reviewed.digest)
|
||||
.map_err(anyhow::Error::msg)?;
|
||||
let (addr, _, handle) = spawn_test_server_with_root_token_mobile_workspace(
|
||||
root.path().to_owned(),
|
||||
root.path().join("sessions"),
|
||||
|
||||
@@ -1588,6 +1588,7 @@ async fn mutate_in_owner(
|
||||
create.model_provider_id = thread.model_provider_id.clone();
|
||||
create.reasoning_effort = thread.reasoning_effort.clone();
|
||||
create.allowed_tools = thread.allowed_tools.clone();
|
||||
create.disallowed_tools = thread.disallowed_tools.clone();
|
||||
create.mode = Some(thread.mode.clone());
|
||||
create.permission_posture = thread.permission_posture.clone();
|
||||
create.allow_shell = Some(thread.allow_shell);
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -580,6 +580,7 @@ impl RuntimeChatRelayHost {
|
||||
dynamic_tools: Vec::new(),
|
||||
environments: Vec::new(),
|
||||
add_dirs: Vec::new(),
|
||||
disallowed_tools: Vec::new(),
|
||||
})
|
||||
.await
|
||||
.map_err(|_| "Runtime Chat could not create its replay fixture.".to_string())?
|
||||
@@ -725,6 +726,7 @@ impl RuntimeChatRelayHost {
|
||||
agent_preset: None,
|
||||
dynamic_tools: Vec::new(),
|
||||
environments: Vec::new(),
|
||||
disallowed_tools: Vec::new(),
|
||||
})
|
||||
.await
|
||||
.map_err(|_| {
|
||||
|
||||
@@ -1592,6 +1592,7 @@ pub struct PlanSections {
|
||||
pub verification_plan: Option<String>,
|
||||
pub risks_and_unknowns: Option<String>,
|
||||
pub handoff_packet: Option<String>,
|
||||
pub replan_if: Vec<String>,
|
||||
pub items: Vec<PlanStep>,
|
||||
}
|
||||
|
||||
|
||||
@@ -102,6 +102,8 @@ pub struct StructcopyPlan {
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub handoff_packet: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub replan_if: Vec<String>,
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub items: Vec<StructcopyPlanItem>,
|
||||
}
|
||||
|
||||
|
||||
+2
-1
@@ -12821,7 +12821,8 @@ var init_compatibility = __esm({
|
||||
"avatars",
|
||||
"mcp",
|
||||
"logger",
|
||||
"userQuestions"
|
||||
"userQuestions",
|
||||
"remote"
|
||||
],
|
||||
row_bridges: {
|
||||
"@deepseek-ai/dsh-mcp-client": "mcp",
|
||||
|
||||
@@ -15173,7 +15173,8 @@ var init_compatibility = __esm({
|
||||
"avatars",
|
||||
"mcp",
|
||||
"logger",
|
||||
"userQuestions"
|
||||
"userQuestions",
|
||||
"remote"
|
||||
],
|
||||
row_bridges: {
|
||||
"@deepseek-ai/dsh-mcp-client": "mcp",
|
||||
|
||||
@@ -12799,7 +12799,8 @@ var init_compatibility = __esm({
|
||||
"avatars",
|
||||
"mcp",
|
||||
"logger",
|
||||
"userQuestions"
|
||||
"userQuestions",
|
||||
"remote"
|
||||
],
|
||||
row_bridges: {
|
||||
"@deepseek-ai/dsh-mcp-client": "mcp",
|
||||
|
||||
@@ -49,7 +49,8 @@
|
||||
"avatars",
|
||||
"mcp",
|
||||
"logger",
|
||||
"userQuestions"
|
||||
"userQuestions",
|
||||
"remote"
|
||||
],
|
||||
"row_bridges": {
|
||||
"@deepseek-ai/dsh-mcp-client": "mcp",
|
||||
|
||||
@@ -337,7 +337,8 @@
|
||||
"avatars",
|
||||
"mcp",
|
||||
"logger",
|
||||
"userQuestions"
|
||||
"userQuestions",
|
||||
"remote"
|
||||
],
|
||||
"events": [
|
||||
{
|
||||
|
||||
@@ -18,6 +18,19 @@ function credentialRef(ref: unknown): string {
|
||||
return ref
|
||||
}
|
||||
|
||||
function credentialSite(site: unknown): string {
|
||||
let url: URL
|
||||
try {
|
||||
url = new URL(String(site))
|
||||
} catch {
|
||||
throw new RpcError(ErrorCode.InvalidParams, 'a credential is stored for one https site, such as https://api.example.com')
|
||||
}
|
||||
if (typeof site !== 'string' || url.protocol !== 'https:' || url.username !== '' || url.password !== '' || url.pathname !== '/' || url.search !== '' || url.hash !== '') {
|
||||
throw new RpcError(ErrorCode.InvalidParams, 'a credential is stored for one https site, such as https://api.example.com')
|
||||
}
|
||||
return url.origin
|
||||
}
|
||||
|
||||
function brokered(value: unknown): BrokeredResponse {
|
||||
const response = value as Partial<BrokeredResponse> | null
|
||||
if (response === null || typeof response !== 'object' || typeof response.status !== 'number' || typeof response.url !== 'string'
|
||||
@@ -71,15 +84,15 @@ export const credentialsAdapter: DshAdapter = {
|
||||
return { value }
|
||||
}
|
||||
|
||||
async describe(ref: unknown): Promise<{ ref: string, set: boolean }> {
|
||||
const result = await host.broker(this.ctx, 'network', 'credentials.describe', { ref: credentialRef(ref) }) as { ref?: unknown, set?: unknown } | null
|
||||
async describe(ref: unknown): Promise<{ ref: string, set: boolean, site?: string }> {
|
||||
const result = await host.broker(this.ctx, 'network', 'credentials.describe', { ref: credentialRef(ref) }) as { ref?: unknown, set?: unknown, site?: unknown } | null
|
||||
if (result === null || typeof result !== 'object' || typeof result.set !== 'boolean') throw new RpcError(ErrorCode.Internal, 'invalid credential description')
|
||||
return { ref: credentialRef(ref), set: result.set }
|
||||
return typeof result.site === 'string' ? { ref: credentialRef(ref), set: result.set, site: result.site } : { ref: credentialRef(ref), set: result.set }
|
||||
}
|
||||
|
||||
async set(ref: unknown, value: unknown): Promise<void> {
|
||||
async set(ref: unknown, value: unknown, site: unknown): Promise<void> {
|
||||
if (typeof value !== 'string' || value.length === 0) throw new RpcError(ErrorCode.InvalidParams, 'a credential value is a non-empty string')
|
||||
await host.broker(this.ctx, 'network', 'credentials.set', { ref: credentialRef(ref), value })
|
||||
await host.broker(this.ctx, 'network', 'credentials.set', { ref: credentialRef(ref), value, site: credentialSite(site) })
|
||||
}
|
||||
|
||||
fetch(input: string | URL | Request, init?: RequestInit): Promise<Response> {
|
||||
|
||||
@@ -36,15 +36,20 @@ function brokerHost(answers) {
|
||||
test('credentials resolve to an opaque handle and go only to the network broker', async () => {
|
||||
const { host, calls } = brokerHost({
|
||||
'credentials.resolve': { ref: 'KEY', value: 'codewhale-credential:KEY' },
|
||||
'credentials.describe': { ref: 'KEY', set: true },
|
||||
'credentials.describe': { ref: 'KEY', set: true, site: 'https://api.example.com' },
|
||||
'credentials.set': { ref: 'KEY', set: true },
|
||||
})
|
||||
const Credentials = credentialsAdapter.define(host)
|
||||
const service = Object.create(Credentials.prototype)
|
||||
service.ctx = {}
|
||||
assert.deepEqual(await service.resolve('KEY'), { value: 'codewhale-credential:KEY' })
|
||||
assert.deepEqual(await service.describe('KEY'), { ref: 'KEY', set: true })
|
||||
await service.set('KEY', 'sk-secret')
|
||||
assert.deepEqual(await service.describe('KEY'), { ref: 'KEY', set: true, site: 'https://api.example.com' })
|
||||
await service.set('KEY', 'sk-secret', 'https://API.example.com/')
|
||||
assert.deepEqual(calls[2].input, { ref: 'KEY', value: 'sk-secret', site: 'https://api.example.com' })
|
||||
for (const site of [undefined, 'http://api.example.com', 'https://user:[email protected]', 'https://api.example.com/v1', 'https://api.example.com/?q=1', 'api.example.com']) {
|
||||
await assert.rejects(service.set('KEY', 'sk-secret', site), /one https site/, String(site))
|
||||
}
|
||||
assert.equal(calls.length, 3, 'a site that is not an https origin never reaches the broker')
|
||||
assert.deepEqual(calls.map((c) => [c.service, c.method]), [
|
||||
['network', 'credentials.resolve'], ['network', 'credentials.describe'], ['network', 'credentials.set'],
|
||||
])
|
||||
@@ -83,6 +88,6 @@ test('brokeredFetch sends a typed fetch and rebuilds a Response from the broker
|
||||
})
|
||||
|
||||
test('a broker refusal reaches the plugin as the refusal', async () => {
|
||||
const { host } = brokerHost({ fetch: new Error('egress refused: `evil.example` is not one of this plugin\'s reviewed network hosts') })
|
||||
await assert.rejects(brokeredFetch(host, {}, 'https://evil.example/'), /egress refused/)
|
||||
const { host } = brokerHost({ fetch: new Error('this site is not one of the plugin\'s reviewed network sites') })
|
||||
await assert.rejects(brokeredFetch(host, {}, 'https://evil.example/'), /not one of the plugin's reviewed network sites/)
|
||||
})
|
||||
@@ -1,9 +1,11 @@
|
||||
import { test, before } from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import { mkdtempSync, rmSync } from 'node:fs'
|
||||
import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join, dirname } from 'node:path'
|
||||
import { fileURLToPath, pathToFileURL } from 'node:url'
|
||||
import { build } from 'esbuild'
|
||||
import { activate, startHost } from './harness.mjs'
|
||||
|
||||
const here = dirname(fileURLToPath(import.meta.url))
|
||||
let remoteAdapter
|
||||
@@ -66,3 +68,33 @@ test('remote refuses malformed arguments locally, before any broker call', async
|
||||
assert.equal(typeof ctx.remote.listen, 'undefined')
|
||||
assert.deepEqual(calls, [])
|
||||
})
|
||||
|
||||
test('a plugin may inject `remote` and may never provide it, in the shared scope or an isolated one', async (t) => {
|
||||
const host = await startHost()
|
||||
t.after(() => host.stop())
|
||||
const plugin = (source) => {
|
||||
const dir = mkdtempSync(join(tmpdir(), 'cw-ext-host-remote-'))
|
||||
t.after(() => rmSync(dir, { recursive: true, force: true }))
|
||||
const entry = join(dir, 'index.mjs')
|
||||
writeFileSync(entry, source)
|
||||
return entry
|
||||
}
|
||||
const fake = `{ status() { return 'fake' }, pair() { return 'fake' }, clients() { return 'fake' }, revoke() { return 'fake' } }`
|
||||
const providers = {
|
||||
'remote-root-provider': `ctx.root.provide('remote', ${fake})`,
|
||||
'remote-own-provider': `ctx.provide('remote', ${fake})`,
|
||||
'remote-isolated-provider': `ctx.isolate('remote').provide('remote', ${fake})`,
|
||||
}
|
||||
for (const [name, body] of Object.entries(providers)) {
|
||||
const { result } = await activate(host, name, plugin(`export const inject = ['tools']\nexport function apply(ctx) { ${body} }\n`))
|
||||
assert.equal(result.status, 'failed', name)
|
||||
assert.match(result.diagnostic, /may not provide core service `remote`/, name)
|
||||
}
|
||||
const user = await activate(host, 'remote-user', plugin(`export const inject = ['remote']
|
||||
export function apply(ctx) {
|
||||
for (const method of ['status', 'pair', 'clients', 'revoke']) if (typeof ctx.remote[method] !== 'function') throw new Error('ctx.remote.' + method + ' is missing')
|
||||
if (ctx.remote.constructor.name !== 'RemoteService') throw new Error('ctx.remote is not the host service')
|
||||
}
|
||||
`))
|
||||
assert.equal(user.result.status, 'ok', user.result.diagnostic)
|
||||
})
|
||||
@@ -22,7 +22,7 @@ use chrono::{
|
||||
DateTime, Datelike, Duration, Local, NaiveDateTime, TimeZone, Timelike, Utc, Weekday,
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use tokio::sync::Mutex;
|
||||
use tokio::sync::{Mutex, Notify};
|
||||
use tokio::time::sleep;
|
||||
use tokio_util::sync::CancellationToken;
|
||||
use uuid::Uuid;
|
||||
@@ -44,8 +44,43 @@ impl std::fmt::Debug for ThreadEventSink {
|
||||
// v2 pins provider identity. Older runtimes must reject a pinned definition
|
||||
// instead of silently sending its model through their current provider.
|
||||
pub const CURRENT_AUTOMATION_SCHEMA_VERSION: u32 = 3;
|
||||
const DENY_RULES_AUTOMATION_SCHEMA_VERSION: u32 = 4;
|
||||
const MAX_SUPPORTED_AUTOMATION_SCHEMA_VERSION: u32 = DENY_RULES_AUTOMATION_SCHEMA_VERSION;
|
||||
const CURRENT_RUN_SCHEMA_VERSION: u32 = 3;
|
||||
const DENY_RULES_RUN_SCHEMA_VERSION: u32 = 4;
|
||||
const MAX_SUPPORTED_RUN_SCHEMA_VERSION: u32 = DENY_RULES_RUN_SCHEMA_VERSION;
|
||||
const CURRENT_TRIGGER_SCHEMA_VERSION: u32 = 3;
|
||||
const DENY_RULES_TRIGGER_SCHEMA_VERSION: u32 = 4;
|
||||
const MAX_SUPPORTED_TRIGGER_SCHEMA_VERSION: u32 = DENY_RULES_TRIGGER_SCHEMA_VERSION;
|
||||
|
||||
pub const fn required_automation_schema_version(disallowed_tools: &[String]) -> u32 {
|
||||
if disallowed_tools.is_empty() {
|
||||
CURRENT_AUTOMATION_SCHEMA_VERSION
|
||||
} else {
|
||||
DENY_RULES_AUTOMATION_SCHEMA_VERSION
|
||||
}
|
||||
}
|
||||
|
||||
const fn required_run_schema_version(dispatch: Option<&AutomationDispatch>) -> u32 {
|
||||
match dispatch {
|
||||
Some(dispatch) if !dispatch.request.disallowed_tools.is_empty() => {
|
||||
DENY_RULES_RUN_SCHEMA_VERSION
|
||||
}
|
||||
_ => CURRENT_RUN_SCHEMA_VERSION,
|
||||
}
|
||||
}
|
||||
|
||||
const fn required_trigger_schema_version(record: &DelayedTriggerRecord) -> u32 {
|
||||
let dispatch_denied = match &record.dispatch {
|
||||
Some(dispatch) => !dispatch.request.disallowed_tools.is_empty(),
|
||||
None => false,
|
||||
};
|
||||
if record.disallowed_tools.is_empty() && !dispatch_denied {
|
||||
CURRENT_TRIGGER_SCHEMA_VERSION
|
||||
} else {
|
||||
DENY_RULES_TRIGGER_SCHEMA_VERSION
|
||||
}
|
||||
}
|
||||
const DEFAULT_AUTOMATION_MODE: &str = "agent";
|
||||
const DEFAULT_AUTOMATION_ALLOW_SHELL: bool = false;
|
||||
const DEFAULT_AUTOMATION_TRUST_MODE: bool = false;
|
||||
@@ -108,6 +143,8 @@ pub struct DelayedTriggerRecord {
|
||||
pub owner_session_id: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub destination_thread_id: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub disallowed_tools: Vec<String>,
|
||||
pub status: DelayedTriggerStatus,
|
||||
pub created_at: DateTime<Utc>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
@@ -143,6 +180,7 @@ pub struct CreateDelayedTriggerRequest {
|
||||
/// Optional parent trigger id for re-arm lineage tracking.
|
||||
pub parent_trigger_id: Option<String>,
|
||||
pub destination_thread_id: Option<String>,
|
||||
pub disallowed_tools: Vec<String>,
|
||||
}
|
||||
|
||||
// ── End delayed-trigger types ──────────────────────────────────────────────
|
||||
@@ -298,6 +336,8 @@ pub struct AutomationRecord {
|
||||
pub delivery_mode: Option<AutomationDeliveryMode>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub destination_thread_id: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub disallowed_tools: Vec<String>,
|
||||
pub status: AutomationStatus,
|
||||
pub created_at: DateTime<Utc>,
|
||||
pub updated_at: DateTime<Utc>,
|
||||
@@ -415,6 +455,8 @@ pub struct CreateAutomationRequest {
|
||||
pub delivery_mode: Option<AutomationDeliveryMode>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub destination_thread_id: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub disallowed_tools: Vec<String>,
|
||||
#[serde(default)]
|
||||
pub status: Option<AutomationStatus>,
|
||||
}
|
||||
@@ -1175,6 +1217,7 @@ pub struct AutomationManager {
|
||||
triggers_dir: PathBuf,
|
||||
archive_dir: PathBuf,
|
||||
missed_dir: PathBuf,
|
||||
trigger_wakeup: Arc<Notify>,
|
||||
}
|
||||
|
||||
impl AutomationManager {
|
||||
@@ -1276,6 +1319,7 @@ impl AutomationManager {
|
||||
triggers_dir,
|
||||
archive_dir,
|
||||
missed_dir,
|
||||
trigger_wakeup: Arc::new(Notify::new()),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1361,7 +1405,12 @@ impl AutomationManager {
|
||||
}
|
||||
} else {
|
||||
automation.execution_scope = Some(scope.to_string());
|
||||
automation.schema_version = CURRENT_AUTOMATION_SCHEMA_VERSION;
|
||||
automation.schema_version =
|
||||
automation
|
||||
.schema_version
|
||||
.max(required_automation_schema_version(
|
||||
&automation.disallowed_tools,
|
||||
));
|
||||
automation.updated_at = Utc::now();
|
||||
if automation.status == AutomationStatus::Active {
|
||||
let schedule = AutomationSchedule::parse_rrule(&automation.rrule)?;
|
||||
@@ -1449,8 +1498,9 @@ impl AutomationManager {
|
||||
None
|
||||
};
|
||||
|
||||
let disallowed_tools = crate::task_manager::normalize_deny_rules(req.disallowed_tools);
|
||||
let record = AutomationRecord {
|
||||
schema_version: CURRENT_AUTOMATION_SCHEMA_VERSION,
|
||||
schema_version: required_automation_schema_version(&disallowed_tools),
|
||||
execution_scope: self.execution_scope.clone(),
|
||||
id: Uuid::new_v4().to_string(),
|
||||
name: req.name.trim().to_string(),
|
||||
@@ -1466,6 +1516,7 @@ impl AutomationManager {
|
||||
auto_approve: req.auto_approve,
|
||||
delivery_mode: req.delivery_mode,
|
||||
destination_thread_id: normalize_optional_string(req.destination_thread_id),
|
||||
disallowed_tools,
|
||||
status,
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
@@ -1489,16 +1540,24 @@ impl AutomationManager {
|
||||
}
|
||||
|
||||
fn save_automation_unlocked(&self, record: &AutomationRecord) -> Result<()> {
|
||||
if record.model_provider.is_some() || record.model_provider_id.is_some() {
|
||||
if record
|
||||
if (record.model_provider.is_some() || record.model_provider_id.is_some())
|
||||
&& record
|
||||
.model
|
||||
.as_deref()
|
||||
.is_none_or(|model| model.trim().is_empty())
|
||||
{
|
||||
bail!("A pinned automation provider requires an explicit model");
|
||||
}
|
||||
{
|
||||
bail!("A pinned automation provider requires an explicit model");
|
||||
}
|
||||
let min_version = if !record.disallowed_tools.is_empty() {
|
||||
DENY_RULES_AUTOMATION_SCHEMA_VERSION
|
||||
} else if record.model_provider.is_some() || record.model_provider_id.is_some() {
|
||||
CURRENT_AUTOMATION_SCHEMA_VERSION
|
||||
} else {
|
||||
record.schema_version
|
||||
};
|
||||
if record.schema_version < min_version {
|
||||
let mut record = record.clone();
|
||||
record.schema_version = record.schema_version.max(CURRENT_AUTOMATION_SCHEMA_VERSION);
|
||||
record.schema_version = min_version;
|
||||
return write_json_atomic(&self.automation_path(&record.id)?, &record);
|
||||
}
|
||||
write_json_atomic(&self.automation_path(&record.id)?, record)
|
||||
@@ -1609,8 +1668,14 @@ impl AutomationManager {
|
||||
if existing.execution_scope.is_some()
|
||||
|| existing.model_provider.is_some()
|
||||
|| existing.model_provider_id.is_some()
|
||||
|| !existing.disallowed_tools.is_empty()
|
||||
{
|
||||
existing.schema_version = CURRENT_AUTOMATION_SCHEMA_VERSION;
|
||||
existing.schema_version =
|
||||
existing
|
||||
.schema_version
|
||||
.max(required_automation_schema_version(
|
||||
&existing.disallowed_tools,
|
||||
));
|
||||
}
|
||||
|
||||
existing.updated_at = Utc::now();
|
||||
@@ -1981,7 +2046,14 @@ impl AutomationManager {
|
||||
let dir = self.runs_dir_for(&run.automation_id)?;
|
||||
fs::create_dir_all(&dir).with_context(|| format!("Failed to create {}", dir.display()))?;
|
||||
let path = self.run_path(run)?;
|
||||
write_json_atomic(&path, run)?;
|
||||
let min_version = required_run_schema_version(run.dispatch.as_ref());
|
||||
if run.schema_version < min_version {
|
||||
let mut updated = run.clone();
|
||||
updated.schema_version = min_version;
|
||||
write_json_atomic(&path, &updated)?;
|
||||
} else {
|
||||
write_json_atomic(&path, run)?;
|
||||
}
|
||||
// Rewrites of a legacy-named run migrate it to the sortable name; drop
|
||||
// the old file so the run never exists twice.
|
||||
let legacy = self.legacy_run_path(&run.automation_id, &run.id)?;
|
||||
@@ -2551,8 +2623,13 @@ impl AutomationManager {
|
||||
if req.message.trim().is_empty() {
|
||||
bail!("Trigger message must not be empty");
|
||||
}
|
||||
let disallowed_tools = crate::task_manager::normalize_deny_rules(req.disallowed_tools);
|
||||
let record = DelayedTriggerRecord {
|
||||
schema_version: CURRENT_TRIGGER_SCHEMA_VERSION,
|
||||
schema_version: if disallowed_tools.is_empty() {
|
||||
CURRENT_TRIGGER_SCHEMA_VERSION
|
||||
} else {
|
||||
DENY_RULES_TRIGGER_SCHEMA_VERSION
|
||||
},
|
||||
execution_scope: self.execution_scope.clone(),
|
||||
trigger_id: format!("trig_{}", Uuid::new_v4().simple()),
|
||||
fire_at: req.fire_at,
|
||||
@@ -2560,6 +2637,7 @@ impl AutomationManager {
|
||||
workspace: req.workspace,
|
||||
owner_session_id: req.owner_session_id,
|
||||
destination_thread_id: normalize_optional_string(req.destination_thread_id),
|
||||
disallowed_tools,
|
||||
status: DelayedTriggerStatus::Pending,
|
||||
created_at: now,
|
||||
fired_at: None,
|
||||
@@ -2570,6 +2648,7 @@ impl AutomationManager {
|
||||
dispatch: None,
|
||||
};
|
||||
self.save_trigger(&record)?;
|
||||
self.trigger_wakeup.notify_one();
|
||||
self.emit_trigger_event("trigger.scheduled", &record);
|
||||
Ok(record)
|
||||
}
|
||||
@@ -2581,11 +2660,11 @@ impl AutomationManager {
|
||||
.with_context(|| format!("Trigger '{trigger_id}' not found"))?;
|
||||
let record: DelayedTriggerRecord = serde_json::from_str(&raw)
|
||||
.with_context(|| format!("Failed to parse trigger '{trigger_id}'"))?;
|
||||
if record.schema_version > CURRENT_TRIGGER_SCHEMA_VERSION {
|
||||
if record.schema_version > MAX_SUPPORTED_TRIGGER_SCHEMA_VERSION {
|
||||
bail!(
|
||||
"Trigger schema v{} is newer than supported v{}",
|
||||
record.schema_version,
|
||||
CURRENT_TRIGGER_SCHEMA_VERSION
|
||||
MAX_SUPPORTED_TRIGGER_SCHEMA_VERSION
|
||||
);
|
||||
}
|
||||
Ok(record)
|
||||
@@ -2613,6 +2692,12 @@ impl AutomationManager {
|
||||
|
||||
fn save_trigger_unlocked(&self, record: &DelayedTriggerRecord) -> Result<()> {
|
||||
let path = self.trigger_path(&record.trigger_id)?;
|
||||
let min_version = required_trigger_schema_version(record);
|
||||
if record.schema_version < min_version {
|
||||
let mut updated = record.clone();
|
||||
updated.schema_version = min_version;
|
||||
return write_json_atomic(&path, &updated);
|
||||
}
|
||||
write_json_atomic(&path, record)
|
||||
}
|
||||
|
||||
@@ -2639,6 +2724,14 @@ impl AutomationManager {
|
||||
.and_then(|raw| serde_json::from_str::<DelayedTriggerRecord>(&raw).ok())
|
||||
{
|
||||
Some(record) => {
|
||||
if record.schema_version > MAX_SUPPORTED_TRIGGER_SCHEMA_VERSION {
|
||||
tracing::warn!(
|
||||
"Skipping trigger file {} with unsupported schema v{}",
|
||||
path.display(),
|
||||
record.schema_version
|
||||
);
|
||||
continue;
|
||||
}
|
||||
if let Some(filter) = status_filter
|
||||
&& record.status != filter
|
||||
{
|
||||
@@ -2706,10 +2799,40 @@ impl AutomationManager {
|
||||
self.save_trigger_unlocked(&record)?;
|
||||
Ok(record)
|
||||
})?;
|
||||
self.trigger_wakeup.notify_one();
|
||||
self.emit_trigger_event("trigger.canceled", &record);
|
||||
Ok(record)
|
||||
}
|
||||
|
||||
pub(crate) fn trigger_wakeup(&self) -> Arc<Notify> {
|
||||
Arc::clone(&self.trigger_wakeup)
|
||||
}
|
||||
|
||||
pub(crate) fn trigger_wait_duration(
|
||||
&self,
|
||||
now: DateTime<Utc>,
|
||||
max_wait: std::time::Duration,
|
||||
) -> Result<std::time::Duration> {
|
||||
let next_fire_at = self
|
||||
.list_triggers(Some(DelayedTriggerStatus::Pending), None)?
|
||||
.into_iter()
|
||||
.filter(|trigger| {
|
||||
trigger.owner_session_id.is_some()
|
||||
&& self.eligible_scope(trigger.execution_scope.as_deref())
|
||||
})
|
||||
.map(|trigger| trigger.fire_at)
|
||||
.min();
|
||||
let wait = match next_fire_at {
|
||||
Some(fire_at) if fire_at <= now => std::time::Duration::ZERO,
|
||||
Some(fire_at) => (fire_at - now)
|
||||
.to_std()
|
||||
.unwrap_or(max_wait)
|
||||
.clamp(std::time::Duration::ZERO, max_wait),
|
||||
None => max_wait,
|
||||
};
|
||||
Ok(wait)
|
||||
}
|
||||
|
||||
/// Return due proposals and unfinished durable trigger admissions.
|
||||
pub fn collect_due_triggers(&self, now: DateTime<Utc>) -> Result<Vec<DelayedTriggerRecord>> {
|
||||
Ok(self
|
||||
@@ -2859,6 +2982,7 @@ fn automation_task_request(automation: &AutomationRecord) -> NewTaskRequest {
|
||||
trust_mode: Some(automation.task_trust_mode()),
|
||||
auto_approve: Some(automation.task_auto_approve()),
|
||||
permission_posture: Some(automation_posture(automation.task_auto_approve())),
|
||||
disallowed_tools: automation.disallowed_tools.clone(),
|
||||
owner_session_id: None,
|
||||
destination_thread_id: automation.destination_thread_id.clone(),
|
||||
}
|
||||
@@ -2873,11 +2997,17 @@ fn bind_run_dispatch(
|
||||
if automation.execution_scope.is_none() {
|
||||
bail!("Automation execution ownership is unverified");
|
||||
}
|
||||
run.schema_version = CURRENT_RUN_SCHEMA_VERSION;
|
||||
let request = automation_task_request(automation);
|
||||
let min_version = if request.disallowed_tools.is_empty() {
|
||||
CURRENT_RUN_SCHEMA_VERSION
|
||||
} else {
|
||||
DENY_RULES_RUN_SCHEMA_VERSION
|
||||
};
|
||||
run.schema_version = run.schema_version.max(min_version);
|
||||
run.task_id = Some(crate::task_manager::TaskManager::new_task_id());
|
||||
run.dispatch = Some(AutomationDispatch {
|
||||
execution_scope: automation.execution_scope.clone(),
|
||||
request: automation_task_request(automation),
|
||||
request,
|
||||
task_data_dir: task_data_dir
|
||||
.canonicalize()
|
||||
.context("resolve task store before automation admission")?,
|
||||
@@ -3285,7 +3415,6 @@ where
|
||||
{
|
||||
return Ok(None);
|
||||
}
|
||||
current.schema_version = CURRENT_TRIGGER_SCHEMA_VERSION;
|
||||
current.status = DelayedTriggerStatus::Dispatching;
|
||||
current.task_id = Some(crate::task_manager::TaskManager::new_task_id());
|
||||
current.dispatch = Some(AutomationDispatch {
|
||||
@@ -3302,6 +3431,7 @@ where
|
||||
trust_mode: Some(false),
|
||||
auto_approve: Some(false),
|
||||
permission_posture: Some(automation_posture(false)),
|
||||
disallowed_tools: current.disallowed_tools.clone(),
|
||||
owner_session_id: current.owner_session_id.clone(),
|
||||
destination_thread_id: current.destination_thread_id.clone(),
|
||||
},
|
||||
@@ -3311,6 +3441,9 @@ where
|
||||
suppress_report: false,
|
||||
schedule: None,
|
||||
});
|
||||
current.schema_version = current
|
||||
.schema_version
|
||||
.max(required_trigger_schema_version(¤t));
|
||||
manager.save_trigger_unlocked(¤t)?;
|
||||
Ok(Some(current))
|
||||
})
|
||||
@@ -3581,7 +3714,9 @@ async fn reconcile_run_statuses_shared(
|
||||
suppress_report: false,
|
||||
schedule: None,
|
||||
});
|
||||
run.schema_version = CURRENT_RUN_SCHEMA_VERSION;
|
||||
run.schema_version = run
|
||||
.schema_version
|
||||
.max(required_run_schema_version(Some(&*dispatch)));
|
||||
dispatch.accepted = true;
|
||||
dispatch.suppress_report = watcher_noop;
|
||||
if let Err(error) = with_manager_blocking(automations, {
|
||||
@@ -3631,11 +3766,11 @@ fn read_automation_file(path: &Path) -> Result<AutomationRecord> {
|
||||
.with_context(|| format!("Failed to read automation {}", path.display()))?;
|
||||
let record: AutomationRecord = serde_json::from_str(&raw)
|
||||
.with_context(|| format!("Failed to parse automation {}", path.display()))?;
|
||||
if record.schema_version > CURRENT_AUTOMATION_SCHEMA_VERSION {
|
||||
if record.schema_version > MAX_SUPPORTED_AUTOMATION_SCHEMA_VERSION {
|
||||
bail!(
|
||||
"Automation schema v{} is newer than supported v{}",
|
||||
record.schema_version,
|
||||
CURRENT_AUTOMATION_SCHEMA_VERSION
|
||||
MAX_SUPPORTED_AUTOMATION_SCHEMA_VERSION
|
||||
);
|
||||
}
|
||||
Ok(record)
|
||||
@@ -3646,11 +3781,11 @@ fn read_run_file(path: &Path) -> Result<AutomationRunRecord> {
|
||||
fs::read_to_string(path).with_context(|| format!("Failed to read {}", path.display()))?;
|
||||
let run: AutomationRunRecord = serde_json::from_str(&raw)
|
||||
.with_context(|| format!("Failed to parse {}", path.display()))?;
|
||||
if run.schema_version > CURRENT_RUN_SCHEMA_VERSION {
|
||||
if run.schema_version > MAX_SUPPORTED_RUN_SCHEMA_VERSION {
|
||||
bail!(
|
||||
"Automation run schema v{} is newer than supported v{}",
|
||||
run.schema_version,
|
||||
CURRENT_RUN_SCHEMA_VERSION
|
||||
MAX_SUPPORTED_RUN_SCHEMA_VERSION
|
||||
);
|
||||
}
|
||||
Ok(run)
|
||||
@@ -3747,6 +3882,11 @@ pub fn spawn_scheduler(
|
||||
std::panic::Location::caller(),
|
||||
async move {
|
||||
let interval = config.tick_interval_secs.max(5);
|
||||
let max_wait = std::time::Duration::from_secs(interval);
|
||||
let wakeup =
|
||||
with_manager_blocking(&automations, |manager| Ok(manager.trigger_wakeup()))
|
||||
.await
|
||||
.unwrap_or_else(|_| Arc::new(Notify::new()));
|
||||
let grace = Duration::seconds(
|
||||
SCHEDULER_PRESENCE_MIN_GRACE_SECS.max(
|
||||
i64::try_from(interval)
|
||||
@@ -3780,9 +3920,16 @@ pub fn spawn_scheduler(
|
||||
tracing::warn!("delayed trigger tick failed: {err}");
|
||||
}
|
||||
|
||||
let wait_duration = with_manager_blocking(&automations, move |manager| {
|
||||
manager.trigger_wait_duration(Utc::now(), max_wait)
|
||||
})
|
||||
.await
|
||||
.unwrap_or(max_wait);
|
||||
|
||||
tokio::select! {
|
||||
_ = cancel.cancelled() => break,
|
||||
_ = sleep(std::time::Duration::from_secs(interval)) => {}
|
||||
_ = sleep(wait_duration) => {}
|
||||
_ = wakeup.notified() => {}
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -4218,6 +4365,7 @@ mod tests {
|
||||
workspace: None,
|
||||
owner_session_id: Some("fixture-owner".into()),
|
||||
parent_trigger_id: None,
|
||||
disallowed_tools: Vec::new(),
|
||||
})?;
|
||||
trigger.fire_at = Utc::now() - Duration::minutes(1);
|
||||
trigger.created_at = Utc::now() - Duration::minutes(index);
|
||||
@@ -4636,6 +4784,7 @@ mod tests {
|
||||
next_run_at: None,
|
||||
last_run_at: None,
|
||||
revision: 0,
|
||||
disallowed_tools: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5038,6 +5187,7 @@ mod tests {
|
||||
auto_approve: None,
|
||||
delivery_mode: None,
|
||||
status: Some(AutomationStatus::Active),
|
||||
disallowed_tools: Vec::new(),
|
||||
})
|
||||
.expect("create");
|
||||
assert_eq!(created.model.as_deref(), Some("scheduled-model"));
|
||||
@@ -5084,6 +5234,7 @@ mod tests {
|
||||
auto_approve: None,
|
||||
delivery_mode: None,
|
||||
status: Some(AutomationStatus::Active),
|
||||
disallowed_tools: Vec::new(),
|
||||
})
|
||||
.expect("create");
|
||||
|
||||
@@ -5610,6 +5761,7 @@ model = "private-model"
|
||||
workspace: None,
|
||||
owner_session_id: Some("session-a".to_string()),
|
||||
parent_trigger_id: None,
|
||||
disallowed_tools: Vec::new(),
|
||||
})?;
|
||||
owned.fire_at = Utc::now() - Duration::minutes(1);
|
||||
manager.save_trigger(&owned)?;
|
||||
@@ -5621,6 +5773,7 @@ model = "private-model"
|
||||
workspace: None,
|
||||
owner_session_id: None,
|
||||
parent_trigger_id: None,
|
||||
disallowed_tools: Vec::new(),
|
||||
})?;
|
||||
legacy.fire_at = Utc::now() - Duration::minutes(1);
|
||||
manager.save_trigger(&legacy)?;
|
||||
@@ -5664,6 +5817,263 @@ model = "private-model"
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn denied_automation_and_run_use_v4_schema_while_open_stay_v3() -> Result<()> {
|
||||
let tempdir = tempfile::tempdir()?;
|
||||
let manager = AutomationManager::open_for_test(tempdir.path().join("automations"))?;
|
||||
|
||||
let open = manager.create_automation(CreateAutomationRequest {
|
||||
destination_thread_id: None,
|
||||
name: "Open".to_string(),
|
||||
prompt: "prompt".to_string(),
|
||||
rrule: "FREQ=HOURLY;INTERVAL=1".to_string(),
|
||||
cwds: Vec::new(),
|
||||
model: None,
|
||||
model_provider: None,
|
||||
model_provider_id: None,
|
||||
mode: None,
|
||||
allow_shell: None,
|
||||
trust_mode: None,
|
||||
auto_approve: None,
|
||||
delivery_mode: None,
|
||||
status: Some(AutomationStatus::Active),
|
||||
disallowed_tools: vec![" ".to_string()],
|
||||
})?;
|
||||
assert_eq!(open.schema_version, CURRENT_AUTOMATION_SCHEMA_VERSION);
|
||||
assert!(open.disallowed_tools.is_empty());
|
||||
|
||||
let open_disk: serde_json::Value =
|
||||
serde_json::from_str(&fs::read_to_string(manager.automation_path(&open.id)?)?)?;
|
||||
assert_eq!(
|
||||
open_disk["schema_version"],
|
||||
CURRENT_AUTOMATION_SCHEMA_VERSION
|
||||
);
|
||||
assert!(open_disk.get("disallowed_tools").is_none());
|
||||
|
||||
let mut open_run = new_run_record(&open.id, Utc::now(), Utc::now());
|
||||
bind_run_dispatch(&mut open_run, &open, tempdir.path(), true)?;
|
||||
manager.save_run(&open_run)?;
|
||||
let open_run_disk: serde_json::Value =
|
||||
serde_json::from_str(&fs::read_to_string(manager.run_path(&open_run)?)?)?;
|
||||
assert_eq!(open_run.schema_version, CURRENT_RUN_SCHEMA_VERSION);
|
||||
assert_eq!(open_run_disk["schema_version"], CURRENT_RUN_SCHEMA_VERSION);
|
||||
|
||||
let restricted = manager.create_automation(CreateAutomationRequest {
|
||||
destination_thread_id: None,
|
||||
name: "Restricted".to_string(),
|
||||
prompt: "prompt".to_string(),
|
||||
rrule: "FREQ=HOURLY;INTERVAL=1".to_string(),
|
||||
cwds: Vec::new(),
|
||||
model: None,
|
||||
model_provider: None,
|
||||
model_provider_id: None,
|
||||
mode: None,
|
||||
allow_shell: None,
|
||||
trust_mode: None,
|
||||
auto_approve: None,
|
||||
delivery_mode: None,
|
||||
status: Some(AutomationStatus::Active),
|
||||
disallowed_tools: vec![
|
||||
"shell".to_string(),
|
||||
" shell ".to_string(),
|
||||
"mcp_secret_*".to_string(),
|
||||
],
|
||||
})?;
|
||||
assert_eq!(
|
||||
restricted.schema_version,
|
||||
DENY_RULES_AUTOMATION_SCHEMA_VERSION
|
||||
);
|
||||
assert_eq!(restricted.disallowed_tools, vec!["shell", "mcp_secret_*"]);
|
||||
|
||||
let updated = manager.update_automation(
|
||||
&restricted.id,
|
||||
UpdateAutomationRequest {
|
||||
allow_shell: Some(false),
|
||||
..Default::default()
|
||||
},
|
||||
)?;
|
||||
assert_eq!(updated.schema_version, DENY_RULES_AUTOMATION_SCHEMA_VERSION);
|
||||
assert_eq!(updated.disallowed_tools, vec!["shell", "mcp_secret_*"]);
|
||||
|
||||
let auto_path = manager.automation_path(&restricted.id)?;
|
||||
let mut restricted_disk: serde_json::Value =
|
||||
serde_json::from_str(&fs::read_to_string(&auto_path)?)?;
|
||||
assert_eq!(
|
||||
restricted_disk["schema_version"],
|
||||
DENY_RULES_AUTOMATION_SCHEMA_VERSION
|
||||
);
|
||||
assert_eq!(
|
||||
restricted_disk["disallowed_tools"],
|
||||
serde_json::json!(["shell", "mcp_secret_*"])
|
||||
);
|
||||
assert_eq!(
|
||||
manager.get_automation(&restricted.id)?.schema_version,
|
||||
DENY_RULES_AUTOMATION_SCHEMA_VERSION
|
||||
);
|
||||
|
||||
let mut restricted_run = new_run_record(&restricted.id, Utc::now(), Utc::now());
|
||||
bind_run_dispatch(&mut restricted_run, &restricted, tempdir.path(), true)?;
|
||||
assert_eq!(restricted_run.schema_version, DENY_RULES_RUN_SCHEMA_VERSION);
|
||||
manager.save_run(&restricted_run)?;
|
||||
let run_path = manager.run_path(&restricted_run)?;
|
||||
let mut run_disk: serde_json::Value =
|
||||
serde_json::from_str(&fs::read_to_string(&run_path)?)?;
|
||||
assert_eq!(run_disk["schema_version"], DENY_RULES_RUN_SCHEMA_VERSION);
|
||||
assert_eq!(
|
||||
run_disk["dispatch"]["request"]["disallowed_tools"],
|
||||
serde_json::json!(["shell", "mcp_secret_*"])
|
||||
);
|
||||
assert_eq!(manager.list_runs(&restricted.id, None)?.len(), 1);
|
||||
|
||||
run_disk["schema_version"] = serde_json::json!(MAX_SUPPORTED_RUN_SCHEMA_VERSION + 1);
|
||||
fs::write(&run_path, serde_json::to_string_pretty(&run_disk)?)?;
|
||||
assert!(
|
||||
manager
|
||||
.list_runs(&restricted.id, None)
|
||||
.unwrap_err()
|
||||
.to_string()
|
||||
.contains("newer than supported")
|
||||
);
|
||||
|
||||
restricted_disk["schema_version"] =
|
||||
serde_json::json!(MAX_SUPPORTED_AUTOMATION_SCHEMA_VERSION + 1);
|
||||
fs::write(&auto_path, serde_json::to_string_pretty(&restricted_disk)?)?;
|
||||
assert!(
|
||||
manager
|
||||
.get_automation(&restricted.id)
|
||||
.unwrap_err()
|
||||
.to_string()
|
||||
.contains("newer than supported")
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn denied_delayed_trigger_uses_v4_and_preserves_deny_rules_when_fired() -> Result<()> {
|
||||
let tempdir = tempfile::tempdir()?;
|
||||
let task_dir = tempdir.path().join("tasks");
|
||||
let task_manager = TaskManager::start_with_executor(
|
||||
automation_task_config(task_dir.clone()),
|
||||
std::sync::Arc::new(AutomationNoopExecutor),
|
||||
)
|
||||
.await?;
|
||||
let manager = AutomationManager::open_for_test(tempdir.path().join("automations"))?;
|
||||
|
||||
let open = manager.create_trigger(CreateDelayedTriggerRequest {
|
||||
fire_at: Utc::now() + Duration::hours(1),
|
||||
message: "open later".to_string(),
|
||||
workspace: None,
|
||||
owner_session_id: Some("session-open".to_string()),
|
||||
parent_trigger_id: None,
|
||||
destination_thread_id: None,
|
||||
disallowed_tools: vec![" ".to_string()],
|
||||
})?;
|
||||
assert_eq!(open.schema_version, CURRENT_TRIGGER_SCHEMA_VERSION);
|
||||
assert!(open.disallowed_tools.is_empty());
|
||||
let open_disk: serde_json::Value = serde_json::from_str(&fs::read_to_string(
|
||||
manager.trigger_path(&open.trigger_id)?,
|
||||
)?)?;
|
||||
assert_eq!(open_disk["schema_version"], CURRENT_TRIGGER_SCHEMA_VERSION);
|
||||
assert!(open_disk.get("disallowed_tools").is_none());
|
||||
|
||||
let mut restricted = manager.create_trigger(CreateDelayedTriggerRequest {
|
||||
fire_at: Utc::now() + Duration::hours(1),
|
||||
message: "restricted later".to_string(),
|
||||
workspace: Some(tempdir.path().to_path_buf()),
|
||||
owner_session_id: Some("session-deny".to_string()),
|
||||
parent_trigger_id: None,
|
||||
destination_thread_id: None,
|
||||
disallowed_tools: vec!["shell".to_string(), " mcp_secret_* ".to_string()],
|
||||
})?;
|
||||
assert_eq!(restricted.schema_version, DENY_RULES_TRIGGER_SCHEMA_VERSION);
|
||||
assert_eq!(restricted.disallowed_tools, vec!["shell", "mcp_secret_*"]);
|
||||
restricted.fire_at = Utc::now() - Duration::minutes(1);
|
||||
manager.save_trigger(&restricted)?;
|
||||
|
||||
let trigger_path = manager.trigger_path(&restricted.trigger_id)?;
|
||||
let initial_disk: serde_json::Value =
|
||||
serde_json::from_str(&fs::read_to_string(&trigger_path)?)?;
|
||||
assert_eq!(
|
||||
initial_disk["schema_version"],
|
||||
DENY_RULES_TRIGGER_SCHEMA_VERSION
|
||||
);
|
||||
assert_eq!(
|
||||
initial_disk["disallowed_tools"],
|
||||
serde_json::json!(["shell", "mcp_secret_*"])
|
||||
);
|
||||
|
||||
let shared = Arc::new(Mutex::new(manager));
|
||||
fire_due_triggers_with(&shared, &task_dir, |claimed| async move {
|
||||
assert_eq!(claimed.status, DelayedTriggerStatus::Dispatching);
|
||||
assert_eq!(claimed.schema_version, DENY_RULES_TRIGGER_SCHEMA_VERSION);
|
||||
assert_eq!(
|
||||
claimed
|
||||
.dispatch
|
||||
.as_ref()
|
||||
.expect("dispatch")
|
||||
.request
|
||||
.disallowed_tools,
|
||||
vec!["shell", "mcp_secret_*"]
|
||||
);
|
||||
anyhow::bail!("pause at dispatching boundary")
|
||||
})
|
||||
.await?;
|
||||
|
||||
let dispatching_disk: serde_json::Value =
|
||||
serde_json::from_str(&fs::read_to_string(&trigger_path)?)?;
|
||||
assert_eq!(dispatching_disk["status"], "dispatching");
|
||||
assert_eq!(
|
||||
dispatching_disk["schema_version"],
|
||||
DENY_RULES_TRIGGER_SCHEMA_VERSION
|
||||
);
|
||||
assert_eq!(
|
||||
dispatching_disk["dispatch"]["request"]["disallowed_tools"],
|
||||
serde_json::json!(["shell", "mcp_secret_*"])
|
||||
);
|
||||
|
||||
fire_due_triggers_shared(&shared, &task_manager).await?;
|
||||
|
||||
let manager = shared.lock().await;
|
||||
let fired = manager.get_trigger(&restricted.trigger_id)?;
|
||||
assert_eq!(fired.status, DelayedTriggerStatus::Fired);
|
||||
assert_eq!(fired.schema_version, DENY_RULES_TRIGGER_SCHEMA_VERSION);
|
||||
assert_eq!(fired.disallowed_tools, vec!["shell", "mcp_secret_*"]);
|
||||
assert_eq!(
|
||||
fired
|
||||
.dispatch
|
||||
.as_ref()
|
||||
.expect("dispatch")
|
||||
.request
|
||||
.disallowed_tools,
|
||||
vec!["shell", "mcp_secret_*"]
|
||||
);
|
||||
|
||||
let task = task_manager
|
||||
.get_task(fired.task_id.as_deref().expect("fired task id"))
|
||||
.await?;
|
||||
assert_eq!(task.schema_version, 5);
|
||||
assert_eq!(task.disallowed_tools, vec!["shell", "mcp_secret_*"]);
|
||||
|
||||
let mut future_disk: serde_json::Value =
|
||||
serde_json::from_str(&fs::read_to_string(&trigger_path)?)?;
|
||||
future_disk["schema_version"] = serde_json::json!(MAX_SUPPORTED_TRIGGER_SCHEMA_VERSION + 1);
|
||||
fs::write(&trigger_path, serde_json::to_string_pretty(&future_disk)?)?;
|
||||
assert!(
|
||||
manager
|
||||
.get_trigger(&restricted.trigger_id)
|
||||
.unwrap_err()
|
||||
.to_string()
|
||||
.contains("newer than supported")
|
||||
);
|
||||
let listed = manager.list_triggers(None, None)?;
|
||||
assert_eq!(listed.len(), 1);
|
||||
assert_eq!(listed[0].trigger_id, open.trigger_id);
|
||||
|
||||
drop(manager);
|
||||
task_manager.shutdown();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn write_legacy_run_file(manager: &AutomationManager, run: &AutomationRunRecord) {
|
||||
let dir = manager.runs_dir_for(&run.automation_id).expect("runs dir");
|
||||
fs::create_dir_all(&dir).expect("create runs dir");
|
||||
@@ -5992,6 +6402,7 @@ model = "private-model"
|
||||
auto_approve: None,
|
||||
delivery_mode: None,
|
||||
status: Some(AutomationStatus::Active),
|
||||
disallowed_tools: Vec::new(),
|
||||
})
|
||||
.expect("create");
|
||||
let shared: SharedAutomationManager = Arc::new(Mutex::new(manager));
|
||||
@@ -6226,4 +6637,176 @@ model = "private-model"
|
||||
assert_eq!(run.status, AutomationRunStatus::Canceled);
|
||||
assert_eq!(run.error, None);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn trigger_wait_duration_clamps_and_cancel_wakes_notifier() -> Result<()> {
|
||||
let tempdir = tempfile::tempdir()?;
|
||||
let mut manager = AutomationManager::open_for_test(tempdir.path().to_path_buf())?;
|
||||
let max_wait = std::time::Duration::from_secs(60);
|
||||
let now = Utc::now();
|
||||
assert_eq!(manager.trigger_wait_duration(now, max_wait)?, max_wait);
|
||||
|
||||
let far = manager.create_trigger(CreateDelayedTriggerRequest {
|
||||
destination_thread_id: None,
|
||||
fire_at: now + Duration::seconds(120),
|
||||
message: "far".to_string(),
|
||||
workspace: None,
|
||||
owner_session_id: Some("session-a".to_string()),
|
||||
parent_trigger_id: None,
|
||||
disallowed_tools: Vec::new(),
|
||||
})?;
|
||||
assert_eq!(manager.trigger_wait_duration(now, max_wait)?, max_wait);
|
||||
|
||||
let wakeup = manager.trigger_wakeup();
|
||||
assert!(
|
||||
tokio::time::timeout(std::time::Duration::from_millis(100), wakeup.notified())
|
||||
.await
|
||||
.is_ok()
|
||||
);
|
||||
|
||||
let subminute = manager.create_trigger(CreateDelayedTriggerRequest {
|
||||
destination_thread_id: None,
|
||||
fire_at: now + Duration::seconds(2),
|
||||
message: "subminute".to_string(),
|
||||
workspace: None,
|
||||
owner_session_id: Some("session-a".to_string()),
|
||||
parent_trigger_id: None,
|
||||
disallowed_tools: Vec::new(),
|
||||
})?;
|
||||
assert!(
|
||||
tokio::time::timeout(std::time::Duration::from_millis(100), wakeup.notified())
|
||||
.await
|
||||
.is_ok()
|
||||
);
|
||||
let sub_wait = manager.trigger_wait_duration(now, max_wait)?;
|
||||
assert!(sub_wait <= std::time::Duration::from_secs(2));
|
||||
assert!(sub_wait > std::time::Duration::ZERO);
|
||||
|
||||
let mut past = manager.create_trigger(CreateDelayedTriggerRequest {
|
||||
destination_thread_id: None,
|
||||
fire_at: now + Duration::seconds(30),
|
||||
message: "past".to_string(),
|
||||
workspace: None,
|
||||
owner_session_id: Some("session-a".to_string()),
|
||||
parent_trigger_id: None,
|
||||
disallowed_tools: Vec::new(),
|
||||
})?;
|
||||
assert!(
|
||||
tokio::time::timeout(std::time::Duration::from_millis(100), wakeup.notified())
|
||||
.await
|
||||
.is_ok()
|
||||
);
|
||||
past.fire_at = now - Duration::seconds(5);
|
||||
manager.save_trigger(&past)?;
|
||||
assert_eq!(
|
||||
manager.trigger_wait_duration(now, max_wait)?,
|
||||
std::time::Duration::ZERO
|
||||
);
|
||||
manager.cancel_trigger(&past.trigger_id)?;
|
||||
assert!(
|
||||
tokio::time::timeout(std::time::Duration::from_millis(100), wakeup.notified())
|
||||
.await
|
||||
.is_ok()
|
||||
);
|
||||
|
||||
let notified = tokio::spawn({
|
||||
let wakeup = Arc::clone(&wakeup);
|
||||
async move { wakeup.notified().await }
|
||||
});
|
||||
tokio::task::yield_now().await;
|
||||
manager.cancel_trigger(&subminute.trigger_id)?;
|
||||
assert!(
|
||||
tokio::time::timeout(std::time::Duration::from_millis(100), notified)
|
||||
.await
|
||||
.is_ok()
|
||||
);
|
||||
assert_eq!(manager.trigger_wait_duration(now, max_wait)?, max_wait);
|
||||
|
||||
let mut ownerless = manager.create_trigger(CreateDelayedTriggerRequest {
|
||||
destination_thread_id: None,
|
||||
fire_at: now + Duration::seconds(1),
|
||||
message: "ownerless".to_string(),
|
||||
workspace: None,
|
||||
owner_session_id: None,
|
||||
parent_trigger_id: None,
|
||||
disallowed_tools: Vec::new(),
|
||||
})?;
|
||||
ownerless.fire_at = now - Duration::seconds(1);
|
||||
manager.save_trigger(&ownerless)?;
|
||||
manager.execution_scope = Some("other-scope".to_string());
|
||||
let mut foreign = manager.create_trigger(CreateDelayedTriggerRequest {
|
||||
destination_thread_id: None,
|
||||
fire_at: now + Duration::seconds(1),
|
||||
message: "foreign".to_string(),
|
||||
workspace: None,
|
||||
owner_session_id: Some("session-b".to_string()),
|
||||
parent_trigger_id: None,
|
||||
disallowed_tools: Vec::new(),
|
||||
})?;
|
||||
foreign.fire_at = now - Duration::seconds(1);
|
||||
manager.save_trigger(&foreign)?;
|
||||
manager.execution_scope = far.execution_scope.clone();
|
||||
assert_eq!(manager.trigger_wait_duration(now, max_wait)?, max_wait);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn scheduler_wakes_within_one_second_for_subminute_trigger() -> Result<()> {
|
||||
let tempdir = tempfile::tempdir()?;
|
||||
let task_manager = TaskManager::start_with_executor(
|
||||
automation_task_config(tempdir.path().join("tasks")),
|
||||
std::sync::Arc::new(AutomationNoopExecutor),
|
||||
)
|
||||
.await?;
|
||||
let mut manager = AutomationManager::open(tempdir.path().join("automations"))?;
|
||||
manager.bind_task_manager(&task_manager)?;
|
||||
let shared = Arc::new(Mutex::new(manager));
|
||||
let cancel = CancellationToken::new();
|
||||
let handle = spawn_scheduler(
|
||||
Arc::clone(&shared),
|
||||
Arc::clone(&task_manager),
|
||||
cancel.clone(),
|
||||
AutomationSchedulerConfig {
|
||||
tick_interval_secs: 60,
|
||||
},
|
||||
);
|
||||
|
||||
tokio::time::sleep(std::time::Duration::from_millis(50)).await;
|
||||
let start = std::time::Instant::now();
|
||||
let trigger = {
|
||||
let guard = shared.lock().await;
|
||||
guard.create_trigger(CreateDelayedTriggerRequest {
|
||||
destination_thread_id: None,
|
||||
fire_at: Utc::now() + Duration::milliseconds(60),
|
||||
message: "sub-second notify wakeup".to_string(),
|
||||
workspace: None,
|
||||
owner_session_id: Some("session-notify".to_string()),
|
||||
parent_trigger_id: None,
|
||||
disallowed_tools: Vec::new(),
|
||||
})?
|
||||
};
|
||||
|
||||
let mut fired = None;
|
||||
for _ in 0..95 {
|
||||
tokio::time::sleep(std::time::Duration::from_millis(10)).await;
|
||||
let current = shared.lock().await.get_trigger(&trigger.trigger_id)?;
|
||||
if current.status == DelayedTriggerStatus::Fired {
|
||||
fired = Some(current);
|
||||
break;
|
||||
}
|
||||
}
|
||||
let elapsed = start.elapsed();
|
||||
|
||||
cancel.cancel();
|
||||
let _ = tokio::time::timeout(std::time::Duration::from_secs(2), handle).await;
|
||||
task_manager.shutdown();
|
||||
|
||||
let fired = fired.expect("sub-minute trigger must fire before 1s via Notify");
|
||||
assert!(
|
||||
elapsed < std::time::Duration::from_secs(1),
|
||||
"expected wakeup and fire under 1s, took {elapsed:?}"
|
||||
);
|
||||
assert!(fired.task_id.is_some());
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -105,6 +105,7 @@ async fn foreign_scoped_automation_and_unbound_trigger_never_dispatch_through_cu
|
||||
workspace: Some(root.path().to_path_buf()),
|
||||
owner_session_id: None,
|
||||
parent_trigger_id: None,
|
||||
disallowed_tools: Vec::new(),
|
||||
})?;
|
||||
trigger.execution_scope = None;
|
||||
trigger.fire_at = Utc::now() - Duration::seconds(1);
|
||||
|
||||
@@ -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()
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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()))
|
||||
);
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
@@ -18,7 +18,7 @@ const MAX_COMMAND_BYTES: usize = 16 * 1024;
|
||||
const MAX_INPUT_BYTES: usize = 64 * 1024;
|
||||
const MAX_TIMEOUT_SECS: u64 = 600;
|
||||
const MAX_SETTLE_MS: u64 = 60_000;
|
||||
pub(super) const MAX_TERMINALS: usize = 8;
|
||||
pub(crate) const MAX_TERMINALS: usize = 8;
|
||||
const LOG: &str = "codewhale::dsh_terminal";
|
||||
|
||||
static REGISTRY_GATE: tokio::sync::Semaphore = tokio::sync::Semaphore::const_new(4);
|
||||
@@ -224,13 +224,16 @@ fn refused(message: &str) -> BrokerRefusal {
|
||||
}
|
||||
}
|
||||
|
||||
fn session_name(name: &str) -> Result<&str, BrokerRefusal> {
|
||||
let ok = !name.is_empty()
|
||||
pub(crate) fn is_valid_session_name(name: &str) -> bool {
|
||||
!name.is_empty()
|
||||
&& name.len() <= MAX_SESSION_NAME
|
||||
&& name
|
||||
.bytes()
|
||||
.all(|b| b.is_ascii_alphanumeric() || matches!(b, b'_' | b'-' | b'.'));
|
||||
if ok {
|
||||
.all(|b| b.is_ascii_alphanumeric() || matches!(b, b'_' | b'-' | b'.'))
|
||||
}
|
||||
|
||||
fn session_name(name: &str) -> Result<&str, BrokerRefusal> {
|
||||
if is_valid_session_name(name) {
|
||||
Ok(name)
|
||||
} else {
|
||||
Err(invalid(format!(
|
||||
@@ -239,6 +242,13 @@ fn session_name(name: &str) -> Result<&str, BrokerRefusal> {
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(all(unix, not(target_env = "ohos")))]
|
||||
pub(crate) fn session_parts(full_name: &str) -> Option<(&str, &str)> {
|
||||
let rest = full_name.strip_prefix("dsh:")?;
|
||||
let (plugin_id, session) = rest.rsplit_once(':')?;
|
||||
(!plugin_id.is_empty() && is_valid_session_name(session)).then_some((plugin_id, session))
|
||||
}
|
||||
|
||||
fn timeout(secs: Option<u64>) -> Result<Option<u64>, BrokerRefusal> {
|
||||
match secs {
|
||||
Some(secs) if !(1..=MAX_TIMEOUT_SECS).contains(&secs) => Err(invalid(format!(
|
||||
@@ -248,10 +258,21 @@ fn timeout(secs: Option<u64>) -> Result<Option<u64>, BrokerRefusal> {
|
||||
}
|
||||
}
|
||||
|
||||
fn prefix(plugin: &str) -> String {
|
||||
pub(crate) fn prefix(plugin: &str) -> String {
|
||||
format!("dsh:{plugin}:")
|
||||
}
|
||||
|
||||
#[cfg(all(unix, not(target_env = "ohos")))]
|
||||
pub(super) fn kill_plugin_sessions(plugin_id: &str) {
|
||||
let prefix = prefix(plugin_id);
|
||||
if let Err(error) = crate::tools::terminal_session::kill_sessions_named(&prefix) {
|
||||
tracing::warn!(target: LOG, plugin = %plugin_id, %error, "failed to end plugin terminal sessions");
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(all(unix, not(target_env = "ohos"))))]
|
||||
pub(super) fn kill_plugin_sessions(_plugin_id: &str) {}
|
||||
|
||||
fn owned_name(call: &BrokerCall, session: &str) -> String {
|
||||
format!("{}{session}", prefix(&call.owner.plugin_id))
|
||||
}
|
||||
|
||||
@@ -7,14 +7,17 @@ use serde_json::{Value, json};
|
||||
use super::dsh_broker::{BrokerCall, BrokerFuture, BrokerMethods, BrokerRefusal, decode};
|
||||
use super::protocol::DshService;
|
||||
use crate::tools::execution_envelope::enforce_tool_denial;
|
||||
use crate::tools::spec::{ToolContext, resolve_strict_authority_path};
|
||||
use crate::tools::spec::{
|
||||
ToolContext, has_forbidden_windows_colon, has_windows_drive_or_unc_prefix,
|
||||
is_protected_workspace_metadata_segment, resolve_strict_authority_path,
|
||||
strip_workspace_root_str, validate_windows_relative_segments,
|
||||
};
|
||||
|
||||
const MAX_PATH_BYTES: usize = 512;
|
||||
const MAX_WRITE_BYTES: usize = 1024 * 1024;
|
||||
const MAX_READ_LINES: u64 = 10_000;
|
||||
const MAX_ARGS: usize = 8;
|
||||
const MAX_ARG_BYTES: usize = 1024;
|
||||
const PROTECTED: &[&str] = &[".git", ".codewhale"];
|
||||
const LOG: &str = "codewhale::dsh_workspace";
|
||||
|
||||
static CONFINE_GATE: tokio::sync::Semaphore = tokio::sync::Semaphore::const_new(4);
|
||||
@@ -96,7 +99,7 @@ pub(super) fn serve(call: BrokerCall) -> BrokerFuture {
|
||||
"max_lines is between 1 and {MAX_READ_LINES}"
|
||||
)));
|
||||
}
|
||||
let tool = caller(&call, "read_file")?;
|
||||
let tool = caller(&call, "read_file", &json!({}))?;
|
||||
let (shown, resolved) = confine(&call, &tool, &path, Access::Read).await?;
|
||||
let mut input = json!({ "path": resolved });
|
||||
if let Some(start_line) = start_line {
|
||||
@@ -110,7 +113,7 @@ pub(super) fn serve(call: BrokerCall) -> BrokerFuture {
|
||||
.map(|response| file_outcome(&shown, response))
|
||||
}
|
||||
WorkspaceMethod::List(ListInput { path }) => {
|
||||
let tool = caller(&call, "list_dir")?;
|
||||
let tool = caller(&call, "list_dir", &json!({}))?;
|
||||
let raw = path.as_deref().unwrap_or(".");
|
||||
let (shown, resolved) = confine(&call, &tool, raw, Access::Read).await?;
|
||||
nested(&call, "list_dir", json!({ "path": resolved }))
|
||||
@@ -137,7 +140,7 @@ pub(super) fn serve(call: BrokerCall) -> BrokerFuture {
|
||||
tracing::info!(target: LOG, plugin = %call.owner.plugin_id, "workspace write refused: no reviewed overlay grant");
|
||||
return Err(refused("this plugin may not write to the workspace"));
|
||||
}
|
||||
let tool = caller(&call, "write_file")?;
|
||||
let tool = caller(&call, "write_file", &json!({}))?;
|
||||
let (shown, resolved) = confine(&call, &tool, &path, Access::Overlay).await?;
|
||||
let mut input = json!({ "path": resolved, "content": content });
|
||||
if let Some(expected_hash) = expected_hash {
|
||||
@@ -148,8 +151,9 @@ pub(super) fn serve(call: BrokerCall) -> BrokerFuture {
|
||||
.map(|response| file_outcome(&shown, response))
|
||||
}
|
||||
WorkspaceMethod::Exec(ExecInput { argv, cwd }) => {
|
||||
let query = literal_git(&argv)?;
|
||||
let tool = caller(&call, "Git")?;
|
||||
let mut input = literal_git(&argv)?;
|
||||
input["action"] = json!("review_scope");
|
||||
let tool = caller(&call, "Git", &input)?;
|
||||
if let Some(cwd) = cwd.as_deref() {
|
||||
let (shown, _) = confine(&call, &tool, cwd, Access::Read).await?;
|
||||
if shown != "." {
|
||||
@@ -157,8 +161,6 @@ pub(super) fn serve(call: BrokerCall) -> BrokerFuture {
|
||||
return Err(refused("git runs only at the workspace root"));
|
||||
}
|
||||
}
|
||||
let mut input = query;
|
||||
input["action"] = json!("review_scope");
|
||||
nested(&call, "Git", input).await.map(exec_outcome)
|
||||
}
|
||||
}
|
||||
@@ -212,10 +214,14 @@ fn overlay_granted() -> bool {
|
||||
}
|
||||
}
|
||||
|
||||
fn caller(call: &BrokerCall, tool_name: &'static str) -> Result<ToolContext, BrokerRefusal> {
|
||||
fn caller(
|
||||
call: &BrokerCall,
|
||||
tool_name: &'static str,
|
||||
input: &Value,
|
||||
) -> Result<ToolContext, BrokerRefusal> {
|
||||
let mut tool = call.context.tool().clone();
|
||||
let _ = tool.refresh_live_posture();
|
||||
if enforce_tool_denial(&tool, tool_name, &json!({})).is_err() {
|
||||
if enforce_tool_denial(&tool, tool_name, input).is_err() {
|
||||
tracing::info!(target: LOG, plugin = %call.owner.plugin_id, tool = tool_name, "workspace refused: the caller may not use this tool");
|
||||
return Err(refused(
|
||||
"this workspace call is not available to this caller",
|
||||
@@ -224,23 +230,17 @@ fn caller(call: &BrokerCall, tool_name: &'static str) -> Result<ToolContext, Bro
|
||||
Ok(tool)
|
||||
}
|
||||
|
||||
fn relative(workspace: &Path, raw: &str) -> Option<String> {
|
||||
fn relative_for(workspace: &Path, canonical: &Path, raw: &str, windows: bool) -> Option<String> {
|
||||
let path = Path::new(raw);
|
||||
if !path.is_absolute() {
|
||||
let is_absolute = path.is_absolute()
|
||||
|| raw.starts_with('/')
|
||||
|| (windows && (raw.starts_with('\\') || has_windows_drive_or_unc_prefix(raw)));
|
||||
if !is_absolute {
|
||||
return Some(raw.to_string());
|
||||
}
|
||||
let canonical = workspace.canonicalize().ok();
|
||||
let rest = path.strip_prefix(workspace).ok().or_else(|| {
|
||||
canonical
|
||||
.as_deref()
|
||||
.and_then(|root| path.strip_prefix(root).ok())
|
||||
})?;
|
||||
let rest = rest.to_str()?;
|
||||
Some(if rest.is_empty() {
|
||||
".".into()
|
||||
} else {
|
||||
rest.into()
|
||||
})
|
||||
let ws_str = workspace.to_str()?;
|
||||
let canonical_str = canonical.to_str();
|
||||
strip_workspace_root_str(ws_str, canonical_str, raw, windows)
|
||||
}
|
||||
|
||||
fn shape(raw: &str) -> Result<(), BrokerRefusal> {
|
||||
@@ -256,18 +256,37 @@ fn shape(raw: &str) -> Result<(), BrokerRefusal> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn protected(relative: &str) -> bool {
|
||||
relative.replace('\\', "/").split('/').any(|segment| {
|
||||
PROTECTED
|
||||
.iter()
|
||||
.any(|name| segment.eq_ignore_ascii_case(name))
|
||||
})
|
||||
fn protected_for(relative: &str, windows: bool) -> bool {
|
||||
relative
|
||||
.replace('\\', "/")
|
||||
.split('/')
|
||||
.any(|segment| is_protected_workspace_metadata_segment(segment, windows))
|
||||
}
|
||||
|
||||
fn resolve(tool: &ToolContext, raw: &str, access: Access) -> Result<(String, PathBuf), String> {
|
||||
let relative = relative(&tool.workspace, raw).ok_or_else(|| "outside".to_string())?;
|
||||
let resolved = resolve_strict_authority_path(tool, &relative).map_err(|e| e.to_string())?;
|
||||
resolve_for(tool, raw, access, cfg!(windows))
|
||||
}
|
||||
|
||||
fn resolve_for(
|
||||
tool: &ToolContext,
|
||||
raw: &str,
|
||||
access: Access,
|
||||
windows: bool,
|
||||
) -> Result<(String, PathBuf), String> {
|
||||
if has_forbidden_windows_colon(raw, windows) {
|
||||
return Err("windows colons and alternate data streams are not allowed".to_string());
|
||||
}
|
||||
let root = tool.workspace.canonicalize().map_err(|e| e.to_string())?;
|
||||
let relative =
|
||||
relative_for(&tool.workspace, &root, raw, windows).ok_or_else(|| "outside".to_string())?;
|
||||
validate_windows_relative_segments(&relative, windows)?;
|
||||
let resolved = resolve_strict_authority_path(tool, &relative).map_err(|e| e.to_string())?;
|
||||
#[cfg(windows)]
|
||||
let resolved = if resolved.exists() {
|
||||
resolved.canonicalize().map_err(|e| e.to_string())?
|
||||
} else {
|
||||
resolved
|
||||
};
|
||||
let shown = resolved
|
||||
.strip_prefix(&root)
|
||||
.map_err(|_| "outside".to_string())?
|
||||
@@ -275,7 +294,8 @@ fn resolve(tool: &ToolContext, raw: &str, access: Access) -> Result<(String, Pat
|
||||
.ok_or_else(|| "not UTF-8".to_string())?
|
||||
.replace('\\', "/");
|
||||
let shown = if shown.is_empty() { ".".into() } else { shown };
|
||||
if access == Access::Overlay && (shown == "." || protected(&shown)) {
|
||||
validate_windows_relative_segments(&shown, windows)?;
|
||||
if access == Access::Overlay && (shown == "." || protected_for(&shown, windows)) {
|
||||
return Err(format!("overlay excludes {shown}"));
|
||||
}
|
||||
Ok((shown, resolved))
|
||||
@@ -685,6 +705,91 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn windows_metadata_aliases_short_names_and_ads_are_refused_in_validation() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let root = dir.path().canonicalize().unwrap();
|
||||
std::fs::create_dir_all(root.join("src")).unwrap();
|
||||
std::fs::write(root.join("src/a.txt"), "a").unwrap();
|
||||
let tool = ToolContext::new(&root);
|
||||
|
||||
for alias in [
|
||||
"GIT~1",
|
||||
"GIT~1/hooks/pre-commit",
|
||||
"git~12/config",
|
||||
"sub/GIT~2/HEAD",
|
||||
"CODEWH~1",
|
||||
"CODEWH~1/config.json",
|
||||
"codew~1/auth.json",
|
||||
"code~1/sessions",
|
||||
"SUBDIR~1/file.txt",
|
||||
".git::$INDEX_ALLOCATION",
|
||||
".GIT::$INDEX_ALLOCATION",
|
||||
"sub/.git:stream",
|
||||
"sub/.codewhale:secret",
|
||||
"file.txt:Zone.Identifier",
|
||||
"C:rel.txt",
|
||||
".git.",
|
||||
".git /config",
|
||||
".codewhale.",
|
||||
"sub/.codewhale /x",
|
||||
] {
|
||||
assert!(
|
||||
resolve_for(&tool, alias, Access::Read, true).is_err(),
|
||||
"read must reject {alias}"
|
||||
);
|
||||
assert!(
|
||||
resolve_for(&tool, alias, Access::Overlay, true).is_err(),
|
||||
"overlay must reject {alias}"
|
||||
);
|
||||
}
|
||||
|
||||
for metadata in [
|
||||
"GIT~1/hooks/pre-commit",
|
||||
"git~1",
|
||||
"CODEWH~1/config.json",
|
||||
"codew~2",
|
||||
".git::$INDEX_ALLOCATION",
|
||||
"sub/.git:stream",
|
||||
"sub/.codewhale:secret",
|
||||
".git.",
|
||||
".git ",
|
||||
".codewhale.",
|
||||
".codewhale ",
|
||||
] {
|
||||
assert!(
|
||||
protected_for(metadata, true),
|
||||
"protected_for must catch {metadata}"
|
||||
);
|
||||
}
|
||||
|
||||
assert_eq!(
|
||||
resolve_for(&tool, "src/a.txt", Access::Overlay, true)
|
||||
.unwrap()
|
||||
.0,
|
||||
"src/a.txt"
|
||||
);
|
||||
assert!(has_forbidden_windows_colon(".git::$INDEX_ALLOCATION", true));
|
||||
assert!(has_forbidden_windows_colon("sub/.git:stream", true));
|
||||
assert!(has_forbidden_windows_colon("C:rel.txt", true));
|
||||
assert!(!has_forbidden_windows_colon(r"C:\ws\src\a.txt", true));
|
||||
assert!(!has_forbidden_windows_colon(r"\\?\C:\ws\src\a.txt", true));
|
||||
|
||||
assert_eq!(
|
||||
strip_workspace_root_str(r"C:\ws", Some(r"\\?\C:\ws"), r"\\?\C:\ws\src\a.txt", true)
|
||||
.as_deref(),
|
||||
Some("src/a.txt")
|
||||
);
|
||||
assert_eq!(
|
||||
strip_workspace_root_str(r"C:\ws", None, r"c:/ws/src/a.txt", true).as_deref(),
|
||||
Some("src/a.txt")
|
||||
);
|
||||
assert!(
|
||||
has_forbidden_windows_colon(r"C:\ws\.git::$INDEX_ALLOCATION", true),
|
||||
"drive prefix must not exempt trailing colons"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn results_are_typed_projections() {
|
||||
let read = file_outcome(
|
||||
|
||||
@@ -128,11 +128,13 @@ mod dsh_llm;
|
||||
mod dsh_network;
|
||||
mod dsh_plugins;
|
||||
mod dsh_remote;
|
||||
pub use dsh_remote::{RemoteClient, RemoteDenial, RemoteGrants, RemotePairing, remote_grants};
|
||||
pub(crate) use dsh_remote::end_remote_session;
|
||||
pub use dsh_remote::{RemoteClient, RemoteDenial, RemotePairing, RemoteSecret, RemoteState};
|
||||
mod dsh_settings;
|
||||
mod dsh_terminal;
|
||||
pub(crate) mod dsh_terminal;
|
||||
mod dsh_workspace;
|
||||
pub mod execution;
|
||||
pub use dsh_network::forget_plugin_credentials;
|
||||
pub use execution::StockOperation;
|
||||
pub mod avatars;
|
||||
mod hooks;
|
||||
@@ -772,6 +774,14 @@ impl ManagerShared {
|
||||
.record_dirty_teardown(Instant::now(), &self.options.supervision);
|
||||
}
|
||||
|
||||
fn revoke_owner_state(&self, plugin_id: &str) {
|
||||
self.core_calls.revoke_owner(plugin_id);
|
||||
self.execution_broker.revoke_owner(plugin_id);
|
||||
self.mcp_users
|
||||
.fetch_sub(self.mcp_broker.revoke_owner(plugin_id), Ordering::SeqCst);
|
||||
dsh_terminal::kill_plugin_sessions(plugin_id);
|
||||
}
|
||||
|
||||
fn diagnostic(&self, message: String) {
|
||||
self.record_diagnostic(None, message);
|
||||
}
|
||||
@@ -1202,14 +1212,8 @@ impl HostEvents for Events {
|
||||
host.revoke_calls_of(¶ms.owner.plugin_id);
|
||||
}
|
||||
drop(slot);
|
||||
shared.core_calls.revoke_owner(¶ms.owner.plugin_id);
|
||||
shared
|
||||
.execution_broker
|
||||
.revoke_owner(¶ms.owner.plugin_id);
|
||||
shared.mcp_users.fetch_sub(
|
||||
shared.mcp_broker.revoke_owner(¶ms.owner.plugin_id),
|
||||
Ordering::SeqCst,
|
||||
);
|
||||
shared.revoke_owner_state(¶ms.owner.plugin_id);
|
||||
dsh_remote::withdraw_owner(¶ms.owner);
|
||||
shared.plugin_diagnostic(
|
||||
¶ms.owner.plugin_id,
|
||||
format!(
|
||||
@@ -1974,6 +1978,9 @@ impl ExtensionHostManager {
|
||||
desired.revision,
|
||||
)));
|
||||
}
|
||||
if !installed.is_empty() && tool_registry.get(dsh_network::SEND_TOOL).is_none() {
|
||||
tool_registry.register(Arc::new(dsh_network::SendConsent));
|
||||
}
|
||||
installed
|
||||
}
|
||||
|
||||
@@ -2204,12 +2211,8 @@ impl ExtensionHostManager {
|
||||
}
|
||||
let host = shared.ready_host(HostTier::Plugin).ok();
|
||||
for owner in revoked {
|
||||
shared.core_calls.revoke_owner(&owner.plugin_id);
|
||||
shared.execution_broker.revoke_owner(&owner.plugin_id);
|
||||
shared.mcp_users.fetch_sub(
|
||||
shared.mcp_broker.revoke_owner(&owner.plugin_id),
|
||||
Ordering::SeqCst,
|
||||
);
|
||||
shared.revoke_owner_state(&owner.plugin_id);
|
||||
dsh_remote::withdraw_owner(&owner);
|
||||
shared.plugin_diagnostic(
|
||||
&owner.plugin_id,
|
||||
format!("extension `{}` revoked", owner.plugin_id),
|
||||
@@ -2334,12 +2337,7 @@ impl ExtensionHostManager {
|
||||
.owner(owner_id)
|
||||
.is_some_and(|entry| entry.state != OwnerState::Active);
|
||||
if stale {
|
||||
self.shared.core_calls.revoke_owner(owner_id);
|
||||
self.shared.execution_broker.revoke_owner(owner_id);
|
||||
self.shared.mcp_users.fetch_sub(
|
||||
self.shared.mcp_broker.revoke_owner(owner_id),
|
||||
Ordering::SeqCst,
|
||||
);
|
||||
self.shared.revoke_owner_state(owner_id);
|
||||
self.shared
|
||||
.registry
|
||||
.lock()
|
||||
@@ -3515,6 +3513,7 @@ impl HostAttachment {
|
||||
}
|
||||
|
||||
pub fn set_identity(&self, session_id: Option<String>, agent_id: Option<String>) {
|
||||
let mut left = None;
|
||||
if let Some(state) = self
|
||||
.manager
|
||||
.shared
|
||||
@@ -3523,9 +3522,15 @@ impl HostAttachment {
|
||||
.expect("attachments lock")
|
||||
.get_mut(&self.id)
|
||||
{
|
||||
if state.session_id != session_id {
|
||||
left = state.session_id.take();
|
||||
}
|
||||
state.session_id = session_id;
|
||||
state.agent_id = agent_id;
|
||||
}
|
||||
if let Some(left) = left {
|
||||
dsh_remote::end_remote_session(&left);
|
||||
}
|
||||
}
|
||||
pub fn plugin_view(&self) -> Arc<PluginRegistry> {
|
||||
self.manager
|
||||
|
||||
@@ -3,46 +3,145 @@ use codewhale_config::ProjectHookAuthority;
|
||||
use sha2::{Digest, Sha256};
|
||||
use std::path::Path;
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
pub(crate) struct ProjectFileKind {
|
||||
label: &'static str,
|
||||
file_name: &'static str,
|
||||
receipt_key: &'static str,
|
||||
review_hint: &'static str,
|
||||
}
|
||||
|
||||
pub(crate) const HOOKS_FILE: ProjectFileKind = ProjectFileKind {
|
||||
label: "hooks",
|
||||
file_name: "hooks.toml",
|
||||
receipt_key: codewhale_runtime::config::HOOKS_RECEIPT_KEY,
|
||||
review_hint: "/hooks review, then /hooks approve <digest>",
|
||||
};
|
||||
|
||||
pub(crate) const MCP_FILE: ProjectFileKind = ProjectFileKind {
|
||||
label: "MCP servers",
|
||||
file_name: "mcp.json",
|
||||
receipt_key: codewhale_runtime::config::MCP_RECEIPT_KEY,
|
||||
review_hint: "/mcp review, then /mcp approve <digest>",
|
||||
};
|
||||
|
||||
pub struct ReviewedProjectFile {
|
||||
pub workspace: std::path::PathBuf,
|
||||
pub digest: String,
|
||||
pub contents: String,
|
||||
}
|
||||
|
||||
/// Read once for both the digest and parsing. Repository symlinks and special
|
||||
/// files are not executable configuration, even if the workspace is trusted.
|
||||
pub fn review_project_hooks(workspace: &Path) -> Result<(ProjectHookAuthority, String), String> {
|
||||
pub(crate) fn review_project_file(
|
||||
workspace: &Path,
|
||||
kind: ProjectFileKind,
|
||||
) -> Result<ReviewedProjectFile, String> {
|
||||
let label = kind.label;
|
||||
let workspace = workspace
|
||||
.canonicalize()
|
||||
.map_err(|_| "Cannot resolve hooks workspace")?;
|
||||
.map_err(|_| format!("Cannot resolve {label} workspace"))?;
|
||||
let mut path = workspace.clone();
|
||||
for component in [".codewhale", "hooks.toml"] {
|
||||
for component in [".codewhale", kind.file_name] {
|
||||
path.push(component);
|
||||
let metadata =
|
||||
std::fs::symlink_metadata(&path).map_err(|_| "Cannot read project hooks path")?;
|
||||
let metadata = std::fs::symlink_metadata(&path)
|
||||
.map_err(|_| format!("Cannot read project {label} path"))?;
|
||||
if metadata.file_type().is_symlink() {
|
||||
return Err("Project hooks path must not contain symlinks".into());
|
||||
return Err(format!("Project {label} path must not contain symlinks"));
|
||||
}
|
||||
if (component == ".codewhale" && !metadata.is_dir())
|
||||
|| (component == "hooks.toml" && !metadata.is_file())
|
||||
|| (component == kind.file_name && !metadata.is_file())
|
||||
{
|
||||
return Err("Project hooks must be a regular file in .codewhale".into());
|
||||
return Err(format!(
|
||||
"Project {label} must be a regular file in .codewhale"
|
||||
));
|
||||
}
|
||||
}
|
||||
let contents = super::config::read_project_hooks_file(&path)
|
||||
.map_err(|_| "Cannot read project hooks file (maximum 1 MiB)")?;
|
||||
.map_err(|_| format!("Cannot read project {label} file (maximum 1 MiB)"))?;
|
||||
let digest = Sha256::digest(contents.as_bytes())
|
||||
.iter()
|
||||
.map(|byte| format!("{byte:02x}"))
|
||||
.collect();
|
||||
Ok((ProjectHookAuthority { workspace, digest }, contents))
|
||||
Ok(ReviewedProjectFile {
|
||||
workspace,
|
||||
digest,
|
||||
contents,
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn approved_project_file(
|
||||
workspace: &Path,
|
||||
kind: ProjectFileKind,
|
||||
) -> Result<ReviewedProjectFile, String> {
|
||||
let label = kind.label;
|
||||
if !codewhale_runtime::config::is_workspace_trusted(workspace) {
|
||||
return Err(format!(
|
||||
"Project {label} require workspace trust and separate approval"
|
||||
));
|
||||
}
|
||||
let reviewed = review_project_file(workspace, kind)?;
|
||||
if codewhale_runtime::config::project_receipt_for_workspace(
|
||||
&reviewed.workspace,
|
||||
kind.receipt_key,
|
||||
)
|
||||
.as_deref()
|
||||
!= Some(&reviewed.digest)
|
||||
{
|
||||
return Err(format!(
|
||||
"Project {label} are unapproved or changed; use {}",
|
||||
kind.review_hint
|
||||
));
|
||||
}
|
||||
Ok(reviewed)
|
||||
}
|
||||
|
||||
pub(crate) fn approve_project_file(
|
||||
workspace: &Path,
|
||||
kind: ProjectFileKind,
|
||||
reviewed_digest: &str,
|
||||
validate: impl FnOnce(&str) -> Result<(), String>,
|
||||
) -> Result<(), String> {
|
||||
let label = kind.label;
|
||||
if !codewhale_runtime::config::is_workspace_trusted(workspace) {
|
||||
return Err(format!("Trust the workspace before approving its {label}"));
|
||||
}
|
||||
let reviewed = review_project_file(workspace, kind)?;
|
||||
if reviewed_digest != reviewed.digest {
|
||||
return Err(format!(
|
||||
"The {label} file does not match the reviewed digest; review it again"
|
||||
));
|
||||
}
|
||||
validate(&reviewed.contents)?;
|
||||
codewhale_runtime::config::save_workspace_project_receipt(
|
||||
&reviewed.workspace,
|
||||
kind.receipt_key,
|
||||
reviewed_digest,
|
||||
)
|
||||
.map_err(|_| format!("Could not save {label} approval in user config"))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn review_project_hooks(workspace: &Path) -> Result<(ProjectHookAuthority, String), String> {
|
||||
let reviewed = review_project_file(workspace, HOOKS_FILE)?;
|
||||
Ok((
|
||||
ProjectHookAuthority {
|
||||
workspace: reviewed.workspace,
|
||||
digest: reviewed.digest,
|
||||
},
|
||||
reviewed.contents,
|
||||
))
|
||||
}
|
||||
|
||||
pub fn approved_project_hooks(workspace: &Path) -> Result<(ProjectHookAuthority, String), String> {
|
||||
if !codewhale_runtime::config::is_workspace_trusted(workspace) {
|
||||
return Err("Project hooks require workspace trust and separate hook approval".into());
|
||||
}
|
||||
let (authority, contents) = review_project_hooks(workspace)?;
|
||||
if codewhale_runtime::config::hook_receipt_for_workspace(&authority.workspace).as_deref()
|
||||
!= Some(&authority.digest)
|
||||
{
|
||||
return Err("Project hooks are unapproved or changed; use /hooks review, then /hooks approve <digest>".into());
|
||||
}
|
||||
Ok((authority, contents))
|
||||
let reviewed = approved_project_file(workspace, HOOKS_FILE)?;
|
||||
Ok((
|
||||
ProjectHookAuthority {
|
||||
workspace: reviewed.workspace,
|
||||
digest: reviewed.digest,
|
||||
},
|
||||
reviewed.contents,
|
||||
))
|
||||
}
|
||||
|
||||
pub fn verify_hook_authorities(
|
||||
@@ -65,21 +164,14 @@ pub fn verify_hook_authorities(
|
||||
}
|
||||
|
||||
pub fn approve_project_hooks(workspace: &Path, reviewed_digest: &str) -> Result<(), String> {
|
||||
if !codewhale_runtime::config::is_workspace_trusted(workspace) {
|
||||
return Err("Trust the workspace before approving its hooks".into());
|
||||
}
|
||||
let (authority, contents) = review_project_hooks(workspace)?;
|
||||
if reviewed_digest != authority.digest {
|
||||
return Err("Hooks do not match the reviewed digest; use /hooks review again".into());
|
||||
}
|
||||
let parsed = toml::from_str::<codewhale_config::hooks::HooksConfig>(&contents)
|
||||
.map_err(|_| "Invalid hooks TOML")?;
|
||||
if parsed.validate().iter().any(|problem| problem.rejected) {
|
||||
return Err("Hooks contain invalid entries; correct them before approval".into());
|
||||
}
|
||||
codewhale_runtime::config::save_workspace_hook_receipt(&authority.workspace, reviewed_digest)
|
||||
.map_err(|_| "Could not save hook approval in user config".to_string())?;
|
||||
Ok(())
|
||||
approve_project_file(workspace, HOOKS_FILE, reviewed_digest, |contents| {
|
||||
let parsed = toml::from_str::<codewhale_config::hooks::HooksConfig>(contents)
|
||||
.map_err(|_| "Invalid hooks TOML")?;
|
||||
if parsed.validate().iter().any(|problem| problem.rejected) {
|
||||
return Err("Hooks contain invalid entries; correct them before approval".into());
|
||||
}
|
||||
Ok(())
|
||||
})
|
||||
}
|
||||
|
||||
/// The Native floor is sufficient for Native-authored shell contributions; it is not a legacy Hooks grant.
|
||||
|
||||
@@ -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;
|
||||
|
||||
+22
-18
@@ -32,6 +32,7 @@ mod http;
|
||||
pub mod http_client;
|
||||
pub mod oauth;
|
||||
pub mod process_broker;
|
||||
pub mod project_authority;
|
||||
pub mod sse;
|
||||
mod stdio;
|
||||
mod streamable_http;
|
||||
@@ -7440,7 +7441,7 @@ pub enum McpServerScope {
|
||||
/// The user's own config file, shared by every workspace.
|
||||
Global,
|
||||
/// `<workspace>/.codewhale/mcp.json`, honoured only once the workspace
|
||||
/// is trusted in user-owned config (#417).
|
||||
/// is trusted in user-owned config (#417) and its exact contents are approved.
|
||||
Project(PathBuf),
|
||||
/// Contributed by an installed plugin bundle. It lives in no config
|
||||
/// file, so it is switched off by disabling the plugin that owns it.
|
||||
@@ -7475,16 +7476,15 @@ pub fn project_server_names(global_path: &Path, workspace: &Path) -> BTreeSet<St
|
||||
let Ok(workspace) = checked_workspace_path(workspace) else {
|
||||
return BTreeSet::new();
|
||||
};
|
||||
if !workspace_allows_project_mcp_config(&workspace) {
|
||||
return BTreeSet::new();
|
||||
}
|
||||
let Ok(project_path) = checked_workspace_mcp_config_path(&workspace) else {
|
||||
return BTreeSet::new();
|
||||
};
|
||||
if !project_path.exists() || paths_refer_to_same_config(global_path, &project_path) {
|
||||
return BTreeSet::new();
|
||||
}
|
||||
load_config(&project_path)
|
||||
project_authority::approved_project_mcp(&workspace)
|
||||
.ok()
|
||||
.and_then(|reviewed| serde_json::from_str::<McpConfig>(&reviewed.contents).ok())
|
||||
.map(|config| config.servers.into_keys().collect())
|
||||
.unwrap_or_default()
|
||||
}
|
||||
@@ -7492,11 +7492,13 @@ pub fn project_server_names(global_path: &Path, workspace: &Path) -> BTreeSet<St
|
||||
#[must_use]
|
||||
pub fn resolve_server_scope(global_path: &Path, workspace: &Path, name: &str) -> McpServerScope {
|
||||
if let Ok(workspace) = checked_workspace_path(workspace)
|
||||
&& workspace_allows_project_mcp_config(&workspace)
|
||||
&& let Ok(project_path) = checked_workspace_mcp_config_path(&workspace)
|
||||
&& project_path.exists()
|
||||
&& !paths_refer_to_same_config(global_path, &project_path)
|
||||
&& load_config(&project_path).is_ok_and(|config| config.servers.contains_key(name))
|
||||
&& project_authority::approved_project_mcp(&workspace)
|
||||
.ok()
|
||||
.and_then(|reviewed| serde_json::from_str::<McpConfig>(&reviewed.contents).ok())
|
||||
.is_some_and(|config| config.servers.contains_key(name))
|
||||
{
|
||||
return McpServerScope::Project(project_path);
|
||||
}
|
||||
@@ -7731,15 +7733,20 @@ pub fn load_config_with_workspace_and_plugins(
|
||||
return merge_plugin_mcp_servers(merged, plugins);
|
||||
}
|
||||
// Workspace-local MCP can spawn stdio servers, so it is only honored after
|
||||
// the user has trusted this workspace in user-owned config. Do not accept
|
||||
// project-local legacy trust markers here: a repository could carry those
|
||||
// files itself and silently reintroduce the project-scope `mcp_config_path`
|
||||
// risk denied in #417.
|
||||
if !workspace_allows_project_mcp_config(&workspace) {
|
||||
// the user has trusted this workspace and approved the exact file contents
|
||||
// in user-owned config. Do not accept project-local legacy trust markers
|
||||
// here: a repository could carry those files itself and silently
|
||||
// reintroduce the project-scope `mcp_config_path` risk denied in #417.
|
||||
let Ok(reviewed) = project_authority::approved_project_mcp(&workspace) else {
|
||||
return merge_plugin_mcp_servers(merged, plugins);
|
||||
}
|
||||
};
|
||||
|
||||
let mut project = load_config(&project_path)?;
|
||||
let mut project: McpConfig = serde_json::from_str(&reviewed.contents).map_err(|_| {
|
||||
anyhow::anyhow!(
|
||||
"Failed to parse MCP config {}; file contents were omitted",
|
||||
codewhale_config::quote_os_path(&project_path)
|
||||
)
|
||||
})?;
|
||||
for server in project.servers.values_mut() {
|
||||
server.always_allow_tools.clear();
|
||||
if server.command.is_some() && server.url.is_none() {
|
||||
@@ -7983,10 +7990,6 @@ fn resolve_plugin_mcp_cwd(plugin_path: &Path, cwd: Option<&Path>) -> Result<Path
|
||||
Ok(resolved)
|
||||
}
|
||||
|
||||
fn workspace_allows_project_mcp_config(workspace: &Path) -> bool {
|
||||
codewhale_runtime::config::is_workspace_trusted(workspace)
|
||||
}
|
||||
|
||||
fn checked_workspace_mcp_config_path(workspace: &Path) -> Result<PathBuf> {
|
||||
Ok(checked_workspace_path(workspace)?
|
||||
.join(".codewhale")
|
||||
@@ -8308,6 +8311,7 @@ pub fn mutate_config<T>(
|
||||
}
|
||||
write_atomic(path, rendered.as_bytes())
|
||||
.with_context(|| format!("Failed to write MCP config {}", path.display()))?;
|
||||
project_authority::carry_approval_across_product_edit(path, original.as_deref(), &rendered);
|
||||
Ok((result, config_revision(Some(&rendered))))
|
||||
})
|
||||
}
|
||||
|
||||
@@ -0,0 +1,127 @@
|
||||
use crate::hooks::authority::{
|
||||
MCP_FILE, ReviewedProjectFile, approve_project_file, approved_project_file, review_project_file,
|
||||
};
|
||||
use sha2::{Digest, Sha256};
|
||||
use std::path::Path;
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum ProjectMcpStatus {
|
||||
Absent,
|
||||
Untrusted,
|
||||
Unreadable(String),
|
||||
Unapproved { digest: String },
|
||||
Approved { digest: String },
|
||||
}
|
||||
|
||||
pub fn review_project_mcp(workspace: &Path) -> Result<ReviewedProjectFile, String> {
|
||||
review_project_file(workspace, MCP_FILE)
|
||||
}
|
||||
|
||||
pub fn approved_project_mcp(workspace: &Path) -> Result<ReviewedProjectFile, String> {
|
||||
approved_project_file(workspace, MCP_FILE)
|
||||
}
|
||||
|
||||
pub fn approve_project_mcp(workspace: &Path, reviewed_digest: &str) -> Result<(), String> {
|
||||
approve_project_file(workspace, MCP_FILE, reviewed_digest, |contents| {
|
||||
let workspace =
|
||||
super::checked_workspace_path(workspace).map_err(|error| error.to_string())?;
|
||||
let config: codewhale_mcp::config::McpConfig = serde_json::from_str(contents)
|
||||
.map_err(|_| "Invalid MCP JSON; correct it before approval".to_string())?;
|
||||
for server in config.servers.values() {
|
||||
if server.command.is_some() && server.url.is_none() {
|
||||
super::resolve_project_mcp_cwd(&workspace, server.cwd.as_deref())
|
||||
.map_err(|error| error.to_string())?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
})
|
||||
}
|
||||
|
||||
pub fn revoke_project_mcp(workspace: &Path) -> Result<(), String> {
|
||||
codewhale_runtime::config::save_workspace_project_receipt(
|
||||
workspace,
|
||||
codewhale_runtime::config::MCP_RECEIPT_KEY,
|
||||
"",
|
||||
)
|
||||
.map(|_| ())
|
||||
.map_err(|_| "Could not revoke project MCP approval".to_string())
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn project_mcp_status(workspace: &Path) -> ProjectMcpStatus {
|
||||
let Ok(workspace) = super::checked_workspace_path(workspace) else {
|
||||
return ProjectMcpStatus::Absent;
|
||||
};
|
||||
let path = workspace.join(".codewhale").join("mcp.json");
|
||||
if path
|
||||
.symlink_metadata()
|
||||
.is_err_and(|e| e.kind() == std::io::ErrorKind::NotFound)
|
||||
{
|
||||
return ProjectMcpStatus::Absent;
|
||||
}
|
||||
if !codewhale_runtime::config::is_workspace_trusted(&workspace) {
|
||||
return ProjectMcpStatus::Untrusted;
|
||||
}
|
||||
match review_project_mcp(&workspace) {
|
||||
Err(error) => ProjectMcpStatus::Unreadable(error),
|
||||
Ok(reviewed) => {
|
||||
let approved = codewhale_runtime::config::project_receipt_for_workspace(
|
||||
&reviewed.workspace,
|
||||
codewhale_runtime::config::MCP_RECEIPT_KEY,
|
||||
)
|
||||
.as_deref()
|
||||
== Some(&reviewed.digest);
|
||||
if approved {
|
||||
ProjectMcpStatus::Approved {
|
||||
digest: reviewed.digest,
|
||||
}
|
||||
} else {
|
||||
ProjectMcpStatus::Unapproved {
|
||||
digest: reviewed.digest,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn carry_approval_across_product_edit(
|
||||
path: &Path,
|
||||
original: Option<&str>,
|
||||
written: &str,
|
||||
) {
|
||||
let Some(original) = original else { return };
|
||||
if path.file_name() != Some(std::ffi::OsStr::new("mcp.json")) {
|
||||
return;
|
||||
}
|
||||
let Some(dir) = path.parent() else { return };
|
||||
if dir.file_name() != Some(std::ffi::OsStr::new(".codewhale")) {
|
||||
return;
|
||||
}
|
||||
let Some(workspace) = dir.parent().map(super::normalize_workspace_path) else {
|
||||
return;
|
||||
};
|
||||
let digest = |text: &str| -> String {
|
||||
Sha256::digest(text.as_bytes())
|
||||
.iter()
|
||||
.map(|byte| format!("{byte:02x}"))
|
||||
.collect()
|
||||
};
|
||||
let receipt = codewhale_runtime::config::project_receipt_for_workspace(
|
||||
&workspace,
|
||||
codewhale_runtime::config::MCP_RECEIPT_KEY,
|
||||
);
|
||||
if receipt.as_deref() != Some(digest(original).as_str())
|
||||
|| !codewhale_runtime::config::is_workspace_trusted(&workspace)
|
||||
{
|
||||
return;
|
||||
}
|
||||
if codewhale_runtime::config::save_workspace_project_receipt(
|
||||
&workspace,
|
||||
codewhale_runtime::config::MCP_RECEIPT_KEY,
|
||||
&digest(written),
|
||||
)
|
||||
.is_err()
|
||||
{
|
||||
tracing::warn!(target: "mcp", "Could not carry project MCP approval across an edit");
|
||||
}
|
||||
}
|
||||
@@ -76,6 +76,21 @@ fn mark_workspace_trusted(workspace: &Path) -> WorkspaceTrustConfigGuard {
|
||||
guard
|
||||
}
|
||||
|
||||
fn approve_project_mcp_for_test(workspace: &Path) {
|
||||
let reviewed = project_authority::review_project_mcp(workspace).unwrap();
|
||||
project_authority::approve_project_mcp(workspace, &reviewed.digest).unwrap();
|
||||
}
|
||||
|
||||
fn record_project_mcp_receipt_for_test(workspace: &Path) {
|
||||
let reviewed = project_authority::review_project_mcp(workspace).unwrap();
|
||||
codewhale_runtime::config::save_workspace_project_receipt(
|
||||
&reviewed.workspace,
|
||||
codewhale_runtime::config::MCP_RECEIPT_KEY,
|
||||
&reviewed.digest,
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_mcp_config_defaults() {
|
||||
let config = McpConfig::default();
|
||||
@@ -882,6 +897,7 @@ fn workspace_mcp_config_merges_with_project_overrides() {
|
||||
}"#,
|
||||
)
|
||||
.unwrap();
|
||||
approve_project_mcp_for_test(&workspace);
|
||||
|
||||
let cfg = load_config_with_workspace(&global_path, &workspace).unwrap();
|
||||
let workspace = workspace.canonicalize().unwrap();
|
||||
@@ -922,6 +938,7 @@ fn workspace_manager_snapshot_counts_global_and_project_servers() {
|
||||
}"#,
|
||||
)
|
||||
.unwrap();
|
||||
approve_project_mcp_for_test(&workspace);
|
||||
|
||||
let plain = manager_snapshot_from_config(&global_path, false).unwrap();
|
||||
let merged =
|
||||
@@ -2377,6 +2394,220 @@ fn workspace_mcp_config_ignores_project_file_until_workspace_trusted() {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn workspace_mcp_config_does_not_load_project_file_on_trust_alone() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let global_path = dir.path().join("global-mcp.json");
|
||||
let workspace = dir.path().join("workspace");
|
||||
let project_dir = workspace.join(".codewhale");
|
||||
fs::create_dir_all(&project_dir).unwrap();
|
||||
let _trust = mark_workspace_trusted(&workspace);
|
||||
fs::write(
|
||||
&global_path,
|
||||
r#"{"servers": {"global": {"command": "node", "args": ["global.js"]}}}"#,
|
||||
)
|
||||
.unwrap();
|
||||
fs::write(
|
||||
project_dir.join("mcp.json"),
|
||||
r#"{"servers": {"project": {"command": "php", "args": ["artisan", "boost:mcp"]}}}"#,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let cfg = load_config_with_workspace(&global_path, &workspace).unwrap();
|
||||
|
||||
assert!(cfg.servers.contains_key("global"));
|
||||
assert!(!cfg.servers.contains_key("project"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn project_mcp_approval_binds_the_exact_file_contents() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let global_path = dir.path().join("global-mcp.json");
|
||||
let workspace = dir.path().join("workspace");
|
||||
let project_dir = workspace.join(".codewhale");
|
||||
fs::create_dir_all(&project_dir).unwrap();
|
||||
let _trust = mark_workspace_trusted(&workspace);
|
||||
fs::write(&global_path, r#"{"servers": {}}"#).unwrap();
|
||||
let project_path = project_dir.join("mcp.json");
|
||||
let reviewed_bytes = r#"{"servers": {"project": {"command": "node", "args": ["a.js"]}}}"#;
|
||||
fs::write(&project_path, reviewed_bytes).unwrap();
|
||||
|
||||
assert!(matches!(
|
||||
project_authority::project_mcp_status(&workspace),
|
||||
project_authority::ProjectMcpStatus::Unapproved { .. }
|
||||
));
|
||||
assert!(project_server_names(&global_path, &workspace).is_empty());
|
||||
assert!(matches!(
|
||||
resolve_server_scope(&global_path, &workspace, "project"),
|
||||
McpServerScope::Plugin
|
||||
));
|
||||
assert!(
|
||||
project_authority::approve_project_mcp(&workspace, "0".repeat(64).as_str()).is_err(),
|
||||
"a digest that does not match the file is refused"
|
||||
);
|
||||
|
||||
approve_project_mcp_for_test(&workspace);
|
||||
let cfg = load_config_with_workspace(&global_path, &workspace).unwrap();
|
||||
assert!(cfg.servers.contains_key("project"));
|
||||
assert!(project_server_names(&global_path, &workspace).contains("project"));
|
||||
assert!(matches!(
|
||||
resolve_server_scope(&global_path, &workspace, "project"),
|
||||
McpServerScope::Project(_)
|
||||
));
|
||||
|
||||
fs::write(
|
||||
&project_path,
|
||||
r#"{"servers": {"project": {"command": "node", "args": ["b.js"]}}}"#,
|
||||
)
|
||||
.unwrap();
|
||||
let cfg = load_config_with_workspace(&global_path, &workspace).unwrap();
|
||||
assert!(
|
||||
!cfg.servers.contains_key("project"),
|
||||
"a changed project file loads nothing until it is approved again"
|
||||
);
|
||||
assert!(project_server_names(&global_path, &workspace).is_empty());
|
||||
|
||||
fs::write(&project_path, reviewed_bytes).unwrap();
|
||||
let cfg = load_config_with_workspace(&global_path, &workspace).unwrap();
|
||||
assert!(
|
||||
cfg.servers.contains_key("project"),
|
||||
"the approval names bytes, not a moment in time"
|
||||
);
|
||||
|
||||
project_authority::revoke_project_mcp(&workspace).unwrap();
|
||||
let cfg = load_config_with_workspace(&global_path, &workspace).unwrap();
|
||||
assert!(!cfg.servers.contains_key("project"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn project_mcp_approval_cannot_come_from_the_repository_or_another_workspace() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let global_path = dir.path().join("global-mcp.json");
|
||||
let workspace = dir.path().join("workspace");
|
||||
let other = dir.path().join("other");
|
||||
fs::create_dir_all(workspace.join(".codewhale")).unwrap();
|
||||
fs::create_dir_all(other.join(".codewhale")).unwrap();
|
||||
let _trust = mark_workspace_trusted(&workspace);
|
||||
fs::write(&global_path, r#"{"servers": {}}"#).unwrap();
|
||||
let body = r#"{"servers": {"project": {"command": "node", "args": ["a.js"]}}}"#;
|
||||
fs::write(workspace.join(".codewhale").join("mcp.json"), body).unwrap();
|
||||
fs::write(other.join(".codewhale").join("mcp.json"), body).unwrap();
|
||||
|
||||
let reviewed = project_authority::review_project_mcp(&workspace).unwrap();
|
||||
let key = workspace.canonicalize().unwrap();
|
||||
let key = key.to_string_lossy().replace('\\', "\\\\");
|
||||
fs::write(
|
||||
workspace.join(".codewhale").join("config.toml"),
|
||||
format!(
|
||||
"mcp_sha256 = \"{d}\"\n[projects.\"{key}\"]\ntrust_level = \"trusted\"\nmcp_sha256 = \"{d}\"\n",
|
||||
d = reviewed.digest
|
||||
),
|
||||
)
|
||||
.unwrap();
|
||||
let cfg = load_config_with_workspace(&global_path, &workspace).unwrap();
|
||||
assert!(!cfg.servers.contains_key("project"));
|
||||
|
||||
approve_project_mcp_for_test(&workspace);
|
||||
let cfg = load_config_with_workspace(&global_path, &other).unwrap();
|
||||
assert!(
|
||||
!cfg.servers.contains_key("project"),
|
||||
"an approval is bound to one workspace"
|
||||
);
|
||||
assert!(
|
||||
project_authority::approve_project_mcp(&other, &reviewed.digest).is_err(),
|
||||
"an untrusted workspace cannot be approved"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn project_mcp_approval_refuses_invalid_files() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let workspace = dir.path().join("workspace");
|
||||
fs::create_dir_all(workspace.join(".codewhale")).unwrap();
|
||||
let _trust = mark_workspace_trusted(&workspace);
|
||||
let path = workspace.join(".codewhale").join("mcp.json");
|
||||
|
||||
fs::write(&path, "{ not json").unwrap();
|
||||
let reviewed = project_authority::review_project_mcp(&workspace).unwrap();
|
||||
assert!(project_authority::approve_project_mcp(&workspace, &reviewed.digest).is_err());
|
||||
|
||||
fs::write(
|
||||
&path,
|
||||
r#"{"servers": {"project": {"command": "node", "cwd": "../outside"}}}"#,
|
||||
)
|
||||
.unwrap();
|
||||
let reviewed = project_authority::review_project_mcp(&workspace).unwrap();
|
||||
let error = project_authority::approve_project_mcp(&workspace, &reviewed.digest).unwrap_err();
|
||||
assert!(error.contains("must stay within workspace"), "{error}");
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn project_mcp_approval_refuses_a_symlinked_project_directory() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let global_path = dir.path().join("global-mcp.json");
|
||||
let workspace = dir.path().join("workspace");
|
||||
let project_dir = workspace.join(".codewhale");
|
||||
fs::create_dir_all(&project_dir).unwrap();
|
||||
let _trust = mark_workspace_trusted(&workspace);
|
||||
fs::write(&global_path, r#"{"servers": {}}"#).unwrap();
|
||||
fs::write(
|
||||
project_dir.join("mcp.json"),
|
||||
r#"{"servers": {"project": {"command": "node", "args": ["a.js"]}}}"#,
|
||||
)
|
||||
.unwrap();
|
||||
approve_project_mcp_for_test(&workspace);
|
||||
assert!(
|
||||
load_config_with_workspace(&global_path, &workspace)
|
||||
.unwrap()
|
||||
.servers
|
||||
.contains_key("project")
|
||||
);
|
||||
|
||||
let moved = dir.path().join("moved");
|
||||
fs::rename(&project_dir, &moved).unwrap();
|
||||
std::os::unix::fs::symlink(&moved, &project_dir).unwrap();
|
||||
|
||||
assert!(project_authority::review_project_mcp(&workspace).is_err());
|
||||
let cfg = load_config_with_workspace(&global_path, &workspace).unwrap();
|
||||
assert!(!cfg.servers.contains_key("project"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn product_edits_of_an_approved_project_file_keep_its_approval() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let global_path = dir.path().join("global-mcp.json");
|
||||
let workspace = dir.path().join("workspace");
|
||||
let project_dir = workspace.join(".codewhale");
|
||||
fs::create_dir_all(&project_dir).unwrap();
|
||||
let _trust = mark_workspace_trusted(&workspace);
|
||||
fs::write(&global_path, r#"{"servers": {}}"#).unwrap();
|
||||
let project_path = project_dir.join("mcp.json");
|
||||
fs::write(
|
||||
&project_path,
|
||||
r#"{"servers": {"project": {"command": "node", "args": ["a.js"]}, "second": {"command": "node", "args": ["b.js"]}}}"#,
|
||||
)
|
||||
.unwrap();
|
||||
approve_project_mcp_for_test(&workspace);
|
||||
|
||||
set_server_enabled(&project_path, "project", false).unwrap();
|
||||
let cfg = load_config_with_workspace(&global_path, &workspace).unwrap();
|
||||
assert!(!cfg.servers["project"].is_enabled());
|
||||
assert!(cfg.servers.contains_key("second"));
|
||||
|
||||
fs::write(
|
||||
&project_path,
|
||||
r#"{"servers": {"project": {"command": "node", "args": ["evil.js"]}}}"#,
|
||||
)
|
||||
.unwrap();
|
||||
let _ = set_server_enabled(&project_path, "project", true);
|
||||
let cfg = load_config_with_workspace(&global_path, &workspace).unwrap();
|
||||
assert!(
|
||||
!cfg.servers.contains_key("project"),
|
||||
"an edit on top of unreviewed bytes does not carry the approval"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn workspace_mcp_config_ignores_project_local_legacy_trust_marker() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
@@ -2457,6 +2688,7 @@ fn workspace_mcp_config_resolves_relative_cwd_from_workspace() {
|
||||
r#"{"servers": {"project": {"command": "node", "args": ["server.js"], "cwd": "tools/mcp"}}}"#,
|
||||
)
|
||||
.unwrap();
|
||||
approve_project_mcp_for_test(&workspace);
|
||||
|
||||
let cfg = load_config_with_workspace(&global_path, &workspace).unwrap();
|
||||
let workspace = workspace.canonicalize().unwrap();
|
||||
@@ -2482,6 +2714,7 @@ fn workspace_mcp_config_rejects_project_cwd_escape() {
|
||||
r#"{"servers": {"project": {"command": "node", "args": ["server.js"], "cwd": "../outside"}}}"#,
|
||||
)
|
||||
.unwrap();
|
||||
record_project_mcp_receipt_for_test(&workspace);
|
||||
|
||||
let err = load_config_with_workspace(&global_path, &workspace)
|
||||
.expect_err("project MCP cwd escape must be rejected");
|
||||
@@ -2511,6 +2744,7 @@ fn workspace_mcp_config_rejects_symlinked_project_cwd_escape() {
|
||||
r#"{"servers": {"project": {"command": "node", "args": ["server.js"], "cwd": "tools"}}}"#,
|
||||
)
|
||||
.unwrap();
|
||||
record_project_mcp_receipt_for_test(&workspace);
|
||||
|
||||
let err = load_config_with_workspace(&global_path, &workspace)
|
||||
.expect_err("project MCP symlink cwd escape must be rejected");
|
||||
@@ -2564,6 +2798,7 @@ async fn workspace_mcp_pool_reload_picks_up_project_config_creation() {
|
||||
r#"{"servers": {"project": {"command": "php", "args": ["artisan", "boost:mcp"]}}}"#,
|
||||
)
|
||||
.unwrap();
|
||||
approve_project_mcp_for_test(&workspace);
|
||||
|
||||
assert!(pool.reload_if_config_changed().await.unwrap());
|
||||
let names: std::collections::BTreeSet<String> = pool.server_names().into_iter().collect();
|
||||
@@ -2599,6 +2834,7 @@ async fn workspace_mcp_pool_reload_picks_up_project_config_after_workspace_trust
|
||||
assert_eq!(pool.server_names(), vec!["global".to_string()]);
|
||||
|
||||
write_workspace_trust_config(&trust_env.config_path, &workspace);
|
||||
approve_project_mcp_for_test(&workspace);
|
||||
|
||||
assert!(pool.reload_if_config_changed().await.unwrap());
|
||||
let names: std::collections::BTreeSet<String> = pool.server_names().into_iter().collect();
|
||||
@@ -2627,6 +2863,7 @@ async fn workspace_mcp_pool_reload_drops_project_config_after_workspace_trust_re
|
||||
r#"{"servers": {"project": {"command": "php", "args": ["artisan", "boost:mcp"]}}}"#,
|
||||
)
|
||||
.unwrap();
|
||||
approve_project_mcp_for_test(&workspace);
|
||||
|
||||
let mut pool = McpPool::from_config_path_with_workspace(&global_path, &workspace)
|
||||
.unwrap()
|
||||
@@ -2663,6 +2900,7 @@ async fn workspace_mcp_pool_reload_drops_project_config_after_deletion() {
|
||||
r#"{"servers": {"project": {"command": "php", "args": ["artisan", "boost:mcp"]}}}"#,
|
||||
)
|
||||
.unwrap();
|
||||
approve_project_mcp_for_test(&workspace);
|
||||
|
||||
let mut pool = McpPool::from_config_path_with_workspace(&global_path, &workspace)
|
||||
.unwrap()
|
||||
|
||||
@@ -1300,8 +1300,11 @@ pub fn upsert_keepalive(
|
||||
next_run_at: None,
|
||||
last_run_at: None,
|
||||
revision: 0,
|
||||
disallowed_tools: Vec::new(),
|
||||
});
|
||||
record.schema_version = crate::automation_manager::CURRENT_AUTOMATION_SCHEMA_VERSION;
|
||||
record.schema_version = record.schema_version.max(
|
||||
crate::automation_manager::required_automation_schema_version(&record.disallowed_tools),
|
||||
);
|
||||
if record.execution_scope.is_none() {
|
||||
record.execution_scope = Some(scope.to_string());
|
||||
}
|
||||
|
||||
@@ -227,7 +227,16 @@ const IMAGE_RUNTIME_SCHEMA_VERSION: u32 = 3;
|
||||
const OUTPUT_LIMIT_RUNTIME_SCHEMA_VERSION: u32 = 4;
|
||||
const PREFIX_CHECKPOINT_RUNTIME_SCHEMA_VERSION: u32 = 5;
|
||||
const SOURCE_ACCOUNTING_RUNTIME_SCHEMA_VERSION: u32 = 6;
|
||||
const MAX_SUPPORTED_RUNTIME_SCHEMA_VERSION: u32 = SOURCE_ACCOUNTING_RUNTIME_SCHEMA_VERSION;
|
||||
const DISALLOWED_TOOLS_RUNTIME_SCHEMA_VERSION: u32 = 7;
|
||||
const MAX_SUPPORTED_RUNTIME_SCHEMA_VERSION: u32 = DISALLOWED_TOOLS_RUNTIME_SCHEMA_VERSION;
|
||||
|
||||
pub(crate) const fn required_thread_schema_version(disallowed_tools: &[String]) -> u32 {
|
||||
if disallowed_tools.is_empty() {
|
||||
CURRENT_RUNTIME_SCHEMA_VERSION
|
||||
} else {
|
||||
DISALLOWED_TOOLS_RUNTIME_SCHEMA_VERSION
|
||||
}
|
||||
}
|
||||
|
||||
fn is_zero_u64(value: &u64) -> bool {
|
||||
*value == 0
|
||||
@@ -386,6 +395,8 @@ pub struct ThreadRecord {
|
||||
/// normal configured tool catalog; `Some([])` deliberately exposes none.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub allowed_tools: Option<Vec<String>>,
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub disallowed_tools: Vec<String>,
|
||||
/// Extra roots the file tools may read and write outside the workspace.
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub add_dirs: Vec<PathBuf>,
|
||||
@@ -2526,6 +2537,19 @@ impl RuntimeThreadStore {
|
||||
|
||||
pub fn save_thread(&self, thread: &ThreadRecord) -> Result<()> {
|
||||
let path = self.thread_path(&thread.id)?;
|
||||
let min_version = required_thread_schema_version(&thread.disallowed_tools);
|
||||
if thread.schema_version < min_version {
|
||||
let mut updated = thread.clone();
|
||||
updated.schema_version = min_version;
|
||||
return write_json_atomic(&path, &updated).with_context(|| {
|
||||
RuntimeStoreRecordFailure::new(
|
||||
RuntimeStoreOperation::Write,
|
||||
RuntimeStoreRecordKind::Thread,
|
||||
&updated.id,
|
||||
&path,
|
||||
)
|
||||
});
|
||||
}
|
||||
write_json_atomic(&path, thread).with_context(|| {
|
||||
RuntimeStoreRecordFailure::new(
|
||||
RuntimeStoreOperation::Write,
|
||||
|
||||
@@ -831,6 +831,7 @@ pub(super) fn thread_execution_state_matches(left: &ThreadRecord, right: &Thread
|
||||
&& left.model_provider_id == right.model_provider_id
|
||||
&& left.reasoning_effort == right.reasoning_effort
|
||||
&& left.allowed_tools == right.allowed_tools
|
||||
&& left.disallowed_tools == right.disallowed_tools
|
||||
&& left.workspace == right.workspace
|
||||
&& left.mode == right.mode
|
||||
&& left.permission_posture == right.permission_posture
|
||||
@@ -1438,6 +1439,12 @@ pub struct CreateThreadRequest {
|
||||
/// An empty array intentionally disables every model-visible tool.
|
||||
#[serde(default)]
|
||||
pub allowed_tools: Option<Vec<String>>,
|
||||
#[serde(
|
||||
default,
|
||||
alias = "disallowedTools",
|
||||
skip_serializing_if = "Vec::is_empty"
|
||||
)]
|
||||
pub disallowed_tools: Vec<String>,
|
||||
/// Extra directories the file tools may read and write outside the
|
||||
/// workspace. Each path must exist and be a directory.
|
||||
#[serde(default, alias = "addDirs")]
|
||||
@@ -5606,6 +5613,7 @@ impl RuntimeThreadManager {
|
||||
/// deleting a thread calls this, so a grant never outlives the
|
||||
/// conversation it was given in.
|
||||
pub(super) fn take_approval_grants(&self, thread_id: &str) -> Vec<RuntimeApprovalGrant> {
|
||||
crate::extension_host::end_remote_session(thread_id);
|
||||
self.grants
|
||||
.revoke_thread(thread_id, "runtime", "thread archived or deleted")
|
||||
.iter()
|
||||
@@ -7997,8 +8005,9 @@ impl RuntimeThreadManager {
|
||||
}
|
||||
None => None,
|
||||
};
|
||||
let disallowed_tools = crate::task_manager::normalize_deny_rules(req.disallowed_tools);
|
||||
let thread = ThreadRecord {
|
||||
schema_version: CURRENT_RUNTIME_SCHEMA_VERSION,
|
||||
schema_version: super::required_thread_schema_version(&disallowed_tools),
|
||||
id: match reserved_id {
|
||||
Some(id) => validated_record_id(&id, "reserved history thread id")?.to_string(),
|
||||
None => runtime_record_id("thr"),
|
||||
@@ -8010,6 +8019,7 @@ impl RuntimeThreadManager {
|
||||
model_provider_id,
|
||||
reasoning_effort,
|
||||
allowed_tools: req.allowed_tools,
|
||||
disallowed_tools,
|
||||
add_dirs,
|
||||
workspace,
|
||||
mode,
|
||||
@@ -8061,6 +8071,9 @@ impl RuntimeThreadManager {
|
||||
if let Some(preset) = thread.agent_preset.as_ref() {
|
||||
fingerprint_body["agent_preset"] = json!(preset);
|
||||
}
|
||||
if !thread.disallowed_tools.is_empty() {
|
||||
fingerprint_body["disallowed_tools"] = json!(thread.disallowed_tools);
|
||||
}
|
||||
let request_fingerprint =
|
||||
codewhale_runtime::hashing::sha256_hex(serde_json::to_vec(&fingerprint_body)?);
|
||||
let _emit_order = self.event_emit.lock().await;
|
||||
@@ -11902,7 +11915,7 @@ impl RuntimeThreadManager {
|
||||
};
|
||||
if !turn_seed.image_content.is_empty() {
|
||||
item.set_image_content(turn_seed.image_content.clone());
|
||||
thread.schema_version = IMAGE_RUNTIME_SCHEMA_VERSION;
|
||||
thread.schema_version = thread.schema_version.max(IMAGE_RUNTIME_SCHEMA_VERSION);
|
||||
}
|
||||
seed_items.push(item);
|
||||
item_ids.push(item_id);
|
||||
@@ -15523,7 +15536,8 @@ source_usage_records: Default::default(),
|
||||
cfg.reasoning_only_reprompt_message().to_string(),
|
||||
),
|
||||
allowed_tools: isolated_chat.then(Vec::new),
|
||||
disallowed_tools: None,
|
||||
disallowed_tools: (!thread.disallowed_tools.is_empty())
|
||||
.then(|| thread.disallowed_tools.clone()),
|
||||
max_tool_calls: None,
|
||||
hook_executor: Some(Arc::clone(&thread_hooks)),
|
||||
locale_tag: codewhale_localization::resolve_locale(&settings.locale)
|
||||
|
||||
@@ -1824,6 +1824,135 @@ mod recovery {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn thread_deny_rules_persist_and_reach_the_engine_that_runs_the_thread() -> Result<()> {
|
||||
let _env = crate::test_support::lock_test_env();
|
||||
let dir = tempfile::tempdir()?;
|
||||
let _home = crate::test_support::EnvVarGuard::set("CODEWHALE_HOME", dir.path());
|
||||
let workspace = dir.path().join("workspace");
|
||||
fs::create_dir(&workspace)?;
|
||||
let manager = RuntimeThreadManager::open(
|
||||
config(),
|
||||
workspace,
|
||||
test_manager_config(dir.path().join("runtime")),
|
||||
)?;
|
||||
let mock = Arc::new(MockLlmClient::new(vec![
|
||||
canned::simple_text_turn("open"),
|
||||
canned::simple_text_turn("restricted"),
|
||||
]));
|
||||
manager.set_test_model_client(mock.clone());
|
||||
let rules = vec!["file".to_string()];
|
||||
let tool_names = |request: &codewhale_models::MessageRequest| -> Vec<String> {
|
||||
request
|
||||
.tools
|
||||
.iter()
|
||||
.flatten()
|
||||
.map(|tool| tool.name.clone())
|
||||
.collect()
|
||||
};
|
||||
|
||||
let open = manager
|
||||
.create_thread(CreateThreadRequest::default())
|
||||
.await?;
|
||||
let first = manager
|
||||
.start_turn(
|
||||
&open.id,
|
||||
StartTurnRequest {
|
||||
prompt: "list".into(),
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
wait_for_terminal_turn(&manager, &first.id).await?;
|
||||
|
||||
let restricted: CreateThreadRequest =
|
||||
serde_json::from_value(json!({ "disallowed_tools": ["file"] }))?;
|
||||
let restricted = manager.create_thread(restricted).await?;
|
||||
assert_eq!(
|
||||
serde_json::to_value(manager.get_thread(&restricted.id).await?)?["disallowed_tools"],
|
||||
json!(["file"])
|
||||
);
|
||||
let second = manager
|
||||
.start_turn(
|
||||
&restricted.id,
|
||||
StartTurnRequest {
|
||||
prompt: "list".into(),
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
wait_for_terminal_turn(&manager, &second.id).await?;
|
||||
|
||||
let requests = mock.captured_requests();
|
||||
assert_eq!(requests.len(), 2);
|
||||
let denied = |names: &[String]| {
|
||||
names
|
||||
.iter()
|
||||
.any(|name| codewhale_execpolicy::tool_rules::tool_denied(Some(&rules), name))
|
||||
};
|
||||
assert!(
|
||||
denied(&tool_names(&requests[0])),
|
||||
"the control thread must expose a tool the rule covers"
|
||||
);
|
||||
assert!(
|
||||
!denied(&tool_names(&requests[1])),
|
||||
"{:?}",
|
||||
tool_names(&requests[1])
|
||||
);
|
||||
manager.shutdown_and_wait().await?;
|
||||
drop(manager);
|
||||
|
||||
let open_disk: serde_json::Value = serde_json::from_str(&fs::read_to_string(
|
||||
dir.path()
|
||||
.join("runtime")
|
||||
.join("threads")
|
||||
.join(format!("{}.json", open.id)),
|
||||
)?)?;
|
||||
assert_eq!(open_disk["schema_version"], CURRENT_RUNTIME_SCHEMA_VERSION);
|
||||
assert!(open_disk.get("disallowed_tools").is_none());
|
||||
|
||||
let restricted_path = dir
|
||||
.path()
|
||||
.join("runtime")
|
||||
.join("threads")
|
||||
.join(format!("{}.json", restricted.id));
|
||||
let mut restricted_disk: serde_json::Value =
|
||||
serde_json::from_str(&fs::read_to_string(&restricted_path)?)?;
|
||||
assert_eq!(
|
||||
restricted_disk["schema_version"],
|
||||
DISALLOWED_TOOLS_RUNTIME_SCHEMA_VERSION
|
||||
);
|
||||
assert_eq!(restricted_disk["disallowed_tools"], json!(["file"]));
|
||||
|
||||
let reopened = RuntimeThreadManager::open(
|
||||
config(),
|
||||
dir.path().join("workspace"),
|
||||
test_manager_config(dir.path().join("runtime")),
|
||||
)?;
|
||||
let reloaded = reopened.get_thread(&restricted.id).await?;
|
||||
assert_eq!(
|
||||
reloaded.schema_version,
|
||||
DISALLOWED_TOOLS_RUNTIME_SCHEMA_VERSION
|
||||
);
|
||||
assert_eq!(reloaded.disallowed_tools, vec!["file"]);
|
||||
|
||||
restricted_disk["schema_version"] = json!(MAX_SUPPORTED_RUNTIME_SCHEMA_VERSION + 1);
|
||||
fs::write(
|
||||
&restricted_path,
|
||||
serde_json::to_string_pretty(&restricted_disk)?,
|
||||
)?;
|
||||
let err = reopened
|
||||
.get_thread(&restricted.id)
|
||||
.await
|
||||
.expect_err("schema v8 must be rejected");
|
||||
assert!(
|
||||
format!("{err:#}").contains("newer than supported"),
|
||||
"{err:#}"
|
||||
);
|
||||
reopened.shutdown_and_wait().await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn control_case(interrupt: bool, follow_up: bool) -> Result<()> {
|
||||
let _env = crate::test_support::lock_test_env();
|
||||
let dir = tempfile::tempdir()?;
|
||||
@@ -2669,6 +2798,7 @@ fn sample_thread(thread_id: &str) -> ThreadRecord {
|
||||
executor: None,
|
||||
environment: None,
|
||||
agent_preset: None,
|
||||
disallowed_tools: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19969,6 +20099,7 @@ fn opening_manager_recovers_stale_queued_and_in_progress_work() -> Result<()> {
|
||||
executor: None,
|
||||
environment: None,
|
||||
agent_preset: None,
|
||||
disallowed_tools: Vec::new(),
|
||||
};
|
||||
manager.store.save_thread(&thread)?;
|
||||
|
||||
@@ -23834,6 +23965,7 @@ mod adoption_refusal {
|
||||
auto_approve: Some(false),
|
||||
delivery_mode: None,
|
||||
status: Some(AutomationStatus::Paused),
|
||||
disallowed_tools: Vec::new(),
|
||||
})?;
|
||||
automations.edit_automation(&created.id, |record| {
|
||||
let mut record =
|
||||
|
||||
@@ -49,6 +49,16 @@ const PARKED_POLL_CAP: Duration = Duration::from_secs(2);
|
||||
// v4 binds execution to a trusted Runtime scope. Older executors must not
|
||||
// ignore its eligibility or generation fence.
|
||||
const CURRENT_TASK_SCHEMA_VERSION: u32 = 4;
|
||||
const DENY_RULES_TASK_SCHEMA_VERSION: u32 = 5;
|
||||
const MAX_SUPPORTED_TASK_SCHEMA_VERSION: u32 = DENY_RULES_TASK_SCHEMA_VERSION;
|
||||
|
||||
const fn required_task_schema_version(disallowed_tools: &[String]) -> u32 {
|
||||
if disallowed_tools.is_empty() {
|
||||
CURRENT_TASK_SCHEMA_VERSION
|
||||
} else {
|
||||
DENY_RULES_TASK_SCHEMA_VERSION
|
||||
}
|
||||
}
|
||||
const STORE_REFRESH_INTERVAL: Duration = Duration::from_millis(200);
|
||||
/// How long a worker with nothing claimable naps between queue-fingerprint
|
||||
/// checks. An in-process notify still wakes it at once; this only bounds how
|
||||
@@ -342,6 +352,8 @@ pub struct TaskRecord {
|
||||
/// posture from that bit exactly as it always did.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub permission_posture: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub disallowed_tools: Vec<String>,
|
||||
pub status: TaskStatus,
|
||||
pub created_at: DateTime<Utc>,
|
||||
pub started_at: Option<DateTime<Utc>>,
|
||||
@@ -485,6 +497,8 @@ pub struct NewTaskRequest {
|
||||
/// Posture for the thread this task runs on. Takes precedence over
|
||||
/// `auto_approve`, which the runtime only reads when no posture is given.
|
||||
pub permission_posture: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub disallowed_tools: Vec<String>,
|
||||
pub owner_session_id: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub destination_thread_id: Option<String>,
|
||||
@@ -505,6 +519,7 @@ impl NewTaskRequest {
|
||||
trust_mode: Some(task.trust_mode),
|
||||
auto_approve: Some(task.auto_approve),
|
||||
permission_posture: task.permission_posture.clone(),
|
||||
disallowed_tools: task.disallowed_tools.clone(),
|
||||
owner_session_id: task.owner_session_id.clone(),
|
||||
destination_thread_id: task.destination_thread_id.clone(),
|
||||
}
|
||||
@@ -525,6 +540,7 @@ impl NewTaskRequest {
|
||||
trust_mode: None,
|
||||
auto_approve: Some(true),
|
||||
permission_posture: None,
|
||||
disallowed_tools: Vec::new(),
|
||||
owner_session_id: None,
|
||||
destination_thread_id: None,
|
||||
}
|
||||
@@ -852,6 +868,7 @@ pub struct ExecutionTask {
|
||||
trust_mode: bool,
|
||||
auto_approve: bool,
|
||||
permission_posture: Option<String>,
|
||||
disallowed_tools: Vec<String>,
|
||||
destination_thread_id: Option<String>,
|
||||
}
|
||||
|
||||
@@ -869,6 +886,7 @@ impl From<&TaskRecord> for ExecutionTask {
|
||||
trust_mode: task.trust_mode,
|
||||
auto_approve: task.auto_approve,
|
||||
permission_posture: task.permission_posture.clone(),
|
||||
disallowed_tools: task.disallowed_tools.clone(),
|
||||
destination_thread_id: task.destination_thread_id.clone(),
|
||||
}
|
||||
}
|
||||
@@ -886,6 +904,7 @@ impl ExecutionTask {
|
||||
trust_mode: Some(self.trust_mode),
|
||||
auto_approve: Some(self.auto_approve),
|
||||
permission_posture: self.permission_posture.clone(),
|
||||
disallowed_tools: self.disallowed_tools.clone(),
|
||||
task_id: Some(self.id.clone()),
|
||||
..Default::default()
|
||||
}
|
||||
@@ -1067,6 +1086,22 @@ impl TaskExecutor for EngineTaskExecutor {
|
||||
return TaskExecutionResult::from_reason(TaskTerminalReason::Canceled, None);
|
||||
}
|
||||
let (thread_id, turn) = if let Some(destination) = task.destination_thread_id.clone() {
|
||||
if !task.disallowed_tools.is_empty() {
|
||||
match self.runtime_threads.get_thread(&destination).await {
|
||||
Ok(thread)
|
||||
if deny_rules_cover(&thread.disallowed_tools, &task.disallowed_tools) => {}
|
||||
Ok(_) => {
|
||||
return TaskExecutionResult::failed(format!(
|
||||
"Destination thread {destination} does not run under the deny rules this task was created with"
|
||||
));
|
||||
}
|
||||
Err(err) => {
|
||||
return TaskExecutionResult::failed(format!(
|
||||
"Destination thread {destination} cannot take this turn: {err}"
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
let deadline = Instant::now() + self.limits.wall_time;
|
||||
let turn = loop {
|
||||
match self
|
||||
@@ -2349,9 +2384,10 @@ impl TaskManager {
|
||||
req.auto_approve,
|
||||
)?;
|
||||
validate_preallocated_task_id(&task_id)?;
|
||||
let disallowed_tools = normalize_deny_rules(req.disallowed_tools);
|
||||
|
||||
let task = TaskRecord {
|
||||
schema_version: CURRENT_TASK_SCHEMA_VERSION,
|
||||
schema_version: required_task_schema_version(&disallowed_tools),
|
||||
// 16 random hex chars (was 8; ~60 bits of entropy once UUIDv4's
|
||||
// fixed version nibble is discounted): task ids live in durable
|
||||
// state that accumulates across restarts, and a collision
|
||||
@@ -2378,6 +2414,7 @@ impl TaskManager {
|
||||
// (GHSA-72w5-pf8h-xfp4).
|
||||
auto_approve: req.auto_approve.unwrap_or(false),
|
||||
permission_posture: req.permission_posture,
|
||||
disallowed_tools,
|
||||
status: TaskStatus::Queued,
|
||||
created_at: Utc::now(),
|
||||
started_at: None,
|
||||
@@ -3877,6 +3914,12 @@ impl TaskManager {
|
||||
|
||||
fn persist_task_locked(&self, task: &TaskRecord) -> Result<()> {
|
||||
let path = self.tasks_dir.join(format!("{}.json", task.id));
|
||||
let min_version = required_task_schema_version(&task.disallowed_tools);
|
||||
if task.schema_version < min_version {
|
||||
let mut updated = task.clone();
|
||||
updated.schema_version = min_version;
|
||||
return write_json_atomic(&path, &updated);
|
||||
}
|
||||
write_json_atomic(&path, task)
|
||||
}
|
||||
}
|
||||
@@ -3898,12 +3941,40 @@ fn read_bound_task_file(path: &Path, task_id: &str) -> Result<Option<TaskRecord>
|
||||
Err(error) => return Err(error).context("read bound task"),
|
||||
};
|
||||
let task: TaskRecord = serde_json::from_slice(&bytes).context("decode bound task")?;
|
||||
if task.id != task_id || task.schema_version > CURRENT_TASK_SCHEMA_VERSION {
|
||||
if task.id != task_id || task.schema_version > MAX_SUPPORTED_TASK_SCHEMA_VERSION {
|
||||
bail!("Bound task identity or schema does not match its durable admission");
|
||||
}
|
||||
Ok(Some(task))
|
||||
}
|
||||
|
||||
pub fn normalize_deny_rules(rules: Vec<String>) -> Vec<String> {
|
||||
let mut seen = Vec::with_capacity(rules.len());
|
||||
for rule in rules {
|
||||
let rule = rule.trim();
|
||||
if !rule.is_empty() && !seen.iter().any(|existing: &String| existing == rule) {
|
||||
seen.push(rule.to_string());
|
||||
}
|
||||
}
|
||||
seen
|
||||
}
|
||||
|
||||
fn deny_rule_covers(held: &str, required: &str) -> bool {
|
||||
let held = held.to_ascii_lowercase();
|
||||
let required = required.to_ascii_lowercase();
|
||||
match (held.strip_suffix('*'), required.strip_suffix('*')) {
|
||||
(Some(held_prefix), Some(required_prefix)) => required_prefix.starts_with(held_prefix),
|
||||
(Some(held_prefix), None) => required.starts_with(held_prefix),
|
||||
(None, Some(_)) => false,
|
||||
(None, None) => held == required,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn deny_rules_cover(held: &[String], required: &[String]) -> bool {
|
||||
required
|
||||
.iter()
|
||||
.all(|rule| held.iter().any(|held| deny_rule_covers(held, rule)))
|
||||
}
|
||||
|
||||
pub fn validate_bound_task_request(task: &TaskRecord, request: &NewTaskRequest) -> Result<()> {
|
||||
if task.prompt != request.prompt.trim()
|
||||
|| task.owner_session_id != request.owner_session_id
|
||||
@@ -3933,6 +4004,7 @@ pub fn validate_bound_task_request(task: &TaskRecord, request: &NewTaskRequest)
|
||||
.as_deref()
|
||||
.is_some_and(|value| Some(value) != task.permission_posture.as_deref())
|
||||
|| task.auto_approve != request.auto_approve.unwrap_or(false)
|
||||
|| task.disallowed_tools != normalize_deny_rules(request.disallowed_tools.clone())
|
||||
{
|
||||
bail!("Task admission replay does not match the bound request");
|
||||
}
|
||||
@@ -3961,11 +4033,11 @@ fn load_state(tasks_dir: &Path, queue_path: &Path) -> Result<LoadedTaskState> {
|
||||
.with_context(|| format!("Failed to read task file {}", path.display()))?;
|
||||
let task: TaskRecord = serde_json::from_str(&content)
|
||||
.with_context(|| format!("Failed to parse task file {}", path.display()))?;
|
||||
if task.schema_version > CURRENT_TASK_SCHEMA_VERSION {
|
||||
if task.schema_version > MAX_SUPPORTED_TASK_SCHEMA_VERSION {
|
||||
bail!(
|
||||
"Task schema v{} is newer than supported v{}",
|
||||
task.schema_version,
|
||||
CURRENT_TASK_SCHEMA_VERSION
|
||||
MAX_SUPPORTED_TASK_SCHEMA_VERSION
|
||||
);
|
||||
}
|
||||
ensure_safe_storage_id("task id", &task.id)?;
|
||||
@@ -4282,6 +4354,33 @@ mod tests {
|
||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
use tokio::time::Duration;
|
||||
|
||||
#[test]
|
||||
fn deny_rules_cover_only_when_every_required_rule_is_held() {
|
||||
let rules = |items: &[&str]| items.iter().map(ToString::to_string).collect::<Vec<_>>();
|
||||
assert!(deny_rules_cover(&[], &[]));
|
||||
assert!(!deny_rules_cover(&[], &rules(&["mcp_secret_*"])));
|
||||
assert!(deny_rules_cover(
|
||||
&rules(&["MCP_secret_*", "Bash"]),
|
||||
&rules(&["mcp_secret_*"])
|
||||
));
|
||||
assert!(deny_rules_cover(
|
||||
&rules(&["mcp_*"]),
|
||||
&rules(&["mcp_secret_*", "mcp_secret_read"])
|
||||
));
|
||||
assert!(!deny_rules_cover(
|
||||
&rules(&["mcp_secret_read"]),
|
||||
&rules(&["mcp_secret_*"])
|
||||
));
|
||||
assert!(!deny_rules_cover(
|
||||
&rules(&["mcp_secret_*"]),
|
||||
&rules(&["mcp_*"])
|
||||
));
|
||||
assert_eq!(
|
||||
normalize_deny_rules(rules(&[" a ", "", "a", "b*"])),
|
||||
rules(&["a", "b*"])
|
||||
);
|
||||
}
|
||||
|
||||
struct MockExecutor;
|
||||
|
||||
/// Poll until the task is claimed as `Running`, or fail at `timeout`.
|
||||
@@ -5761,6 +5860,7 @@ mod tests {
|
||||
summary: "Task started".to_string(),
|
||||
detail_path: None,
|
||||
}],
|
||||
disallowed_tools: Vec::new(),
|
||||
};
|
||||
fs::write(
|
||||
tasks_dir.join(format!("{task_id}.json")),
|
||||
@@ -5929,6 +6029,7 @@ mod tests {
|
||||
auto_approve: None,
|
||||
permission_posture: None,
|
||||
owner_session_id: None,
|
||||
disallowed_tools: Vec::new(),
|
||||
};
|
||||
let task = manager.add_task(req).await?;
|
||||
|
||||
@@ -6459,6 +6560,7 @@ mod tests {
|
||||
github_events: Vec::new(),
|
||||
tool_calls: Vec::new(),
|
||||
timeline: Vec::new(),
|
||||
disallowed_tools: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7079,6 +7181,34 @@ mod tests {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn task_with_deny_rules_refuses_a_destination_thread_that_lacks_them() -> Result<()> {
|
||||
let runtime = Arc::new(test_runtime_manager().await?);
|
||||
let open = runtime
|
||||
.create_thread(CreateThreadRequest::default())
|
||||
.await?;
|
||||
let mut record = sample_task_record();
|
||||
record.destination_thread_id = Some(open.id.clone());
|
||||
record.disallowed_tools = vec!["mcp_secret_*".to_string()];
|
||||
let (tx, rx) = mpsc::channel(64);
|
||||
tokio::spawn(drain_task_events(rx));
|
||||
let executor =
|
||||
EngineTaskExecutor::new(runtime, TaskExecutionLimits::short_for_tests(), false);
|
||||
let result = executor
|
||||
.execute(ExecutionTask::from(&record), tx, CancellationToken::new())
|
||||
.await;
|
||||
assert_eq!(result.status, TaskStatus::Failed);
|
||||
assert!(
|
||||
result
|
||||
.error
|
||||
.as_deref()
|
||||
.is_some_and(|error| error.contains("deny rules")),
|
||||
"{:?}",
|
||||
result.error
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn engine_turn_without_terminal_event_idle_times_out() -> Result<()> {
|
||||
let runtime = test_runtime_manager().await?;
|
||||
@@ -8480,6 +8610,70 @@ mod tests {
|
||||
assert!(error.is_some_and(|message| message.contains(path)));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn denied_task_uses_v5_schema_while_open_task_stays_v4() -> Result<()> {
|
||||
let tmp = tempfile::tempdir()?;
|
||||
let root = tmp.path().to_path_buf();
|
||||
let manager =
|
||||
TaskManager::start_with_executor(test_config(root.clone()), Arc::new(MockExecutor))
|
||||
.await?;
|
||||
|
||||
let open = manager
|
||||
.add_task(NewTaskRequest {
|
||||
disallowed_tools: vec![" ".to_string()],
|
||||
..NewTaskRequest::from_prompt("open task")
|
||||
})
|
||||
.await?;
|
||||
let mut restricted_req = NewTaskRequest::from_prompt("restricted task");
|
||||
restricted_req.disallowed_tools = vec!["shell".to_string(), " shell ".to_string()];
|
||||
let restricted = manager.add_task(restricted_req).await?;
|
||||
|
||||
assert_eq!(open.schema_version, CURRENT_TASK_SCHEMA_VERSION);
|
||||
assert!(open.disallowed_tools.is_empty());
|
||||
assert_eq!(restricted.schema_version, DENY_RULES_TASK_SCHEMA_VERSION);
|
||||
assert_eq!(restricted.disallowed_tools, vec!["shell"]);
|
||||
|
||||
let _ = wait_for_terminal_state(&manager, &open.id, Duration::from_secs(10)).await?;
|
||||
let _ = wait_for_terminal_state(&manager, &restricted.id, Duration::from_secs(10)).await?;
|
||||
manager.shutdown_and_wait().await?;
|
||||
drop(manager);
|
||||
|
||||
let open_json: serde_json::Value = serde_json::from_str(&fs::read_to_string(
|
||||
root.join("tasks").join(format!("{}.json", open.id)),
|
||||
)?)?;
|
||||
assert_eq!(open_json["schema_version"], CURRENT_TASK_SCHEMA_VERSION);
|
||||
assert!(open_json.get("disallowed_tools").is_none());
|
||||
|
||||
let restricted_path = root.join("tasks").join(format!("{}.json", restricted.id));
|
||||
let mut restricted_json: serde_json::Value =
|
||||
serde_json::from_str(&fs::read_to_string(&restricted_path)?)?;
|
||||
assert_eq!(
|
||||
restricted_json["schema_version"],
|
||||
DENY_RULES_TASK_SCHEMA_VERSION
|
||||
);
|
||||
assert_eq!(restricted_json["disallowed_tools"], json!(["shell"]));
|
||||
|
||||
let reloaded =
|
||||
TaskManager::start_with_executor(test_config(root.clone()), Arc::new(MockExecutor))
|
||||
.await?;
|
||||
let reloaded_task = reloaded.get_task(&restricted.id).await?;
|
||||
assert_eq!(reloaded_task.schema_version, DENY_RULES_TASK_SCHEMA_VERSION);
|
||||
assert_eq!(reloaded_task.disallowed_tools, vec!["shell"]);
|
||||
reloaded.shutdown_and_wait().await?;
|
||||
drop(reloaded);
|
||||
|
||||
restricted_json["schema_version"] = json!(MAX_SUPPORTED_TASK_SCHEMA_VERSION + 1);
|
||||
fs::write(
|
||||
&restricted_path,
|
||||
serde_json::to_string_pretty(&restricted_json)?,
|
||||
)?;
|
||||
match TaskManager::start_with_executor(test_config(root), Arc::new(MockExecutor)).await {
|
||||
Ok(_) => panic!("manager should reject task schema v6"),
|
||||
Err(err) => assert!(err.to_string().contains("newer than supported"), "{err:#}"),
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -357,6 +357,7 @@ impl AutomationTool {
|
||||
trust_mode,
|
||||
auto_approve,
|
||||
delivery_mode: optional_delivery_mode(input)?,
|
||||
disallowed_tools: context.disallowed_tools.clone(),
|
||||
status: Some(
|
||||
if input
|
||||
.get("paused")
|
||||
@@ -568,6 +569,15 @@ fn require_within_session_authority(
|
||||
automation: &AutomationRecord,
|
||||
verb: &str,
|
||||
) -> Result<(), ToolError> {
|
||||
if !crate::task_manager::deny_rules_cover(
|
||||
&automation.disallowed_tools,
|
||||
&context.disallowed_tools,
|
||||
) {
|
||||
return Err(ToolError::permission_denied(format!(
|
||||
"automation: cannot {verb} `{}`: it was created without every tool deny rule this session runs under. Ask the user to {verb} it from a session that holds that access.",
|
||||
automation.id,
|
||||
)));
|
||||
}
|
||||
let stored = [
|
||||
("allow_shell", automation.allow_shell),
|
||||
("trust_mode", automation.trust_mode),
|
||||
@@ -750,6 +760,70 @@ mod tests {
|
||||
use super::*;
|
||||
use crate::tools::spec::ToolSpec;
|
||||
|
||||
#[tokio::test]
|
||||
async fn stored_automation_carries_the_creating_session_deny_rules() {
|
||||
let workspace = tempfile::tempdir().expect("workspace");
|
||||
let manager = crate::automation_manager::AutomationManager::open_for_test(
|
||||
workspace.path().join("automations"),
|
||||
)
|
||||
.expect("automation manager");
|
||||
let shared = std::sync::Arc::new(tokio::sync::Mutex::new(manager));
|
||||
let tool = AutomationTool::new("automation");
|
||||
let mut restricted = ToolContext::new(workspace.path());
|
||||
restricted.runtime.automations = Some(shared.clone());
|
||||
restricted.disallowed_tools = vec!["mcp_secret_*".to_string()];
|
||||
let mut open = ToolContext::new(workspace.path());
|
||||
open.runtime.automations = Some(shared.clone());
|
||||
let create = |name: &str| {
|
||||
json!({
|
||||
"action": "create",
|
||||
"name": name,
|
||||
"prompt": "summarise the day",
|
||||
"rrule": "FREQ=HOURLY",
|
||||
"paused": true
|
||||
})
|
||||
};
|
||||
|
||||
let created = tool
|
||||
.execute(create("restricted"), &restricted)
|
||||
.await
|
||||
.expect("create accepted");
|
||||
let record: Value = serde_json::from_str(&created.content).expect("record json");
|
||||
assert_eq!(
|
||||
record["disallowed_tools"],
|
||||
json!(["mcp_secret_*"]),
|
||||
"{record}"
|
||||
);
|
||||
let restricted_id = record["id"].as_str().expect("id").to_string();
|
||||
|
||||
let created = tool
|
||||
.execute(create("open"), &open)
|
||||
.await
|
||||
.expect("create accepted");
|
||||
let record: Value = serde_json::from_str(&created.content).expect("record json");
|
||||
assert!(record.get("disallowed_tools").is_none(), "{record}");
|
||||
let open_id = record["id"].as_str().expect("id").to_string();
|
||||
|
||||
tool.execute(
|
||||
json!({"action": "update", "automation_id": restricted_id, "prompt": "again"}),
|
||||
&restricted,
|
||||
)
|
||||
.await
|
||||
.expect("a session may edit what runs under its own rules");
|
||||
for action in ["update", "resume"] {
|
||||
let refused = tool
|
||||
.execute(
|
||||
json!({"action": action, "automation_id": open_id, "prompt": "inject"}),
|
||||
&restricted,
|
||||
)
|
||||
.await;
|
||||
assert!(
|
||||
refused.is_err(),
|
||||
"{action}: an automation without the session's deny rules is out of reach"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn create_and_update_cap_requested_authority_at_the_session() {
|
||||
let workspace = tempfile::tempdir().expect("workspace");
|
||||
|
||||
@@ -730,6 +730,10 @@ impl CodemodeInvoker {
|
||||
extra_refusal: ExtraRefusal,
|
||||
) -> Self {
|
||||
context.execution.nested_call_gate = None;
|
||||
context.execution.strict_workspace_confinement = true;
|
||||
context.execution.trust_mode = false;
|
||||
context.execution.follow_symlinks = false;
|
||||
context.execution.trusted_external_paths.clear();
|
||||
Self::with_gate(
|
||||
specs,
|
||||
context,
|
||||
|
||||
@@ -54,9 +54,8 @@
|
||||
//! forks a process, which `analyst`/`scout` were never granted), and
|
||||
//! anything that can name a program is held to the raw-shell bar. Every
|
||||
//! consumer of that contract — the catalog filter, the dispatch guard, and
|
||||
//! `reject_unbounded_verification` / `is_delegated_builtin_verification` in
|
||||
//! [`crate::tools::subagent`] — reads this one classifier rather than
|
||||
//! re-deriving it.
|
||||
//! `reject_unbounded_verification` in [`crate::tools::subagent`] — reads this
|
||||
//! one classifier rather than re-deriving it.
|
||||
|
||||
use serde_json::Value;
|
||||
|
||||
|
||||
+604
-45
@@ -47,15 +47,13 @@ pub fn content_hash(bytes: &[u8]) -> String {
|
||||
/// never has the full contents to hash. Digesting through a separate streaming
|
||||
/// pass keeps that memory bound while still producing a hash over the entire
|
||||
/// file — the only value an edit guard can verify against.
|
||||
fn hash_file_streaming(path: &Path) -> std::io::Result<String> {
|
||||
fn hash_reader_streaming(reader: &mut impl std::io::Read) -> std::io::Result<String> {
|
||||
use sha2::{Digest, Sha256};
|
||||
use std::io::Read as _;
|
||||
|
||||
let mut file = fs::File::open(path)?;
|
||||
let mut hasher = Sha256::new();
|
||||
let mut buf = vec![0_u8; 64 * 1024];
|
||||
loop {
|
||||
let read = file.read(&mut buf)?;
|
||||
let read = reader.read(&mut buf)?;
|
||||
if read == 0 {
|
||||
break;
|
||||
}
|
||||
@@ -67,6 +65,11 @@ fn hash_file_streaming(path: &Path) -> std::io::Result<String> {
|
||||
))
|
||||
}
|
||||
|
||||
fn hash_file_streaming(path: &Path) -> std::io::Result<String> {
|
||||
let mut file = fs::File::open(path)?;
|
||||
hash_reader_streaming(&mut file)
|
||||
}
|
||||
|
||||
/// The one-line header that reports a snapshot hash to the model.
|
||||
///
|
||||
/// This goes in `ToolResult::content`, not in `ToolResult::metadata`, and that
|
||||
@@ -683,6 +686,196 @@ async fn run_blocking_write_atomic(path: &Path, contents: Vec<u8>) -> Result<(),
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, PartialEq, Eq)]
|
||||
enum ConfinedFileId {
|
||||
#[cfg(unix)]
|
||||
Unix { dev: u64, ino: u64 },
|
||||
#[cfg(windows)]
|
||||
Windows { volume: u32, index: u64 },
|
||||
#[cfg(all(not(unix), not(windows)))]
|
||||
Unsupported,
|
||||
}
|
||||
|
||||
fn confined_file_id(file: &fs::File) -> std::io::Result<ConfinedFileId> {
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::MetadataExt as _;
|
||||
let meta = file.metadata()?;
|
||||
Ok(ConfinedFileId::Unix {
|
||||
dev: meta.dev(),
|
||||
ino: meta.ino(),
|
||||
})
|
||||
}
|
||||
#[cfg(windows)]
|
||||
{
|
||||
let identity = codewhale_util::path_identity::windows_file_identity(file)?;
|
||||
Ok(ConfinedFileId::Windows {
|
||||
volume: identity.volume,
|
||||
index: identity.index,
|
||||
})
|
||||
}
|
||||
#[cfg(all(not(unix), not(windows)))]
|
||||
{
|
||||
let _ = file;
|
||||
Err(std::io::ErrorKind::Unsupported.into())
|
||||
}
|
||||
}
|
||||
|
||||
fn is_confined_policy_error(error: &std::io::Error) -> bool {
|
||||
#[cfg(unix)]
|
||||
if error.raw_os_error() == Some(libc::ELOOP) {
|
||||
return true;
|
||||
}
|
||||
matches!(
|
||||
error.kind(),
|
||||
std::io::ErrorKind::InvalidInput
|
||||
| std::io::ErrorKind::InvalidData
|
||||
| std::io::ErrorKind::AlreadyExists
|
||||
| std::io::ErrorKind::PermissionDenied
|
||||
)
|
||||
}
|
||||
|
||||
fn map_confined_io_error(error: std::io::Error, path: &Path, action: &str) -> ToolError {
|
||||
if is_confined_policy_error(&error) {
|
||||
return ToolError::permission_denied(format!(
|
||||
"{action} refused symlink or confined path constraint for {}",
|
||||
path.display()
|
||||
));
|
||||
}
|
||||
ToolError::execution_failed(format!("Failed to {action} {}: {error}", path.display()))
|
||||
}
|
||||
|
||||
enum ConfinedPrior {
|
||||
Missing,
|
||||
Existing { bytes: Vec<u8>, id: ConfinedFileId },
|
||||
}
|
||||
|
||||
fn write_uses_confined_io(context: &ToolContext) -> bool {
|
||||
context.strict_workspace_confinement
|
||||
|| (!context.trust_mode
|
||||
&& !context.follow_symlinks
|
||||
&& context.trusted_external_paths.is_empty())
|
||||
}
|
||||
|
||||
async fn resolve_confined_target_async(
|
||||
context: &ToolContext,
|
||||
raw: &str,
|
||||
allow_root_dot: bool,
|
||||
) -> Result<(PathBuf, PathBuf), ToolError> {
|
||||
let workspace_canonical =
|
||||
tokio::fs::canonicalize(&context.workspace)
|
||||
.await
|
||||
.map_err(|error| {
|
||||
ToolError::execution_failed(format!(
|
||||
"Failed to canonicalize workspace {}: {error}",
|
||||
context.workspace.display()
|
||||
))
|
||||
})?;
|
||||
let relative =
|
||||
super::spec::resolve_confined_target(context, &workspace_canonical, raw, allow_root_dot)?;
|
||||
Ok((workspace_canonical, relative))
|
||||
}
|
||||
|
||||
async fn load_confined_prior_file(
|
||||
workspace_canonical: &Path,
|
||||
relative: &Path,
|
||||
display_path: &Path,
|
||||
max_bytes: Option<usize>,
|
||||
context: &ToolContext,
|
||||
) -> Result<ConfinedPrior, ToolError> {
|
||||
check_file_operation_cancelled(context)?;
|
||||
let workspace = workspace_canonical.to_path_buf();
|
||||
let relative = relative.to_path_buf();
|
||||
let display = display_path.to_path_buf();
|
||||
let cancel = context.cancel_token.clone();
|
||||
let mut worker = tokio::task::spawn_blocking(move || {
|
||||
use std::io::Read as _;
|
||||
check_contract_cancelled(cancel.as_ref())?;
|
||||
let ws =
|
||||
match codewhale_util::files::WorkspaceFile::open_shared(&workspace, &relative, false) {
|
||||
Ok(ws) => ws,
|
||||
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
|
||||
return Ok(ConfinedPrior::Missing);
|
||||
}
|
||||
Err(error) => return Err(map_confined_io_error(error, &display, "inspect")),
|
||||
};
|
||||
let mut file = match ws.open_file() {
|
||||
Ok(file) => file,
|
||||
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
|
||||
return Ok(ConfinedPrior::Missing);
|
||||
}
|
||||
Err(error) => return Err(map_confined_io_error(error, &display, "read")),
|
||||
};
|
||||
let id = confined_file_id(&file)
|
||||
.map_err(|error| map_confined_io_error(error, &display, "inspect"))?;
|
||||
let bytes = if let Some(cap) = max_bytes {
|
||||
let meta = file
|
||||
.metadata()
|
||||
.map_err(|error| map_confined_io_error(error, &display, "read"))?;
|
||||
if meta.len() > cap as u64 {
|
||||
check_contract_file_size(CONTRACT_FILE_MAX_BYTES + 1)?;
|
||||
}
|
||||
read_contract_source(&mut file, cancel.as_ref())?
|
||||
} else {
|
||||
let mut bytes = Vec::new();
|
||||
file.read_to_end(&mut bytes)
|
||||
.map_err(|error| map_confined_io_error(error, &display, "read"))?;
|
||||
bytes
|
||||
};
|
||||
Ok(ConfinedPrior::Existing { bytes, id })
|
||||
});
|
||||
let result = if let Some(cancel) = context.cancel_token.as_ref() {
|
||||
tokio::select! {
|
||||
biased;
|
||||
() = cancel.cancelled() => return Err(ToolError::cancelled("Operation aborted")),
|
||||
result = &mut worker => result,
|
||||
}
|
||||
} else {
|
||||
worker.await
|
||||
};
|
||||
result.map_err(|error| ToolError::execution_failed(format!("File read task: {error}")))?
|
||||
}
|
||||
|
||||
async fn commit_confined_write(
|
||||
workspace_canonical: &Path,
|
||||
relative: &Path,
|
||||
display_path: &Path,
|
||||
expected_id: Option<ConfinedFileId>,
|
||||
contents: Vec<u8>,
|
||||
) -> Result<(), ToolError> {
|
||||
let workspace = workspace_canonical.to_path_buf();
|
||||
let relative = relative.to_path_buf();
|
||||
let display = display_path.to_path_buf();
|
||||
tokio::task::spawn_blocking(move || {
|
||||
let create = expected_id.is_none();
|
||||
let ws = codewhale_util::files::WorkspaceFile::open_shared(&workspace, &relative, create)
|
||||
.map_err(|error| map_confined_io_error(error, &display, "write"))?;
|
||||
match expected_id {
|
||||
None => ws
|
||||
.publish(&contents)
|
||||
.map_err(|error| map_confined_io_error(error, &display, "write")),
|
||||
Some(prior_id) => {
|
||||
let probe = ws
|
||||
.identity_probe()
|
||||
.map_err(|error| map_confined_io_error(error, &display, "write"))?;
|
||||
let current_id = confined_file_id(&probe)
|
||||
.map_err(|error| map_confined_io_error(error, &display, "write"))?;
|
||||
if current_id != prior_id {
|
||||
return Err(ToolError::permission_denied(format!(
|
||||
"write target changed before replacement: {}",
|
||||
display.display()
|
||||
)));
|
||||
}
|
||||
drop(probe);
|
||||
ws.replace(&contents)
|
||||
.map_err(|error| map_confined_io_error(error, &display, "write"))
|
||||
}
|
||||
}
|
||||
})
|
||||
.await
|
||||
.map_err(|error| ToolError::execution_failed(format!("File write task: {error}")))?
|
||||
}
|
||||
|
||||
fn check_file_operation_cancelled(context: &ToolContext) -> Result<(), ToolError> {
|
||||
check_contract_cancelled(context.cancel_token.as_ref())
|
||||
}
|
||||
@@ -1024,6 +1217,9 @@ impl ToolSpec for ReadFileTool {
|
||||
READ_PARAMS.reject_unknown(&input)?;
|
||||
|
||||
let path_str = required_str(&input, "path")?;
|
||||
if context.strict_workspace_confinement {
|
||||
return execute_confined_read(&input, path_str, context).await;
|
||||
}
|
||||
// S1/F2: raw spelling first, resolved path after — see the matching
|
||||
// comment in `execute_contract_read`. Only the raw-spelling denial can
|
||||
// promise an error that never names the symlink target's location.
|
||||
@@ -1163,7 +1359,7 @@ impl ToolSpec for ReadFileTool {
|
||||
e
|
||||
))
|
||||
})?;
|
||||
let (window, total_lines) = read_window_streaming(file, start_line, max_lines)
|
||||
let (window, total_lines) = read_window_streaming(&file, start_line, max_lines)
|
||||
.map_err(|e| {
|
||||
ToolError::execution_failed(format!(
|
||||
"Failed to read {}: {}",
|
||||
@@ -1221,6 +1417,244 @@ impl ToolSpec for ReadFileTool {
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_read_range(input: &Value) -> Result<(usize, usize), ToolError> {
|
||||
let start_line = match optional_u64(input, "start_line", 1)? {
|
||||
0 => {
|
||||
return Err(ToolError::invalid_input(
|
||||
"start_line must be 1-based and greater than 0".to_string(),
|
||||
));
|
||||
}
|
||||
v => usize::try_from(v).map_err(|_| {
|
||||
ToolError::invalid_input("start_line exceeds platform addressable range".to_string())
|
||||
})?,
|
||||
};
|
||||
let max_lines = match optional_u64(input, "max_lines", DEFAULT_READ_LINES as u64)? {
|
||||
0 => {
|
||||
return Err(ToolError::invalid_input(
|
||||
"max_lines must be greater than 0".to_string(),
|
||||
));
|
||||
}
|
||||
v => {
|
||||
let converted = usize::try_from(v).map_err(|_| {
|
||||
ToolError::invalid_input("max_lines exceeds platform addressable range".to_string())
|
||||
})?;
|
||||
std::cmp::min(converted, HARD_MAX_READ_LINES)
|
||||
}
|
||||
};
|
||||
Ok((start_line, max_lines))
|
||||
}
|
||||
|
||||
enum ConfinedReadOutput {
|
||||
Pdf {
|
||||
id: ConfinedFileId,
|
||||
},
|
||||
Image {
|
||||
id: ConfinedFileId,
|
||||
},
|
||||
Small(String),
|
||||
Window {
|
||||
window: Vec<String>,
|
||||
total_lines: usize,
|
||||
start_line: usize,
|
||||
max_lines: usize,
|
||||
hash: Option<String>,
|
||||
},
|
||||
}
|
||||
|
||||
fn is_confined_pdf_handle(file: &mut fs::File, path: &Path) -> std::io::Result<bool> {
|
||||
use std::io::{Read as _, Seek as _};
|
||||
if path
|
||||
.extension()
|
||||
.and_then(|e| e.to_str())
|
||||
.is_some_and(|ext| ext.eq_ignore_ascii_case("pdf"))
|
||||
{
|
||||
return Ok(true);
|
||||
}
|
||||
let mut buf = [0u8; 4];
|
||||
let matched = file.read_exact(&mut buf).is_ok() && &buf == b"%PDF";
|
||||
file.seek(std::io::SeekFrom::Start(0))?;
|
||||
Ok(matched)
|
||||
}
|
||||
|
||||
async fn execute_confined_read(
|
||||
input: &Value,
|
||||
path_str: &str,
|
||||
context: &ToolContext,
|
||||
) -> Result<ToolResult, ToolError> {
|
||||
enforce_read_denylist(Path::new(path_str), "read_file")?;
|
||||
let (workspace_canonical, relative) =
|
||||
resolve_confined_target_async(context, path_str, false).await?;
|
||||
let file_path = workspace_canonical.join(&relative);
|
||||
if is_codewhale_credential_path(&file_path) {
|
||||
return Err(ToolError::permission_denied(
|
||||
"read_file cannot expose Codewhale configuration or credential-store files; use `codewhale config list` or `codewhale auth status` for safe inspection"
|
||||
.to_string(),
|
||||
));
|
||||
}
|
||||
enforce_read_denylist(&file_path, "read_file")?;
|
||||
let pages = optional_str(input, "pages")?;
|
||||
let explicit_range = input
|
||||
.get("start_line")
|
||||
.or_else(|| input.get("max_lines"))
|
||||
.is_some();
|
||||
|
||||
let output = tokio::task::spawn_blocking({
|
||||
let workspace_canonical = workspace_canonical.clone();
|
||||
let relative = relative.clone();
|
||||
let file_path = file_path.clone();
|
||||
let input_clone = input.clone();
|
||||
move || {
|
||||
use std::io::{Read as _, Seek as _};
|
||||
let ws = codewhale_util::files::WorkspaceFile::open_shared(
|
||||
&workspace_canonical,
|
||||
&relative,
|
||||
false,
|
||||
)
|
||||
.map_err(|error| map_confined_io_error(error, &file_path, "read"))?;
|
||||
let mut file = ws
|
||||
.open_file_shared()
|
||||
.map_err(|error| map_confined_io_error(error, &file_path, "read"))?;
|
||||
let id = confined_file_id(&file)
|
||||
.map_err(|error| map_confined_io_error(error, &file_path, "read"))?;
|
||||
if is_confined_pdf_handle(&mut file, &file_path)
|
||||
.map_err(|error| map_confined_io_error(error, &file_path, "read"))?
|
||||
{
|
||||
return Ok::<_, ToolError>(ConfinedReadOutput::Pdf { id });
|
||||
}
|
||||
if is_image_for_ocr(&file_path) {
|
||||
return Ok(ConfinedReadOutput::Image { id });
|
||||
}
|
||||
let file_bytes = file.metadata().map(|meta| meta.len()).unwrap_or(u64::MAX);
|
||||
if !explicit_range && file_bytes <= SMALL_FILE_BYTES as u64 {
|
||||
let mut contents = String::new();
|
||||
file.read_to_string(&mut contents)
|
||||
.map_err(|error| map_confined_io_error(error, &file_path, "read"))?;
|
||||
return Ok(ConfinedReadOutput::Small(contents));
|
||||
}
|
||||
let (start_line, max_lines) = parse_read_range(&input_clone)?;
|
||||
let (window, total_lines) = read_window_streaming(&file, start_line, max_lines)
|
||||
.map_err(|error| map_confined_io_error(error, &file_path, "read"))?;
|
||||
let hash = if file.seek(std::io::SeekFrom::Start(0)).is_ok() {
|
||||
hash_reader_streaming(&mut file).ok()
|
||||
} else {
|
||||
None
|
||||
};
|
||||
Ok(ConfinedReadOutput::Window {
|
||||
window,
|
||||
total_lines,
|
||||
start_line,
|
||||
max_lines,
|
||||
hash,
|
||||
})
|
||||
}
|
||||
})
|
||||
.await
|
||||
.map_err(|e| ToolError::execution_failed(format!("File read task: {e}")))??;
|
||||
|
||||
match output {
|
||||
ConfinedReadOutput::Pdf { id } => {
|
||||
verify_confined_probe(&workspace_canonical, &relative, &file_path, id, "read")?;
|
||||
if let Some(result) = read_pdf_if_detected(
|
||||
&file_path,
|
||||
pages,
|
||||
super::pdf::PdfTextCommand::system(Some(context)),
|
||||
)
|
||||
.await?
|
||||
{
|
||||
return Ok(result);
|
||||
}
|
||||
Err(ToolError::execution_failed(format!(
|
||||
"Failed to read {}",
|
||||
file_path.display()
|
||||
)))
|
||||
}
|
||||
ConfinedReadOutput::Image { id } => {
|
||||
verify_confined_probe(&workspace_canonical, &relative, &file_path, id, "read")?;
|
||||
read_image_via_ocr(&file_path, path_str, context).await
|
||||
}
|
||||
ConfinedReadOutput::Small(contents) => {
|
||||
context.note_file_read(&file_path);
|
||||
let total_lines = contents.lines().count();
|
||||
if total_lines <= SMALL_FILE_LINES {
|
||||
let hash = content_hash(contents.as_bytes());
|
||||
let body = format!("{}{contents}", content_hash_header(&hash));
|
||||
return Ok(ToolResult::success(body).with_metadata(json!({
|
||||
"evidence_routing": "inline",
|
||||
"content_hash": hash
|
||||
})));
|
||||
}
|
||||
let hash = content_hash(contents.as_bytes());
|
||||
let window: Vec<String> = contents
|
||||
.lines()
|
||||
.take(DEFAULT_READ_LINES)
|
||||
.map(str::to_string)
|
||||
.collect();
|
||||
Ok(render_line_window(
|
||||
path_str,
|
||||
&window,
|
||||
total_lines,
|
||||
1,
|
||||
DEFAULT_READ_LINES,
|
||||
Some(hash.as_str()),
|
||||
))
|
||||
}
|
||||
ConfinedReadOutput::Window {
|
||||
window,
|
||||
total_lines,
|
||||
start_line,
|
||||
max_lines,
|
||||
hash,
|
||||
} => {
|
||||
context.note_file_read(&file_path);
|
||||
if start_line > total_lines {
|
||||
let hash_attr = hash
|
||||
.as_deref()
|
||||
.map(|hash| format!(" content_hash=\"{hash}\""))
|
||||
.unwrap_or_default();
|
||||
let output = format!(
|
||||
"<file path=\"{path_str}\" total_lines=\"{total_lines}\" shown_lines=\"none\" truncated=\"false\"{hash_attr}>\n\n[NO CONTENT] start_line {start_line} is beyond total_lines {total_lines}.\n</file>"
|
||||
);
|
||||
return Ok(ToolResult::success(output).with_metadata(json!({
|
||||
"evidence_routing": "inline",
|
||||
"content_hash": hash
|
||||
})));
|
||||
}
|
||||
Ok(render_line_window(
|
||||
path_str,
|
||||
&window,
|
||||
total_lines,
|
||||
start_line,
|
||||
max_lines,
|
||||
hash.as_deref(),
|
||||
))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn verify_confined_probe(
|
||||
workspace_canonical: &Path,
|
||||
relative: &Path,
|
||||
display_path: &Path,
|
||||
expected_id: ConfinedFileId,
|
||||
action: &str,
|
||||
) -> Result<(), ToolError> {
|
||||
let ws =
|
||||
codewhale_util::files::WorkspaceFile::open_shared(workspace_canonical, relative, false)
|
||||
.map_err(|error| map_confined_io_error(error, display_path, action))?;
|
||||
let probe = ws
|
||||
.identity_probe()
|
||||
.map_err(|error| map_confined_io_error(error, display_path, action))?;
|
||||
let current_id = confined_file_id(&probe)
|
||||
.map_err(|error| map_confined_io_error(error, display_path, action))?;
|
||||
if current_id != expected_id {
|
||||
return Err(ToolError::permission_denied(format!(
|
||||
"{action} target changed before inspection: {}",
|
||||
display_path.display()
|
||||
)));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// Bounded output for large files. The small-file fast path keeps the
|
||||
// historical "return contents unchanged" behavior so existing flows
|
||||
// (small configs, single source files, etc.) don't suddenly start
|
||||
@@ -1246,7 +1680,7 @@ const SMALL_FILE_BYTES: usize = 16 * 1024;
|
||||
/// Returns the collected window plus the total line count. Only the window
|
||||
/// is ever held in memory.
|
||||
fn read_window_streaming(
|
||||
file: fs::File,
|
||||
file: &fs::File,
|
||||
start_line: usize,
|
||||
max_lines: usize,
|
||||
) -> std::io::Result<(Vec<String>, usize)> {
|
||||
@@ -1565,11 +1999,36 @@ impl WriteFileTool {
|
||||
let path_str = required_str(&input, "path")?;
|
||||
let file_content = required_str(&input, "content")?;
|
||||
check_contract_file_size(file_content.len())?;
|
||||
let file_path = context.resolve_path(path_str)?;
|
||||
let confined_target = if write_uses_confined_io(context) {
|
||||
Some(resolve_confined_target_async(context, path_str, false).await?)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let file_path = match confined_target.as_ref() {
|
||||
Some((workspace_canonical, relative)) => workspace_canonical.join(relative),
|
||||
None => context.resolve_path(path_str)?,
|
||||
};
|
||||
let mutation_guard = acquire_file_mutation(&file_path, context).await?;
|
||||
check_file_operation_cancelled(context)?;
|
||||
|
||||
let prior = load_contract_source(&file_path, false, context).await?;
|
||||
let (prior_id, prior) = match confined_target.as_ref() {
|
||||
Some((workspace_canonical, relative)) => match load_confined_prior_file(
|
||||
workspace_canonical,
|
||||
relative,
|
||||
&file_path,
|
||||
Some(CONTRACT_FILE_MAX_BYTES),
|
||||
context,
|
||||
)
|
||||
.await?
|
||||
{
|
||||
ConfinedPrior::Missing => (None, None),
|
||||
ConfinedPrior::Existing { bytes, id } => (Some(id), Some(bytes)),
|
||||
},
|
||||
None => (
|
||||
None,
|
||||
load_contract_source(&file_path, false, context).await?,
|
||||
),
|
||||
};
|
||||
let existed_before = prior.is_some();
|
||||
if existed_before {
|
||||
context.require_fresh_read_before_write(&file_path, path_str)?;
|
||||
@@ -1598,17 +2057,27 @@ impl WriteFileTool {
|
||||
}
|
||||
check_contract_file_size(written.len())?;
|
||||
check_file_operation_cancelled(context)?;
|
||||
if let Some(parent) = file_path.parent() {
|
||||
tokio::fs::create_dir_all(parent).await.map_err(|error| {
|
||||
ToolError::execution_failed(format!(
|
||||
"Failed to create directory {}: {error}",
|
||||
parent.display()
|
||||
))
|
||||
})?;
|
||||
if let Some((workspace_canonical, relative)) = confined_target.as_ref() {
|
||||
commit_confined_write(
|
||||
workspace_canonical,
|
||||
relative,
|
||||
&file_path,
|
||||
prior_id,
|
||||
written.clone().into_bytes(),
|
||||
)
|
||||
.await?;
|
||||
} else {
|
||||
if let Some(parent) = file_path.parent() {
|
||||
tokio::fs::create_dir_all(parent).await.map_err(|error| {
|
||||
ToolError::execution_failed(format!(
|
||||
"Failed to create directory {}: {error}",
|
||||
parent.display()
|
||||
))
|
||||
})?;
|
||||
}
|
||||
check_file_operation_cancelled(context)?;
|
||||
run_blocking_write_atomic(&file_path, written.clone().into_bytes()).await?;
|
||||
}
|
||||
check_file_operation_cancelled(context)?;
|
||||
// Once replacement starts, report its actual completion even if cancelled.
|
||||
run_blocking_write_atomic(&file_path, written.clone().into_bytes()).await?;
|
||||
context.note_file_read(&file_path);
|
||||
drop(mutation_guard);
|
||||
|
||||
@@ -1700,29 +2169,61 @@ impl ToolSpec for WriteFileTool {
|
||||
let expected_hash = optional_str(&input, "expected_hash")?;
|
||||
let line_ending = requested_line_ending(&input)?;
|
||||
|
||||
let file_path = context.resolve_path(path_str)?;
|
||||
let confined_target = if write_uses_confined_io(context) {
|
||||
Some(resolve_confined_target_async(context, path_str, false).await?)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let file_path = match confined_target.as_ref() {
|
||||
Some((workspace_canonical, relative)) => workspace_canonical.join(relative),
|
||||
None => context.resolve_path(path_str)?,
|
||||
};
|
||||
let mutation_guard = acquire_file_mutation(&file_path, context).await?;
|
||||
|
||||
// Snapshot the existing contents (if any) before we overwrite — used
|
||||
// to render an inline diff in the tool result. Only a genuinely
|
||||
// absent path is "new": a stat that fails for any other reason must
|
||||
// not let an existing file be overwritten as if it were empty.
|
||||
let existed_before = tokio::fs::try_exists(&file_path).await.map_err(|error| {
|
||||
ToolError::execution_failed(format!(
|
||||
"Failed to inspect {}: {error}",
|
||||
file_path.display()
|
||||
))
|
||||
})?;
|
||||
let prior_contents = if existed_before {
|
||||
tokio::fs::read_to_string(&file_path)
|
||||
.await
|
||||
.map_err(|error| {
|
||||
let (existed_before, prior_id, prior_contents) = match confined_target.as_ref() {
|
||||
Some((workspace_canonical, relative)) => {
|
||||
match load_confined_prior_file(
|
||||
workspace_canonical,
|
||||
relative,
|
||||
&file_path,
|
||||
None,
|
||||
context,
|
||||
)
|
||||
.await?
|
||||
{
|
||||
ConfinedPrior::Missing => (false, None, String::new()),
|
||||
ConfinedPrior::Existing { bytes, id } => {
|
||||
let contents = String::from_utf8(bytes).map_err(|error| {
|
||||
ToolError::execution_failed(format!(
|
||||
"Failed to read {}: {error}",
|
||||
file_path.display()
|
||||
))
|
||||
})?;
|
||||
(true, Some(id), contents)
|
||||
}
|
||||
}
|
||||
}
|
||||
None => {
|
||||
let existed_before = tokio::fs::try_exists(&file_path).await.map_err(|error| {
|
||||
ToolError::execution_failed(format!(
|
||||
"Failed to read {}: {error}",
|
||||
"Failed to inspect {}: {error}",
|
||||
file_path.display()
|
||||
))
|
||||
})?
|
||||
} else {
|
||||
String::new()
|
||||
})?;
|
||||
let prior_contents = if existed_before {
|
||||
tokio::fs::read_to_string(&file_path)
|
||||
.await
|
||||
.map_err(|error| {
|
||||
ToolError::execution_failed(format!(
|
||||
"Failed to read {}: {error}",
|
||||
file_path.display()
|
||||
))
|
||||
})?
|
||||
} else {
|
||||
String::new()
|
||||
};
|
||||
(existed_before, None, prior_contents)
|
||||
}
|
||||
};
|
||||
|
||||
// Content-hash guard (#3979), checked against the same snapshot the
|
||||
@@ -1758,16 +2259,28 @@ impl ToolSpec for WriteFileTool {
|
||||
if line_ending.is_some() {
|
||||
written = write_line_endings(&written, "", line_ending, true)?;
|
||||
}
|
||||
if let Some(parent) = file_path.parent() {
|
||||
tokio::fs::create_dir_all(parent).await.map_err(|error| {
|
||||
ToolError::execution_failed(format!(
|
||||
"Failed to create directory {}: {error}",
|
||||
parent.display()
|
||||
))
|
||||
})?;
|
||||
if let Some((workspace_canonical, relative)) = confined_target.as_ref() {
|
||||
commit_confined_write(
|
||||
workspace_canonical,
|
||||
relative,
|
||||
&file_path,
|
||||
prior_id,
|
||||
written.clone().into_bytes(),
|
||||
)
|
||||
.await?;
|
||||
} else {
|
||||
if let Some(parent) = file_path.parent() {
|
||||
tokio::fs::create_dir_all(parent).await.map_err(|error| {
|
||||
ToolError::execution_failed(format!(
|
||||
"Failed to create directory {}: {error}",
|
||||
parent.display()
|
||||
))
|
||||
})?;
|
||||
}
|
||||
run_blocking_write_atomic(&file_path, written.clone().into_bytes()).await?;
|
||||
}
|
||||
run_blocking_write_atomic(&file_path, written.clone().into_bytes()).await?;
|
||||
context.note_file_read(&file_path);
|
||||
drop(mutation_guard);
|
||||
|
||||
let display = file_path.display().to_string();
|
||||
let diff = make_unified_diff(&display, &prior_contents, &written);
|
||||
@@ -3436,6 +3949,52 @@ impl ToolSpec for ListDirTool {
|
||||
// raw spelling is checked first (F2) so the refusal names the caller's
|
||||
// path, never a symlink target it might resolve to.
|
||||
enforce_read_denylist(Path::new(path_str), "list_dir")?;
|
||||
if context.strict_workspace_confinement {
|
||||
let (workspace_canonical, relative) =
|
||||
resolve_confined_target_async(context, path_str, true).await?;
|
||||
let dir_path = if relative.as_os_str().is_empty() {
|
||||
workspace_canonical.clone()
|
||||
} else {
|
||||
workspace_canonical.join(&relative)
|
||||
};
|
||||
enforce_read_denylist(&dir_path, "list_dir")?;
|
||||
let worker_cancel_token = context.cancel_token.clone();
|
||||
let entries =
|
||||
run_blocking_list_dir(LIST_DIR_TIMEOUT, context.cancel_token.clone(), move || {
|
||||
check_list_dir_cancelled(worker_cancel_token.as_ref())?;
|
||||
let rel = (!relative.as_os_str().is_empty()).then_some(relative.as_path());
|
||||
let listing = codewhale_util::files::read_confined_dir(
|
||||
&workspace_canonical,
|
||||
rel,
|
||||
LIST_DIR_MAX_ENTRIES,
|
||||
)
|
||||
.map_err(|error| map_confined_io_error(error, &dir_path, "list"))?;
|
||||
check_list_dir_cancelled(worker_cancel_token.as_ref())?;
|
||||
let entries = listing
|
||||
.entries
|
||||
.into_iter()
|
||||
.map(|entry| {
|
||||
json!({
|
||||
"name": entry.name,
|
||||
"is_dir": entry.is_dir,
|
||||
})
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
if listing.total_entries > entries.len() {
|
||||
Ok(json!({
|
||||
"entries": entries,
|
||||
"listed_entries": LIST_DIR_MAX_ENTRIES,
|
||||
"total_entries": listing.total_entries,
|
||||
"truncated": true,
|
||||
}))
|
||||
} else {
|
||||
Ok(Value::Array(entries))
|
||||
}
|
||||
})
|
||||
.await?;
|
||||
return ToolResult::json(&entries)
|
||||
.map_err(|e| ToolError::execution_failed(e.to_string()));
|
||||
}
|
||||
let dir_path = context.resolve_path(path_str)?;
|
||||
enforce_read_denylist(&dir_path, "list_dir")?;
|
||||
|
||||
|
||||
@@ -3317,3 +3317,95 @@ async fn read_file_refusal_names_home_spelling_not_denied_symlink_target() {
|
||||
denied_file.display()
|
||||
);
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[tokio::test]
|
||||
async fn write_file_refuses_symlinks_to_git_hooks_and_strict_trust_escape() {
|
||||
let workspace_dir = tempfile::tempdir().expect("workspace");
|
||||
let outside_dir = tempfile::tempdir().expect("outside");
|
||||
let workspace = workspace_dir.path().canonicalize().expect("canonical ws");
|
||||
let hooks_dir = workspace.join(".git/hooks");
|
||||
fs::create_dir_all(&hooks_dir).expect("hooks dir");
|
||||
fs::create_dir_all(workspace.join("notes")).expect("notes dir");
|
||||
let hook = hooks_dir.join("pre-commit");
|
||||
fs::write(&hook, "clean-hook\n").expect("write hook");
|
||||
let outside_target = outside_dir.path().join("outside.txt");
|
||||
fs::write(&outside_target, "clean-outside\n").expect("write outside");
|
||||
|
||||
let link_leaf = workspace.join("notes/hook_link");
|
||||
std::os::unix::fs::symlink(&hook, &link_leaf).expect("symlink leaf");
|
||||
let link_dir = workspace.join("linked_hooks");
|
||||
std::os::unix::fs::symlink(&hooks_dir, &link_dir).expect("symlink dir");
|
||||
let hardlink_leaf = workspace.join("notes/hook_hard");
|
||||
fs::hard_link(&hook, &hardlink_leaf).expect("hard link leaf");
|
||||
|
||||
let ctx = ToolContext::new(&workspace);
|
||||
for bad in [
|
||||
"notes/hook_link",
|
||||
"linked_hooks/new-hook",
|
||||
"notes/hook_hard",
|
||||
] {
|
||||
let err = WriteFileTool
|
||||
.execute(json!({"path": bad, "content": "pwned\n"}), &ctx)
|
||||
.await
|
||||
.expect_err(bad);
|
||||
assert!(
|
||||
matches!(err, ToolError::PermissionDenied { .. }),
|
||||
"expected permission denied for {bad}, got {err:?}"
|
||||
);
|
||||
let contract_err =
|
||||
WriteFileTool::execute_contract_write(json!({"path": bad, "content": "pwned\n"}), &ctx)
|
||||
.await
|
||||
.expect_err(bad);
|
||||
assert!(
|
||||
matches!(contract_err, ToolError::PermissionDenied { .. }),
|
||||
"expected contract permission denied for {bad}, got {contract_err:?}"
|
||||
);
|
||||
}
|
||||
|
||||
let mut strict_trusted =
|
||||
ToolContext::with_options(&workspace, true, workspace.join("notes.md"));
|
||||
strict_trusted.execution.follow_symlinks = true;
|
||||
strict_trusted
|
||||
.execution
|
||||
.trusted_external_paths
|
||||
.push(outside_dir.path().to_path_buf());
|
||||
strict_trusted.execution.strict_workspace_confinement = true;
|
||||
|
||||
let escape_link = workspace.join("notes/escape_link");
|
||||
std::os::unix::fs::symlink(&outside_target, &escape_link).expect("escape symlink");
|
||||
let escape_dir = workspace.join("escape_dir");
|
||||
std::os::unix::fs::symlink(outside_dir.path(), &escape_dir).expect("escape dir");
|
||||
|
||||
for escaped in [
|
||||
"notes/escape_link",
|
||||
"escape_dir/created.txt",
|
||||
outside_target.to_str().expect("utf8 outside"),
|
||||
] {
|
||||
let err = WriteFileTool
|
||||
.execute(
|
||||
json!({"path": escaped, "content": "pwned\n"}),
|
||||
&strict_trusted,
|
||||
)
|
||||
.await
|
||||
.expect_err(escaped);
|
||||
assert!(
|
||||
matches!(
|
||||
err,
|
||||
ToolError::PermissionDenied { .. } | ToolError::PathEscape { .. }
|
||||
),
|
||||
"strict confinement must fail closed for {escaped}, got {err:?}"
|
||||
);
|
||||
}
|
||||
|
||||
assert_eq!(
|
||||
fs::read_to_string(&hook).expect("read hook"),
|
||||
"clean-hook\n"
|
||||
);
|
||||
assert!(!hooks_dir.join("new-hook").exists());
|
||||
assert_eq!(
|
||||
fs::read_to_string(&outside_target).expect("read outside"),
|
||||
"clean-outside\n"
|
||||
);
|
||||
assert!(!outside_dir.path().join("created.txt").exists());
|
||||
}
|
||||
@@ -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))]
|
||||
|
||||
@@ -67,6 +67,8 @@ pub struct UpdatePlanArgs {
|
||||
pub risks_and_unknowns: Option<String>,
|
||||
#[serde(default)]
|
||||
pub handoff_packet: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub replan_if: Vec<String>,
|
||||
#[serde(default)]
|
||||
pub plan: Vec<PlanItemArg>,
|
||||
}
|
||||
@@ -122,6 +124,8 @@ pub struct PlanSnapshot {
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub handoff_packet: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub replan_if: Vec<String>,
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub items: Vec<PlanItemArg>,
|
||||
}
|
||||
|
||||
@@ -139,6 +143,7 @@ impl PlanSnapshot {
|
||||
&& self.verification_plan.is_none()
|
||||
&& self.risks_and_unknowns.is_none()
|
||||
&& self.handoff_packet.is_none()
|
||||
&& self.replan_if.is_empty()
|
||||
&& self.items.is_empty()
|
||||
}
|
||||
|
||||
@@ -182,6 +187,7 @@ impl PlanSnapshot {
|
||||
verification_plan: clean_optional(string_field(input, "verification_plan")),
|
||||
risks_and_unknowns: clean_optional(string_field(input, "risks_and_unknowns")),
|
||||
handoff_packet: clean_optional(string_field(input, "handoff_packet")),
|
||||
replan_if: clean_list(string_vec_field(input, "replan_if")),
|
||||
items,
|
||||
}
|
||||
}
|
||||
@@ -201,6 +207,7 @@ pub struct PlanState {
|
||||
verification_plan: Option<String>,
|
||||
risks_and_unknowns: Option<String>,
|
||||
handoff_packet: Option<String>,
|
||||
replan_if: Vec<String>,
|
||||
steps: Vec<PlanStep>,
|
||||
}
|
||||
|
||||
@@ -217,6 +224,7 @@ impl PlanState {
|
||||
self.verification_plan = clean_optional(args.verification_plan);
|
||||
self.risks_and_unknowns = clean_optional(args.risks_and_unknowns);
|
||||
self.handoff_packet = clean_optional(args.handoff_packet);
|
||||
self.replan_if = clean_list(args.replan_if);
|
||||
|
||||
let now = Instant::now();
|
||||
let mut new_steps = Vec::new();
|
||||
@@ -281,6 +289,7 @@ impl PlanState {
|
||||
verification_plan: self.verification_plan.clone(),
|
||||
risks_and_unknowns: self.risks_and_unknowns.clone(),
|
||||
handoff_packet: self.handoff_packet.clone(),
|
||||
replan_if: self.replan_if.clone(),
|
||||
items: self
|
||||
.steps
|
||||
.iter()
|
||||
@@ -309,6 +318,7 @@ impl PlanState {
|
||||
verification_plan: snapshot.verification_plan.clone(),
|
||||
risks_and_unknowns: snapshot.risks_and_unknowns.clone(),
|
||||
handoff_packet: snapshot.handoff_packet.clone(),
|
||||
replan_if: snapshot.replan_if.clone(),
|
||||
plan: snapshot.items.clone(),
|
||||
});
|
||||
state
|
||||
@@ -447,6 +457,11 @@ impl ToolSpec for UpdatePlanTool {
|
||||
"type": "string",
|
||||
"description": "Concise continuation notes for another agent or a later session"
|
||||
},
|
||||
"replan_if": {
|
||||
"type": "array",
|
||||
"description": "Conditions that invalidate the current plan and trigger replanning",
|
||||
"items": { "type": "string" }
|
||||
},
|
||||
"plan": {
|
||||
"type": "array",
|
||||
"description": "Legacy replay field; new work must use work_update",
|
||||
@@ -510,6 +525,7 @@ impl ToolSpec for UpdatePlanTool {
|
||||
verification_plan: string_field(&input, "verification_plan"),
|
||||
risks_and_unknowns: string_field(&input, "risks_and_unknowns"),
|
||||
handoff_packet: string_field(&input, "handoff_packet"),
|
||||
replan_if: string_vec_field(&input, "replan_if"),
|
||||
plan: plan_args,
|
||||
};
|
||||
|
||||
@@ -536,6 +552,7 @@ impl ToolSpec for UpdatePlanTool {
|
||||
verification_plan: desired.verification_plan.clone(),
|
||||
risks_and_unknowns: desired.risks_and_unknowns.clone(),
|
||||
handoff_packet: desired.handoff_packet.clone(),
|
||||
replan_if: desired.replan_if.clone(),
|
||||
plan: desired.items.clone(),
|
||||
});
|
||||
state.snapshot()
|
||||
@@ -815,4 +832,62 @@ mod tests {
|
||||
vec!["crates/engine/src/tools/plan.rs"]
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn replan_if_round_trips_through_schema_state_serde_and_work_graph() {
|
||||
let state = new_shared_plan_state();
|
||||
let todos = crate::tools::todo::new_shared_todo_list();
|
||||
let work = crate::work_graph::new_shared_work_runtime(todos, state.clone());
|
||||
let tool = UpdatePlanTool::new(state.clone());
|
||||
|
||||
let schema = tool.input_schema();
|
||||
assert_eq!(schema["properties"]["replan_if"]["type"], "array");
|
||||
assert_eq!(schema["properties"]["replan_if"]["items"]["type"], "string");
|
||||
|
||||
let empty_json = serde_json::to_value(PlanSnapshot::default()).unwrap();
|
||||
assert!(empty_json.get("replan_if").is_none());
|
||||
|
||||
let from_input = PlanSnapshot::from_tool_input(&json!({
|
||||
"replan_if": [" api schema changes ", " "]
|
||||
}));
|
||||
assert_eq!(from_input.replan_if, vec!["api schema changes"]);
|
||||
assert!(!from_input.is_empty());
|
||||
|
||||
let args = UpdatePlanArgs {
|
||||
replan_if: vec![" auth scope narrows ".to_string(), "".to_string()],
|
||||
..UpdatePlanArgs::default()
|
||||
};
|
||||
let mut isolated = PlanState::default();
|
||||
isolated.update(args);
|
||||
let isolated_snap = isolated.snapshot();
|
||||
assert_eq!(isolated_snap.replan_if, vec!["auth scope narrows"]);
|
||||
assert_eq!(
|
||||
PlanState::from_snapshot(&isolated_snap).snapshot(),
|
||||
isolated_snap
|
||||
);
|
||||
|
||||
let mut context = ToolContext::new(std::env::temp_dir());
|
||||
context.runtime.work = Some(work.clone());
|
||||
let result = tool
|
||||
.execute(
|
||||
json!({
|
||||
"replan_if": [" latency regresses above 1s ", " "]
|
||||
}),
|
||||
&context,
|
||||
)
|
||||
.await
|
||||
.expect("replan_if update_plan through work_graph should succeed");
|
||||
assert!(result.content.contains("latency regresses above 1s"));
|
||||
|
||||
let captured = work
|
||||
.capture(Some(&context.state_namespace))
|
||||
.expect("capture")
|
||||
.expect("graph state");
|
||||
assert_eq!(captured.plan.replan_if, vec!["latency regresses above 1s"]);
|
||||
let serialized = serde_json::to_value(&captured.plan).unwrap();
|
||||
assert_eq!(
|
||||
serialized["replan_if"],
|
||||
json!(["latency regresses above 1s"])
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -103,7 +103,7 @@ impl ToolSpec for SendLaterTool {
|
||||
Actions: \"schedule\" (create a pending trigger; requires approval), \
|
||||
\"list\" (recent triggers), \"read\" (one trigger by trigger_id), \
|
||||
\"cancel\" (cancel a pending trigger before it fires; requires approval). \
|
||||
Use delay_minutes or fire_at (ISO 8601 UTC) — not both. \
|
||||
Specify exactly one of delay_seconds, delay_minutes, or fire_at (ISO 8601 UTC). \
|
||||
Returns trigger_id and resolved fire_at."
|
||||
}
|
||||
}
|
||||
@@ -120,19 +120,27 @@ Returns trigger_id and resolved fire_at."
|
||||
}),
|
||||
);
|
||||
if !self.read_only {
|
||||
properties.insert(
|
||||
"delay_seconds".to_string(),
|
||||
json!({
|
||||
"type": "integer",
|
||||
"minimum": 1,
|
||||
"description": "Seconds from now to fire (minimum 1). Mutually exclusive with delay_minutes and fire_at. (action=schedule)"
|
||||
}),
|
||||
);
|
||||
properties.insert(
|
||||
"delay_minutes".to_string(),
|
||||
json!({
|
||||
"type": "integer",
|
||||
"minimum": 1,
|
||||
"description": "Minutes from now to fire. Mutually exclusive with fire_at. (action=schedule)"
|
||||
"description": "Minutes from now to fire. Mutually exclusive with delay_seconds and fire_at. (action=schedule)"
|
||||
}),
|
||||
);
|
||||
properties.insert(
|
||||
"fire_at".to_string(),
|
||||
json!({
|
||||
"type": "string",
|
||||
"description": "Absolute UTC fire time as an ISO 8601 string, e.g. \"2026-07-08T00:43:00Z\". Mutually exclusive with delay_minutes. (action=schedule)"
|
||||
"description": "Absolute UTC fire time as an ISO 8601 string, e.g. \"2026-07-08T00:43:00Z\". Mutually exclusive with delay_seconds and delay_minutes. (action=schedule)"
|
||||
}),
|
||||
);
|
||||
properties.insert(
|
||||
@@ -247,10 +255,42 @@ Returns trigger_id and resolved fire_at."
|
||||
|
||||
// ── Action handlers ────────────────────────────────────────────────────────
|
||||
|
||||
fn optional_delay_arg(input: &Value, key: &str) -> Result<Option<i64>, ToolError> {
|
||||
let Some(raw) = input.get(key) else {
|
||||
return Ok(None);
|
||||
};
|
||||
if raw.is_null() {
|
||||
return Ok(None);
|
||||
}
|
||||
let value = codewhale_tools::json_nonnegative_integer(raw).ok_or_else(|| {
|
||||
ToolError::invalid_input(format!(
|
||||
"send_later schedule: `{key}` must be a positive integer"
|
||||
))
|
||||
})?;
|
||||
if value == 0 {
|
||||
return Err(ToolError::invalid_input(format!(
|
||||
"send_later schedule: `{key}` must be >= 1"
|
||||
)));
|
||||
}
|
||||
i64::try_from(value)
|
||||
.map(Some)
|
||||
.map_err(|_| ToolError::invalid_input(format!("send_later schedule: `{key}` is too large")))
|
||||
}
|
||||
|
||||
fn checked_delay_fire_at(delta: Option<Duration>, key: &str) -> Result<DateTime<Utc>, ToolError> {
|
||||
let delta = delta.ok_or_else(|| {
|
||||
ToolError::invalid_input(format!("send_later schedule: `{key}` is too large"))
|
||||
})?;
|
||||
Utc::now().checked_add_signed(delta).ok_or_else(|| {
|
||||
ToolError::invalid_input(format!("send_later schedule: `{key}` is too large"))
|
||||
})
|
||||
}
|
||||
|
||||
async fn execute_schedule(input: &Value, context: &ToolContext) -> Result<ToolResult, ToolError> {
|
||||
let automations = SendLaterTool::automations_from_context(context)?;
|
||||
|
||||
let delay_minutes = input.get("delay_minutes").and_then(Value::as_u64);
|
||||
let delay_seconds = optional_delay_arg(input, "delay_seconds")?;
|
||||
let delay_minutes = optional_delay_arg(input, "delay_minutes")?;
|
||||
let fire_at_str = optional_str(input, "fire_at")?;
|
||||
let message = input
|
||||
.get("message")
|
||||
@@ -263,35 +303,29 @@ async fn execute_schedule(input: &Value, context: &ToolContext) -> Result<ToolRe
|
||||
));
|
||||
}
|
||||
|
||||
// Validate mutual exclusivity and resolve fire_at.
|
||||
let fire_at: DateTime<Utc> = match (delay_minutes, fire_at_str) {
|
||||
(Some(_), Some(_)) => {
|
||||
return Err(ToolError::invalid_input(
|
||||
"send_later schedule: `delay_minutes` and `fire_at` are mutually exclusive",
|
||||
));
|
||||
let fire_at: DateTime<Utc> = match (delay_seconds, delay_minutes, fire_at_str) {
|
||||
(Some(seconds), None, None) => {
|
||||
checked_delay_fire_at(Duration::try_seconds(seconds), "delay_seconds")?
|
||||
}
|
||||
(None, None) => {
|
||||
return Err(ToolError::invalid_input(
|
||||
"send_later schedule: one of `delay_minutes` or `fire_at` is required",
|
||||
));
|
||||
(None, Some(minutes), None) => {
|
||||
checked_delay_fire_at(Duration::try_minutes(minutes), "delay_minutes")?
|
||||
}
|
||||
(Some(minutes), None) => {
|
||||
if minutes == 0 {
|
||||
return Err(ToolError::invalid_input(
|
||||
"send_later schedule: `delay_minutes` must be >= 1",
|
||||
));
|
||||
}
|
||||
let minutes_i64 = i64::try_from(minutes).map_err(|_| {
|
||||
ToolError::invalid_input("send_later schedule: `delay_minutes` is too large")
|
||||
})?;
|
||||
Utc::now() + Duration::minutes(minutes_i64)
|
||||
}
|
||||
(None, Some(fire_at_s)) => fire_at_s.parse::<DateTime<Utc>>().map_err(|err| {
|
||||
(None, None, Some(fire_at_s)) => fire_at_s.parse::<DateTime<Utc>>().map_err(|err| {
|
||||
ToolError::invalid_input(format!(
|
||||
"send_later schedule: invalid `fire_at` — expected ISO 8601 UTC, e.g. \
|
||||
\"2026-07-08T00:43:00Z\": {err}"
|
||||
))
|
||||
})?,
|
||||
(None, None, None) => {
|
||||
return Err(ToolError::invalid_input(
|
||||
"send_later schedule: exactly one of `delay_seconds`, `delay_minutes`, or `fire_at` is required",
|
||||
));
|
||||
}
|
||||
_ => {
|
||||
return Err(ToolError::invalid_input(
|
||||
"send_later schedule: `delay_seconds`, `delay_minutes`, and `fire_at` are mutually exclusive",
|
||||
));
|
||||
}
|
||||
};
|
||||
|
||||
let workspace: Option<PathBuf> = match optional_str(input, "workspace")? {
|
||||
@@ -321,6 +355,7 @@ async fn execute_schedule(input: &Value, context: &ToolContext) -> Result<ToolRe
|
||||
workspace,
|
||||
owner_session_id: Some(context.state_namespace.clone()),
|
||||
parent_trigger_id,
|
||||
disallowed_tools: context.disallowed_tools.clone(),
|
||||
};
|
||||
|
||||
let record = {
|
||||
@@ -1005,6 +1040,7 @@ mod tests {
|
||||
workspace: None,
|
||||
owner_session_id: None,
|
||||
parent_trigger_id: None,
|
||||
disallowed_tools: Vec::new(),
|
||||
})
|
||||
.unwrap();
|
||||
|
||||
@@ -1084,6 +1120,7 @@ mod tests {
|
||||
workspace: None,
|
||||
owner_session_id: Some("session-a".to_string()),
|
||||
parent_trigger_id: None,
|
||||
disallowed_tools: Vec::new(),
|
||||
};
|
||||
let record = manager.create_trigger(req).unwrap();
|
||||
let due = manager.collect_due_triggers(chrono::Utc::now()).unwrap();
|
||||
@@ -1144,4 +1181,119 @@ mod tests {
|
||||
listed.content
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn schedule_with_delay_seconds_succeeds() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let ctx = make_context(&tmp);
|
||||
let tool = SendLaterTool::new("send_later");
|
||||
|
||||
let before = chrono::Utc::now();
|
||||
let result = tool
|
||||
.execute(
|
||||
json!({
|
||||
"action": "schedule",
|
||||
"delay_seconds": 15,
|
||||
"message": "Sub-minute check."
|
||||
}),
|
||||
&ctx,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let after = chrono::Utc::now();
|
||||
|
||||
let parsed: serde_json::Value = serde_json::from_str(&result.content).unwrap();
|
||||
assert!(parsed["trigger_id"].as_str().unwrap().starts_with("trig_"));
|
||||
let fire_at: chrono::DateTime<chrono::Utc> =
|
||||
parsed["fire_at"].as_str().unwrap().parse().unwrap();
|
||||
assert!(fire_at >= before + Duration::seconds(14));
|
||||
assert!(fire_at <= after + Duration::seconds(16));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn schedule_rejects_invalid_and_overflow_delay_seconds() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let ctx = make_context(&tmp);
|
||||
let tool = SendLaterTool::new("send_later");
|
||||
|
||||
for bad in [
|
||||
json!(0),
|
||||
json!(-5),
|
||||
json!(1.5),
|
||||
json!("10"),
|
||||
json!(u64::MAX),
|
||||
json!(i64::MAX),
|
||||
] {
|
||||
let err = tool
|
||||
.execute(
|
||||
json!({
|
||||
"action": "schedule",
|
||||
"delay_seconds": bad,
|
||||
"message": "Invalid delay."
|
||||
}),
|
||||
&ctx,
|
||||
)
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert!(
|
||||
err.to_string().contains("delay_seconds"),
|
||||
"unexpected error for {bad}: {err}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn schedule_enforces_three_way_timing_mutual_exclusion() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let ctx = make_context(&tmp);
|
||||
let tool = SendLaterTool::new("send_later");
|
||||
let fire_at = (chrono::Utc::now() + Duration::minutes(10)).to_rfc3339();
|
||||
|
||||
for payload in [
|
||||
json!({
|
||||
"action": "schedule",
|
||||
"delay_seconds": 30,
|
||||
"delay_minutes": 1,
|
||||
"message": "Conflict"
|
||||
}),
|
||||
json!({
|
||||
"action": "schedule",
|
||||
"delay_seconds": 30,
|
||||
"fire_at": fire_at,
|
||||
"message": "Conflict"
|
||||
}),
|
||||
json!({
|
||||
"action": "schedule",
|
||||
"delay_minutes": 1,
|
||||
"fire_at": fire_at,
|
||||
"message": "Conflict"
|
||||
}),
|
||||
json!({
|
||||
"action": "schedule",
|
||||
"delay_seconds": 30,
|
||||
"delay_minutes": 1,
|
||||
"fire_at": fire_at,
|
||||
"message": "Conflict"
|
||||
}),
|
||||
] {
|
||||
let err = tool.execute(payload, &ctx).await.unwrap_err();
|
||||
assert!(
|
||||
err.to_string().contains("mutually exclusive"),
|
||||
"unexpected error: {err}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn schema_and_description_expose_delay_seconds_when_writable() {
|
||||
let writable = SendLaterTool::new("send_later");
|
||||
let schema = writable.input_schema();
|
||||
let prop = &schema["properties"]["delay_seconds"];
|
||||
assert_eq!(prop["type"], "integer");
|
||||
assert_eq!(prop["minimum"], 1);
|
||||
assert!(writable.description().contains("delay_seconds"));
|
||||
|
||||
let readonly = SendLaterTool::read_only("send_later_read");
|
||||
assert!(readonly.input_schema()["properties"]["delay_seconds"].is_null());
|
||||
}
|
||||
}
|
||||
@@ -538,7 +538,9 @@ pub fn resolve_strict_authority_path(
|
||||
for segment in normalized.split('/') {
|
||||
current.push(segment);
|
||||
match fs::symlink_metadata(¤t) {
|
||||
Ok(metadata) if metadata.file_type().is_symlink() => {
|
||||
Ok(metadata)
|
||||
if codewhale_util::path_identity::metadata_is_link_or_reparse(&metadata) =>
|
||||
{
|
||||
return Err(ToolError::permission_denied(format!(
|
||||
"machine-readable authority paths must not traverse symlinks: {}",
|
||||
current.display()
|
||||
@@ -732,6 +734,7 @@ pub struct ToolExecutionState {
|
||||
pub live_posture: Option<crate::core::engine::LivePosture>,
|
||||
pub browser_tool: Option<String>,
|
||||
pub child_activation: Option<Arc<crate::tools::subagent::ChildActivationRequest>>,
|
||||
pub strict_workspace_confinement: bool,
|
||||
}
|
||||
|
||||
impl std::ops::Deref for ToolContext {
|
||||
@@ -837,6 +840,7 @@ impl ToolContext {
|
||||
live_posture: None,
|
||||
browser_tool: None,
|
||||
child_activation: None,
|
||||
strict_workspace_confinement: false,
|
||||
}),
|
||||
}
|
||||
}
|
||||
@@ -1475,6 +1479,302 @@ pub fn normalize_path(path: &Path) -> PathBuf {
|
||||
normalized
|
||||
}
|
||||
|
||||
pub(crate) fn has_forbidden_windows_colon(raw: &str, windows: bool) -> bool {
|
||||
if !windows {
|
||||
return false;
|
||||
}
|
||||
let bytes = raw.as_bytes();
|
||||
let rest = if raw.starts_with(r"\\?\")
|
||||
|| raw.starts_with("//?/")
|
||||
|| raw.starts_with(r"\\.\")
|
||||
|| raw.starts_with("//./")
|
||||
{
|
||||
if bytes.get(4).is_some_and(u8::is_ascii_alphabetic)
|
||||
&& bytes.get(5) == Some(&b':')
|
||||
&& matches!(bytes.get(6), Some(b'\\') | Some(b'/'))
|
||||
{
|
||||
&raw[6..]
|
||||
} else {
|
||||
raw
|
||||
}
|
||||
} else if bytes.first().is_some_and(u8::is_ascii_alphabetic)
|
||||
&& bytes.get(1) == Some(&b':')
|
||||
&& matches!(bytes.get(2), Some(b'\\') | Some(b'/'))
|
||||
{
|
||||
&raw[2..]
|
||||
} else {
|
||||
raw
|
||||
};
|
||||
rest.contains(':')
|
||||
}
|
||||
|
||||
pub(crate) fn is_windows_short_name_segment(segment: &str) -> bool {
|
||||
for (idx, _) in segment.match_indices('~') {
|
||||
let after = &segment[idx + 1..];
|
||||
let digits = after.split('.').next().unwrap_or(after);
|
||||
if !digits.is_empty() && digits.bytes().all(|byte| byte.is_ascii_digit()) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
pub(crate) fn has_windows_trailing_dot_or_space(segment: &str, windows: bool) -> bool {
|
||||
windows && segment != "." && segment != ".." && segment.ends_with(['.', ' '])
|
||||
}
|
||||
|
||||
pub(crate) fn is_protected_workspace_metadata_segment(segment: &str, windows: bool) -> bool {
|
||||
if codewhale_util::git_metadata::git_metadata_name(segment, windows) {
|
||||
return true;
|
||||
}
|
||||
let trimmed = if windows {
|
||||
segment
|
||||
.split(':')
|
||||
.next()
|
||||
.unwrap_or_default()
|
||||
.trim_end_matches(['.', ' '])
|
||||
} else {
|
||||
segment
|
||||
};
|
||||
if trimmed.eq_ignore_ascii_case(".codewhale") {
|
||||
return true;
|
||||
}
|
||||
if windows {
|
||||
let stem = trimmed.split('.').next().unwrap_or(trimmed);
|
||||
if let Some((prefix, digits)) = stem.rsplit_once('~')
|
||||
&& !digits.is_empty()
|
||||
&& digits.bytes().all(|byte| byte.is_ascii_digit())
|
||||
{
|
||||
let prefix = prefix.strip_prefix('.').unwrap_or(prefix);
|
||||
let upper = prefix.to_ascii_uppercase();
|
||||
if !upper.is_empty() && "CODEWHALE".starts_with(&upper) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
pub(crate) fn validate_windows_relative_segments(
|
||||
relative: &str,
|
||||
windows: bool,
|
||||
) -> Result<(), String> {
|
||||
if !windows {
|
||||
return Ok(());
|
||||
}
|
||||
if has_forbidden_windows_colon(relative, true) {
|
||||
return Err("windows colons and alternate data streams are not allowed".to_string());
|
||||
}
|
||||
for segment in relative.replace('\\', "/").split('/') {
|
||||
if segment.is_empty() || segment == "." {
|
||||
continue;
|
||||
}
|
||||
if has_windows_trailing_dot_or_space(segment, true) {
|
||||
return Err(format!(
|
||||
"trailing dots or spaces are not allowed: {segment}"
|
||||
));
|
||||
}
|
||||
if is_windows_short_name_segment(segment) {
|
||||
return Err(format!(
|
||||
"windows short-name aliases are not allowed: {segment}"
|
||||
));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn strip_windows_verbatim_slashes(raw: &str) -> String {
|
||||
let mut normalized = raw.replace('\\', "/");
|
||||
if let Some(rest) = normalized
|
||||
.strip_prefix("//?/UNC/")
|
||||
.or_else(|| normalized.strip_prefix("//?/unc/"))
|
||||
{
|
||||
normalized = format!("//{rest}");
|
||||
} else if let Some(rest) = normalized
|
||||
.strip_prefix("//?/")
|
||||
.or_else(|| normalized.strip_prefix("//./"))
|
||||
{
|
||||
normalized = rest.to_string();
|
||||
}
|
||||
while normalized.len() > 1 && normalized.ends_with('/') {
|
||||
if normalized.len() == 3 && normalized.as_bytes().get(1) == Some(&b':') {
|
||||
break;
|
||||
}
|
||||
normalized.pop();
|
||||
}
|
||||
normalized
|
||||
}
|
||||
|
||||
pub(crate) fn has_windows_drive_or_unc_prefix(raw: &str) -> bool {
|
||||
raw.starts_with(r"\\")
|
||||
|| raw.starts_with("//")
|
||||
|| (raw.as_bytes().first().is_some_and(u8::is_ascii_alphabetic)
|
||||
&& raw.as_bytes().get(1) == Some(&b':'))
|
||||
}
|
||||
|
||||
pub(crate) fn strip_workspace_root_str(
|
||||
workspace_raw: &str,
|
||||
workspace_canonical: Option<&str>,
|
||||
raw: &str,
|
||||
windows: bool,
|
||||
) -> Option<String> {
|
||||
if !windows {
|
||||
let candidate = Path::new(raw);
|
||||
let rest = candidate
|
||||
.strip_prefix(Path::new(workspace_raw))
|
||||
.ok()
|
||||
.or_else(|| {
|
||||
workspace_canonical.and_then(|root| candidate.strip_prefix(Path::new(root)).ok())
|
||||
})?;
|
||||
let mut clean = PathBuf::new();
|
||||
for component in rest.components() {
|
||||
match component {
|
||||
Component::Normal(part) => clean.push(part),
|
||||
Component::CurDir => {}
|
||||
_ => return None,
|
||||
}
|
||||
}
|
||||
let shown = clean.to_str()?;
|
||||
return Some(if shown.is_empty() {
|
||||
".".to_string()
|
||||
} else {
|
||||
shown.to_string()
|
||||
});
|
||||
}
|
||||
|
||||
let cand = strip_windows_verbatim_slashes(raw);
|
||||
for root in std::iter::once(workspace_raw).chain(workspace_canonical) {
|
||||
let root_norm = strip_windows_verbatim_slashes(root);
|
||||
if cand.eq_ignore_ascii_case(&root_norm) {
|
||||
return Some(".".to_string());
|
||||
}
|
||||
let cand_bytes = cand.as_bytes();
|
||||
let root_len = root_norm.len();
|
||||
if cand_bytes.len() > root_len
|
||||
&& cand[..root_len].eq_ignore_ascii_case(&root_norm)
|
||||
&& (root_norm.ends_with('/') || cand_bytes[root_len] == b'/')
|
||||
{
|
||||
let tail = &cand[if root_norm.ends_with('/') {
|
||||
root_len
|
||||
} else {
|
||||
root_len + 1
|
||||
}..];
|
||||
let mut segments = Vec::new();
|
||||
for segment in tail.split('/') {
|
||||
match segment {
|
||||
"" | "." => {}
|
||||
".." => return None,
|
||||
part => segments.push(part),
|
||||
}
|
||||
}
|
||||
return Some(if segments.is_empty() {
|
||||
".".to_string()
|
||||
} else {
|
||||
segments.join("/")
|
||||
});
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
pub(crate) fn resolve_confined_target(
|
||||
context: &ToolContext,
|
||||
workspace_canonical: &Path,
|
||||
raw: &str,
|
||||
allow_root_dot: bool,
|
||||
) -> Result<PathBuf, ToolError> {
|
||||
if context.workspace.as_os_str().is_empty() || raw.is_empty() {
|
||||
return Err(ToolError::permission_denied(
|
||||
"workspace path cannot be empty".to_string(),
|
||||
));
|
||||
}
|
||||
if has_forbidden_windows_colon(raw, cfg!(windows)) {
|
||||
return Err(ToolError::permission_denied(
|
||||
"workspace path cannot contain a colon outside a Windows drive prefix".to_string(),
|
||||
));
|
||||
}
|
||||
if context.strict_workspace_confinement
|
||||
&& (raw == "~" || raw.starts_with("~/") || (cfg!(windows) && raw.starts_with("~\\")))
|
||||
{
|
||||
return Err(ToolError::permission_denied(
|
||||
"workspace path cannot use home shorthand".to_string(),
|
||||
));
|
||||
}
|
||||
let home_resolved = if !context.strict_workspace_confinement {
|
||||
resolve_home_path(raw)?
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let check_path = home_resolved.as_deref().unwrap_or_else(|| Path::new(raw));
|
||||
if check_path
|
||||
.components()
|
||||
.any(|component| matches!(component, Component::ParentDir))
|
||||
{
|
||||
return Err(ToolError::PathEscape {
|
||||
path: normalize_path(&context.workspace.join(check_path)),
|
||||
});
|
||||
}
|
||||
let relative = if let Some(home_path) = home_resolved.as_deref() {
|
||||
let home_str = home_path.to_str().ok_or_else(|| {
|
||||
ToolError::permission_denied("workspace path must be valid UTF-8".to_string())
|
||||
})?;
|
||||
let ws_plain = normalize_path(&context.workspace);
|
||||
let ws_str = ws_plain.to_string_lossy();
|
||||
let can_str = workspace_canonical.to_string_lossy();
|
||||
let rel = strip_workspace_root_str(&ws_str, Some(&can_str), home_str, cfg!(windows))
|
||||
.ok_or_else(|| ToolError::PathEscape {
|
||||
path: normalize_path(home_path),
|
||||
})?;
|
||||
if rel == "." {
|
||||
PathBuf::new()
|
||||
} else {
|
||||
PathBuf::from(rel)
|
||||
}
|
||||
} else if check_path.is_absolute()
|
||||
|| check_path.has_root()
|
||||
|| (cfg!(windows) && has_windows_drive_or_unc_prefix(raw))
|
||||
{
|
||||
let ws_str = context.workspace.to_string_lossy();
|
||||
let can_str = workspace_canonical.to_string_lossy();
|
||||
let rel = strip_workspace_root_str(&ws_str, Some(&can_str), raw, cfg!(windows))
|
||||
.ok_or_else(|| ToolError::PathEscape {
|
||||
path: PathBuf::from(raw),
|
||||
})?;
|
||||
if rel == "." {
|
||||
PathBuf::new()
|
||||
} else {
|
||||
PathBuf::from(rel)
|
||||
}
|
||||
} else {
|
||||
let mut rel = PathBuf::new();
|
||||
for component in check_path.components() {
|
||||
match component {
|
||||
Component::Normal(part) => rel.push(part),
|
||||
Component::CurDir => {}
|
||||
_ => {
|
||||
return Err(ToolError::PathEscape {
|
||||
path: PathBuf::from(raw),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
rel
|
||||
};
|
||||
|
||||
if relative.as_os_str().is_empty() {
|
||||
if !allow_root_dot {
|
||||
return Err(ToolError::permission_denied(
|
||||
"workspace path must name an entry inside the workspace".to_string(),
|
||||
));
|
||||
}
|
||||
} else {
|
||||
validate_windows_relative_segments(&relative.to_string_lossy(), cfg!(windows))
|
||||
.map_err(ToolError::permission_denied)?;
|
||||
}
|
||||
|
||||
Ok(relative)
|
||||
}
|
||||
|
||||
/// Resolve an exact `~` or `~/` path prefix to the current user's home directory.
|
||||
///
|
||||
/// Only exact `~` and `~/` (or `~\` on Windows) prefixes are resolved. Prefixes like
|
||||
|
||||
@@ -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)]
|
||||
|
||||
@@ -493,10 +493,7 @@ impl ChildAuthority {
|
||||
|| Self::role_can_delegate_writes(&self.agent_type)
|
||||
|| self.workspace_write_carve_out_permits(registry, name, input))
|
||||
}
|
||||
ApprovalRequirement::Required => {
|
||||
self.runtime.accept_verification
|
||||
&& Self::is_delegated_builtin_verification(name, input)
|
||||
}
|
||||
ApprovalRequirement::Required => false,
|
||||
})
|
||||
}
|
||||
pub fn role_can_delegate_writes(agent_type: &FleetRole) -> bool {
|
||||
@@ -523,17 +520,6 @@ impl ChildAuthority {
|
||||
)
|
||||
}
|
||||
|
||||
pub fn is_delegated_builtin_verification(name: &str, input: &Value) -> bool {
|
||||
use crate::tools::execution_envelope::{VerificationBound, classify_verification};
|
||||
|
||||
// Reuse the same classifier as the execution envelope. This prevents a
|
||||
// second, looser notion of "test command" from growing in this module.
|
||||
matches!(
|
||||
classify_verification(canonical_action_alias(name, input), input),
|
||||
Some(VerificationBound::Default | VerificationBound::Filter)
|
||||
)
|
||||
}
|
||||
|
||||
pub fn posture_permits_tool(
|
||||
&self,
|
||||
registry: &ToolRegistry,
|
||||
|
||||
@@ -3091,11 +3091,6 @@ pub struct SubAgentRuntime {
|
||||
/// without full parent auto-approve. Shell/network/MCP still gated.
|
||||
/// Set for Workflow-spawned children.
|
||||
pub accept_edits: bool,
|
||||
/// Allow the built-in, non-custom verification tools after a worker
|
||||
/// start has crossed the parent's approval boundary. This is not a
|
||||
/// general shell grant: arbitrary commands and custom verifier programs
|
||||
/// remain blocked unless the parent session is auto-approved.
|
||||
pub accept_verification: bool,
|
||||
/// Native Agent-mode tool surface inherited from the parent turn. Carries
|
||||
/// feature/config-dependent families such as web search, patch, memory,
|
||||
/// vision, notify, and FIM so child catalogs stay in parity with the parent.
|
||||
@@ -3259,7 +3254,6 @@ impl SubAgentRuntime {
|
||||
context,
|
||||
allow_shell,
|
||||
accept_edits: false,
|
||||
accept_verification: false,
|
||||
agent_tool_surface_options: AgentToolSurfaceOptions::new(
|
||||
ShellPolicy::from_legacy_allow_shell(allow_shell),
|
||||
),
|
||||
@@ -3597,10 +3591,6 @@ impl SubAgentRuntime {
|
||||
context: child_context,
|
||||
allow_shell: self.allow_shell,
|
||||
accept_edits: self.accept_edits,
|
||||
// A parent-approved verification lease belongs to its
|
||||
// direct worker only; nested children must cross their own
|
||||
// approval boundary instead of silently inheriting it.
|
||||
accept_verification: self.accept_verification && self.spawn_depth == 0,
|
||||
agent_tool_surface_options: self.agent_tool_surface_options.clone(),
|
||||
worker_profile: self.worker_profile.clone(),
|
||||
event_tx: self.event_tx.clone(),
|
||||
@@ -3696,23 +3686,6 @@ impl SubAgentWorkLifecycle {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
// A root verification lease is provenance, not delegated
|
||||
// authority. Nested workers cannot inherit this bit.
|
||||
if runtime.accept_verification && runtime.spawn_depth == 1 {
|
||||
let reference = format!("operate-verification:{agent_id}");
|
||||
if let Err(err) = lifecycle.work.record_operation_approval(
|
||||
&lifecycle.session_id,
|
||||
&lifecycle.external,
|
||||
&reference,
|
||||
"agent",
|
||||
&format!("agent:{agent_id}:approval"),
|
||||
) {
|
||||
let _ = lifecycle.reconcile_state(OwnerState::Failed, 1, None);
|
||||
return Err(anyhow!(
|
||||
"failed to record sub-agent verification approval: {err}"
|
||||
));
|
||||
}
|
||||
}
|
||||
Ok(Some(lifecycle))
|
||||
}
|
||||
|
||||
@@ -15591,9 +15564,6 @@ async fn spawn_subagent_from_input(
|
||||
let reserved_agent_id = member.and_then(|(_, agent_id)| agent_id.map(str::to_string));
|
||||
let exact_fleet_binding =
|
||||
workflow_identity.and_then(|identity| identity.exact_fleet_binding.as_ref());
|
||||
if start_requests_read_only_role(&input) {
|
||||
runtime.accept_verification = false;
|
||||
}
|
||||
if exact_fleet_binding.is_none() {
|
||||
refresh_spawn_route_sources(&mut runtime);
|
||||
}
|
||||
@@ -18871,35 +18841,6 @@ fn parse_spawn_request(input: &Value) -> Result<SpawnRequest, ToolError> {
|
||||
)));
|
||||
}
|
||||
}
|
||||
// Enforce the declared workspace policy: `worktree` materializes a real
|
||||
// worktree request (the separate `worktree` field is the mechanism that
|
||||
// actually creates one), and `shared` must not contradict an explicit
|
||||
// worktree ask.
|
||||
let worktree = match workspace_policy_str
|
||||
.map(|policy| policy.trim().to_ascii_lowercase())
|
||||
.as_deref()
|
||||
{
|
||||
None => worktree,
|
||||
Some("worktree") => worktree.or(Some(SubAgentWorktreeRequest {
|
||||
branch: None,
|
||||
path: None,
|
||||
base_ref: None,
|
||||
})),
|
||||
Some("shared") => {
|
||||
if worktree.is_some() {
|
||||
return Err(ToolError::invalid_input(
|
||||
"workspace_policy 'shared' conflicts with worktree isolation options; \
|
||||
use workspace_policy 'worktree' or drop the worktree fields.",
|
||||
));
|
||||
}
|
||||
worktree
|
||||
}
|
||||
Some(other) => {
|
||||
return Err(ToolError::invalid_input(format!(
|
||||
"Invalid workspace_policy '{other}'. Use shared or worktree."
|
||||
)));
|
||||
}
|
||||
};
|
||||
let write_authority = match write_authority_str
|
||||
.map(|auth| auth.trim().to_ascii_lowercase())
|
||||
.as_deref()
|
||||
@@ -21221,14 +21162,38 @@ fn parse_optional_worktree_request(
|
||||
"worktree=false conflicts with worktree isolation options".to_string(),
|
||||
));
|
||||
}
|
||||
if worktree_flag.unwrap_or(false) || isolation_wants_worktree || has_worktree_details {
|
||||
Ok(Some(SubAgentWorktreeRequest {
|
||||
branch,
|
||||
path,
|
||||
base_ref,
|
||||
}))
|
||||
} else {
|
||||
Ok(None)
|
||||
let requested =
|
||||
if worktree_flag.unwrap_or(false) || isolation_wants_worktree || has_worktree_details {
|
||||
Some(SubAgentWorktreeRequest {
|
||||
branch,
|
||||
path,
|
||||
base_ref,
|
||||
})
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
let workspace_policy = optional_input_str(input, &["workspace_policy", "workspacePolicy"])?
|
||||
.map(|policy| policy.trim().to_ascii_lowercase());
|
||||
match workspace_policy.as_deref() {
|
||||
None => Ok(requested),
|
||||
Some("worktree") => Ok(requested.or(Some(SubAgentWorktreeRequest {
|
||||
branch: None,
|
||||
path: None,
|
||||
base_ref: None,
|
||||
}))),
|
||||
Some("shared") => {
|
||||
if requested.is_some() {
|
||||
return Err(ToolError::invalid_input(
|
||||
"workspace_policy 'shared' conflicts with worktree isolation options; \
|
||||
use workspace_policy 'worktree' or drop the worktree fields.",
|
||||
));
|
||||
}
|
||||
Ok(None)
|
||||
}
|
||||
Some(other) => Err(ToolError::invalid_input(format!(
|
||||
"Invalid workspace_policy '{other}'. Use shared or worktree."
|
||||
))),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -12823,6 +12823,129 @@ fn create_isolated_worktree_keeps_absolute_paths_under_worktree_root() {
|
||||
assert!(path.exists());
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn create_isolated_worktree_refuses_a_worktree_root_that_passes_through_a_link() {
|
||||
let branch_count = |repo: &std::path::Path| {
|
||||
let output = Command::new("git")
|
||||
.args(["branch", "--list", "codex/*"])
|
||||
.current_dir(repo)
|
||||
.output()
|
||||
.expect("git branch");
|
||||
String::from_utf8_lossy(&output.stdout).lines().count()
|
||||
};
|
||||
let requests = || {
|
||||
[
|
||||
SubAgentWorktreeRequest {
|
||||
branch: None,
|
||||
path: None,
|
||||
base_ref: None,
|
||||
},
|
||||
SubAgentWorktreeRequest {
|
||||
branch: Some("codex/agent-linked".to_string()),
|
||||
path: Some(PathBuf::from("linked")),
|
||||
base_ref: None,
|
||||
},
|
||||
SubAgentWorktreeRequest {
|
||||
branch: Some("codex/agent-dot".to_string()),
|
||||
path: Some(PathBuf::from(".")),
|
||||
base_ref: None,
|
||||
},
|
||||
]
|
||||
};
|
||||
|
||||
for link_at_repo_dir in [false, true] {
|
||||
for dangling in [false, true] {
|
||||
let (harness, repo) = git_repo_in_harness();
|
||||
let outside = tempdir().expect("outside dir");
|
||||
let target = if dangling {
|
||||
outside.path().join("missing")
|
||||
} else {
|
||||
outside.path().to_path_buf()
|
||||
};
|
||||
let worktrees = harness.path().join(".codewhale-worktrees");
|
||||
if link_at_repo_dir {
|
||||
std::fs::create_dir_all(&worktrees).expect("worktree root");
|
||||
std::os::unix::fs::symlink(&target, worktrees.join("repo")).expect("symlink");
|
||||
} else {
|
||||
std::os::unix::fs::symlink(&target, &worktrees).expect("symlink");
|
||||
}
|
||||
for request in requests() {
|
||||
let err = create_isolated_worktree(&repo, &request, None, &FleetRole::Scout)
|
||||
.expect_err("a linked worktree root must be refused");
|
||||
assert!(err.to_string().contains("link"), "unexpected error: {err}");
|
||||
}
|
||||
assert_eq!(
|
||||
std::fs::read_dir(outside.path()).expect("outside").count(),
|
||||
0,
|
||||
"nothing is created behind the link"
|
||||
);
|
||||
assert_eq!(branch_count(&repo), 0, "no branch is left behind");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn create_isolated_worktree_does_not_adopt_a_linked_repository_outside_the_workspace() {
|
||||
let harness = tempdir().expect("harness");
|
||||
let outside = init_subagent_git_repo();
|
||||
std::os::unix::fs::symlink(outside.path(), harness.path().join("linked-repo"))
|
||||
.expect("symlink");
|
||||
let outside_parent = outside
|
||||
.path()
|
||||
.canonicalize()
|
||||
.expect("canonical outside")
|
||||
.parent()
|
||||
.expect("outside parent")
|
||||
.to_path_buf();
|
||||
let worktree_roots_before = std::fs::read_dir(&outside_parent)
|
||||
.expect("outside parent")
|
||||
.flatten()
|
||||
.filter(|entry| entry.file_name() == ".codewhale-worktrees")
|
||||
.count();
|
||||
|
||||
let adopted = git_repo_root(harness.path());
|
||||
assert!(
|
||||
adopted.is_err(),
|
||||
"a repository reached through a link must not be adopted: {adopted:?}"
|
||||
);
|
||||
|
||||
let err = create_isolated_worktree(
|
||||
harness.path(),
|
||||
&SubAgentWorktreeRequest {
|
||||
branch: Some("codex/agent-linked-adopt".to_string()),
|
||||
path: None,
|
||||
base_ref: None,
|
||||
},
|
||||
None,
|
||||
&FleetRole::Scout,
|
||||
)
|
||||
.expect_err("a linked repository outside the workspace must be refused");
|
||||
assert!(
|
||||
err.to_string().contains("git repository"),
|
||||
"unexpected error: {err}"
|
||||
);
|
||||
let worktree_roots_after = std::fs::read_dir(&outside_parent)
|
||||
.expect("outside parent")
|
||||
.flatten()
|
||||
.filter(|entry| entry.file_name() == ".codewhale-worktrees")
|
||||
.count();
|
||||
assert_eq!(
|
||||
worktree_roots_before, worktree_roots_after,
|
||||
"nothing is created beside the outside repository"
|
||||
);
|
||||
let branches = Command::new("git")
|
||||
.args(["branch", "--list", "codex/*"])
|
||||
.current_dir(outside.path())
|
||||
.output()
|
||||
.expect("git branch");
|
||||
assert!(
|
||||
String::from_utf8_lossy(&branches.stdout).trim().is_empty(),
|
||||
"no branch is created in the outside repository"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn create_isolated_worktree_rejects_option_shaped_base_ref() {
|
||||
let (harness, repo) = git_repo_in_harness();
|
||||
@@ -15311,6 +15434,34 @@ fn write_capable_or_unproven_starts_keep_the_approval_gate() {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn read_only_start_with_a_workspace_policy_worktree_keeps_the_approval_gate() {
|
||||
let tmp = tempdir().expect("tempdir");
|
||||
let manager = new_shared_subagent_manager(tmp.path().to_path_buf(), 1);
|
||||
let tool = AgentTool::new(manager, stub_runtime());
|
||||
|
||||
for role in ["scout", "planner", "reviewer", "verifier", "consultant"] {
|
||||
for key in ["workspace_policy", "workspacePolicy"] {
|
||||
for value in ["worktree", " Worktree ", "WORKTREE", "bogus"] {
|
||||
let mut input = json!({"action": "start", "type": role, "prompt": "x"});
|
||||
input[key] = json!(value);
|
||||
assert_eq!(
|
||||
tool.approval_requirement_for(&input),
|
||||
ApprovalRequirement::Required,
|
||||
"{input} must keep the approval gate"
|
||||
);
|
||||
}
|
||||
let mut shared = json!({"action": "start", "type": role, "prompt": "x"});
|
||||
shared[key] = json!("shared");
|
||||
assert_eq!(
|
||||
tool.approval_requirement_for(&shared),
|
||||
ApprovalRequirement::Auto,
|
||||
"{shared} provisions nothing"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn worker_child_inherits_workspace_write_carve_out_without_parent_auto_approve() {
|
||||
// #5186: a write-posture child that is NOT an explicit write-delegated
|
||||
@@ -15395,9 +15546,9 @@ async fn worker_child_write_carve_out_requires_a_git_work_tree() {
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn builder_child_verification_asks_like_shell_without_an_operate_lease() {
|
||||
// Outside a root Operate lease, built-in verification, arbitrary shell
|
||||
// and unbounded verification argv all reach the parent's approval.
|
||||
async fn builder_child_verification_asks_like_shell() {
|
||||
// Built-in verification, arbitrary shell and unbounded verification argv
|
||||
// all reach the parent's approval.
|
||||
let tmp = tempdir().expect("tempdir");
|
||||
let mut runtime = stub_runtime();
|
||||
runtime.context = ToolContext::new(tmp.path().to_path_buf());
|
||||
@@ -15415,7 +15566,7 @@ async fn builder_child_verification_asks_like_shell_without_an_operate_lease() {
|
||||
let verification_err = registry
|
||||
.execute("agent_test", "Run", json!({"action": "tests"}))
|
||||
.await
|
||||
.expect_err("built-in verification asks the parent outside an Operate lease");
|
||||
.expect_err("built-in verification asks the parent");
|
||||
assert!(
|
||||
matches!(verification_err.downcast_ref::<ToolError>(), Some(ToolError::NotAvailable { message })
|
||||
if message == "child caller has no host that can answer this approval"),
|
||||
@@ -15459,6 +15610,75 @@ async fn builder_child_verification_asks_like_shell_without_an_operate_lease() {
|
||||
assert!(replay.unmatched_asks.is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn verifier_child_started_without_approval_still_asks_for_built_in_verification() {
|
||||
let tmp = tempdir().expect("tempdir");
|
||||
let marker = tmp.path().join("verification-marker.txt");
|
||||
std::fs::write(
|
||||
tmp.path().join("package.json"),
|
||||
r#"{"name":"fixture","version":"1.0.0","scripts":{"lint":"echo ran > verification-marker.txt"}}"#,
|
||||
)
|
||||
.expect("package.json");
|
||||
|
||||
let manager = new_shared_subagent_manager(tmp.path().to_path_buf(), 1);
|
||||
let tool = AgentTool::new(manager, stub_runtime());
|
||||
assert_eq!(
|
||||
tool.approval_requirement_for(
|
||||
&json!({"action": "start", "type": "test", "prompt": "run the project checks"})
|
||||
),
|
||||
ApprovalRequirement::Auto,
|
||||
"a read-only verifier start opens no approval card"
|
||||
);
|
||||
|
||||
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.auto_approve = false;
|
||||
runtime.allow_shell = true;
|
||||
runtime.worker_profile = WorkerRuntimeProfile::for_role(FleetRole::Verifier);
|
||||
runtime.worker_profile.denied_tools = authority.disallowed_tools.clone();
|
||||
let receipt_store = crate::approval_log::ApprovalReceiptStore::new(tmp.path().join("sessions"));
|
||||
runtime = runtime.with_approval_receipt_store(Ok(receipt_store.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 calls = [
|
||||
json!({"action": "verifiers"}),
|
||||
json!({"action": "tests"}),
|
||||
json!({"action": "tests", "args": "exact_fleet"}),
|
||||
];
|
||||
for input in &calls {
|
||||
let err = registry
|
||||
.execute("agent_test", "Run", input.clone())
|
||||
.await
|
||||
.expect_err("built-in verification must reach the parent's approval");
|
||||
assert!(
|
||||
matches!(err.downcast_ref::<ToolError>(), Some(ToolError::NotAvailable { message })
|
||||
if message == "child caller has no host that can answer this approval"),
|
||||
"{input}: unexpected error: {err}"
|
||||
);
|
||||
}
|
||||
assert!(!marker.exists(), "no workspace script may have run");
|
||||
let replay = receipt_store.replay("workspace").unwrap();
|
||||
assert_eq!(replay.completed.len(), calls.len());
|
||||
assert!(
|
||||
replay
|
||||
.completed
|
||||
.iter()
|
||||
.all(|receipt| receipt.outcome == crate::approval_log::ApprovalOutcome::Unavailable)
|
||||
);
|
||||
assert!(replay.unmatched_asks.is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn auto_approved_parent_runs_required_tools_in_subagent() {
|
||||
// Baseline: when the parent runtime IS auto-approved, every approval
|
||||
@@ -16160,7 +16380,6 @@ pub fn stub_runtime() -> SubAgentRuntime {
|
||||
context,
|
||||
allow_shell: true,
|
||||
accept_edits: false,
|
||||
accept_verification: false,
|
||||
agent_tool_surface_options: AgentToolSurfaceOptions::new(ShellPolicy::Full),
|
||||
worker_profile: WorkerRuntimeProfile::for_role(FleetRole::Worker),
|
||||
event_tx: None,
|
||||
@@ -16204,105 +16423,6 @@ pub fn stub_runtime() -> SubAgentRuntime {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn worker_lifecycle_records_direct_operate_approval_without_delegating_authority() {
|
||||
let todos = crate::tools::todo::new_shared_todo_list();
|
||||
let plan = crate::tools::plan::new_shared_plan_state();
|
||||
let work = crate::work_graph::new_shared_work_runtime(todos, plan);
|
||||
|
||||
let mut direct = stub_runtime();
|
||||
direct.context.state_namespace = "worker-lifecycle".to_string();
|
||||
direct.context.runtime.work = Some(work.clone());
|
||||
direct.accept_verification = true;
|
||||
direct.spawn_depth = 1;
|
||||
let lifecycle =
|
||||
SubAgentWorkLifecycle::register(&direct, "agent_01234567", "verify installed acceptance")
|
||||
.expect("direct worker registration")
|
||||
.expect("work runtime attached");
|
||||
lifecycle
|
||||
.reconcile_state(OwnerState::Running, 2, None)
|
||||
.expect("running owner report");
|
||||
let receipt = EvidenceRef::new(
|
||||
EvidenceKind::Receipt {
|
||||
owner: "worker".to_string(),
|
||||
},
|
||||
"worker:agent_01234567:result",
|
||||
Some(512),
|
||||
false,
|
||||
)
|
||||
.expect("safe worker receipt");
|
||||
lifecycle
|
||||
.reconcile_state(OwnerState::Completed, 3, Some(receipt))
|
||||
.expect("terminal owner report");
|
||||
|
||||
let direct_graph = work
|
||||
.capture(Some("worker-lifecycle"))
|
||||
.expect("capture direct worker")
|
||||
.expect("graph")
|
||||
.graph;
|
||||
assert_eq!(
|
||||
direct_graph
|
||||
.nodes
|
||||
.iter()
|
||||
.filter(|node| node.kind == crate::work_graph::NodeKind::Approval)
|
||||
.count(),
|
||||
1,
|
||||
"direct Operate verification must leave one provenance record"
|
||||
);
|
||||
assert!(direct_graph.edges.iter().any(|edge| {
|
||||
edge.kind == crate::work_graph::EdgeKind::RequiresApproval
|
||||
&& direct_graph
|
||||
.node(&edge.from)
|
||||
.is_some_and(|node| node.title == "verify installed acceptance")
|
||||
&& direct_graph
|
||||
.node(&edge.to)
|
||||
.is_some_and(|node| node.kind == crate::work_graph::NodeKind::Approval)
|
||||
}));
|
||||
let direct_operation = direct_graph
|
||||
.nodes
|
||||
.iter()
|
||||
.find(|node| {
|
||||
node.binding
|
||||
.as_ref()
|
||||
.is_some_and(|binding| binding.external == "worker:agent_01234567")
|
||||
})
|
||||
.expect("bound direct worker operation");
|
||||
assert_eq!(
|
||||
direct_operation.state,
|
||||
crate::work_graph::NodeState::Completed
|
||||
);
|
||||
assert_eq!(
|
||||
direct_operation
|
||||
.binding
|
||||
.as_ref()
|
||||
.and_then(|binding| binding.last_observation.as_ref())
|
||||
.and_then(|observation| observation.output.as_ref())
|
||||
.and_then(EvidenceRef::raw_bytes),
|
||||
Some(512)
|
||||
);
|
||||
|
||||
let mut nested = direct.child_runtime();
|
||||
nested.accept_verification = true;
|
||||
nested.spawn_depth = 2;
|
||||
SubAgentWorkLifecycle::register(&nested, "agent_89abcdef", "nested verification")
|
||||
.expect("nested worker registration")
|
||||
.expect("work runtime attached");
|
||||
let nested_graph = work
|
||||
.capture(Some("worker-lifecycle"))
|
||||
.expect("capture nested worker")
|
||||
.expect("graph")
|
||||
.graph;
|
||||
assert_eq!(
|
||||
nested_graph
|
||||
.nodes
|
||||
.iter()
|
||||
.filter(|node| node.kind == crate::work_graph::NodeKind::Approval)
|
||||
.count(),
|
||||
1,
|
||||
"nested workers must not inherit Operate approval authority"
|
||||
);
|
||||
}
|
||||
|
||||
/// A minimal stub client. Test helpers below only ever check struct fields
|
||||
/// (depth, cancel_token, context); they don't call the network. We need a
|
||||
/// *some* `CodewhaleClient` because `SubAgentRuntime.client` isn't
|
||||
|
||||
@@ -92,7 +92,7 @@ fn is_sibling_worktree_path(parent_workspace: &Path, canonical: &Path) -> bool {
|
||||
else {
|
||||
return false;
|
||||
};
|
||||
if !worktree_path_within_root(&rebased, &default_root) {
|
||||
if relative_to_root(&rebased, &default_root).is_none() {
|
||||
return false;
|
||||
}
|
||||
match (git_common_dir(parent_workspace), git_common_dir(canonical)) {
|
||||
@@ -127,6 +127,7 @@ pub(super) fn create_isolated_worktree(
|
||||
)));
|
||||
}
|
||||
let worktree_path = resolve_worktree_path(&repo_root, &branch, request.path.as_ref())?;
|
||||
ensure_worktree_path_unlinked(&repo_root, &worktree_path)?;
|
||||
// One worktree implementation: the Runtime lane's (#4176). It creates the
|
||||
// parent directory and captures git output instead of inheriting the TUI.
|
||||
codewhale_lane::provision_worktree(&codewhale_lane::WorktreeProvision {
|
||||
@@ -138,6 +139,10 @@ pub(super) fn create_isolated_worktree(
|
||||
.map_err(|err| {
|
||||
ToolError::execution_failed(format!("Failed to create sub-agent worktree: {err:#}"))
|
||||
})?;
|
||||
if let Err(err) = ensure_worktree_path_unlinked(&repo_root, &worktree_path) {
|
||||
remove_unstarted_worktree(&worktree_path);
|
||||
return Err(err);
|
||||
}
|
||||
worktree_path.canonicalize().map_err(|err| {
|
||||
ToolError::execution_failed(format!(
|
||||
"Created worktree path '{}' could not be resolved: {err}",
|
||||
@@ -146,6 +151,19 @@ pub(super) fn create_isolated_worktree(
|
||||
})
|
||||
}
|
||||
|
||||
fn ensure_worktree_path_unlinked(repo_root: &Path, worktree_path: &Path) -> Result<(), ToolError> {
|
||||
let base = repo_root.parent().unwrap_or(repo_root);
|
||||
if worktree_path.strip_prefix(base).is_err() {
|
||||
return Err(ToolError::invalid_input(format!(
|
||||
"worktree_path '{}' must stay under {}",
|
||||
worktree_path.display(),
|
||||
base.display()
|
||||
)));
|
||||
}
|
||||
codewhale_lane::reject_linked_below(base, worktree_path)
|
||||
.map_err(|err| ToolError::invalid_input(format!("Invalid worktree_path: {err:#}")))
|
||||
}
|
||||
|
||||
/// Remove a finished worker's isolated worktree (and its merged branch) when
|
||||
/// the worker changed nothing in it. `changed` is the delivery-evidence
|
||||
/// inventory: `None` means git could not answer, and nothing is removed.
|
||||
@@ -233,8 +251,19 @@ pub(super) fn git_repo_root(workspace: &Path) -> Result<PathBuf, ToolError> {
|
||||
{
|
||||
let mut nested_roots = Vec::new();
|
||||
for entry in entries.flatten() {
|
||||
if !entry
|
||||
.file_type()
|
||||
.is_ok_and(|ft| ft.is_dir() && !ft.is_symlink())
|
||||
{
|
||||
continue;
|
||||
}
|
||||
let child = entry.path();
|
||||
if !child.is_dir() || !path_looks_like_git_checkout(&child) {
|
||||
if !path_looks_like_git_checkout(&child)
|
||||
|| child
|
||||
.join(".git")
|
||||
.symlink_metadata()
|
||||
.is_ok_and(|meta| meta.file_type().is_symlink())
|
||||
{
|
||||
continue;
|
||||
}
|
||||
if child
|
||||
@@ -244,7 +273,10 @@ pub(super) fn git_repo_root(workspace: &Path) -> Result<PathBuf, ToolError> {
|
||||
{
|
||||
continue;
|
||||
}
|
||||
if let Some(root) = try_git_toplevel(&child) {
|
||||
if let Some(root) = try_git_toplevel(&child)
|
||||
&& let Ok(canonical_root) = root.canonicalize()
|
||||
&& relative_to_root(&canonical_root, dir).is_some()
|
||||
{
|
||||
nested_roots.push(root);
|
||||
}
|
||||
}
|
||||
@@ -370,7 +402,7 @@ fn resolve_worktree_path(
|
||||
let requested = normalize_path_lexically(&default_root.join(path));
|
||||
match relative_to_root(&requested, &default_root)
|
||||
.map(|rest| normalize_path_lexically(&default_root.join(rest)))
|
||||
.filter(|resolved| worktree_path_within_root(resolved, &default_root))
|
||||
.filter(|resolved| resolved.starts_with(&default_root))
|
||||
{
|
||||
Some(resolved) => resolved,
|
||||
None => {
|
||||
@@ -396,14 +428,6 @@ fn resolve_worktree_path(
|
||||
Ok(normalized)
|
||||
}
|
||||
|
||||
/// `candidate` (already lexically normalized) must sit under `root` both as
|
||||
/// written and after resolving symlinks in its nearest existing ancestor, so
|
||||
/// a symlink planted under the worktree root cannot redirect the checkout.
|
||||
fn worktree_path_within_root(candidate: &Path, root: &Path) -> bool {
|
||||
candidate.starts_with(root)
|
||||
&& canonicalize_existing_prefix(candidate).starts_with(canonicalize_existing_prefix(root))
|
||||
}
|
||||
|
||||
/// The part of `candidate` below `root`, or `None` when it is not under it.
|
||||
/// On Windows the two may spell one directory differently, so they are
|
||||
/// compared in a simplified, case-insensitive form.
|
||||
@@ -456,29 +480,6 @@ fn windows_relative_to_root(candidate: &str, root: &str) -> Option<String> {
|
||||
Some(rest.to_string())
|
||||
}
|
||||
|
||||
/// Canonicalize the deepest existing ancestor of `path` and re-append the
|
||||
/// components that do not exist yet.
|
||||
fn canonicalize_existing_prefix(path: &Path) -> PathBuf {
|
||||
let mut missing = Vec::new();
|
||||
let mut cursor = path;
|
||||
loop {
|
||||
if let Ok(canonical) = cursor.canonicalize() {
|
||||
let mut resolved = canonical;
|
||||
for name in missing.iter().rev() {
|
||||
resolved.push(name);
|
||||
}
|
||||
return resolved;
|
||||
}
|
||||
match (cursor.file_name(), cursor.parent()) {
|
||||
(Some(name), Some(parent)) => {
|
||||
missing.push(name.to_os_string());
|
||||
cursor = parent;
|
||||
}
|
||||
_ => return path.to_path_buf(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn default_worktree_root(repo_root: &Path) -> PathBuf {
|
||||
let repo_name = repo_root
|
||||
.file_name()
|
||||
|
||||
@@ -561,6 +561,7 @@ impl TasksTool {
|
||||
permission_posture: Some(
|
||||
codewhale_runtime::runtime_policy::approval_wire(context.approval_mode).to_string(),
|
||||
),
|
||||
disallowed_tools: context.disallowed_tools.clone(),
|
||||
owner_session_id: Some(context.state_namespace.clone()),
|
||||
};
|
||||
let task_id = crate::task_manager::TaskManager::new_task_id();
|
||||
@@ -1779,12 +1780,9 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// Creating a task from a session runs it on the posture that session
|
||||
/// holds: `auto_approve` is a legacy bit the engine only reads when no
|
||||
/// posture is given, so a task cannot talk itself into more authority than
|
||||
/// the session that asked for it was granted.
|
||||
#[tokio::test]
|
||||
async fn create_pins_the_session_posture_on_the_task() {
|
||||
async fn noop_task_manager(
|
||||
workspace: &std::path::Path,
|
||||
) -> crate::task_manager::SharedTaskManager {
|
||||
struct NoopExecutor;
|
||||
|
||||
#[async_trait::async_trait]
|
||||
@@ -1804,12 +1802,11 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
let workspace = tempfile::tempdir().expect("workspace");
|
||||
let manager = crate::task_manager::TaskManager::start_with_executor(
|
||||
crate::task_manager::TaskManager::start_with_executor(
|
||||
crate::task_manager::TaskManagerConfig {
|
||||
data_dir: workspace.path().to_path_buf(),
|
||||
data_dir: workspace.to_path_buf(),
|
||||
worker_count: 1,
|
||||
default_workspace: workspace.path().to_path_buf(),
|
||||
default_workspace: workspace.to_path_buf(),
|
||||
default_model: "deepseek-v4-pro".to_string(),
|
||||
default_mode: "agent".to_string(),
|
||||
allow_shell: false,
|
||||
@@ -1820,7 +1817,42 @@ mod tests {
|
||||
std::sync::Arc::new(NoopExecutor),
|
||||
)
|
||||
.await
|
||||
.expect("task manager");
|
||||
.expect("task manager")
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn create_carries_the_session_deny_rules_onto_the_task_and_its_thread() {
|
||||
let workspace = tempfile::tempdir().expect("workspace");
|
||||
let manager = noop_task_manager(workspace.path()).await;
|
||||
let mut context = ToolContext::new(workspace.path());
|
||||
context.shell_policy = codewhale_agent_contract::worker_profile::ShellPolicy::None;
|
||||
context.disallowed_tools = vec!["mcp_secret_*".to_string()];
|
||||
context.runtime.task_manager = Some(manager.clone());
|
||||
|
||||
TasksTool::new("tasks")
|
||||
.execute(json!({"action": "create", "prompt": "probe"}), &context)
|
||||
.await
|
||||
.expect("create accepted");
|
||||
|
||||
let queued = manager.list_tasks(Some(1)).await.expect("queued task");
|
||||
let created = manager.get_task(&queued[0].id).await.expect("created task");
|
||||
let record = serde_json::to_value(&created).expect("record json");
|
||||
assert_eq!(record["disallowed_tools"], json!(["mcp_secret_*"]));
|
||||
let thread = serde_json::to_value(
|
||||
crate::task_manager::ExecutionTask::from(&created).thread_request(),
|
||||
)
|
||||
.expect("thread request json");
|
||||
assert_eq!(thread["disallowed_tools"], json!(["mcp_secret_*"]));
|
||||
}
|
||||
|
||||
/// Creating a task from a session runs it on the posture that session
|
||||
/// holds: `auto_approve` is a legacy bit the engine only reads when no
|
||||
/// posture is given, so a task cannot talk itself into more authority than
|
||||
/// the session that asked for it was granted.
|
||||
#[tokio::test]
|
||||
async fn create_pins_the_session_posture_on_the_task() {
|
||||
let workspace = tempfile::tempdir().expect("workspace");
|
||||
let manager = noop_task_manager(workspace.path()).await;
|
||||
|
||||
let mut context = ToolContext::new(workspace.path());
|
||||
context.approval_mode = codewhale_execpolicy::ApprovalMode::Auto;
|
||||
|
||||
@@ -499,6 +499,23 @@ pub fn get_or_create(
|
||||
}
|
||||
return Ok(session);
|
||||
}
|
||||
if let Some((plugin_id, _)) = crate::extension_host::dsh_terminal::session_parts(name) {
|
||||
let held = registry
|
||||
.keys()
|
||||
.filter(|entry| {
|
||||
entry.workspace == key.workspace
|
||||
&& entry.owner == key.owner
|
||||
&& crate::extension_host::dsh_terminal::session_parts(&entry.name)
|
||||
.is_some_and(|(other, _)| other == plugin_id)
|
||||
})
|
||||
.count();
|
||||
if held >= crate::extension_host::dsh_terminal::MAX_TERMINALS {
|
||||
return Err(
|
||||
"this plugin already holds the most terminals it may open in this session"
|
||||
.to_string(),
|
||||
);
|
||||
}
|
||||
}
|
||||
let session = create_session(owner, name, workspace, policy, shell_manager)?;
|
||||
registry.insert(key, Registered::new(&session)?);
|
||||
Ok(session)
|
||||
@@ -659,14 +676,13 @@ pub fn kill_session(session: &mut TerminalSession) -> Result<(), String> {
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
pub fn kill_owner_sessions(workspace: &Path, owner: &str) -> Result<usize, String> {
|
||||
let workspace = canonical(workspace);
|
||||
fn kill_matching_sessions(mut matches: impl FnMut(&SessionKey) -> bool) -> Result<usize, String> {
|
||||
let mut registry = sessions()
|
||||
.lock()
|
||||
.map_err(|_| "terminal session registry lock poisoned".to_string())?;
|
||||
let keys = registry
|
||||
.keys()
|
||||
.filter(|key| key.workspace == workspace && key.owner == owner)
|
||||
.filter(|key| matches(key))
|
||||
.cloned()
|
||||
.collect::<Vec<_>>();
|
||||
let mut ended = 0;
|
||||
@@ -687,11 +703,38 @@ pub fn kill_owner_sessions(workspace: &Path, owner: &str) -> Result<usize, Strin
|
||||
Ok(ended)
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
pub fn kill_owner_sessions(workspace: &Path, owner: &str) -> Result<usize, String> {
|
||||
let workspace = canonical(workspace);
|
||||
kill_matching_sessions(|key| key.workspace == workspace && key.owner == owner)
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
pub fn kill_sessions_named(prefix: &str) -> Result<usize, String> {
|
||||
let valid_prefix = prefix
|
||||
.strip_prefix("dsh:")
|
||||
.and_then(|rest| rest.strip_suffix(':'))
|
||||
.is_some_and(|plugin_id| !plugin_id.is_empty());
|
||||
if !valid_prefix {
|
||||
return Ok(0);
|
||||
}
|
||||
kill_matching_sessions(|key| {
|
||||
key.name
|
||||
.strip_prefix(prefix)
|
||||
.is_some_and(crate::extension_host::dsh_terminal::is_valid_session_name)
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(not(unix))]
|
||||
pub fn kill_owner_sessions(_workspace: &std::path::Path, _owner: &str) -> Result<usize, String> {
|
||||
Ok(0)
|
||||
}
|
||||
|
||||
#[cfg(not(unix))]
|
||||
pub fn kill_sessions_named(_prefix: &str) -> Result<usize, String> {
|
||||
Ok(0)
|
||||
}
|
||||
|
||||
/// Resolve a live session by the identity it was issued, never creating one.
|
||||
///
|
||||
/// HTTP clients attach to shells the Engine already owns (the agent's
|
||||
@@ -2413,4 +2456,136 @@ mod tests {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[cfg(unix)]
|
||||
fn kill_sessions_named_ends_only_matching_plugin_sessions_and_get_or_create_caps_per_plugin() {
|
||||
let workspace = DockWorkspace::new();
|
||||
let path = workspace.path();
|
||||
let manager = test_shell_manager();
|
||||
let policy = codewhale_sandbox::SandboxPolicy::DangerFullAccess;
|
||||
let thread = format!("thread-named-{}", Uuid::new_v4());
|
||||
let suffix = Uuid::new_v4().simple().to_string();
|
||||
let plugin_a = format!("plug-a-{suffix}");
|
||||
let plugin_ab = format!("{plugin_a}b");
|
||||
let plugin_b = format!("plug-b-{suffix}");
|
||||
|
||||
let term1 = get_or_create(&thread, "term-1", path, policy.clone(), &manager).unwrap();
|
||||
let colliding = get_or_create(
|
||||
&thread,
|
||||
&format!("dsh:{plugin_a}:b:x"),
|
||||
path,
|
||||
policy.clone(),
|
||||
&manager,
|
||||
)
|
||||
.unwrap();
|
||||
let other_prefix = get_or_create(
|
||||
&thread,
|
||||
&format!("dsh:{plugin_ab}:s0"),
|
||||
path,
|
||||
policy.clone(),
|
||||
&manager,
|
||||
)
|
||||
.unwrap();
|
||||
let other_plugin = get_or_create(
|
||||
&thread,
|
||||
&format!("dsh:{plugin_b}:s0"),
|
||||
path,
|
||||
policy.clone(),
|
||||
&manager,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let mut plugin_sessions = Vec::new();
|
||||
for idx in 0..crate::extension_host::dsh_terminal::MAX_TERMINALS {
|
||||
let session = get_or_create(
|
||||
&thread,
|
||||
&format!("dsh:{plugin_a}:s{idx}"),
|
||||
path,
|
||||
policy.clone(),
|
||||
&manager,
|
||||
)
|
||||
.unwrap();
|
||||
plugin_sessions.push(session);
|
||||
}
|
||||
assert!(
|
||||
get_or_create(
|
||||
&thread,
|
||||
&format!("dsh:{plugin_a}:s0"),
|
||||
path,
|
||||
policy.clone(),
|
||||
&manager,
|
||||
)
|
||||
.is_ok()
|
||||
);
|
||||
assert!(
|
||||
get_or_create(
|
||||
&thread,
|
||||
&format!("dsh:{plugin_a}:overflow"),
|
||||
path,
|
||||
policy.clone(),
|
||||
&manager,
|
||||
)
|
||||
.is_err()
|
||||
);
|
||||
assert!(get_or_create(&thread, "term-2", path, policy, &manager).is_ok());
|
||||
{
|
||||
let mut first = plugin_sessions[0].lock().unwrap();
|
||||
start_command(&mut first, "sleep 30").unwrap();
|
||||
}
|
||||
|
||||
assert_eq!(kill_sessions_named("").unwrap(), 0);
|
||||
assert_eq!(kill_sessions_named("dsh::").unwrap(), 0);
|
||||
assert_eq!(kill_sessions_named("term-").unwrap(), 0);
|
||||
assert_eq!(
|
||||
kill_sessions_named(&format!("dsh:{plugin_a}:")).unwrap(),
|
||||
crate::extension_host::dsh_terminal::MAX_TERMINALS
|
||||
);
|
||||
|
||||
let deadline = Instant::now() + Duration::from_secs(5);
|
||||
for session in &plugin_sessions {
|
||||
while session_exit_status(&mut session.lock().unwrap())
|
||||
.unwrap()
|
||||
.is_none()
|
||||
{
|
||||
assert!(
|
||||
Instant::now() < deadline,
|
||||
"killed plugin shell did not exit"
|
||||
);
|
||||
std::thread::sleep(Duration::from_millis(20));
|
||||
}
|
||||
}
|
||||
for survivor in [&term1, &colliding, &other_prefix, &other_plugin] {
|
||||
assert!(
|
||||
session_exit_status(&mut survivor.lock().unwrap())
|
||||
.unwrap()
|
||||
.is_none()
|
||||
);
|
||||
}
|
||||
let remaining: Vec<String> = owned_sessions(path, &thread)
|
||||
.unwrap()
|
||||
.into_iter()
|
||||
.map(|info| info.name)
|
||||
.collect();
|
||||
assert!(remaining.contains(&"term-1".to_string()), "{remaining:?}");
|
||||
assert!(remaining.contains(&"term-2".to_string()), "{remaining:?}");
|
||||
assert!(
|
||||
remaining.contains(&format!("dsh:{plugin_a}:b:x")),
|
||||
"{remaining:?}"
|
||||
);
|
||||
assert!(
|
||||
remaining.contains(&format!("dsh:{plugin_ab}:s0")),
|
||||
"{remaining:?}"
|
||||
);
|
||||
assert!(
|
||||
remaining.contains(&format!("dsh:{plugin_b}:s0")),
|
||||
"{remaining:?}"
|
||||
);
|
||||
assert!(
|
||||
!remaining
|
||||
.iter()
|
||||
.any(|name| name.starts_with(&format!("dsh:{plugin_a}:s"))),
|
||||
"{remaining:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
+26
-394
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -27,6 +27,7 @@ impl CompatPlanMetadata {
|
||||
verification_plan: snapshot.verification_plan.clone(),
|
||||
risks_and_unknowns: snapshot.risks_and_unknowns.clone(),
|
||||
handoff_packet: snapshot.handoff_packet.clone(),
|
||||
replan_if: snapshot.replan_if.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -44,6 +45,7 @@ impl CompatPlanMetadata {
|
||||
verification_plan: self.verification_plan.clone(),
|
||||
risks_and_unknowns: self.risks_and_unknowns.clone(),
|
||||
handoff_packet: self.handoff_packet.clone(),
|
||||
replan_if: self.replan_if.clone(),
|
||||
items: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -755,6 +755,8 @@ pub struct CompatPlanMetadata {
|
||||
pub risks_and_unknowns: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub handoff_packet: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub replan_if: Vec<String>,
|
||||
}
|
||||
|
||||
impl CompatPlanMetadata {
|
||||
@@ -771,6 +773,7 @@ impl CompatPlanMetadata {
|
||||
&& self.verification_plan.is_none()
|
||||
&& self.risks_and_unknowns.is_none()
|
||||
&& self.handoff_packet.is_none()
|
||||
&& self.replan_if.is_empty()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
export const name = 'remote-pair'
|
||||
export const inject = ['tools', 'remote']
|
||||
|
||||
const outcome = async (call) => {
|
||||
try {
|
||||
return { ok: await call() }
|
||||
} catch (error) {
|
||||
return { failed: { name: error.name, code: error.code ?? null, message: error.message } }
|
||||
}
|
||||
}
|
||||
|
||||
export function apply(ctx) {
|
||||
ctx.tools.register({
|
||||
name: 'remote_call',
|
||||
description: 'Call one method of the remote service and report the outcome.',
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: { method: { type: 'string' }, label: { type: 'string' }, grant: { type: 'string' } },
|
||||
required: ['method'],
|
||||
additionalProperties: false,
|
||||
},
|
||||
execute: ({ method, label, grant }) => outcome(() => {
|
||||
if (method === 'status') return ctx.remote.status()
|
||||
if (method === 'pair') return ctx.remote.pair(label === undefined ? undefined : { label })
|
||||
if (method === 'clients') return ctx.remote.clients()
|
||||
if (method === 'revoke') return ctx.remote.revoke(grant)
|
||||
throw new Error(`the fixture has no remote method ${method}`)
|
||||
}),
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"$schema": "https://agent-plugins.org/schemas/plugin.json",
|
||||
"name": "remote-pair",
|
||||
"version": "0.1.0",
|
||||
"description": "Test fixture: one tool that calls the `remote` service and reports exactly what came back.",
|
||||
"license": "MIT",
|
||||
"extensions": {
|
||||
"net.codewhale": {
|
||||
"native": {
|
||||
"path": "index.mjs"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -16,9 +16,6 @@ fn requires_raw_shell(name: &str) -> bool {
|
||||
| "exec_interact"
|
||||
| "task_shell_start"
|
||||
| "task_gate_run"
|
||||
// These owners start a fresh execution and currently cannot
|
||||
// transport this command's deny ceiling. Fail closed until they
|
||||
// can preserve it; inspection and cancellation stay available.
|
||||
| "task_create"
|
||||
| "automation_create"
|
||||
| "automation_update"
|
||||
|
||||
@@ -26,5 +26,6 @@ pub use runtime::{
|
||||
TmuxRuntime, backend_for, resolve_backend, run_lane_log_proxy,
|
||||
};
|
||||
pub use worktree::{
|
||||
ProvisionedWorktree, WorktreeProvision, provision_worktree, remove_worktree_if_expired,
|
||||
ProvisionedWorktree, WorktreeProvision, provision_worktree, reject_linked_below,
|
||||
remove_worktree_if_expired,
|
||||
};
|
||||
@@ -30,7 +30,7 @@ pub struct ProvisionedWorktree {
|
||||
/// Refuse `path` when an existing component between `root` and `path` is a
|
||||
/// link. A path outside `root` (an explicit `--worktree-path`) is the caller's
|
||||
/// own choice and is not checked.
|
||||
fn reject_linked_below(root: &Path, path: &Path) -> Result<()> {
|
||||
pub fn reject_linked_below(root: &Path, path: &Path) -> Result<()> {
|
||||
let Ok(relative) = path.strip_prefix(root) else {
|
||||
return Ok(());
|
||||
};
|
||||
@@ -40,7 +40,7 @@ fn reject_linked_below(root: &Path, path: &Path) -> Result<()> {
|
||||
match fs::symlink_metadata(¤t) {
|
||||
Ok(metadata) if metadata.file_type().is_symlink() => {
|
||||
bail!(
|
||||
"refusing to create a lane worktree through the link {}",
|
||||
"refusing to create a worktree through the link {}",
|
||||
current.display()
|
||||
);
|
||||
}
|
||||
|
||||
@@ -49,7 +49,8 @@
|
||||
"avatars",
|
||||
"mcp",
|
||||
"logger",
|
||||
"userQuestions"
|
||||
"userQuestions",
|
||||
"remote"
|
||||
],
|
||||
"row_bridges": {
|
||||
"@deepseek-ai/dsh-mcp-client": "mcp",
|
||||
|
||||
@@ -7992,31 +7992,46 @@ pub fn set_workspace_trust(workspace: &Path, trusted: bool) -> Result<PathBuf> {
|
||||
Ok(config_path)
|
||||
}
|
||||
|
||||
/// Project hook approval lives only in user-owned config, never in the repo.
|
||||
pub fn hook_receipt_for_workspace(workspace: &Path) -> Option<String> {
|
||||
/// Project file approvals live only in user-owned config, never in the repo.
|
||||
pub const HOOKS_RECEIPT_KEY: &str = "hooks_sha256";
|
||||
pub const MCP_RECEIPT_KEY: &str = "mcp_sha256";
|
||||
|
||||
pub fn project_receipt_for_workspace(workspace: &Path, key: &str) -> Option<String> {
|
||||
let raw = fs::read_to_string(default_config_path().ok()?).ok()?;
|
||||
let doc = toml::from_str::<toml::Value>(&raw).ok()?;
|
||||
doc.get("projects")?
|
||||
.get(workspace_config_key(workspace))?
|
||||
.get("hooks_sha256")?
|
||||
.get(key)?
|
||||
.as_str()
|
||||
.map(str::to_owned)
|
||||
}
|
||||
|
||||
pub fn save_workspace_hook_receipt(workspace: &Path, digest: &str) -> Result<PathBuf> {
|
||||
pub fn save_workspace_project_receipt(
|
||||
workspace: &Path,
|
||||
key: &str,
|
||||
digest: &str,
|
||||
) -> Result<PathBuf> {
|
||||
let config_path = try_default_config_path()?;
|
||||
ensure_parent_dir(&config_path)?;
|
||||
let project_key = workspace_config_key(workspace);
|
||||
crate::config_persistence::mutate_config_document(&config_path, |doc| {
|
||||
crate::config_persistence::set_document_value(
|
||||
doc,
|
||||
&["projects", project_key.as_str(), "hooks_sha256"],
|
||||
&["projects", project_key.as_str(), key],
|
||||
digest,
|
||||
)
|
||||
})?;
|
||||
Ok(config_path)
|
||||
}
|
||||
|
||||
pub fn hook_receipt_for_workspace(workspace: &Path) -> Option<String> {
|
||||
project_receipt_for_workspace(workspace, HOOKS_RECEIPT_KEY)
|
||||
}
|
||||
|
||||
pub fn save_workspace_hook_receipt(workspace: &Path, digest: &str) -> Result<PathBuf> {
|
||||
save_workspace_project_receipt(workspace, HOOKS_RECEIPT_KEY, digest)
|
||||
}
|
||||
|
||||
fn workspace_trust_level_from_doc<'a>(doc: &'a toml::Value, workspace: &Path) -> Option<&'a str> {
|
||||
let workspace = canonicalize_or_keep(workspace);
|
||||
// Trust records may sit at the top level or — from the historic
|
||||
|
||||
@@ -7,7 +7,7 @@ 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)
|
||||
- **Plugin service hardening (terminals, remote pairing, network):** plugin background terminal sessions enforce the per-plugin cap inside the shared session registry after approval waits and close when the plugin owner is revoked or disabled (CW-8051); plugin remote pairing and status serve reviewed plugins only, refuse child-agent and stale requests, validate pairing labels and one-time loopback redemption through host secrets, and keep `remote` in the refused Cordis service and inspect catalogs (CW-8000); plugin network credentials are bound to a single `https` origin per plugin build in a private credential file, check live session posture and destination guards before egress, serialize first-send confirmation per site through `plugin_network_send`, and scrub returned URLs, headers and bodies before returning to plugins (CW-7979).
|
||||
- **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)
|
||||
@@ -17,17 +17,22 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
- **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)
|
||||
- **Tool deny rules across scheduled work:** tasks, automations, delayed messages and the threads that run them now keep the tool deny rules of the session that created them; older versions refuse such records instead of running them without those rules.
|
||||
- **Sub-minute delayed messages and plan replanning triggers:** `send_later` now accepts `delay_seconds` alongside `delay_minutes` and `fire_at` (requiring exactly one of the three), and the automation scheduler wakes immediately on trigger creation or cancellation so sub-minute continuations fire promptly. Plans also support `replan_if` conditions across updates, snapshots, plan cards, and session relay handoff output.
|
||||
- **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)
|
||||
- **Usage-reporting collector:** accepts runtime schema 6 alongside existing versions and keeps schema 6 reports first-party only.
|
||||
- **Project MCP approvals:** Project `.codewhale/mcp.json` servers now load only after you approve the file's exact contents with `/mcp review` and `/mcp approve <digest>`, as project hooks do. A changed file loads none of its servers until it is approved again, and existing project files must be approved once after upgrading.
|
||||
|
||||
- **Source-build toolchain:** Rust 1.100.0 is pinned consistently across local source builds, CI, Docker, Dev Containers and CNB recipes. The minimum supported Rust version remains 1.89.
|
||||
- **Terminal input recovery:** retains a wake event from a replaced reader, suppresses retired events observed during a child-terminal handoff, and closes only the handoff window owned by that scope.
|
||||
|
||||
- **Plugin index hooks:** hooks with the same name and event now have distinct row IDs, so clients can keep every configured hook visible.
|
||||
- **Extension service approvals:** plugin service acts use the session permission gate and retain their approved or refused act records when a tool call fails. Closing a call seals its broker-act record so a late approval cannot authorize an unrecorded act. Ordinary nested-call receipts may still show an in-flight outcome when cancellation ends the call early.
|
||||
- **Extension workspace confinement:** plugin workspace file reads, listings, and overlay writes now verify pinned ancestors and non-linked targets at the point of use without letting session trust flags escape the workspace, reject Windows alternate data streams and 8.3 short-name aliases for protected metadata directories, and check caller deny rules for git review queries before requesting approval. (CW-8056)
|
||||
- **Delayed-message workspaces and shell stdin approvals:** `send_later` `schedule` now keeps its workspace inside the session's reachable paths instead of accepting an arbitrary directory, and `send_later` approval prompts show the requested workspace; session shell grants now match exact inputs when `stdin` (`stdin`, `input`, `data`) is supplied instead of reusing a command-only grant across different `stdin`.
|
||||
- **Sub-agent approvals and worktree isolation:** a child sub-agent's built-in verification calls now ask the same approval as the main session, a read-only agent started with `workspace_policy: "worktree"` shows the approval card, and a sub-agent worktree is refused when its root or nested adoption path passes through a symlink.
|
||||
|
||||
- **Installers no longer say Computer Use comes bundled:** the macOS install script and the install guide no longer print or state that Computer Use ships with the binary or needs Node.js 20. Computer Use is released separately and stays off until you turn it on.
|
||||
- **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.
|
||||
|
||||
@@ -1549,6 +1549,7 @@ fn plan_snapshot_to_sections(
|
||||
verification_plan: snapshot.verification_plan.clone(),
|
||||
risks_and_unknowns: snapshot.risks_and_unknowns.clone(),
|
||||
handoff_packet: snapshot.handoff_packet.clone(),
|
||||
replan_if: snapshot.replan_if.clone(),
|
||||
items: snapshot
|
||||
.items
|
||||
.iter()
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -143,6 +143,7 @@ fn build_relay_instruction(projection: RelayProjection, focus: Option<&str>) ->
|
||||
"Handoff packet",
|
||||
sections.handoff_packet.as_deref(),
|
||||
);
|
||||
write_plan_list(&mut out, "Replan if", §ions.replan_if);
|
||||
for item in sections.items {
|
||||
let _ = writeln!(
|
||||
out,
|
||||
@@ -309,6 +310,7 @@ mod tests {
|
||||
title: Some("Relay Plan".to_string()),
|
||||
explanation: Some(" because ".to_string()),
|
||||
sources_used: vec!["repo-a".to_string(), " ".to_string()],
|
||||
replan_if: vec!["abort on schema mismatch".to_string(), " ".to_string()],
|
||||
items: vec![PlanStep {
|
||||
status: PlanStepStatus::InProgress,
|
||||
text: "port relay".to_string(),
|
||||
@@ -328,6 +330,7 @@ mod tests {
|
||||
assert!(message.contains("- Title: Relay Plan"));
|
||||
assert!(message.contains("- Explanation: because"));
|
||||
assert!(message.contains("- Source: repo-a"));
|
||||
assert!(message.contains("- Replan if: abort on schema mismatch"));
|
||||
assert!(message.contains("- [in_progress] port relay"));
|
||||
}
|
||||
|
||||
|
||||
@@ -16,7 +16,7 @@ const CONTAINER_USE_SOURCE: &str = "https://github.com/dagger/container-use";
|
||||
pub(in crate::commands) const COMMAND_INFO: CommandInfo = CommandInfo {
|
||||
name: "mcp",
|
||||
aliases: &["mcps"],
|
||||
usage: "/mcp [init|import|import approve <name>|import decline <name>|recommendations|add recommended <id>|add stdio <name> <command> [args...]|add http <name> <url>|enable <name>|disable <name>|remove <name>|retry <name>|doctor|validate|restart|reload]",
|
||||
usage: "/mcp [init|review|approve <digest>|revoke|import|import approve <name>|import decline <name>|recommendations|add recommended <id>|add stdio <name> <command> [args...]|add http <name> <url>|enable <name>|disable <name>|remove <name>|retry <name>|doctor|validate|restart|reload]",
|
||||
description_key: "cmd_mcp_description",
|
||||
};
|
||||
|
||||
@@ -60,6 +60,14 @@ fn mcp(presentation: &mut dyn CommandPresentationContext, args: Option<&str>) ->
|
||||
"init" => CommandResult::action(AppAction::Mcp(McpUiAction::Init {
|
||||
force: parts.any(|part| part == "--force" || part == "-f"),
|
||||
})),
|
||||
"review" => CommandResult::action(AppAction::Mcp(McpUiAction::ProjectReview)),
|
||||
"approve" => match parts.next() {
|
||||
Some(digest) => CommandResult::action(AppAction::Mcp(McpUiAction::ProjectApprove {
|
||||
digest: digest.to_string(),
|
||||
})),
|
||||
None => CommandResult::error("Usage: /mcp approve <digest>"),
|
||||
},
|
||||
"revoke" => CommandResult::action(AppAction::Mcp(McpUiAction::ProjectRevoke)),
|
||||
"recommend" | "recommended" | "recommendations" => {
|
||||
CommandResult::message(recommended_mcp_text(presentation))
|
||||
}
|
||||
@@ -132,7 +140,7 @@ fn mcp(presentation: &mut dyn CommandPresentationContext, args: Option<&str>) ->
|
||||
CommandResult::action(AppAction::Mcp(McpUiAction::Reload))
|
||||
}
|
||||
_ => CommandResult::error(
|
||||
"Usage: /mcp [init|import|recommendations|add recommended <id>|add stdio <name> <command> [args...]|add http <name> <url>|enable <name>|disable <name>|remove <name>|login <name>|logout <name>|retry <name>|doctor|validate|restart|reload]",
|
||||
"Usage: /mcp [init|review|approve <digest>|revoke|import|recommendations|add recommended <id>|add stdio <name> <command> [args...]|add http <name> <url>|enable <name>|disable <name>|remove <name>|login <name>|logout <name>|retry <name>|doctor|validate|restart|reload]",
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -117,6 +117,7 @@ fn seed(app: &mut App) {
|
||||
verification_plan: Some("golden comparison".into()),
|
||||
risks_and_unknowns: Some("unknown remains null".into()),
|
||||
handoff_packet: Some("ready".into()),
|
||||
replan_if: Vec::new(),
|
||||
plan: vec![
|
||||
PlanItemArg {
|
||||
step: "first".into(),
|
||||
|
||||
+35
-5
@@ -3428,6 +3428,13 @@ async fn run_async_main_dispatch(
|
||||
None
|
||||
};
|
||||
|
||||
if let Some(note) = project_mcp_approval_note(&resolve_workspace(&cli)) {
|
||||
startup_notice = Some(match startup_notice {
|
||||
Some(existing) => format!("{existing}\n{note}"),
|
||||
None => note,
|
||||
});
|
||||
}
|
||||
|
||||
// Default: Interactive TUI
|
||||
// --yolo starts in YOLO mode (auto-approve; shell enabled)
|
||||
Box::pin(run_interactive_with_notice(
|
||||
@@ -5875,6 +5882,20 @@ async fn run_doctor(
|
||||
"✓".truecolor(aqua_r, aqua_g, aqua_b),
|
||||
codewhale_util::utils::display_path(&project_mcp_config_path)
|
||||
);
|
||||
match codewhale_engine::mcp::project_authority::project_mcp_status(workspace) {
|
||||
codewhale_engine::mcp::project_authority::ProjectMcpStatus::Untrusted => {
|
||||
println!(" Not loaded: the workspace is not trusted.")
|
||||
}
|
||||
codewhale_engine::mcp::project_authority::ProjectMcpStatus::Unapproved { .. } => {
|
||||
println!(
|
||||
" Not loaded: its exact contents are not approved. Run /mcp review, then /mcp approve <digest>, in a session."
|
||||
)
|
||||
}
|
||||
codewhale_engine::mcp::project_authority::ProjectMcpStatus::Unreadable(error) => {
|
||||
println!(" Not loaded: {error}.")
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
} else {
|
||||
println!(
|
||||
" {} Project MCP config not found at {}",
|
||||
@@ -9791,12 +9812,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()
|
||||
);
|
||||
}
|
||||
@@ -14619,6 +14637,18 @@ async fn run_interactive(
|
||||
.await
|
||||
}
|
||||
|
||||
pub(crate) fn project_mcp_approval_note(workspace: &std::path::Path) -> Option<String> {
|
||||
use codewhale_engine::mcp::project_authority::{ProjectMcpStatus, project_mcp_status};
|
||||
match project_mcp_status(workspace) {
|
||||
ProjectMcpStatus::Unapproved { .. } => Some(
|
||||
"Project .codewhale/mcp.json is not approved for these exact contents, so its servers are not loaded. Use /mcp review, then /mcp approve <digest>."
|
||||
.to_string(),
|
||||
),
|
||||
ProjectMcpStatus::Unreadable(error) => Some(format!("Project MCP servers are not loaded: {error}.")),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// As [`run_interactive`], but carrying a one-line startup receipt to show in
|
||||
/// the transcript — used by auto-resume to explain why it did or did not
|
||||
/// reattach to a previous session (#2934).
|
||||
|
||||
@@ -940,6 +940,11 @@ pub enum McpUiAction {
|
||||
ImportDecline {
|
||||
name: String,
|
||||
},
|
||||
ProjectReview,
|
||||
ProjectApprove {
|
||||
digest: String,
|
||||
},
|
||||
ProjectRevoke,
|
||||
Validate,
|
||||
/// Report this server's last observed state without starting a new pool.
|
||||
Diagnose {
|
||||
|
||||
@@ -334,6 +334,7 @@ mod tests {
|
||||
next_run_at: None,
|
||||
last_run_at: None,
|
||||
revision: 0,
|
||||
disallowed_tools: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -617,6 +617,7 @@ mod tests {
|
||||
next_run_at: None,
|
||||
last_run_at: None,
|
||||
revision: 0,
|
||||
disallowed_tools: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -878,6 +879,7 @@ mod tests {
|
||||
auto_approve: None,
|
||||
delivery_mode: None,
|
||||
status: Some(AutomationStatus::Active),
|
||||
disallowed_tools: Vec::new(),
|
||||
})
|
||||
.expect("automation");
|
||||
let manager = Arc::new(Mutex::new(manager));
|
||||
@@ -928,6 +930,7 @@ mod tests {
|
||||
auto_approve: None,
|
||||
delivery_mode: None,
|
||||
status: Some(AutomationStatus::Paused),
|
||||
disallowed_tools: Vec::new(),
|
||||
})
|
||||
.expect("automation");
|
||||
let now = Utc::now();
|
||||
|
||||
@@ -65,6 +65,7 @@ fn render_plan_snapshot_lines(snapshot: &PlanSnapshot, lines: &mut Vec<Line<'sta
|
||||
);
|
||||
render_plan_optional(lines, "risk", snapshot.risks_and_unknowns.as_deref(), width);
|
||||
render_plan_optional(lines, "handoff", snapshot.handoff_packet.as_deref(), width);
|
||||
render_plan_list(lines, "replan if", &snapshot.replan_if, width);
|
||||
|
||||
for step in &snapshot.items {
|
||||
let marker = match step.status {
|
||||
|
||||
@@ -2288,6 +2288,7 @@ fn a_plan_card_surfaces_every_populated_artifact_section() {
|
||||
verification_plan: Some("Run focused renderer tests".to_string()),
|
||||
risks_and_unknowns: Some("Metadata-only plans can disappear".to_string()),
|
||||
handoff_packet: Some("Next agent should inspect relay output".to_string()),
|
||||
replan_if: vec!["Renderer omits plan card sections".to_string()],
|
||||
items: vec![codewhale_engine::tools::plan::PlanItemArg {
|
||||
step: "Render artifact sections".to_string(),
|
||||
status: StepStatus::InProgress,
|
||||
@@ -2307,6 +2308,8 @@ fn a_plan_card_surfaces_every_populated_artifact_section() {
|
||||
"file:",
|
||||
"verify:",
|
||||
"handoff:",
|
||||
"replan if:",
|
||||
"Renderer omits plan card sections",
|
||||
"Render artifact sections",
|
||||
] {
|
||||
assert!(visible.contains(needle), "missing {needle:?}: {visible}");
|
||||
|
||||
@@ -3042,6 +3042,7 @@ async fn apply_command_result_inner(
|
||||
.to_string(),
|
||||
),
|
||||
owner_session_id: Some(owner_session_id),
|
||||
disallowed_tools: Vec::new(),
|
||||
};
|
||||
match task_manager.add_task(request).await {
|
||||
Ok(task) => {
|
||||
|
||||
@@ -977,6 +977,9 @@ pub(crate) async fn handle_mcp_ui_action(
|
||||
false
|
||||
}
|
||||
};
|
||||
if let Some(note) = crate::project_mcp_approval_note(&app.workspace) {
|
||||
sections.push(note);
|
||||
}
|
||||
if app.mcp_reload_required {
|
||||
sections.push(
|
||||
"MCP config changed since the live pool loaded. Run /mcp reload to apply it."
|
||||
@@ -1001,6 +1004,47 @@ pub(crate) async fn handle_mcp_ui_action(
|
||||
return;
|
||||
}
|
||||
crate::tui::app::McpUiAction::Show => Ok(()),
|
||||
crate::tui::app::McpUiAction::ProjectReview => {
|
||||
use codewhale_engine::mcp::project_authority::review_project_mcp;
|
||||
match review_project_mcp(&app.workspace) {
|
||||
Ok(reviewed) => {
|
||||
message = Some(format!(
|
||||
"Project MCP servers start processes and open connections as you. Review this file and any scripts it runs before approval.\n\n{}\n\nTo approve these exact contents: /mcp approve {}",
|
||||
codewhale_engine::hooks::sanitize_hook_text(
|
||||
&reviewed.contents,
|
||||
reviewed.contents.chars().count()
|
||||
),
|
||||
reviewed.digest
|
||||
));
|
||||
Ok(())
|
||||
}
|
||||
Err(error) => Err(anyhow::anyhow!(error)),
|
||||
}
|
||||
}
|
||||
crate::tui::app::McpUiAction::ProjectApprove { digest } => {
|
||||
changed = true;
|
||||
codewhale_engine::mcp::project_authority::approve_project_mcp(
|
||||
&app.workspace,
|
||||
digest.trim(),
|
||||
)
|
||||
.map_err(anyhow::Error::msg)
|
||||
.map(|()| {
|
||||
message = Some(
|
||||
"Approved these exact project MCP servers. Changes to the file require another review."
|
||||
.to_string(),
|
||||
);
|
||||
})
|
||||
}
|
||||
crate::tui::app::McpUiAction::ProjectRevoke => {
|
||||
changed = true;
|
||||
codewhale_engine::mcp::project_authority::revoke_project_mcp(&app.workspace)
|
||||
.map_err(anyhow::Error::msg)
|
||||
.map(|()| {
|
||||
message = Some(
|
||||
"Project MCP approval revoked. Project servers are unloaded.".to_string(),
|
||||
);
|
||||
})
|
||||
}
|
||||
crate::tui::app::McpUiAction::Init { force } => {
|
||||
changed = true;
|
||||
match mcp::init_config(&path, force) {
|
||||
|
||||
@@ -215,6 +215,7 @@ fn runtime_store_binding_survives_launch_snapshot_and_resume() -> anyhow::Result
|
||||
auto_approve: Some(false),
|
||||
delivery_mode: None,
|
||||
status: Some(AutomationStatus::Paused),
|
||||
disallowed_tools: Vec::new(),
|
||||
})?;
|
||||
tasks.shutdown_and_wait().await?;
|
||||
drop(app);
|
||||
@@ -863,6 +864,7 @@ fn adoptable_empty_store_refuses_a_scope_pinned_automation() -> anyhow::Result<(
|
||||
auto_approve: Some(false),
|
||||
delivery_mode: None,
|
||||
status: Some(AutomationStatus::Paused),
|
||||
disallowed_tools: Vec::new(),
|
||||
})?;
|
||||
automations.edit_automation(&created.id, |record| {
|
||||
let mut record = record.ok_or_else(|| anyhow::anyhow!("fresh automation must exist"))?;
|
||||
|
||||
@@ -887,6 +887,7 @@ mod tests {
|
||||
next_run_at: Some(at),
|
||||
last_run_at: None,
|
||||
revision: 0,
|
||||
disallowed_tools: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -701,6 +701,7 @@ impl AutomationEditor {
|
||||
trust_mode: None,
|
||||
auto_approve: None,
|
||||
delivery_mode: None,
|
||||
disallowed_tools: Vec::new(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
+282
-103
@@ -4,6 +4,16 @@ use std::fs::File;
|
||||
use std::io::{self, Write};
|
||||
use std::path::{Component, Path};
|
||||
|
||||
pub struct ConfinedDirEntry {
|
||||
pub name: String,
|
||||
pub is_dir: bool,
|
||||
}
|
||||
|
||||
pub struct ConfinedDirListing {
|
||||
pub entries: Vec<ConfinedDirEntry>,
|
||||
pub total_entries: usize,
|
||||
}
|
||||
|
||||
pub fn path_is_confined(path: &Path) -> bool {
|
||||
!path.as_os_str().is_empty()
|
||||
&& path.components().all(|component| match component {
|
||||
@@ -144,6 +154,186 @@ fn invalid_path() -> io::Error {
|
||||
)
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
fn open_confined_dir_fd(
|
||||
workspace: &Path,
|
||||
relative: Option<&Path>,
|
||||
create: bool,
|
||||
shared: bool,
|
||||
resolve_root: bool,
|
||||
) -> io::Result<File> {
|
||||
use std::os::fd::{AsRawFd, FromRawFd};
|
||||
use std::os::unix::ffi::OsStrExt;
|
||||
if let Some(rel) = relative
|
||||
&& !path_is_confined(rel)
|
||||
{
|
||||
return Err(invalid_path());
|
||||
}
|
||||
let workspace = if resolve_root {
|
||||
workspace.canonicalize()?
|
||||
} else {
|
||||
if !workspace.is_absolute() {
|
||||
return Err(invalid_path());
|
||||
}
|
||||
workspace.to_path_buf()
|
||||
};
|
||||
#[cfg(any(target_os = "linux", target_os = "android"))]
|
||||
let fd = unsafe {
|
||||
libc::open(
|
||||
c"/".as_ptr(),
|
||||
libc::O_PATH | libc::O_DIRECTORY | libc::O_NOFOLLOW | libc::O_CLOEXEC,
|
||||
)
|
||||
};
|
||||
#[cfg(not(any(target_os = "linux", target_os = "android")))]
|
||||
let fd = unsafe {
|
||||
libc::open(
|
||||
c"/".as_ptr(),
|
||||
libc::O_RDONLY | libc::O_DIRECTORY | libc::O_NOFOLLOW | libc::O_CLOEXEC,
|
||||
)
|
||||
};
|
||||
if fd < 0 {
|
||||
return Err(io::Error::last_os_error());
|
||||
}
|
||||
let mut directory = unsafe { File::from_raw_fd(fd) };
|
||||
let empty = Path::new("");
|
||||
for (path, may_create) in [
|
||||
(workspace.as_path(), false),
|
||||
(relative.unwrap_or(empty), create),
|
||||
] {
|
||||
for component in path.components() {
|
||||
let Component::Normal(name) = component else {
|
||||
if component == Component::RootDir {
|
||||
continue;
|
||||
}
|
||||
return Err(invalid_path());
|
||||
};
|
||||
let name = std::ffi::CString::new(name.as_bytes())?;
|
||||
#[cfg(any(target_os = "linux", target_os = "android"))]
|
||||
let flags = libc::O_PATH | libc::O_DIRECTORY | libc::O_NOFOLLOW | libc::O_CLOEXEC;
|
||||
#[cfg(not(any(target_os = "linux", target_os = "android")))]
|
||||
let flags = libc::O_RDONLY | libc::O_DIRECTORY | libc::O_NOFOLLOW | libc::O_CLOEXEC;
|
||||
let mut fd = unsafe { libc::openat(directory.as_raw_fd(), name.as_ptr(), flags) };
|
||||
if fd < 0 && may_create && io::Error::last_os_error().kind() == io::ErrorKind::NotFound
|
||||
{
|
||||
let mode = if shared { 0o777 } else { 0o700 };
|
||||
if unsafe { libc::mkdirat(directory.as_raw_fd(), name.as_ptr(), mode) } != 0
|
||||
&& io::Error::last_os_error().kind() != io::ErrorKind::AlreadyExists
|
||||
{
|
||||
return Err(io::Error::last_os_error());
|
||||
}
|
||||
fd = unsafe { libc::openat(directory.as_raw_fd(), name.as_ptr(), flags) };
|
||||
}
|
||||
if fd < 0 {
|
||||
let err = io::Error::last_os_error();
|
||||
if matches!(err.raw_os_error(), Some(libc::ENOTDIR) | Some(libc::ELOOP)) {
|
||||
let mut st: libc::stat = unsafe { std::mem::zeroed() };
|
||||
if unsafe {
|
||||
libc::fstatat(
|
||||
directory.as_raw_fd(),
|
||||
name.as_ptr(),
|
||||
&mut st,
|
||||
libc::AT_SYMLINK_NOFOLLOW,
|
||||
)
|
||||
} == 0
|
||||
&& (st.st_mode & libc::S_IFMT) == libc::S_IFLNK
|
||||
{
|
||||
return Err(io::Error::from_raw_os_error(libc::ELOOP));
|
||||
}
|
||||
}
|
||||
return Err(err);
|
||||
}
|
||||
directory = unsafe { File::from_raw_fd(fd) };
|
||||
}
|
||||
}
|
||||
let real_fd = unsafe {
|
||||
libc::openat(
|
||||
directory.as_raw_fd(),
|
||||
c".".as_ptr(),
|
||||
libc::O_RDONLY | libc::O_DIRECTORY | libc::O_NOFOLLOW | libc::O_CLOEXEC,
|
||||
)
|
||||
};
|
||||
if real_fd < 0 {
|
||||
return Err(io::Error::last_os_error());
|
||||
}
|
||||
Ok(unsafe { File::from_raw_fd(real_fd) })
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
struct DirStream(*mut libc::DIR);
|
||||
|
||||
#[cfg(unix)]
|
||||
impl Drop for DirStream {
|
||||
fn drop(&mut self) {
|
||||
if !self.0.is_null() {
|
||||
unsafe {
|
||||
libc::closedir(self.0);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
pub fn read_confined_dir(
|
||||
workspace: &Path,
|
||||
relative: Option<&Path>,
|
||||
max_entries: usize,
|
||||
) -> io::Result<ConfinedDirListing> {
|
||||
use std::os::fd::AsRawFd;
|
||||
let normalized = relative.filter(|p| !p.as_os_str().is_empty() && *p != Path::new("."));
|
||||
let dir_file = open_confined_dir_fd(workspace, normalized, false, true, true)?;
|
||||
let iter_fd = unsafe { libc::dup(dir_file.as_raw_fd()) };
|
||||
if iter_fd < 0 {
|
||||
return Err(io::Error::last_os_error());
|
||||
}
|
||||
let raw_dir = unsafe { libc::fdopendir(iter_fd) };
|
||||
if raw_dir.is_null() {
|
||||
let error = io::Error::last_os_error();
|
||||
unsafe { libc::close(iter_fd) };
|
||||
return Err(error);
|
||||
}
|
||||
let stream = DirStream(raw_dir);
|
||||
let mut entries = Vec::new();
|
||||
let mut total_entries = 0usize;
|
||||
loop {
|
||||
let entry = unsafe { libc::readdir(stream.0) };
|
||||
if entry.is_null() {
|
||||
break;
|
||||
}
|
||||
let name_cstr = unsafe { std::ffi::CStr::from_ptr((*entry).d_name.as_ptr()) };
|
||||
let name_bytes = name_cstr.to_bytes();
|
||||
if name_bytes == b"." || name_bytes == b".." {
|
||||
continue;
|
||||
}
|
||||
total_entries += 1;
|
||||
if entries.len() >= max_entries {
|
||||
continue;
|
||||
}
|
||||
let mut stat = std::mem::MaybeUninit::<libc::stat>::zeroed();
|
||||
let st_ok = unsafe {
|
||||
libc::fstatat(
|
||||
dir_file.as_raw_fd(),
|
||||
name_cstr.as_ptr(),
|
||||
stat.as_mut_ptr(),
|
||||
libc::AT_SYMLINK_NOFOLLOW,
|
||||
)
|
||||
} == 0;
|
||||
let is_dir = if st_ok {
|
||||
let stat = unsafe { stat.assume_init() };
|
||||
(stat.st_mode & libc::S_IFMT) == libc::S_IFDIR
|
||||
} else {
|
||||
false
|
||||
};
|
||||
entries.push(ConfinedDirEntry {
|
||||
name: String::from_utf8_lossy(name_bytes).into_owned(),
|
||||
is_dir,
|
||||
});
|
||||
}
|
||||
Ok(ConfinedDirListing {
|
||||
entries,
|
||||
total_entries,
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[derive(Debug)]
|
||||
pub struct WorkspaceFile {
|
||||
@@ -191,114 +381,18 @@ impl WorkspaceFile {
|
||||
shared: bool,
|
||||
resolve_root: bool,
|
||||
) -> io::Result<Self> {
|
||||
use std::os::fd::{AsRawFd, FromRawFd};
|
||||
use std::os::unix::ffi::OsStrExt;
|
||||
if !path_is_confined(relative) {
|
||||
return Err(invalid_path());
|
||||
}
|
||||
let workspace = if resolve_root {
|
||||
workspace.canonicalize()?
|
||||
} else {
|
||||
if !workspace.is_absolute() {
|
||||
return Err(invalid_path());
|
||||
}
|
||||
workspace.to_path_buf()
|
||||
};
|
||||
// Use the established credential/artifact openat pattern, without
|
||||
// touching credentials or creating a second filesystem store.
|
||||
//
|
||||
// The anchor and every intermediate component are only pinned, never
|
||||
// read, and the final `openat` on the filename applies the real
|
||||
// access mode. On Linux, `O_PATH` pins a directory without read
|
||||
// permission, which keeps this working on Android: SELinux grants
|
||||
// apps search but not read on `/` and mount-point directories, so
|
||||
// `open("/", O_RDONLY|O_DIRECTORY)` fails with EACCES there and
|
||||
// every confined read/write was denied (#6980). O_PATH|O_DIRECTORY
|
||||
// still refuses non-directories (ENOTDIR), so the per-component type
|
||||
// check below is unchanged. Other Unixes have no O_PATH and keep the
|
||||
// readable-directory anchor.
|
||||
// SAFETY: static path and immediate ownership of a successful fd.
|
||||
#[cfg(any(target_os = "linux", target_os = "android"))]
|
||||
let fd = unsafe {
|
||||
libc::open(
|
||||
c"/".as_ptr(),
|
||||
libc::O_PATH | libc::O_DIRECTORY | libc::O_NOFOLLOW | libc::O_CLOEXEC,
|
||||
)
|
||||
};
|
||||
#[cfg(not(any(target_os = "linux", target_os = "android")))]
|
||||
let fd = unsafe {
|
||||
libc::open(
|
||||
c"/".as_ptr(),
|
||||
libc::O_RDONLY | libc::O_DIRECTORY | libc::O_NOFOLLOW | libc::O_CLOEXEC,
|
||||
)
|
||||
};
|
||||
if fd < 0 {
|
||||
return Err(io::Error::last_os_error());
|
||||
}
|
||||
// SAFETY: this fd was just created and has no other owner.
|
||||
let mut directory = unsafe { File::from_raw_fd(fd) };
|
||||
let parents = relative.parent().ok_or_else(invalid_path)?;
|
||||
for (path, may_create) in [(workspace.as_path(), false), (parents, create)] {
|
||||
for component in path.components() {
|
||||
let Component::Normal(name) = component else {
|
||||
if component == Component::RootDir {
|
||||
continue;
|
||||
}
|
||||
return Err(invalid_path());
|
||||
};
|
||||
let name = std::ffi::CString::new(name.as_bytes())?;
|
||||
// Pin only: the caller opens the entry for real through
|
||||
// this directory fd; intermediate components need neither
|
||||
// read permission nor access to their contents. The flags
|
||||
// still reject non-directories on every platform.
|
||||
#[cfg(any(target_os = "linux", target_os = "android"))]
|
||||
let flags = libc::O_PATH | libc::O_DIRECTORY | libc::O_NOFOLLOW | libc::O_CLOEXEC;
|
||||
#[cfg(not(any(target_os = "linux", target_os = "android")))]
|
||||
let flags = libc::O_RDONLY | libc::O_DIRECTORY | libc::O_NOFOLLOW | libc::O_CLOEXEC;
|
||||
// SAFETY: directory pins the parent; name is one component.
|
||||
let mut fd = unsafe { libc::openat(directory.as_raw_fd(), name.as_ptr(), flags) };
|
||||
if fd < 0
|
||||
&& may_create
|
||||
&& io::Error::last_os_error().kind() == io::ErrorKind::NotFound
|
||||
{
|
||||
// SAFETY: directory and relative basename remain valid.
|
||||
let mode = if shared { 0o777 } else { 0o700 };
|
||||
if unsafe { libc::mkdirat(directory.as_raw_fd(), name.as_ptr(), mode) } != 0
|
||||
&& io::Error::last_os_error().kind() != io::ErrorKind::AlreadyExists
|
||||
{
|
||||
return Err(io::Error::last_os_error());
|
||||
}
|
||||
// SAFETY: reject a symlink inserted after mkdirat.
|
||||
fd = unsafe { libc::openat(directory.as_raw_fd(), name.as_ptr(), flags) };
|
||||
}
|
||||
if fd < 0 {
|
||||
return Err(io::Error::last_os_error());
|
||||
}
|
||||
// SAFETY: fd is freshly owned.
|
||||
directory = unsafe { File::from_raw_fd(fd) };
|
||||
}
|
||||
}
|
||||
// The walk above pinned every component O_PATH, and O_PATH fds cannot
|
||||
// be fsynced (EBADF) or read. Reopen the file's immediate parent with
|
||||
// real access flags through the pinned walk so `sync_all`, `renameat`
|
||||
// and every other use of `directory` keeps working. This open cannot
|
||||
// follow a link or escape: it resolves "." inside the pinned parent
|
||||
// itself, and O_NOFOLLOW is irrelevant for an exact "." entry.
|
||||
{
|
||||
use std::os::fd::AsRawFd;
|
||||
let real_fd = unsafe {
|
||||
libc::openat(
|
||||
directory.as_raw_fd(),
|
||||
c".".as_ptr(),
|
||||
libc::O_RDONLY | libc::O_DIRECTORY | libc::O_NOFOLLOW | libc::O_CLOEXEC,
|
||||
)
|
||||
};
|
||||
if real_fd < 0 {
|
||||
return Err(io::Error::last_os_error());
|
||||
}
|
||||
// SAFETY: fd is freshly owned; the O_PATH fd it replaces is dropped.
|
||||
directory = unsafe { File::from_raw_fd(real_fd) };
|
||||
}
|
||||
let directory = open_confined_dir_fd(
|
||||
workspace,
|
||||
(!parents.as_os_str().is_empty()).then_some(parents),
|
||||
create,
|
||||
shared,
|
||||
resolve_root,
|
||||
)?;
|
||||
Ok(Self {
|
||||
directory,
|
||||
filename: std::ffi::CString::new(
|
||||
@@ -690,6 +784,49 @@ fn exclusive_rename_outcome(renamed: bool) -> io::Result<bool> {
|
||||
Ok(false)
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
pub fn read_confined_dir(
|
||||
workspace: &Path,
|
||||
relative: Option<&Path>,
|
||||
max_entries: usize,
|
||||
) -> io::Result<ConfinedDirListing> {
|
||||
let normalized = relative.filter(|p| !p.as_os_str().is_empty() && *p != Path::new("."));
|
||||
if let Some(rel) = normalized
|
||||
&& !path_is_confined(rel)
|
||||
{
|
||||
return Err(invalid_path());
|
||||
}
|
||||
let resolved = workspace.canonicalize()?;
|
||||
let root = WindowsDirectory::open(&resolved)?;
|
||||
let pinned = match normalized {
|
||||
Some(rel) => WindowsDirectory::open_inner(&resolved.join(rel), false, false)?,
|
||||
None => WindowsDirectory::open(&resolved)?,
|
||||
};
|
||||
let mut entries = Vec::new();
|
||||
let mut total_entries = 0usize;
|
||||
for entry in std::fs::read_dir(&pinned.directory)? {
|
||||
let entry = entry?;
|
||||
total_entries += 1;
|
||||
if entries.len() >= max_entries {
|
||||
continue;
|
||||
}
|
||||
let file_type = entry.file_type()?;
|
||||
let is_reparse = file_type.is_symlink()
|
||||
|| std::fs::symlink_metadata(entry.path())
|
||||
.ok()
|
||||
.is_some_and(|meta| crate::path_identity::metadata_is_link_or_reparse(&meta));
|
||||
entries.push(ConfinedDirEntry {
|
||||
name: entry.file_name().to_string_lossy().to_string(),
|
||||
is_dir: file_type.is_dir() && !is_reparse,
|
||||
});
|
||||
}
|
||||
let _keep_root_pinned = root;
|
||||
Ok(ConfinedDirListing {
|
||||
entries,
|
||||
total_entries,
|
||||
})
|
||||
}
|
||||
|
||||
/// Windows path fence shared by Fleet publication and Native sandbox ACL
|
||||
/// admission. Every original absolute ancestor is opened without delete/write
|
||||
/// sharing and without following reparse points before path-based calls.
|
||||
@@ -1228,6 +1365,40 @@ mod unix_publication_tests {
|
||||
assert_eq!(mode(&workspace.path().join("private")), 0o700);
|
||||
assert_eq!(mode(&workspace.path().join("private/receipt.json")), 0o600);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn read_confined_dir_lists_entries_and_refuses_symlinks() {
|
||||
let workspace = tempfile::tempdir().unwrap();
|
||||
let outside = tempfile::tempdir().unwrap();
|
||||
std::fs::create_dir(workspace.path().join("sub")).unwrap();
|
||||
std::fs::write(workspace.path().join("sub/file.txt"), "hi").unwrap();
|
||||
std::os::unix::fs::symlink(outside.path(), workspace.path().join("out")).unwrap();
|
||||
std::os::unix::fs::symlink(workspace.path().join("sub"), workspace.path().join("alias"))
|
||||
.unwrap();
|
||||
|
||||
let root_listing = read_confined_dir(workspace.path(), None, 100).unwrap();
|
||||
assert_eq!(root_listing.total_entries, 3);
|
||||
let sub_entry = root_listing
|
||||
.entries
|
||||
.iter()
|
||||
.find(|e| e.name == "sub")
|
||||
.unwrap();
|
||||
assert!(sub_entry.is_dir);
|
||||
let link_entry = root_listing
|
||||
.entries
|
||||
.iter()
|
||||
.find(|e| e.name == "out")
|
||||
.unwrap();
|
||||
assert!(!link_entry.is_dir);
|
||||
|
||||
let sub_listing = read_confined_dir(workspace.path(), Some(Path::new("sub")), 100).unwrap();
|
||||
assert_eq!(sub_listing.entries.len(), 1);
|
||||
assert_eq!(sub_listing.entries[0].name, "file.txt");
|
||||
|
||||
assert!(read_confined_dir(workspace.path(), Some(Path::new("out")), 100).is_err());
|
||||
assert!(read_confined_dir(workspace.path(), Some(Path::new("alias")), 100).is_err());
|
||||
assert!(read_confined_dir(workspace.path(), Some(Path::new("../escape")), 100).is_err());
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(all(test, windows))]
|
||||
@@ -1365,6 +1536,14 @@ mod windows_root_link_tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(all(not(unix), not(windows)))]
|
||||
pub fn read_confined_dir(_: &Path, _: Option<&Path>, _: usize) -> io::Result<ConfinedDirListing> {
|
||||
Err(io::Error::new(
|
||||
io::ErrorKind::Unsupported,
|
||||
"Confined Fleet artifact I/O is unavailable on this platform",
|
||||
))
|
||||
}
|
||||
|
||||
#[cfg(all(not(unix), not(windows)))]
|
||||
#[derive(Debug)]
|
||||
pub struct WorkspaceFile;
|
||||
|
||||
+18
-5
@@ -515,11 +515,24 @@ Overrides:
|
||||
Custom paths must be absolute; a relative `mcp_config_path` falls back to the
|
||||
user-global path rather than changing with the launch directory.
|
||||
|
||||
Codewhale also reads `<workspace>/.codewhale/mcp.json` after the workspace is
|
||||
trusted in user-owned configuration. Project entries override same-named global
|
||||
servers; project-local files cannot grant that trust themselves or redirect the
|
||||
global `mcp_config_path`. Global and trusted-project entries take precedence
|
||||
over a colliding plugin-contributed identity. Project stdio commands default
|
||||
Codewhale also reads `<workspace>/.codewhale/mcp.json`, but only after the
|
||||
workspace is trusted in user-owned configuration **and** you have approved the
|
||||
exact contents of that file. A stdio entry is a command line and a server that
|
||||
is not `lazy` starts at session start, so the project file is executable
|
||||
configuration, like project hooks. Use `/mcp review` to see the file and its
|
||||
digest, then `/mcp approve <digest>` to load those bytes. The approval is stored
|
||||
in user-owned configuration under the workspace's `projects` entry
|
||||
(`mcp_sha256`), never in the repository, and a symlinked `.codewhale` or
|
||||
`mcp.json` is refused. Any later change to the file, such as a pull or a branch
|
||||
switch, loads none of its servers until you review and approve again.
|
||||
`/mcp revoke` removes the approval. `/mcp disable`, `/mcp enable` and
|
||||
`/mcp remove` on a project server edit the file and keep its approval. Review
|
||||
any scripts a server command runs as well: the approval covers the file, not
|
||||
the files it points to. `codewhale doctor` and `/mcp validate` report a project
|
||||
file that is waiting for approval. Project entries override same-named global
|
||||
servers; project-local files cannot grant trust or approval themselves or
|
||||
redirect the global `mcp_config_path`. Global and approved-project entries take
|
||||
precedence over a colliding plugin-contributed identity. Project stdio commands default
|
||||
their working directory to the workspace. In the main candidate,
|
||||
`always_allow_tools` is accepted only from the global server configuration,
|
||||
not from project or plugin declarations.
|
||||
|
||||
+30
-37
@@ -415,8 +415,9 @@ validation as turn operation keys and are scoped to the Runtime store owner.
|
||||
Exact retries return the original thread, including after response loss or
|
||||
restart; a different effective request with the same key returns HTTP 409.
|
||||
The fingerprint includes the resolved model/provider, workspace, reasoning,
|
||||
permission and shell policy, tool allowlist, archival state, prompt/task fields,
|
||||
dynamic tools and environments. Creation defaults are resolved before comparison.
|
||||
permission and shell policy, tool allowlist, tool denylist, archival state,
|
||||
prompt/task fields, dynamic tools and environments. Creation defaults are
|
||||
resolved before comparison.
|
||||
Operation records retain only fingerprints and reserved identity, never the raw
|
||||
key. Missing committed thread data is an error, never permission to create a
|
||||
replacement. Clients must require this capability before relying on creation
|
||||
@@ -1357,7 +1358,8 @@ provider, model, workspace, and permission fields:
|
||||
"model_provider": "openai-codex",
|
||||
"model": "gpt-5.6",
|
||||
"reasoning_effort": "high",
|
||||
"allowed_tools": ["read_file", "search"]
|
||||
"allowed_tools": ["read_file", "search"],
|
||||
"disallowed_tools": ["shell"]
|
||||
}
|
||||
```
|
||||
|
||||
@@ -1366,8 +1368,11 @@ provider, model, workspace, and permission fields:
|
||||
aliases are accepted and persisted canonically). `allowed_tools` is a
|
||||
model-visible allowlist. Omitting it keeps the normal configured catalog; an
|
||||
explicit empty array (`"allowed_tools": []`) exposes no tools to the model.
|
||||
Both fields are additive: older thread records and clients that omit them
|
||||
retain their previous behavior.
|
||||
`disallowed_tools` (also accepted as `disallowedTools`) attaches persistent
|
||||
tool deny rules to the thread (omitted from stored records when empty); denied
|
||||
tools remain blocked even if listed in `allowed_tools`, and non-empty rules
|
||||
participate in creation idempotency fingerprints. These fields are additive:
|
||||
older thread records and clients that omit them retain their previous behavior.
|
||||
|
||||
### Sessions on other agents (external agent threads)
|
||||
|
||||
@@ -2288,12 +2293,12 @@ human gate. Auto-merge is `scripts/check-auto-merge.py --repo … --pr …
|
||||
`scripts/auto-merge-pr.py`. Do not invent a second checker.
|
||||
|
||||
**Workspace trust** (the persisted project decision)
|
||||
- `GET /v1/workspace/status` reports `workspace_trusted`: whether the server’s workspace is trusted in the discovered user config. Project skills, commands and MCP configuration consult this decision. Project hooks require both workspace trust and separate approval of the exact hooks file.
|
||||
- `GET /v1/workspace/status` reports `workspace_trusted`: whether the server’s workspace is trusted in the discovered user config. Project skills and commands consult this decision. Project hooks and project MCP configuration require both workspace trust and separate approval of the exact file (`/hooks review`, `/mcp review`).
|
||||
- `POST /v1/workspace/trust` with `{"trusted": true|false}` records that decision and answers `{workspace, workspace_trusted, config_path, message}`.
|
||||
|
||||
The handler reuses `config::set_workspace_trust`, the writer behind `/trust on|off --save`, and reads the decision back through `is_workspace_trusted`. The request accepts no path; it applies only to the server’s admitted workspace. `config_path` identifies the document written. Both read and write use default config discovery, including configured environment overrides; a runtime’s explicit `--config` argument does not select this trust document.
|
||||
|
||||
Project skills and commands consult trust at their existing load or turn boundaries. Project hooks also need their separate digest approval and recheck authority before execution. Restart the engine to rebuild project MCP connections; this request does not disconnect an existing pool or interrupt a running turn. Project instruction files such as `AGENTS.md` and project rules are loaded by the existing context loader independently of this flag, so revoking workspace trust does not remove those instructions or their persisted history.
|
||||
Project skills and commands consult trust at their existing load or turn boundaries. Project hooks and project MCP servers (when `.codewhale/mcp.json` is already approved) also need their separate digest approval and recheck authority before execution; once approved, restart the engine or reload the pool to rebuild project MCP connections, as this request does not disconnect an existing pool or interrupt a running turn. Project instruction files such as `AGENTS.md` and project rules are loaded by the existing context loader independently of this flag, so revoking workspace trust does not remove those instructions or their persisted history.
|
||||
|
||||
This persisted project decision is separate from a thread’s `trust_mode` / Full Access and from `/trust add <path>` external-path grants. `/trust on|off --save` changes session file-tool trust and the persisted project record. This HTTP route changes only the persisted project record. Its POST requires the same Runtime write authority as other state-changing routes; watch device tokens can read status but cannot write trust.
|
||||
|
||||
@@ -3026,44 +3031,32 @@ answers `200` with `ok: false` and a machine-readable `reason`
|
||||
(`no_server`, `lsp_disabled`, `lsp_error`); malformed input is a 400 and a
|
||||
missing file a 404.
|
||||
|
||||
**Voice** (host dictation & uploaded voice / TTS, CW-8218)
|
||||
- `GET /v1/voice` — capability: `available` (host mic recorder),
|
||||
`upload_available`, `tts_available`, detected `recorder` command, resolved
|
||||
`asr` `{kind, model}`, `modes`, `send_phrases`, `max_record_seconds`,
|
||||
`max_upload_bytes`, `tts_voices`
|
||||
**Voice** (host dictation, APPS-98)
|
||||
- `GET /v1/voice` — capability: `available`, detected `recorder` command,
|
||||
resolved `asr` `{kind, model}`, `modes`, `send_phrases`,
|
||||
`max_record_seconds`
|
||||
- `POST /v1/voice/dictate` — record then transcribe → `{ ok, text }`
|
||||
- `POST /v1/voice/send` — same capture with the "send it" / 发送/發送
|
||||
suffix contract: `send: true` tells the client to submit (empty `text`
|
||||
with `send: true` means submit the client's current draft)
|
||||
- `POST /v1/voice/control` `{ "composer": "draft text" }` — assisted
|
||||
dictation that shows the model the composer text; `assisted: false` in the
|
||||
response means local whisper, Groq, or DeepSeek ASR handled the audio and the
|
||||
composer context was never seen
|
||||
- `POST /v1/voice/transcribe` `{ "audio_base64", "mime_type", "mode",
|
||||
"composer", "language" }` — transcribe client-uploaded WAV/OGG audio without
|
||||
requiring a host microphone (`CODEWHALE_DISABLE_VOICE=1` does not block
|
||||
uploaded audio)
|
||||
- `POST /v1/voice/tts` `{ "text", "voice", "voice_id", "format",
|
||||
"filter_markdown" }` — synthesize speech audio via the active Xiaomi MiMo or
|
||||
configured DeepSeek Tools TTS route and answer JSON `{ ok: true, mime_type,
|
||||
audio_base64, voice_id, provider, model, format }` or `{ ok: false, reason,
|
||||
message }`
|
||||
response means a free ASR backend (local whisper/Groq) handled the audio
|
||||
and the composer context was never seen
|
||||
|
||||
The runtime owns the host microphone, uploaded audio decoding, TTS synthesis,
|
||||
and ASR dispatch — the same implementation the TUI's `/voice` commands run,
|
||||
headless. Recording is one blocking capture per host (requests serialize; the
|
||||
loser gets `ok:false`/`no_speech`, not a fought-over device). Provider ASR
|
||||
resolves its key lazily so local-whisper and Groq paths work without provider
|
||||
auth; uploaded audio and TTS support DeepSeek `/v1/tools/asr` and
|
||||
`/v1/tools/tts` when configured. Interim and final transcription use the
|
||||
selected ASR backend. If local whisper or Groq fails, the error stays on that
|
||||
backend: the runtime never retries the recording or composer text with the
|
||||
active model provider. Select provider ASR explicitly to use that route.
|
||||
The runtime owns the host microphone and the ASR dispatch — the same
|
||||
implementation the TUI's `/voice` commands run, headless. Recording is one
|
||||
blocking capture per host (requests serialize; the loser gets
|
||||
`ok:false`/`no_speech`, not a fought-over device). Provider ASR resolves its
|
||||
key lazily so local-whisper and Groq paths work without provider auth.
|
||||
Interim and final transcription use the selected ASR backend. If local whisper
|
||||
or Groq fails, the error stays on that backend: the runtime never retries the
|
||||
recording or composer text with the active model provider. Select provider ASR
|
||||
explicitly to use that route.
|
||||
Failure is data: `no_recorder`, `no_speech`, `no_provider_auth`,
|
||||
`invalid_input`, `transcription_failed`, `synthesis_failed`.
|
||||
`CODEWHALE_DISABLE_VOICE=1` is an operator kill-switch for host microphone
|
||||
recording (`available: false`), while uploaded audio continues to follow
|
||||
`upload_available`.
|
||||
`transcription_failed`. `CODEWHALE_DISABLE_VOICE=1` is an operator
|
||||
kill-switch — a headless `serve --http` host reports `available: false` and
|
||||
every dictate call fails closed.
|
||||
|
||||
## Provider and model selection
|
||||
|
||||
|
||||
+4
-3
@@ -228,9 +228,10 @@ Optional fields:
|
||||
- `worktree_branch`: exact branch to create.
|
||||
- `worktree_base`: git ref to branch from; defaults to `HEAD`.
|
||||
- `worktree_path`: exact checkout path. Relative and absolute paths must stay
|
||||
under the default sibling `.codewhale-worktrees/<repo>/` root (symlinks are
|
||||
resolved before the check). Any worktree request keeps the approval card,
|
||||
even for a read-only role.
|
||||
under the default sibling `.codewhale-worktrees/<repo>/` root, and a link
|
||||
anywhere on the way down to the checkout, including at the root itself, is
|
||||
refused. Any worktree request keeps the approval card, even for a read-only
|
||||
role, and that includes `workspace_policy: "worktree"`.
|
||||
|
||||
`cwd` may be combined with `worktree`: the requested directory becomes the
|
||||
discovery anchor the repo root (and the new checkout) is resolved from
|
||||
|
||||
+3
-1
@@ -8,6 +8,8 @@ Codewhale dapat memuat alat tambahan melalui **MCP (Model Context Protocol)**. S
|
||||
|
||||
Konfigurasi server MCP disimpan di dalam `mcp.json` (baik di `$CODEWHALE_HOME/mcp.json` untuk global atau `.codewhale/mcp.json` untuk proyek).
|
||||
|
||||
File proyek `.codewhale/mcp.json` hanya dimuat setelah workspace dipercaya di konfigurasi milik pengguna **dan** Anda menyetujui isi persis file tersebut. Entri stdio adalah baris perintah, dan server yang bukan `lazy` berjalan saat sesi dimulai, sehingga file proyek adalah konfigurasi yang dapat dieksekusi, seperti hook proyek. Gunakan `/mcp review` untuk melihat file beserta digest-nya, lalu `/mcp approve <digest>` untuk memuat isi tersebut. Persetujuan disimpan di konfigurasi milik pengguna pada entri `projects` workspace (`mcp_sha256`), bukan di repositori, dan `.codewhale` atau `mcp.json` berupa symlink ditolak. Setiap perubahan file berikutnya, misalnya karena pull atau pindah branch, membuat tidak ada servernya yang dimuat sampai Anda meninjau dan menyetujui lagi. `/mcp revoke` mencabut persetujuan. `/mcp disable`, `/mcp enable` dan `/mcp remove` pada server proyek mengubah file dan mempertahankan persetujuannya. Tinjau juga skrip yang dijalankan perintah server: persetujuan hanya mencakup file itu sendiri. `codewhale doctor` dan `/mcp validate` melaporkan file proyek yang menunggu persetujuan.
|
||||
|
||||
Contoh konfigurasi `mcp.json`:
|
||||
|
||||
```json
|
||||
@@ -29,6 +31,6 @@ Contoh konfigurasi `mcp.json`:
|
||||
|
||||
## Perintah CLI & TUI untuk MCP
|
||||
|
||||
- `/mcp` — Kelola server dan alat MCP dari dalam TUI.
|
||||
- `/mcp` — Kelola server dan alat MCP dari dalam TUI (`/mcp review`, `/mcp approve <digest>`, `/mcp revoke` untuk file proyek).
|
||||
- `codewhale mcp init` — Inisialisasi templat konfigurasi `mcp.json`.
|
||||
- `codewhale doctor` — Periksa kesehatan dan kesiapan server MCP yang terkonfigurasi.
|
||||
+1
-1
@@ -285,7 +285,7 @@ Codewhale 同时读取 `servers` 和 `mcpServers`,因此设置页生成的片
|
||||
|
||||
自定义路径必须是绝对路径;相对 `mcp_config_path` 会回退到用户全局路径,不会随启动目录改变。
|
||||
|
||||
工作区在用户拥有的配置中获准信任后,Codewhale 还会读取 `<workspace>/.codewhale/mcp.json`。项目中的同名服务器覆盖全局条目;项目文件不能自行授予信任,也不能覆盖全局 `mcp_config_path`。全局或受信任项目中的显式条目优先于身份冲突的插件服务器。项目 stdio 命令默认以工作区为工作目录。main 候选版本中的 `always_allow_tools` 仅接受全局服务器配置,不接受项目或插件声明。
|
||||
Codewhale 还会读取 `<workspace>/.codewhale/mcp.json`,但只有在工作区于用户拥有的配置中获准信任、并且你已批准该文件的确切内容之后才会读取。stdio 条目就是一条命令行,未标记 `lazy` 的服务器会在会话启动时运行,所以项目文件与项目 hooks 一样属于可执行配置。用 `/mcp review` 查看该文件及其摘要(digest),再用 `/mcp approve <digest>` 加载这些字节。批准记录保存在用户自有配置中该工作区的 `projects` 条目下(`mcp_sha256`),不在仓库里;符号链接形式的 `.codewhale` 或 `mcp.json` 会被拒绝。之后文件一旦改动(例如拉取代码或切换分支),在你重新审阅并批准之前,其中的服务器一个都不会加载。`/mcp revoke` 会撤销批准。对项目服务器执行 `/mcp disable`、`/mcp enable`、`/mcp remove` 会修改该文件,并保留其批准状态。服务器命令所运行的脚本也请一并审阅:批准只覆盖该文件本身,不覆盖它所指向的文件。`codewhale doctor` 和 `/mcp validate` 会报告正在等待批准的项目文件。项目中的同名服务器覆盖全局条目;项目文件不能自行授予信任或批准,也不能覆盖全局 `mcp_config_path`。全局或已批准项目中的显式条目优先于身份冲突的插件服务器。项目 stdio 命令默认以工作区为工作目录。main 候选版本中的 `always_allow_tools` 仅接受全局服务器配置,不接受项目或插件声明。
|
||||
|
||||
`codewhale mcp init`(以及 `codewhale setup --mcp`)会写入这个解析出的路径。
|
||||
|
||||
|
||||
@@ -1038,7 +1038,8 @@ Runtime 前缀恢复验证与后续写入所需的完整文档条件保持独立
|
||||
"model_provider": "openai-codex",
|
||||
"model": "gpt-5.6",
|
||||
"reasoning_effort": "high",
|
||||
"allowed_tools": ["read_file", "search"]
|
||||
"allowed_tools": ["read_file", "search"],
|
||||
"disallowed_tools": ["shell"]
|
||||
}
|
||||
```
|
||||
|
||||
@@ -1046,8 +1047,10 @@ Runtime 前缀恢复验证与后续写入所需的完整文档条件保持独立
|
||||
`low`、`medium`、`high`、`xhigh`、`ultra` 或 `max`;已记录的兼容
|
||||
别名会被接受,并以规范形式持久化)。`allowed_tools` 是一份对模型可见的允许名单。
|
||||
省略它会保留常规的已配置目录;显式空数组(`"allowed_tools": []`)
|
||||
则不会向模型暴露任何工具。
|
||||
两个字段都是加性(additive)的:省略它们的旧线程记录与客户端
|
||||
则不会向模型暴露任何工具。`disallowed_tools`(也接受 `disallowedTools`)
|
||||
为线程记录持久化的工具禁用规则(为空时从存储记录中省略);被禁用的工具即使列在
|
||||
`allowed_tools` 中也仍会被拦截,非空规则会参与创建幂等性指纹计算。
|
||||
这些字段都是加性(additive)的:省略它们的旧线程记录与客户端
|
||||
保持之前的行为。
|
||||
|
||||
`GET /v1/threads/summary` 是 VS Code Agent View 使用的只读摘要表面。
|
||||
@@ -1684,12 +1687,12 @@ direction 为空、等待 lead 计划、缺少凭据或有
|
||||
`scripts/auto-merge-pr.py`。不要再发明第二个检查器。
|
||||
|
||||
**工作区信任**(持久化的项目决定)
|
||||
- `GET /v1/workspace/status` 返回 `workspace_trusted`:服务器工作区是否已在发现的用户配置中被设为可信。项目技能、命令和 MCP 配置会检查这个决定。项目钩子还需要单独批准钩子文件的精确摘要。
|
||||
- `GET /v1/workspace/status` 返回 `workspace_trusted`:服务器工作区是否已在发现的用户配置中被设为可信。项目技能和命令会检查这个决定。项目钩子和项目 MCP 配置还需要单独批准对应文件的精确摘要(`/hooks review`、`/mcp review`)。
|
||||
- `POST /v1/workspace/trust` 接受 `{"trusted": true|false}`,记录决定并返回 `{workspace, workspace_trusted, config_path, message}`。
|
||||
|
||||
处理器复用 `/trust on|off --save` 使用的 `config::set_workspace_trust`,再通过 `is_workspace_trusted` 读回结果。请求不接受路径,只作用于服务器已接纳的工作区。`config_path` 标识实际写入的文档。读写都使用默认配置发现规则,包括已配置的环境变量覆盖;启动运行时的显式 `--config` 参数不会选择这个信任文档。
|
||||
|
||||
项目技能和命令会在各自现有的加载或回合边界检查信任。项目钩子还需要单独的摘要批准,并在执行前重新检查权限。重启引擎可重新建立项目 MCP 连接;本请求不会断开已有连接池或中断正在运行的回合。现有上下文加载器会独立于此标志加载 `AGENTS.md` 等项目指令文件和项目规则,因此撤销工作区信任不会移除这些指令或已持久化的历史。
|
||||
项目技能和命令会在各自现有的加载或回合边界检查信任。项目钩子和项目 MCP 服务器还需要单独的摘要批准(`.codewhale/mcp.json` 需经 `/mcp approve <digest>` 批准),并在执行前重新检查权限;已获批准时可重启引擎或重载连接池来重新建立项目 MCP 连接,本请求不会断开已有连接池或中断正在运行的回合。现有上下文加载器会独立于此标志加载 `AGENTS.md` 等项目指令文件和项目规则,因此撤销工作区信任不会移除这些指令或已持久化的历史。
|
||||
|
||||
这个持久化的项目决定与线程的 `trust_mode` / Full Access,以及 `/trust add <path>` 的外部路径授权分别独立。`/trust on|off --save` 同时修改会话文件工具信任和持久化项目记录;本 HTTP 路由只修改项目记录。POST 使用其他状态变更路由相同的运行时写权限;watch 设备令牌可以读取状态,但不能写入信任。
|
||||
|
||||
|
||||
@@ -76,7 +76,7 @@ Fleet 角色是委派工作面向用户的词汇:父智能体通过 `agent`
|
||||
|
||||
- `worktree_branch`:要创建的确切分支。
|
||||
- `worktree_base`:要从中开出分支的 git ref;默认为 `HEAD`。
|
||||
- `worktree_path`:确切的检出路径。相对路径和绝对路径都必须位于默认的同级 `.codewhale-worktrees/<repo>/` 根目录之下(检查之前会先解析符号链接)。任何 worktree 请求都保留审批卡片,即使是只读角色。
|
||||
- `worktree_path`:确切的检出路径。相对路径和绝对路径都必须位于默认的同级 `.codewhale-worktrees/<repo>/` 根目录之下,并且通向该检出的路径上的任何符号链接(包括根目录本身)都会被拒绝。任何 worktree 请求都保留审批卡片,即使是只读角色,其中包括 `workspace_policy: "worktree"`。
|
||||
|
||||
`cwd` 可以与 `worktree` 组合:所请求的目录成为发现锚点,仓库根(以及新的检出)由此解析(`prepare_child_workspace`)。没有 `worktree` 时,`cwd` 仍是对父工作区内已创建目录的手动逃生舱。
|
||||
|
||||
|
||||
@@ -261,7 +261,7 @@
|
||||
"thread_sleep": 1
|
||||
},
|
||||
"crates/engine/src/tools/subagent/worktree.rs": {
|
||||
"path_canonicalize": 6
|
||||
"path_canonicalize": 5
|
||||
},
|
||||
"crates/engine/src/tools/terminal_session.rs": {
|
||||
"path_canonicalize": 1,
|
||||
@@ -567,8 +567,8 @@
|
||||
"path_canonicalize": 2
|
||||
},
|
||||
"crates/util/src/files.rs": {
|
||||
"path_canonicalize": 5,
|
||||
"std_fs": 5,
|
||||
"path_canonicalize": 6,
|
||||
"std_fs": 7,
|
||||
"thread_sleep": 1
|
||||
},
|
||||
"crates/util/src/git_metadata.rs": {
|
||||
|
||||
@@ -191,8 +191,8 @@
|
||||
"sources": [
|
||||
{
|
||||
"path": "docs/SUBAGENTS.md",
|
||||
"sha256": "8fdaef0ecf37457e4b6e3a0d84f64ed21963ef7c6c8a17bd2fb5efb3d7004acd",
|
||||
"content": "# Fleet and sub-agents\n\n> 阅读简体中文版:[zh_hans/SUBAGENTS.md](zh_hans/SUBAGENTS.md)\n\nFleet manages saved models and role assignments for these same sub-agents.\nUse `agent` for an individual assignment and `workflow` for phases with\ndependencies and completion checks. See [Workflow authoring](WORKFLOW_AUTHORING.md)\nfor plans that use the Fleet model shortlist.\n\nFleet roles are the user-facing vocabulary for delegated work: a parent\nlaunches a focused `general`, `explore`, `planner`, `reviewer`, `implement`,\n`test`, or `advisor` through `agent` and gets back an `agent_id`, declared\ndeliverables, and effective limits while the worker runs. The default receipt is\ncompact; request addressed detail when you need the transcript handle or ledger.\nThe internal runtime type is `FleetRole` (formerly\n`SubAgentType`); the older role spellings (`worker`, `scout`, `plan`,\n`review`, `builder`, `verifier`, `consultant`, `oracle`, …) remain accepted only as a persisted/deserialize\ncompatibility adapter during v0.9.x. New prompts and config should use fleet\nnames.\n\nChild assignments run through the same `Engine::run_turn` planner, executor,\nSession and approval inbox as ordinary turns. The private captured ChildGrant\nnarrows tool actions, shell, workspace, network and descendant depth at the final\nexecution boundary. The existing SubAgentManager still owns launch slots,\ncoordination, worker checkpoints and terminal delivery; it does not run another\nmodel/tool loop. Work uses the full explicit owner limit. At the limit the host\npreserves a partial-result receipt without an extra model reporting request.\n\nTransient provider failures use Core's dispatch and retry boundaries. An exhausted\nbudget or interrupted request preserves the recorded checkpoint and a continuation\nhandle. Each actually dispatched attempt carries its original session, route and\nsource identity: provider usage is billed once, then projected to the worker\nledger. A successful response without usage and an unknown request outcome retain\ndistinct coverage gaps; neither is priced as zero. For work that must survive\nprocess restarts, sleep or remote execution, use Fleet or a Workflow-backed run.\n\nSub-agents inherit the parent's permitted tool registry, including `agent`\ncoordination. Spawning obeys one absolute depth ceiling: the root is depth 0,\nits child is depth 1, and a child at `max_spawn_depth` cannot spawn again.\nThe default has no product depth cap. Explicit operator, role and saved-profile\nlimits still narrow descendant authority; model requests cannot choose caps. Recovery and transcript\nforking retain the source's position and bounds; they do not buy another\ngeneration. The removed `agent_open`/`agent_eval`/`agent_close` lifecycle tools\nare absent from every registry.\n\nHealthy children continue after an ordinary parent response. Their completion\nreturns through the existing Engine inbox and can wake the parent for another\nnormal turn. Explicit interruption or cancellation remains authoritative.\n`detached: true` additionally opts a subtree out of parent-turn cancellation;\nit does not remove child budgets or the headless host's deadline.\n\nThis doc covers roles and individual worker controls. Use `workflow` to coordinate\nmultiple assignments through the same worker runtime; see the per-role prompts in\n`crates/engine/src/tools/subagent/mod.rs` (`*_AGENT_INTRO`) and the in-line\ntool description.\n\n## Role taxonomy\n\nThe `type` field on `agent` selects a fleet posture for the child\n(`agent_type` is accepted as a compatibility alias). Each role is a distinct\nstance toward the work — not just a different label.\n\n## Maintainer posture\n\nSub-agents help Codewhale move faster, but the parent agent still owns the\nmaintainer decision. Use children to gather evidence, review patches, and run\nverification while keeping the community posture in\n[`AGENT_ETHOS.md`](AGENT_ETHOS.md): issues are open intake, PR gates are\nreview-load controls, and harvested work needs clear contributor credit.\n\nWhen a child reviews community work, the parent should still inspect the PR\ndiff, linked issues, tests, and CI before merging, harvesting, closing, or\ndeferring it. A sub-agent's result is a working set, not a substitute for\nstewardship.\n\n| Role | Stance | Writes? | Network? | Shell posture | Typical use |\n|---------------|----------------------------------------|---------|----------|---------------|----------------------------------------------|\n| `general` | flexible; do whatever the parent says | yes | yes | yes | the default; multi-step tasks |\n| `explore` | read-only; map the relevant code fast | no | yes | bounded inspection | \"find every call site of `Foo`; check the PR with gh\" |\n| `planner` | analyse and produce a strategy | nLine truncated
|
||||
"sha256": "6f500ad678657e319ba39e47b4793fe1a564f23abf22ddb4d07a83fdd69533a8",
|
||||
"content": "# Fleet and sub-agents\n\n> 阅读简体中文版:[zh_hans/SUBAGENTS.md](zh_hans/SUBAGENTS.md)\n\nFleet manages saved models and role assignments for these same sub-agents.\nUse `agent` for an individual assignment and `workflow` for phases with\ndependencies and completion checks. See [Workflow authoring](WORKFLOW_AUTHORING.md)\nfor plans that use the Fleet model shortlist.\n\nFleet roles are the user-facing vocabulary for delegated work: a parent\nlaunches a focused `general`, `explore`, `planner`, `reviewer`, `implement`,\n`test`, or `advisor` through `agent` and gets back an `agent_id`, declared\ndeliverables, and effective limits while the worker runs. The default receipt is\ncompact; request addressed detail when you need the transcript handle or ledger.\nThe internal runtime type is `FleetRole` (formerly\n`SubAgentType`); the older role spellings (`worker`, `scout`, `plan`,\n`review`, `builder`, `verifier`, `consultant`, `oracle`, …) remain accepted only as a persisted/deserialize\ncompatibility adapter during v0.9.x. New prompts and config should use fleet\nnames.\n\nChild assignments run through the same `Engine::run_turn` planner, executor,\nSession and approval inbox as ordinary turns. The private captured ChildGrant\nnarrows tool actions, shell, workspace, network and descendant depth at the final\nexecution boundary. The existing SubAgentManager still owns launch slots,\ncoordination, worker checkpoints and terminal delivery; it does not run another\nmodel/tool loop. Work uses the full explicit owner limit. At the limit the host\npreserves a partial-result receipt without an extra model reporting request.\n\nTransient provider failures use Core's dispatch and retry boundaries. An exhausted\nbudget or interrupted request preserves the recorded checkpoint and a continuation\nhandle. Each actually dispatched attempt carries its original session, route and\nsource identity: provider usage is billed once, then projected to the worker\nledger. A successful response without usage and an unknown request outcome retain\ndistinct coverage gaps; neither is priced as zero. For work that must survive\nprocess restarts, sleep or remote execution, use Fleet or a Workflow-backed run.\n\nSub-agents inherit the parent's permitted tool registry, including `agent`\ncoordination. Spawning obeys one absolute depth ceiling: the root is depth 0,\nits child is depth 1, and a child at `max_spawn_depth` cannot spawn again.\nThe default has no product depth cap. Explicit operator, role and saved-profile\nlimits still narrow descendant authority; model requests cannot choose caps. Recovery and transcript\nforking retain the source's position and bounds; they do not buy another\ngeneration. The removed `agent_open`/`agent_eval`/`agent_close` lifecycle tools\nare absent from every registry.\n\nHealthy children continue after an ordinary parent response. Their completion\nreturns through the existing Engine inbox and can wake the parent for another\nnormal turn. Explicit interruption or cancellation remains authoritative.\n`detached: true` additionally opts a subtree out of parent-turn cancellation;\nit does not remove child budgets or the headless host's deadline.\n\nThis doc covers roles and individual worker controls. Use `workflow` to coordinate\nmultiple assignments through the same worker runtime; see the per-role prompts in\n`crates/engine/src/tools/subagent/mod.rs` (`*_AGENT_INTRO`) and the in-line\ntool description.\n\n## Role taxonomy\n\nThe `type` field on `agent` selects a fleet posture for the child\n(`agent_type` is accepted as a compatibility alias). Each role is a distinct\nstance toward the work — not just a different label.\n\n## Maintainer posture\n\nSub-agents help Codewhale move faster, but the parent agent still owns the\nmaintainer decision. Use children to gather evidence, review patches, and run\nverification while keeping the community posture in\n[`AGENT_ETHOS.md`](AGENT_ETHOS.md): issues are open intake, PR gates are\nreview-load controls, and harvested work needs clear contributor credit.\n\nWhen a child reviews community work, the parent should still inspect the PR\ndiff, linked issues, tests, and CI before merging, harvesting, closing, or\ndeferring it. A sub-agent's result is a working set, not a substitute for\nstewardship.\n\n| Role | Stance | Writes? | Network? | Shell posture | Typical use |\n|---------------|----------------------------------------|---------|----------|---------------|----------------------------------------------|\n| `general` | flexible; do whatever the parent says | yes | yes | yes | the default; multi-step tasks |\n| `explore` | read-only; map the relevant code fast | no | yes | bounded inspection | \"find every call site of `Foo`; check the PR with gh\" |\n| `planner` | analyse and produce a strategy | nLine truncated
|
||||
},
|
||||
{
|
||||
"path": "docs/FLEET.md",
|
||||
@@ -280,8 +280,8 @@
|
||||
"sources": [
|
||||
{
|
||||
"path": "docs/MCP.md",
|
||||
"sha256": "28d6b8e47057503aabc413ab657d5f4895627e945c2aa595a01ac53252e3ea0b",
|
||||
"content": "# MCP (External Tool Servers)\n\nIn the terminal, `/mcp` (also `/mcps`) opens **Plugins → Connectors**. Enter opens the selected server’s recovery action or read-only details. An empty inventory offers server suggestions; browsing them installs nothing. Explicit subcommands such as `/mcp status`, `/mcp doctor`, `/mcp login`, and `/mcp add` retain their existing behavior.\n\n\n> 阅读简体中文版:[zh_hans/MCP.md](zh_hans/MCP.md)\n\nEverything you add to Codewhale is a plugin. An MCP server you configure is a\nplugin of the kind **Connector**, and a plugin bundle can also carry connectors.\nConnectors are still managed under Plugins → Connectors in the terminal and\nCustomize › Connectors on the desktop; one Plugins list that shows every kind\ntogether is planned for 0.10.3 and is not in this build. The configuration\nfiles, sign-in and the `/mcp` commands are unchanged. \"Connection\" on this page\nmeans the live link to a server; as a product noun it is the word for a model\nprovider or another agent.\n\nCodewhale can load additional tools via MCP (Model Context Protocol). MCP servers can be local stdio processes that Codewhale starts, or remote URL-based servers that speak Streamable HTTP with legacy SSE fallback.\n\n## Version scope\n\nThis page describes the main candidate. As of November 7, 2026, the latest\npublished CLI release is [v0.10.2](https://github.com/codewhale-hq/Codewhale/releases/tag/v0.10.2).\nFor that installed release, use its [versioned MCP guide](https://github.com/codewhale-hq/Codewhale/blob/v0.10.2/docs/MCP.md).\nLocal stdio, remote HTTP/SSE, environment-backed authentication, direct remote\nOAuth, and reviewed plugin-contributed MCP are already supported in v0.10.2.\nSelected differences described below are candidate behavior:\n\n| Surface | Published v0.10.2 | Main candidate |\n| --- | --- | --- |\n| Default MCP client backend | Rust; SDK host is opt-in | Official MCP SDK 2.4.0 host; explicit Rust selection remains available |\n| Native stdio server protocols | Legacy revisions through 2025-06-18 | Adds 2025-11-25 and modern 2026-07-28 discovery |\n| Direct remote OAuth | Discovery, dynamic registration or a pre-registered public client | Adds issuer/resource-bound credentials and hosted client metadata support |\n| Catalogue freshness and MCP Skills | No TTL/list-change refresh or MCP Skills extension | Bounded refresh and verified skill resource reads; no native skill activation |\n| Package-runner connect budget | Default 30 seconds | At least 120 seconds unless the server has an explicit override |\n\nThe candidate's default host needs a supported Node runtime (`^22.19 || >=24`);\nBun 1.4.0 or newer is opt-in. See [extension runtime requirements](EXTENSIONS.md).\nAn MCP server's own executable, dependencies, authentication and OS requirements\nremain separate from Codewhale's transport support.\n\nBrowsing note:\n- `Web` is the canonical, deferred built-in browsing tool; it provides\n `search`, `fetch`, and `wait` actions when network policy permits.\n- `web_search`, `fetch_url`, and `wait_for_dev_server` are hidden replay-only\n aliases. New prompts and integrations should use `Web`.\n\nServer mode note:\n- `codewhale serve --mcp` runs the MCP stdio server.\n- `codewhale serve --http` runs the runtime HTTP/SSE API (separate mode).\n- `codewhale mcp-server` is an equivalent stdio entrypoint on the same\n consolidated runtime.\n\nIn 0.10.1, the old child-server aggregation proxy is removed. `mcp-server`\nnow uses the same native tool server as `serve --mcp`; it does not launch the\nlegacy `mcp.server_definitions` list. Saved definitions are left intact. Configure\nexternal servers in `mcp.json` for the existing MCP client, or connect to those\nservers directly from your external client. Native server write tools remain\nwithheld unless the operator explicitly permits them in its server configuration.\n\nThe native stdio server supports MCP **2026-07-28** with required per-request\nmetadata and `server/discover`; no initialization handshake is needed for that\nrevision. It also supports the explicit legacy revisions **2024-11-05**,\n**2025-03-26**, **2025-06-18** and **2025-11-25** through `initialize`.\nInitialization never selects a modern revision; unsupported initialization\nversions fall back to 2025-11-25. Legacy callers should send\n`notifications/initialized`, but calls may proceed after a valid initialize\nresponse. This describes Codewhale as a server; outbound MCP client version\nsupport is configured separately.\n\n`resources/list` pages through the effective native skill instruction catalog,\nand `resources/read` returns the accepted instruction body for an exact listed\n`codewhale://skills/...` URI. Discovery follows the configured skill roots,\nprecedence and workspace trust; disabled skills are omitted. Reads supply inert\nMarkdown and never activate a skill. These are instruction-body resources, not\noriginal SKILL.md files oLine truncated
|
||||
"sha256": "e5ea48a9bc8770c0b4afd9efb67f026628e8f4a3549f29e14eba640099a88ba7",
|
||||
"content": "# MCP (External Tool Servers)\n\nIn the terminal, `/mcp` (also `/mcps`) opens **Plugins → Connectors**. Enter opens the selected server’s recovery action or read-only details. An empty inventory offers server suggestions; browsing them installs nothing. Explicit subcommands such as `/mcp status`, `/mcp doctor`, `/mcp login`, and `/mcp add` retain their existing behavior.\n\n\n> 阅读简体中文版:[zh_hans/MCP.md](zh_hans/MCP.md)\n\nEverything you add to Codewhale is a plugin. An MCP server you configure is a\nplugin of the kind **Connector**, and a plugin bundle can also carry connectors.\nConnectors are still managed under Plugins → Connectors in the terminal and\nCustomize › Connectors on the desktop; one Plugins list that shows every kind\ntogether is planned for 0.10.3 and is not in this build. The configuration\nfiles, sign-in and the `/mcp` commands are unchanged. \"Connection\" on this page\nmeans the live link to a server; as a product noun it is the word for a model\nprovider or another agent.\n\nCodewhale can load additional tools via MCP (Model Context Protocol). MCP servers can be local stdio processes that Codewhale starts, or remote URL-based servers that speak Streamable HTTP with legacy SSE fallback.\n\n## Version scope\n\nThis page describes the main candidate. As of November 7, 2026, the latest\npublished CLI release is [v0.10.2](https://github.com/codewhale-hq/Codewhale/releases/tag/v0.10.2).\nFor that installed release, use its [versioned MCP guide](https://github.com/codewhale-hq/Codewhale/blob/v0.10.2/docs/MCP.md).\nLocal stdio, remote HTTP/SSE, environment-backed authentication, direct remote\nOAuth, and reviewed plugin-contributed MCP are already supported in v0.10.2.\nSelected differences described below are candidate behavior:\n\n| Surface | Published v0.10.2 | Main candidate |\n| --- | --- | --- |\n| Default MCP client backend | Rust; SDK host is opt-in | Official MCP SDK 2.4.0 host; explicit Rust selection remains available |\n| Native stdio server protocols | Legacy revisions through 2025-06-18 | Adds 2025-11-25 and modern 2026-07-28 discovery |\n| Direct remote OAuth | Discovery, dynamic registration or a pre-registered public client | Adds issuer/resource-bound credentials and hosted client metadata support |\n| Catalogue freshness and MCP Skills | No TTL/list-change refresh or MCP Skills extension | Bounded refresh and verified skill resource reads; no native skill activation |\n| Package-runner connect budget | Default 30 seconds | At least 120 seconds unless the server has an explicit override |\n\nThe candidate's default host needs a supported Node runtime (`^22.19 || >=24`);\nBun 1.4.0 or newer is opt-in. See [extension runtime requirements](EXTENSIONS.md).\nAn MCP server's own executable, dependencies, authentication and OS requirements\nremain separate from Codewhale's transport support.\n\nBrowsing note:\n- `Web` is the canonical, deferred built-in browsing tool; it provides\n `search`, `fetch`, and `wait` actions when network policy permits.\n- `web_search`, `fetch_url`, and `wait_for_dev_server` are hidden replay-only\n aliases. New prompts and integrations should use `Web`.\n\nServer mode note:\n- `codewhale serve --mcp` runs the MCP stdio server.\n- `codewhale serve --http` runs the runtime HTTP/SSE API (separate mode).\n- `codewhale mcp-server` is an equivalent stdio entrypoint on the same\n consolidated runtime.\n\nIn 0.10.1, the old child-server aggregation proxy is removed. `mcp-server`\nnow uses the same native tool server as `serve --mcp`; it does not launch the\nlegacy `mcp.server_definitions` list. Saved definitions are left intact. Configure\nexternal servers in `mcp.json` for the existing MCP client, or connect to those\nservers directly from your external client. Native server write tools remain\nwithheld unless the operator explicitly permits them in its server configuration.\n\nThe native stdio server supports MCP **2026-07-28** with required per-request\nmetadata and `server/discover`; no initialization handshake is needed for that\nrevision. It also supports the explicit legacy revisions **2024-11-05**,\n**2025-03-26**, **2025-06-18** and **2025-11-25** through `initialize`.\nInitialization never selects a modern revision; unsupported initialization\nversions fall back to 2025-11-25. Legacy callers should send\n`notifications/initialized`, but calls may proceed after a valid initialize\nresponse. This describes Codewhale as a server; outbound MCP client version\nsupport is configured separately.\n\n`resources/list` pages through the effective native skill instruction catalog,\nand `resources/read` returns the accepted instruction body for an exact listed\n`codewhale://skills/...` URI. Discovery follows the configured skill roots,\nprecedence and workspace trust; disabled skills are omitted. Reads supply inert\nMarkdown and never activate a skill. These are instruction-body resources, not\noriginal SKILL.md files oLine truncated
|
||||
}
|
||||
]
|
||||
},
|
||||
@@ -365,8 +365,8 @@
|
||||
"sources": [
|
||||
{
|
||||
"path": "docs/RUNTIME_API.md",
|
||||
"sha256": "6c604dacc4ea18f940b6689e797fb6fe8c15831863b18f3dd537cc10f53226d2",
|
||||
"content": "# Codewhale Engine Runtime API & Integration Contract\n\n> 阅读简体中文版:[zh_hans/RUNTIME_API.md](zh_hans/RUNTIME_API.md)。\n\n`codewhale app-server --http` exposes the [Codewhale Engine](ARCHITECTURE.md) through\nits canonical local Runtime API and control plane.\nLocal SDKs, mobile/remote-control clients, and editor integrations talk to it\ninstead of screen-scraping terminal output. It serves the full HTTP/SSE runtime\nAPI (`/v1/*`), a JSON-RPC control transport over stdio, and the phone-friendly\nmobile page. `codewhale doctor --json` provides machine-readable health, and\n`codewhale serve --acp` speaks the Agent Client Protocol over stdio for editors\nsuch as Zed.\n\n`codewhale serve --http` / `serve --mobile` remain as **compatibility aliases**\nfor `codewhale app-server --http` / `--mobile`; both launch the identical\nserver. Desktop, web, SDK and custom integrations should target\n`app-server --http`. `--stdio` and `--socket` expose compatibility controls.\nBare `app-server` selects initialized JSONL control over stdio. Local stdio,\nsocket and ACP owners do not bind a hidden TCP listener. Explicit `--http`\nretains HTTP/SSE and compatibility routes on the same Runtime owner.\n\n`codewhale exec` is the separate one-shot headless worker path (stream-json,\nfleet worker subprocess, CI primitive). It is not part of this API, but it\nshares the same runtime, provider/model resolution, permission profiles, and\nevent vocabulary.\n\nThis document is the stable integration contract for native workbench\napplications (and other local supervisors) that embed the Codewhale Engine.\n\n## Architecture\n\n```\nlocal supervisor / SDK / automation harness\n │\n ├─ codewhale app-server --http → HTTP/SSE runtime API (/v1/*) [canonical]\n ├─ codewhale app-server --mobile → runtime API + mobile control page\n ├─ codewhale app-server --stdio → JSON-RPC control transport over stdio\n ├─ codewhale app-server --socket → same JSON-RPC over a unix domain socket (desktop daemon)\n ├─ codewhale doctor --json → machine-readable health & capability\n ├─ codewhale serve --acp → ACP stdio agent for editors such as Zed\n ├─ codewhale serve --mcp → MCP stdio server\n ├─ codewhale serve --http/--mobile → legacy aliases for `app-server --http/--mobile`\n └─ codewhale exec [args] → one-shot headless worker (stream-json)\n```\n\nThe engine runs as a local-only process. All APIs bind to `localhost` by\ndefault. No hosted relay, no provider-token custody, no secret leakage.\n\nFor the read-only record of what a thread or turn did, see\n[`docs/RECEIPTS.md`](RECEIPTS.md): `codewhale receipts` on the CLI and the\n`/receipt` routes under **Threads** below.\n\n## Runtime API entrypoints\n\n| Entry | Transport | Use |\n|---|---|---|\n| `codewhale web [--port 7878]` | HTTP/SSE on `127.0.0.1:7878` + embedded client | First-class loopback-only browser client; opens the default browser |\n| `codewhale app-server --http` | HTTP/SSE on `127.0.0.1:7878` | Full `/v1/*` runtime API (canonical) |\n| `codewhale app-server --mobile` | HTTP/SSE on loopback + `/mobile` | Runtime API + local mobile control page |\n| `codewhale app-server --stdio` | Initialized JSONL over stdio | Compatibility controls and explicit tool-free turns through the held manager; no hidden TCP listener or full Codex interaction channel. |\n| `codewhale app-server --socket [--socket-path P]` | RPC text messages over WebSocket on a `0600` Unix domain socket (macOS/Linux) or an owner-only named pipe (Windows) | Desktop daemon: multi-client, kernel peer identity checked, `daemon/attach` claim handshake. Windows native-app qualification remains open. |\n| `codewhale app-server` | Initialized JSONL over stdio | Same selection as `--stdio`; HTTP requires explicit `--http`. |\n| `codewhale serve --http` / `--mobile` | same server as `app-server --http`/`--mobile` | Compatibility aliases |\n\n`app-server --http` and `--mobile` launch the same mature runtime API server\nhistorically reached through `serve --http` — no routes or behavior changed, so\nevery endpoint documented below is identical across both entrypoints. The\nruntime API token is read from `--auth-token`, then `CODEWHALE_RUNTIME_TOKEN`,\nthen `DEEPSEEK_RUNTIME_TOKEN`; use `--insecure-no-auth` only with a loopback\nbind. The `serve` compatibility aliases keep their `--insecure` flag.\nBare `app-server` does not open an HTTP port. Selected attached HTTP listeners\nretain their explicit bearer and non-loopback security checks.\n\nInitialized stdio/private-socket controls support `thread/loaded/list` with\n`{cursor?: string | null, limit?: number | null}`. The result is\n`{data: string[], nextCursor: string | null}` from the held manager's loaded\nengine snapshot, including idle and archived engines still in memory. It dLine truncated
|
||||
"sha256": "3e8e192fd7b1e21ff11b2a5564ae974091baa1de440a6ccf67ffa3eb7129b906",
|
||||
"content": "# Codewhale Engine Runtime API & Integration Contract\n\n> 阅读简体中文版:[zh_hans/RUNTIME_API.md](zh_hans/RUNTIME_API.md)。\n\n`codewhale app-server --http` exposes the [Codewhale Engine](ARCHITECTURE.md) through\nits canonical local Runtime API and control plane.\nLocal SDKs, mobile/remote-control clients, and editor integrations talk to it\ninstead of screen-scraping terminal output. It serves the full HTTP/SSE runtime\nAPI (`/v1/*`), a JSON-RPC control transport over stdio, and the phone-friendly\nmobile page. `codewhale doctor --json` provides machine-readable health, and\n`codewhale serve --acp` speaks the Agent Client Protocol over stdio for editors\nsuch as Zed.\n\n`codewhale serve --http` / `serve --mobile` remain as **compatibility aliases**\nfor `codewhale app-server --http` / `--mobile`; both launch the identical\nserver. Desktop, web, SDK and custom integrations should target\n`app-server --http`. `--stdio` and `--socket` expose compatibility controls.\nBare `app-server` selects initialized JSONL control over stdio. Local stdio,\nsocket and ACP owners do not bind a hidden TCP listener. Explicit `--http`\nretains HTTP/SSE and compatibility routes on the same Runtime owner.\n\n`codewhale exec` is the separate one-shot headless worker path (stream-json,\nfleet worker subprocess, CI primitive). It is not part of this API, but it\nshares the same runtime, provider/model resolution, permission profiles, and\nevent vocabulary.\n\nThis document is the stable integration contract for native workbench\napplications (and other local supervisors) that embed the Codewhale Engine.\n\n## Architecture\n\n```\nlocal supervisor / SDK / automation harness\n │\n ├─ codewhale app-server --http → HTTP/SSE runtime API (/v1/*) [canonical]\n ├─ codewhale app-server --mobile → runtime API + mobile control page\n ├─ codewhale app-server --stdio → JSON-RPC control transport over stdio\n ├─ codewhale app-server --socket → same JSON-RPC over a unix domain socket (desktop daemon)\n ├─ codewhale doctor --json → machine-readable health & capability\n ├─ codewhale serve --acp → ACP stdio agent for editors such as Zed\n ├─ codewhale serve --mcp → MCP stdio server\n ├─ codewhale serve --http/--mobile → legacy aliases for `app-server --http/--mobile`\n └─ codewhale exec [args] → one-shot headless worker (stream-json)\n```\n\nThe engine runs as a local-only process. All APIs bind to `localhost` by\ndefault. No hosted relay, no provider-token custody, no secret leakage.\n\nFor the read-only record of what a thread or turn did, see\n[`docs/RECEIPTS.md`](RECEIPTS.md): `codewhale receipts` on the CLI and the\n`/receipt` routes under **Threads** below.\n\n## Runtime API entrypoints\n\n| Entry | Transport | Use |\n|---|---|---|\n| `codewhale web [--port 7878]` | HTTP/SSE on `127.0.0.1:7878` + embedded client | First-class loopback-only browser client; opens the default browser |\n| `codewhale app-server --http` | HTTP/SSE on `127.0.0.1:7878` | Full `/v1/*` runtime API (canonical) |\n| `codewhale app-server --mobile` | HTTP/SSE on loopback + `/mobile` | Runtime API + local mobile control page |\n| `codewhale app-server --stdio` | Initialized JSONL over stdio | Compatibility controls and explicit tool-free turns through the held manager; no hidden TCP listener or full Codex interaction channel. |\n| `codewhale app-server --socket [--socket-path P]` | RPC text messages over WebSocket on a `0600` Unix domain socket (macOS/Linux) or an owner-only named pipe (Windows) | Desktop daemon: multi-client, kernel peer identity checked, `daemon/attach` claim handshake. Windows native-app qualification remains open. |\n| `codewhale app-server` | Initialized JSONL over stdio | Same selection as `--stdio`; HTTP requires explicit `--http`. |\n| `codewhale serve --http` / `--mobile` | same server as `app-server --http`/`--mobile` | Compatibility aliases |\n\n`app-server --http` and `--mobile` launch the same mature runtime API server\nhistorically reached through `serve --http` — no routes or behavior changed, so\nevery endpoint documented below is identical across both entrypoints. The\nruntime API token is read from `--auth-token`, then `CODEWHALE_RUNTIME_TOKEN`,\nthen `DEEPSEEK_RUNTIME_TOKEN`; use `--insecure-no-auth` only with a loopback\nbind. The `serve` compatibility aliases keep their `--insecure` flag.\nBare `app-server` does not open an HTTP port. Selected attached HTTP listeners\nretain their explicit bearer and non-loopback security checks.\n\nInitialized stdio/private-socket controls support `thread/loaded/list` with\n`{cursor?: string | null, limit?: number | null}`. The result is\n`{data: string[], nextCursor: string | null}` from the held manager's loaded\nengine snapshot, including idle and archived engines still in memory. It dLine truncated
|
||||
}
|
||||
]
|
||||
},
|
||||
@@ -434,8 +434,8 @@
|
||||
"sources": [
|
||||
{
|
||||
"path": "docs/RUNTIME_API.md",
|
||||
"sha256": "6c604dacc4ea18f940b6689e797fb6fe8c15831863b18f3dd537cc10f53226d2",
|
||||
"content": "# Codewhale Engine Runtime API & Integration Contract\n\n> 阅读简体中文版:[zh_hans/RUNTIME_API.md](zh_hans/RUNTIME_API.md)。\n\n`codewhale app-server --http` exposes the [Codewhale Engine](ARCHITECTURE.md) through\nits canonical local Runtime API and control plane.\nLocal SDKs, mobile/remote-control clients, and editor integrations talk to it\ninstead of screen-scraping terminal output. It serves the full HTTP/SSE runtime\nAPI (`/v1/*`), a JSON-RPC control transport over stdio, and the phone-friendly\nmobile page. `codewhale doctor --json` provides machine-readable health, and\n`codewhale serve --acp` speaks the Agent Client Protocol over stdio for editors\nsuch as Zed.\n\n`codewhale serve --http` / `serve --mobile` remain as **compatibility aliases**\nfor `codewhale app-server --http` / `--mobile`; both launch the identical\nserver. Desktop, web, SDK and custom integrations should target\n`app-server --http`. `--stdio` and `--socket` expose compatibility controls.\nBare `app-server` selects initialized JSONL control over stdio. Local stdio,\nsocket and ACP owners do not bind a hidden TCP listener. Explicit `--http`\nretains HTTP/SSE and compatibility routes on the same Runtime owner.\n\n`codewhale exec` is the separate one-shot headless worker path (stream-json,\nfleet worker subprocess, CI primitive). It is not part of this API, but it\nshares the same runtime, provider/model resolution, permission profiles, and\nevent vocabulary.\n\nThis document is the stable integration contract for native workbench\napplications (and other local supervisors) that embed the Codewhale Engine.\n\n## Architecture\n\n```\nlocal supervisor / SDK / automation harness\n │\n ├─ codewhale app-server --http → HTTP/SSE runtime API (/v1/*) [canonical]\n ├─ codewhale app-server --mobile → runtime API + mobile control page\n ├─ codewhale app-server --stdio → JSON-RPC control transport over stdio\n ├─ codewhale app-server --socket → same JSON-RPC over a unix domain socket (desktop daemon)\n ├─ codewhale doctor --json → machine-readable health & capability\n ├─ codewhale serve --acp → ACP stdio agent for editors such as Zed\n ├─ codewhale serve --mcp → MCP stdio server\n ├─ codewhale serve --http/--mobile → legacy aliases for `app-server --http/--mobile`\n └─ codewhale exec [args] → one-shot headless worker (stream-json)\n```\n\nThe engine runs as a local-only process. All APIs bind to `localhost` by\ndefault. No hosted relay, no provider-token custody, no secret leakage.\n\nFor the read-only record of what a thread or turn did, see\n[`docs/RECEIPTS.md`](RECEIPTS.md): `codewhale receipts` on the CLI and the\n`/receipt` routes under **Threads** below.\n\n## Runtime API entrypoints\n\n| Entry | Transport | Use |\n|---|---|---|\n| `codewhale web [--port 7878]` | HTTP/SSE on `127.0.0.1:7878` + embedded client | First-class loopback-only browser client; opens the default browser |\n| `codewhale app-server --http` | HTTP/SSE on `127.0.0.1:7878` | Full `/v1/*` runtime API (canonical) |\n| `codewhale app-server --mobile` | HTTP/SSE on loopback + `/mobile` | Runtime API + local mobile control page |\n| `codewhale app-server --stdio` | Initialized JSONL over stdio | Compatibility controls and explicit tool-free turns through the held manager; no hidden TCP listener or full Codex interaction channel. |\n| `codewhale app-server --socket [--socket-path P]` | RPC text messages over WebSocket on a `0600` Unix domain socket (macOS/Linux) or an owner-only named pipe (Windows) | Desktop daemon: multi-client, kernel peer identity checked, `daemon/attach` claim handshake. Windows native-app qualification remains open. |\n| `codewhale app-server` | Initialized JSONL over stdio | Same selection as `--stdio`; HTTP requires explicit `--http`. |\n| `codewhale serve --http` / `--mobile` | same server as `app-server --http`/`--mobile` | Compatibility aliases |\n\n`app-server --http` and `--mobile` launch the same mature runtime API server\nhistorically reached through `serve --http` — no routes or behavior changed, so\nevery endpoint documented below is identical across both entrypoints. The\nruntime API token is read from `--auth-token`, then `CODEWHALE_RUNTIME_TOKEN`,\nthen `DEEPSEEK_RUNTIME_TOKEN`; use `--insecure-no-auth` only with a loopback\nbind. The `serve` compatibility aliases keep their `--insecure` flag.\nBare `app-server` does not open an HTTP port. Selected attached HTTP listeners\nretain their explicit bearer and non-loopback security checks.\n\nInitialized stdio/private-socket controls support `thread/loaded/list` with\n`{cursor?: string | null, limit?: number | null}`. The result is\n`{data: string[], nextCursor: string | null}` from the held manager's loaded\nengine snapshot, including idle and archived engines still in memory. It dLine truncated
|
||||
"sha256": "3e8e192fd7b1e21ff11b2a5564ae974091baa1de440a6ccf67ffa3eb7129b906",
|
||||
"content": "# Codewhale Engine Runtime API & Integration Contract\n\n> 阅读简体中文版:[zh_hans/RUNTIME_API.md](zh_hans/RUNTIME_API.md)。\n\n`codewhale app-server --http` exposes the [Codewhale Engine](ARCHITECTURE.md) through\nits canonical local Runtime API and control plane.\nLocal SDKs, mobile/remote-control clients, and editor integrations talk to it\ninstead of screen-scraping terminal output. It serves the full HTTP/SSE runtime\nAPI (`/v1/*`), a JSON-RPC control transport over stdio, and the phone-friendly\nmobile page. `codewhale doctor --json` provides machine-readable health, and\n`codewhale serve --acp` speaks the Agent Client Protocol over stdio for editors\nsuch as Zed.\n\n`codewhale serve --http` / `serve --mobile` remain as **compatibility aliases**\nfor `codewhale app-server --http` / `--mobile`; both launch the identical\nserver. Desktop, web, SDK and custom integrations should target\n`app-server --http`. `--stdio` and `--socket` expose compatibility controls.\nBare `app-server` selects initialized JSONL control over stdio. Local stdio,\nsocket and ACP owners do not bind a hidden TCP listener. Explicit `--http`\nretains HTTP/SSE and compatibility routes on the same Runtime owner.\n\n`codewhale exec` is the separate one-shot headless worker path (stream-json,\nfleet worker subprocess, CI primitive). It is not part of this API, but it\nshares the same runtime, provider/model resolution, permission profiles, and\nevent vocabulary.\n\nThis document is the stable integration contract for native workbench\napplications (and other local supervisors) that embed the Codewhale Engine.\n\n## Architecture\n\n```\nlocal supervisor / SDK / automation harness\n │\n ├─ codewhale app-server --http → HTTP/SSE runtime API (/v1/*) [canonical]\n ├─ codewhale app-server --mobile → runtime API + mobile control page\n ├─ codewhale app-server --stdio → JSON-RPC control transport over stdio\n ├─ codewhale app-server --socket → same JSON-RPC over a unix domain socket (desktop daemon)\n ├─ codewhale doctor --json → machine-readable health & capability\n ├─ codewhale serve --acp → ACP stdio agent for editors such as Zed\n ├─ codewhale serve --mcp → MCP stdio server\n ├─ codewhale serve --http/--mobile → legacy aliases for `app-server --http/--mobile`\n └─ codewhale exec [args] → one-shot headless worker (stream-json)\n```\n\nThe engine runs as a local-only process. All APIs bind to `localhost` by\ndefault. No hosted relay, no provider-token custody, no secret leakage.\n\nFor the read-only record of what a thread or turn did, see\n[`docs/RECEIPTS.md`](RECEIPTS.md): `codewhale receipts` on the CLI and the\n`/receipt` routes under **Threads** below.\n\n## Runtime API entrypoints\n\n| Entry | Transport | Use |\n|---|---|---|\n| `codewhale web [--port 7878]` | HTTP/SSE on `127.0.0.1:7878` + embedded client | First-class loopback-only browser client; opens the default browser |\n| `codewhale app-server --http` | HTTP/SSE on `127.0.0.1:7878` | Full `/v1/*` runtime API (canonical) |\n| `codewhale app-server --mobile` | HTTP/SSE on loopback + `/mobile` | Runtime API + local mobile control page |\n| `codewhale app-server --stdio` | Initialized JSONL over stdio | Compatibility controls and explicit tool-free turns through the held manager; no hidden TCP listener or full Codex interaction channel. |\n| `codewhale app-server --socket [--socket-path P]` | RPC text messages over WebSocket on a `0600` Unix domain socket (macOS/Linux) or an owner-only named pipe (Windows) | Desktop daemon: multi-client, kernel peer identity checked, `daemon/attach` claim handshake. Windows native-app qualification remains open. |\n| `codewhale app-server` | Initialized JSONL over stdio | Same selection as `--stdio`; HTTP requires explicit `--http`. |\n| `codewhale serve --http` / `--mobile` | same server as `app-server --http`/`--mobile` | Compatibility aliases |\n\n`app-server --http` and `--mobile` launch the same mature runtime API server\nhistorically reached through `serve --http` — no routes or behavior changed, so\nevery endpoint documented below is identical across both entrypoints. The\nruntime API token is read from `--auth-token`, then `CODEWHALE_RUNTIME_TOKEN`,\nthen `DEEPSEEK_RUNTIME_TOKEN`; use `--insecure-no-auth` only with a loopback\nbind. The `serve` compatibility aliases keep their `--insecure` flag.\nBare `app-server` does not open an HTTP port. Selected attached HTTP listeners\nretain their explicit bearer and non-loopback security checks.\n\nInitialized stdio/private-socket controls support `thread/loaded/list` with\n`{cursor?: string | null, limit?: number | null}`. The result is\n`{data: string[], nextCursor: string | null}` from the held manager's loaded\nengine snapshot, including idle and archived engines still in memory. It dLine truncated
|
||||
},
|
||||
{
|
||||
"path": "docs/RUNTIME_ROUTES.md",
|
||||
|
||||
Reference in new issue
Block a user