Compare commits

...
Author SHA1 Message Date
CodeWhale BotandClaude Opus 5.5 b3e17477cb feat(plugins): trust, enable, disable and revoke plugins from a shell, recorded by who approved (CW-7891)
`codewhale plugins trust <name>` prints the same capability review the
terminal app shows (one renderer) with its bundle review token, and changes
nothing; in a terminal it then asks once. `--review-token <token> --yes`
trusts without a prompt, only while the token
(`<content-hash>.<capability-hash>`) still matches the installed content and
capability set. enable, disable and revoke go through the existing registry
transitions, each with --json. The token ties trust to exact content; it is
not evidence that anyone read the review, and there is no separate
--content-hash flag because the token's first half is that hash.

Who approved:
- The Engine sets CODEWHALE_AGENT_PROJECT, the session's project, on every
  command the model runs, whatever the sandbox, backend or permission mode:
  the shell tool (exec_shell, Bash, bash, and the task tools that use it),
  ShellManager::prepare_runner (persistent terminal/run and terminal/send
  sessions, and every sandboxed_runner_command: foreground verifiers, test
  runner, JavaScript execution), the background verifier path, the Python
  kernel behind repl blocks, rlm and RLM turns (PythonRuntime::new and
  spawn_with_context now take the session project), and stdio MCP servers
  the model starts with start_mcp_server. Hooks and MCP servers from
  the person's configuration do not get it. It is a new name:
  CODEWHALE_SANDBOX still means only "an OS sandbox is applied".
- With that mark the plugin command opens the mark's project, and a trust or
  enable is the agent's (agent cohort, that project only), including a
  prompt answered through a pseudo-terminal; it is never recorded as the
  person. Before a marked trust the command reads the bundle's
  .installed-from record and refuses unless the agent installed the plugin
  from a local or DSH folder inside that project (R21's caller condition).
- Without the mark, a --yes trust is recorded as the command line and an
  answered prompt as the person.
- Every trust goes through trust_reviewed. An unmarked trust passes no
  host-service token, so it grants no service; a marked one passes the token
  of the review composed for the agent. --review-token is never passed to
  the registry. Output, help, JSON (bundle_review_token) and docs keep the
  bundle review token and the host-service review token apart.
- plugins status and each command's report show approved_by and enabled_by.

The agent's shell commands that run these go through the session's approval
like any other shell command (Ask asks, Auto-Review's reviewer judges, Full
Access runs them); no feature-specific gate. disable and revoke are ungated.
The mark is a label the agent could remove in Full Access; the docs (English
and Chinese) say so, and that the install record shows where files came
from, not who wrote them.

The std::fs reads in headless.rs run inside the command's spawn_blocking,
so the blocking-calls budget is raised for that file.

Checks (Linux build farm):
- cargo nextest run -p codewhale-cli -p codewhale-tui --lib --test integration
  --test help_dispatch -E 'test(commands::groups::plugins) |
  test(plugin_trust_cli) | binary(help_dispatch)': 37 passed, 0 failed,
  6222 skipped.
- cargo nextest run -p codewhale-plugins -p codewhale-engine --lib
  -E 'package(codewhale-plugins) + test(authority::auto_review) +
  test(tools::shell::) + test(tools::terminal_session::) +
  test(tools::verifier::) + test(tools::runtime_mcp::)': 542 passed,
  0 failed, 6891 skipped.
- cargo nextest run -p codewhale-repl: 37 passed, 0 failed.
- The mark tests fail on the code before each fix: the shell tool test
  ("unset"), the terminal/run and terminal/send test and the verifier test
  ("MARK=unset"), the terminal-tools prompt test (recorded "person"), and
  the Python kernel test ("MARK=unset").
- cargo clippy --all-targets -D warnings on codewhale-engine, on
  codewhale-repl with codewhale-engine, and on codewhale-tui with
  codewhale-cli: clean.
- cargo fmt --all -- --check and scripts/preflight.sh --check: pass.
Windows read, not compiled.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-11 12:05:20 -08:00
CodeWhale BotandClaude Sonnet 5.5 af2ac21b36 feat(runtime): conversation modes standard and creator, agent_preset on threads (CW-8049)
GET /v1/modes lists the built-in table; agent_preset is stored on the thread
record and the terminal session file and returned in the thread summary.
Absent means standard. Set on POST /v1/threads or PATCH while the thread has
no turn; unknown, unavailable, locked and executor cases are typed refusals
(unknown_mode, mode_unavailable, mode_locked,
mode_not_supported_with_executor). Creator is gated on host-runtime
availability only (no plugin_creator key exists). Stores and exposes the mode;
what Creator loads is CR-01/CR-04. One-line test-literal fix in
crates/tui/src/tui/ui/tests.rs for the new SavedSession field.

Checks (farm, Linux x86_64): nextest engine+app-server filtered, 12 run, 12 passed, 0 failed
(7 of them the new mode tests); clippy -D warnings --all-targets on engine,
app-server, tui: exit 0. Local: cargo fmt --check and preflight --check OK.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-11 11:56:43 -08:00
44 changed files with 2498 additions and 92 deletions

No files matched your search

+2
View File
@@ -8,6 +8,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
- **Git tool on older git:** the Git tool's `review_scope` action no longer passes `--no-lazy-fetch`, which git older than 2.44 rejects, so it failed on every call there. It sets `GIT_NO_LAZY_FETCH=1` instead, which keeps the same protection on newer git and is ignored by older git.
- **Plugins from a shell:** `codewhale plugins trust <name>` prints the same capability review as `/plugin trust` with its bundle review token and, in a terminal, asks once. `--review-token <token> --yes` trusts without a prompt while that token still matches the installed content and capabilities; the token ties trust to the exact content, does not show that anyone read the review, and is not the host-service review token. A `--yes` trust is recorded as the command line and grants no host service. Every command the agent runs (shell, persistent terminal, verifier and test runs, the Python kernel, MCP servers it starts) now carries `CODEWHALE_AGENT_PROJECT` with the session's project, while hooks and configured MCP servers do not; with it, a trust or enable is recorded as the agent's, for that project only, even when a prompt is answered through a terminal, and the agent may trust only a plugin it installed from a folder inside the project. `plugins enable`, `disable` and `revoke` work the same way, each with `--json`, and `plugins status` shows who approved. The agent's shell commands that run them go through the session's approval like any other shell command, so a Full Access agent can trust and enable a plugin it wrote; the mark is a label the agent could remove in Full Access. (CW-7891)
- **Sandbox:** a managed configuration can set `tool_launcher` (Linux) to run every sandboxed tool command as another user, in front of bubblewrap; commands then run inside the sandbox or not at all, masks are computed for that user, `/tmp` is private per command, and `GET /v1/runtime/info` reports `sandbox.kind` and `sandbox.tool_launcher`. Without the key nothing changes. A managed configuration named by `CODEWHALE_MANAGED_CONFIG_PATH` is now applied on every load; a configuration whose provider comes from a plugin no longer loads with the managed file skipped, it fails to load instead.
- **Receipts:** a terminal session's receipt (`/receipts`, `codewhale receipts`) now shows the provider's token counts and the same cost report `/cost` prints, saying "unknown" and why when the route's price is not known, never a guess. A Runtime thread's receipt shows token counts only; its cost line follows in a later release. `codewhale metrics` now counts usage saved in terminal sessions instead of reporting none. Sessions saved by 0.10.2 carry only a combined token total.
- **ACP server on the official SDK:** The ACP server (`codewhale serve --acp`) now runs on the official Agent Client Protocol SDK. Its messages follow the official v1 types, and it no longer advertises the non-standard `modelSelection` capability or session `models` field. Session list entries carry `createdAt` and `messageCount` under `_meta.codewhale`, terminal sign-in methods are advertised only to clients that enabled terminal authentication, a notification is never answered, a permission request always carries text, and session list and configuration requests are served while a turn runs, response ids are echoed unchanged for every client (the Zed-only string rewrite is gone), and `initialize` without `protocolVersion` is refused with `-32602`.
@@ -15,6 +16,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- **Creator skills:** Codewhale now ships DeepSeek Harness's four plugin-authoring skills (`cordis-plugin-development`, `editing-cordis-compositions`, `cordis-composition-reference`, `agent-experience`) unmodified, with their reference and template files. A bundled skill can now carry files: `load_skill` prints the skill's base directory and a short Codewhale host-notes block after DSH's text. A copy you edited is never overwritten on upgrade. Source-level only: the Creator plugin switch and agent entry that make these useful land in later slices.
- **Creator skills:** Codewhale now ships DeepSeek Harness's four plugin-authoring skills (`cordis-plugin-development`, `editing-cordis-compositions`, `cordis-composition-reference`, `agent-experience`) bundled, unmodified, with their reference and template files and DeepSeek's MIT notice beside each. They stay hidden from the model until Creator mode. A bundled skill can now carry files; `load_skill` prints its base directory and a short Codewhale host-notes block, and a file you edited is never overwritten on upgrade.
- **Website home page, rebuilt around one first screen:** the headline stays "The model can be anyone's. The agent should be yours.", centred, with one plain sentence under it that says what Codewhale is (an open-source coding agent for your terminal, written in Rust, working with any model and able to split a task across several agents), the install command, and one wide picture of a real terminal session that runs off the bottom of the first screen. A row of facts under the picture takes its counts of provider routes and models from the published release, and the page says several agents share a task only while the release has it. The route of where the product is going moves lower and smaller, and the home page names no other tools. The new text in the 17 non-English languages is machine-drafted and is labelled as such in the footer; the Arabic page now reads the opening sentence right to left.
- **Conversation modes (Engine):** `GET /v1/modes` lists two built-in modes, `standard` (default) and `creator`, each with `id`, `name`, `description`, `source` and `available` (plus `reason` when not; Creator is listed unavailable, "not ready in this build", until it loads its own tools, and then also when no Node.js or Bun plugin runtime is found). A thread's mode is `agent_preset`, set on `POST /v1/threads` or by `PATCH /v1/threads/{id}` until the thread has a turn, saved on the thread record and the terminal session file and shown in `GET /v1/threads/summary`; records without it load as `standard`. An unknown or unavailable id, a change after the first turn, or a mode together with another vendor's agent is refused with `error.code` `unknown_mode`, `mode_unavailable`, `mode_locked` or `mode_not_supported_with_executor`. Source only: Creator loads nothing extra yet and no menu uses it.
- **Website home page:** the page is reorganized around what a visitor does: a dated OrcaRouter offer strip with a visible referral tag, one proof strip (release, stars, contributors), a models section with the newest first-party models, one capabilities section that includes Computer Use, an "Everything is a plugin" teaser and a community panel whose live GitHub numbers fill in from the browser and hide when unavailable. Unused home copy was removed from all 18 site languages.
- **Website home page, rebuilt around one first screen:** the headline stays "The model can be anyone's. The agent should be yours.", centred, with one plain sentence under it that says what Codewhale is (an open-source coding agent for your terminal, written in Rust, working with any model and able to split a task across several agents), the install command, and one wide picture of a real terminal session that runs off the bottom of the first screen. A row of facts under the picture takes its counts of provider routes and models from the published release, and the page says several agents share a task only while the release has it. The route of where the product is going moves lower and smaller, and the home page names no other tools. This replaces the earlier reorganization of the home page, so the dated OrcaRouter offer strip and the community panel whose GitHub numbers filled in from the browser are no longer on it; the models and contribute pages keep their own content. The new text in the 17 non-English languages is machine-drafted and is labelled as such in the footer; the Arabic page now reads the opening sentence right to left.
- **Website home page, rebuilt around one first screen:** the headline stays "The model can be anyone's. The agent should be yours.", centred, with one plain sentence under it that says what Codewhale is (an open-source coding agent for your terminal, written in Rust, working with any model and able to split a task across several agents), the install command, and one wide picture of a real terminal session that runs off the bottom of the first screen. A row of facts under the picture takes its counts of provider routes and models from the published release, and the facts row lists many agents on one task only while the release has it. The route of where the product is going moves lower and smaller, and the home page names no other coding agents. This replaces the earlier reorganization of the home page, so the community panel whose GitHub numbers filled in from the browser is no longer on it, and the dated OrcaRouter offer strip, English only, with its referral disclosure, stays below the first screen until it ends; the models and contribute pages keep their own content. The new text in the 17 non-English languages is machine-drafted and is labelled as such in the footer; the Arabic page now reads the opening sentence right to left.
+33 -2
View File
@@ -1323,6 +1323,7 @@ struct ThreadSummary {
preview: String,
model: String,
mode: String,
agent_preset: String,
workspace: PathBuf,
branch: Option<String>,
head: Option<String>,
@@ -3314,6 +3315,7 @@ pub fn build_router(state: RuntimeApiState) -> Router {
.route("/v1/tasks/{id}", get(get_task))
.route("/v1/tasks/{id}/cancel", post(cancel_task))
.route("/v1/skills", get(list_skills))
.route("/v1/modes", get(list_modes))
.route("/v1/commands", get(list_commands))
.route("/v1/threads/{id}/commands", post(run_thread_command))
.route(
@@ -3935,7 +3937,7 @@ async fn create_thread(
)
.await
.map_err(|e| {
if let Some(refusal) = external_agent_refusal(&e) {
if let Some(refusal) = external_agent_refusal(&e).or_else(|| mode_refusal(&e)) {
refusal
} else if e
.is::<codewhale_engine::runtime_threads::manager::RuntimeThreadCreateConflict>()
@@ -4184,6 +4186,10 @@ async fn list_threads_summary(
preview,
model: thread.model,
mode: thread.mode,
agent_preset: codewhale_engine::conversation_modes::effective_id(
thread.agent_preset.as_deref(),
)
.to_string(),
branch: workspace_git.and_then(|git| git.branch.clone()),
head: workspace_git.and_then(|git| git.head.clone()),
dirty: workspace_git.is_some_and(|git| git.dirty),
@@ -6248,6 +6254,17 @@ async fn list_hooks(
}))
}
async fn list_modes(
State(state): State<RuntimeApiState>,
) -> Json<codewhale_engine::conversation_modes::ConversationModeTable> {
Json(
codewhale_engine::conversation_modes::current_table(
state.runtime_threads.extension_host_options(),
)
.await,
)
}
async fn list_skills(
State(state): State<RuntimeApiState>,
) -> Result<Json<SkillsResponse>, ApiError> {
@@ -14829,6 +14846,20 @@ fn map_automation_err(err: anyhow::Error) -> ApiError {
}
}
fn mode_refusal(err: &anyhow::Error) -> Option<ApiError> {
use codewhale_engine::conversation_modes::ModeRefusal;
let error = err.downcast_ref::<ModeRefusal>()?;
Some(ApiError {
status: match error.code {
ModeRefusal::MODE_UNAVAILABLE => StatusCode::FAILED_DEPENDENCY,
ModeRefusal::MODE_LOCKED => StatusCode::CONFLICT,
_ => StatusCode::BAD_REQUEST,
},
message: error.message.clone(),
code: Some(error.code),
})
}
fn external_agent_refusal(err: &anyhow::Error) -> Option<ApiError> {
use codewhale_engine::fleet::agents::ExecutorUnavailable;
let error = err.downcast_ref::<ExecutorUnavailable>()?;
@@ -14844,7 +14875,7 @@ fn external_agent_refusal(err: &anyhow::Error) -> Option<ApiError> {
}
fn map_thread_err(err: anyhow::Error) -> ApiError {
if let Some(refusal) = external_agent_refusal(&err) {
if let Some(refusal) = external_agent_refusal(&err).or_else(|| mode_refusal(&err)) {
return refusal;
}
if let Some(error) = err.downcast_ref::<codewhale_engine::runtime_threads::manager::RuntimeTurnOperationLookupError>() {
@@ -18,6 +18,7 @@ use uuid::Uuid;
mod command_catalog;
#[cfg(unix)]
mod control_socket_selection;
mod conversation_modes;
#[cfg(unix)]
mod external_agent_options;
#[cfg(unix)]
@@ -423,6 +424,7 @@ fn saved_session_with_blocks(blocks: Vec<codewhale_models::ContentBlock>) -> Sav
composer_color: None,
last_auto_route: None,
turn_outcomes: Vec::new(),
agent_preset: None,
}
}
@@ -563,6 +565,7 @@ fn messages_from_thread_detail_batches_tool_results() {
last_read: None,
executor: None,
environment: None,
agent_preset: None,
};
let turn = TurnRecord {
source_usage_origin_turn_id: None,
@@ -795,6 +798,7 @@ fn legacy_exact_thread_export_normalizes_provider_kind_and_id() {
last_read: None,
executor: None,
environment: None,
agent_preset: None,
},
turns: Vec::new(),
items: Vec::new(),
@@ -0,0 +1,82 @@
use super::*;
use codewhale_engine::conversation_modes::CreatorAvailabilityGuard;
#[tokio::test]
async fn modes_route_lists_the_table_and_threads_carry_and_refuse_modes() -> Result<()> {
let _unavailable = CreatorAvailabilityGuard::unforced();
let Some((addr, _threads, handle)) = spawn_test_server().await? else {
return Ok(());
};
let client = codewhale_release::tls::reqwest_client();
let base = format!("http://{addr}");
let table: Value = client
.get(format!("{base}/v1/modes"))
.send()
.await?
.error_for_status()?
.json()
.await?;
assert_eq!(table["default"], "standard");
assert_eq!(table["modes"][0]["id"], "standard");
assert_eq!(table["modes"][0]["source"], "builtin");
assert_eq!(table["modes"][0]["available"], true);
assert!(table["modes"][0].get("reason").is_none());
assert_eq!(table["modes"][1]["id"], "creator");
assert_eq!(table["modes"][1]["available"], false);
assert_eq!(
table["modes"][1]["reason"],
codewhale_engine::conversation_modes::CREATOR_NOT_READY_REASON
);
let unavailable = client
.post(format!("{base}/v1/threads"))
.json(&json!({"agent_preset": "creator"}))
.send()
.await?;
assert_eq!(unavailable.status(), reqwest::StatusCode::FAILED_DEPENDENCY);
let body: Value = unavailable.json().await?;
assert_eq!(body["error"]["code"], "mode_unavailable");
let unknown = client
.post(format!("{base}/v1/threads"))
.json(&json!({"agent_preset": "minimal"}))
.send()
.await?;
assert_eq!(unknown.status(), reqwest::StatusCode::BAD_REQUEST);
let body: Value = unknown.json().await?;
assert_eq!(body["error"]["code"], "unknown_mode");
let created: Value = client
.post(format!("{base}/v1/threads"))
.json(&json!({"agent_preset": "standard"}))
.send()
.await?
.error_for_status()?
.json()
.await?;
let id = created["id"].as_str().expect("thread id").to_string();
let summary: Value = client
.get(format!("{base}/v1/threads/summary"))
.send()
.await?
.error_for_status()?
.json()
.await?;
let row = summary
.as_array()
.and_then(|rows| rows.iter().find(|row| row["id"] == id.as_str()))
.expect("summary row");
assert_eq!(row["agent_preset"], "standard");
let patched = client
.patch(format!("{base}/v1/threads/{id}"))
.json(&json!({"agent_preset": "creator"}))
.send()
.await?;
assert_eq!(patched.status(), reqwest::StatusCode::FAILED_DEPENDENCY);
handle.abort();
let _ = handle.await;
Ok(())
}
@@ -559,6 +559,7 @@ impl RuntimeChatRelayHost {
parent_thread_id: None,
executor: None,
environment: None,
agent_preset: None,
operation_key: None,
model: Some(command.model.clone()),
model_provider: Some(command.model_provider.clone()),
@@ -721,6 +722,7 @@ impl RuntimeChatRelayHost {
parent_thread_id: None,
executor: None,
environment: None,
agent_preset: None,
dynamic_tools: Vec::new(),
environments: Vec::new(),
})
+2 -2
View File
@@ -234,10 +234,10 @@ enum Commands {
#[command(about = "Install a plugin bundle without trusting or enabling it")]
Install(TuiPassthroughArgs),
#[command(
about = "Inspect or restage reviewed plugin bundles",
about = "Inspect, review, trust, enable, disable or revoke plugin bundles",
alias = "plugin",
override_usage = "codewhale plugins <COMMAND>",
after_help = "Commands:\n status [NAME] [--json] Inspect plugin status and diagnostics\n list [NAME] [--json] Alias of status\n restage <NAME> [--json] Restore an eligible reviewed plugin snapshot"
after_help = "Commands:\n status [NAME] [--json] Inspect plugin status and diagnostics\n list [NAME] [--json] Alias of status\n restage <NAME> [--json] Restore an eligible reviewed plugin snapshot\n trust <NAME> [--json] Show the capability review; in a terminal, then ask\n trust <NAME> --review-token T [--yes] [--json] Trust exactly the content the bundle review token names\n enable <NAME> [--json] Enable a trusted plugin\n disable <NAME> [--json] Disable a plugin, keeping its trust\n revoke <NAME> [--json] Revoke trust\n\nThe bundle review token ties trust to exact content; it does not show that anyone read the review,\nand it is not the host-service review token. A --yes trust is recorded as the command line. When\nCODEWHALE_AGENT_PROJECT marks the process, as it does every command the agent runs, trust and enable\nare the agent's, for that project only; that mark is a label the agent could remove in Full Access."
)]
Plugins(TuiPassthroughArgs),
/// Bootstrap MCP config and/or skills directories.
+234
View File
@@ -0,0 +1,234 @@
use std::sync::Mutex;
use std::time::{Duration, Instant};
use serde::Serialize;
use crate::extension_host::ExtensionHostOptions;
pub const STANDARD: &str = "standard";
pub const CREATOR: &str = "creator";
pub const SOURCE_BUILTIN: &str = "builtin";
pub const CREATOR_LOADS_ITS_OWN_TOOLS: bool = false;
pub const CREATOR_NOT_READY_REASON: &str = "Creator mode is not ready in this build.";
const AVAILABILITY_TTL: Duration = Duration::from_secs(30);
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
#[error("{message}")]
pub struct ModeRefusal {
pub code: &'static str,
pub message: String,
}
impl ModeRefusal {
pub const UNKNOWN_MODE: &'static str = "unknown_mode";
pub const MODE_UNAVAILABLE: &'static str = "mode_unavailable";
pub const MODE_LOCKED: &'static str = "mode_locked";
pub const MODE_NOT_SUPPORTED_WITH_EXECUTOR: &'static str = "mode_not_supported_with_executor";
pub fn new(code: &'static str, message: impl Into<String>) -> Self {
Self {
code,
message: message.into(),
}
}
pub fn locked() -> Self {
Self::new(
Self::MODE_LOCKED,
"The conversation mode is fixed once the conversation has its first turn; start a new conversation to use another mode.",
)
}
pub fn with_executor() -> Self {
Self::new(
Self::MODE_NOT_SUPPORTED_WITH_EXECUTOR,
"A session with another agent uses that agent's own tools and instructions; conversation modes are unavailable.",
)
}
}
#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
pub struct ConversationMode {
pub id: String,
pub name: String,
pub description: String,
pub source: &'static str,
pub available: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub reason: Option<String>,
}
#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
pub struct ConversationModeTable {
pub default: &'static str,
pub modes: Vec<ConversationMode>,
}
#[must_use]
pub fn effective_id(stored: Option<&str>) -> &str {
stored.unwrap_or(STANDARD)
}
#[must_use]
pub fn stored_form(id: &str) -> Option<String> {
(id != STANDARD).then(|| id.to_string())
}
#[must_use]
pub fn table(creator_unavailable: Option<String>) -> ConversationModeTable {
ConversationModeTable {
default: STANDARD,
modes: vec![
ConversationMode {
id: STANDARD.to_string(),
name: "Standard".to_string(),
description: "The everyday Codewhale agent with its normal tools and skills."
.to_string(),
source: SOURCE_BUILTIN,
available: true,
reason: None,
},
ConversationMode {
id: CREATOR.to_string(),
name: "Creator".to_string(),
description:
"Build, inspect and test Codewhale plugins in this conversation, with the plugin authoring tools and guides loaded."
.to_string(),
source: SOURCE_BUILTIN,
available: creator_unavailable.is_none(),
reason: creator_unavailable,
},
],
}
}
type Cached = Option<(Instant, Option<String>)>;
static CREATOR_PROBE: Mutex<Cached> = Mutex::new(None);
#[cfg(any(test, feature = "test-support"))]
static CREATOR_PROBE_OVERRIDE: Mutex<Option<Option<String>>> = Mutex::new(None);
#[cfg(any(test, feature = "test-support"))]
static CREATOR_PROBE_LOCK: Mutex<()> = Mutex::new(());
#[cfg(any(test, feature = "test-support"))]
pub struct CreatorAvailabilityGuard {
_serial: std::sync::MutexGuard<'static, ()>,
}
#[cfg(any(test, feature = "test-support"))]
impl CreatorAvailabilityGuard {
pub fn set(unavailable_reason: Option<&str>) -> Self {
Self::hold(Some(unavailable_reason.map(str::to_string)))
}
pub fn unforced() -> Self {
Self::hold(None)
}
fn hold(forced: Option<Option<String>>) -> Self {
let serial = CREATOR_PROBE_LOCK
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
*CREATOR_PROBE_OVERRIDE
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner) = forced;
Self { _serial: serial }
}
}
#[cfg(any(test, feature = "test-support"))]
impl Drop for CreatorAvailabilityGuard {
fn drop(&mut self) {
*CREATOR_PROBE_OVERRIDE
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner) = None;
}
}
fn probe_host_runtime(options: &ExtensionHostOptions) -> Option<String> {
let resolution = codewhale_dependencies::resolve_extension_host_runtime(
options.runtime,
options.node_override.as_deref(),
options.bun_override.as_deref(),
crate::extension_host::bundle_sha256(),
);
if resolution.selected.is_some() {
None
} else {
Some(format!(
"Creator mode needs a plugin runtime (Node.js or Bun), and none was found. {}",
resolution.failure()
))
}
}
async fn creator_unavailable_reason(options: ExtensionHostOptions) -> Option<String> {
#[cfg(any(test, feature = "test-support"))]
if let Some(forced) = CREATOR_PROBE_OVERRIDE
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.clone()
{
return forced;
}
if !CREATOR_LOADS_ITS_OWN_TOOLS {
return Some(CREATOR_NOT_READY_REASON.to_string());
}
if let Some((at, reason)) = CREATOR_PROBE.lock().expect("probe cache").clone()
&& at.elapsed() < AVAILABILITY_TTL
{
return reason;
}
let reason = tokio::task::spawn_blocking(move || probe_host_runtime(&options))
.await
.unwrap_or_else(|_| Some("Creator mode could not check for a plugin runtime.".to_string()));
*CREATOR_PROBE.lock().expect("probe cache") = Some((Instant::now(), reason.clone()));
reason
}
pub async fn current_table(options: ExtensionHostOptions) -> ConversationModeTable {
table(creator_unavailable_reason(options).await)
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ModeSelection {
pub id: String,
pub unavailable: Option<String>,
}
impl ModeSelection {
pub fn require_available(&self) -> Result<(), ModeRefusal> {
match &self.unavailable {
Some(reason) => Err(ModeRefusal::new(
ModeRefusal::MODE_UNAVAILABLE,
reason.clone(),
)),
None => Ok(()),
}
}
}
pub async fn select(id: &str, options: ExtensionHostOptions) -> Result<ModeSelection, ModeRefusal> {
let table = current_table(options).await;
match table.modes.into_iter().find(|mode| mode.id == id) {
Some(mode) => Ok(ModeSelection {
id: mode.id,
unavailable: if mode.available {
None
} else {
Some(
mode.reason
.unwrap_or_else(|| "This mode is unavailable.".to_string()),
)
},
}),
None => Err(ModeRefusal::new(
ModeRefusal::UNKNOWN_MODE,
format!("`{id}` is not a conversation mode; GET /v1/modes lists them."),
)),
}
}
@@ -2758,6 +2758,42 @@ mod tests {
assert!(decision.reason.contains("publish-like"));
}
#[test]
fn plugin_trust_and_enable_from_a_shell_follow_the_session_approval() {
let policy = AutoReviewPolicy::default();
for command in [
"codewhale plugins trust demo --review-token a.b --yes",
"codewhale plugins enable demo",
"codewhale plugins trust demo",
] {
for mode in [
ApprovalMode::Suggest,
ApprovalMode::AcceptEdits,
ApprovalMode::Auto,
] {
let mut ctx = ctx_for(
"exec_shell",
json!({ "command": command }),
RunOrigin::Interactive,
mode,
);
ctx.session_runtime_risk = None;
let decision = policy.evaluate(&ctx);
assert_eq!(decision.action, AutoReviewAction::AskUser, "{command}");
assert!(!decision.built_in_safety_gate, "{command}");
assert!(!decision.reason.contains("plugin"), "{command}");
}
let full_access = policy.evaluate(&ctx_for(
"exec_shell",
json!({ "command": command }),
RunOrigin::Interactive,
ApprovalMode::Bypass,
));
assert!(!full_access.built_in_safety_gate, "{command}");
assert_ne!(full_access.action, AutoReviewAction::Block, "{command}");
}
}
#[test]
fn background_test_shell_is_not_held_by_safety_floor() {
// #3883: an ordinary build/test command flagged background must not
+1 -1
View File
@@ -425,7 +425,7 @@ impl Engine {
biased;
() = cancel.cancelled() => Err("RLM Python startup cancelled by its originating turn".to_string()),
() = wait_for_deadline(invocation.deadline) => Err("RLM Python startup exhausted its original wall-clock deadline".to_string()),
result = codewhale_repl::PythonRuntime::spawn_with_context(&path) => result,
result = codewhale_repl::PythonRuntime::spawn_with_context(&path, &caller.context.workspace) => result,
};
match startup {
Ok(runtime) => {
@@ -1300,7 +1300,7 @@ async fn narrowing_the_posture_discards_python_kernels() {
client,
);
// Without a Python interpreter there is no kernel to discard.
let Ok(kernel) = codewhale_repl::runtime::PythonRuntime::new().await else {
let Ok(kernel) = codewhale_repl::runtime::PythonRuntime::new(&std::env::temp_dir()).await else {
return;
};
engine.repl_kernel = Some(kernel);
@@ -1363,7 +1363,7 @@ async fn a_broken_repl_kernel_is_replaced_by_the_next_turn() {
engine.session.auto_approve = true;
// What a turn dropped mid-round leaves behind.
let mut stale = codewhale_repl::PythonRuntime::new().await.expect("spawn");
let mut stale = codewhale_repl::PythonRuntime::new(&std::env::temp_dir()).await.expect("spawn");
let dropped = tokio::time::timeout(
Duration::from_millis(50),
stale.execute("import time\ntime.sleep(30)"),
@@ -91,7 +91,7 @@ impl Engine {
Err("REPL startup cancelled".into())
}
() = wait_for_deadline(repl_deadline) => Err("parent turn deadline reached during REPL startup".into()),
result = codewhale_repl::runtime::PythonRuntime::new() => result,
result = codewhale_repl::runtime::PythonRuntime::new(&self.session.workspace) => result,
};
self.repl_kernel = match startup {
Ok(runtime) => Some(runtime),
+1
View File
@@ -15,6 +15,7 @@ pub mod codex_model_cache;
pub mod command_catalog;
pub mod compaction;
pub mod compaction_archive;
pub mod conversation_modes;
pub mod core;
pub mod cost_status;
pub mod diagnostics_messages;
+2
View File
@@ -430,6 +430,8 @@ pub struct ThreadRecord {
pub executor: Option<ExecutorRecord>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub environment: Option<ThreadEnvironment>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub agent_preset: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
+86 -22
View File
@@ -1465,6 +1465,8 @@ pub struct CreateThreadRequest {
pub executor: Option<CreateThreadExecutor>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub environment: Option<ThreadEnvironment>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub agent_preset: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
@@ -1514,6 +1516,8 @@ pub struct UpdateThreadRequest {
/// route name in `model_provider`.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub model_provider_id: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub agent_preset: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
@@ -3966,6 +3970,12 @@ impl RuntimeThreadManager {
self.config.read()
}
pub fn extension_host_options(&self) -> crate::extension_host::ExtensionHostOptions {
crate::extension_host::ExtensionHostOptions::from_config(
self.read_config().extension_host.as_ref(),
)
}
pub(super) fn resolved_route_for_thread(
&self,
config: &Config,
@@ -7171,6 +7181,18 @@ impl RuntimeThreadManager {
if let Some(environment) = req.environment.as_ref() {
return Err(environment.refusal().into());
}
if req.executor.is_some() && req.agent_preset.is_some() {
return Err(crate::conversation_modes::ModeRefusal::with_executor().into());
}
let agent_preset = match req.agent_preset.as_deref() {
Some(id) => {
let selection =
crate::conversation_modes::select(id, self.extension_host_options()).await?;
selection.require_available()?;
crate::conversation_modes::stored_form(&selection.id)
}
None => None,
};
let executor = match req.executor.as_ref() {
Some(requested) => {
anyhow::ensure!(
@@ -7311,6 +7333,7 @@ impl RuntimeThreadManager {
last_read: None,
executor,
environment: None,
agent_preset,
};
if let Some(operation_key) = req.operation_key {
let key_fingerprint = runtime_turn_operation_key_fingerprint(
@@ -7318,28 +7341,32 @@ impl RuntimeThreadManager {
":thread-create:",
&operation_key,
)?;
let mut fingerprint_body = json!({
"version": 1,
"model": thread.model,
"model_provider": thread.model_provider,
"model_provider_id": thread.model_provider_id,
"reasoning_effort": thread.reasoning_effort,
"allowed_tools": thread.allowed_tools,
"add_dirs": thread.add_dirs,
"workspace": thread.workspace,
"mode": thread.mode,
"permission_posture": thread.permission_posture,
"allow_shell": thread.allow_shell,
"trust_mode": thread.trust_mode,
"auto_approve": thread.auto_approve,
"archived": thread.archived,
"system_prompt": thread.system_prompt,
"task_id": thread.task_id,
"dynamic_tools": req.dynamic_tools,
"environments": req.environments,
"executor": thread.executor,
});
if let Some(preset) = thread.agent_preset.as_ref() {
fingerprint_body["agent_preset"] = json!(preset);
}
let request_fingerprint =
codewhale_runtime::hashing::sha256_hex(serde_json::to_vec(&json!({
"version": 1,
"model": thread.model,
"model_provider": thread.model_provider,
"model_provider_id": thread.model_provider_id,
"reasoning_effort": thread.reasoning_effort,
"allowed_tools": thread.allowed_tools,
"add_dirs": thread.add_dirs,
"workspace": thread.workspace,
"mode": thread.mode,
"permission_posture": thread.permission_posture,
"allow_shell": thread.allow_shell,
"trust_mode": thread.trust_mode,
"auto_approve": thread.auto_approve,
"archived": thread.archived,
"system_prompt": thread.system_prompt,
"task_id": thread.task_id,
"dynamic_tools": req.dynamic_tools,
"environments": req.environments,
"executor": thread.executor,
}))?);
codewhale_runtime::hashing::sha256_hex(serde_json::to_vec(&fingerprint_body)?);
let _emit_order = self.event_emit.lock().await;
let store = self.store.clone();
let (thread, event) = tokio::task::spawn_blocking(move || {
@@ -8400,9 +8427,26 @@ impl RuntimeThreadManager {
&& req.workspace.is_none()
&& req.model_provider.is_none()
&& req.model_provider_id.is_none()
&& req.agent_preset.is_none()
{
bail!("At least one thread field is required");
}
let preset_selection = match req.agent_preset.as_deref() {
Some(preset_id) => {
if self
.store
.load_thread(id)
.is_ok_and(|thread| thread.executor.is_some())
{
return Err(crate::conversation_modes::ModeRefusal::with_executor().into());
}
Some(
crate::conversation_modes::select(preset_id, self.extension_host_options())
.await?,
)
}
None => None,
};
if (req.model.is_some()
|| req.model_provider.is_some()
|| req.model_provider_id.is_some()
@@ -8571,6 +8615,24 @@ impl RuntimeThreadManager {
thread.trust_mode = trust_mode;
changes.insert("trust_mode".to_string(), json!(trust_mode));
}
if let Some(selection) = preset_selection.as_ref() {
let current =
crate::conversation_modes::effective_id(thread.agent_preset.as_deref());
if selection.id != current {
if thread.latest_turn_id.is_some()
|| active
.engines
.get(id)
.and_then(|state| state.active_turn.as_ref())
.is_some()
{
return Err(crate::conversation_modes::ModeRefusal::locked().into());
}
selection.require_available()?;
thread.agent_preset = crate::conversation_modes::stored_form(&selection.id);
changes.insert("agent_preset".to_string(), json!(selection.id));
}
}
let requested_model = match provider_switch {
Some((model_provider, model_provider_id, model)) => {
if thread.model_provider.as_deref() != Some(model_provider.as_str())
@@ -8639,7 +8701,9 @@ impl RuntimeThreadManager {
}
let workspace_changed = changes.contains_key("workspace");
let rebuild_engine = workspace_changed || changes.contains_key("system_prompt");
let rebuild_engine = workspace_changed
|| changes.contains_key("system_prompt")
|| changes.contains_key("agent_preset");
if rebuild_engine
&& active
.engines
@@ -2668,6 +2668,7 @@ fn sample_thread(thread_id: &str) -> ThreadRecord {
last_read: None,
executor: None,
environment: None,
agent_preset: None,
}
}
@@ -19967,6 +19968,7 @@ fn opening_manager_recovers_stale_queued_and_in_progress_work() -> Result<()> {
last_read: None,
executor: None,
environment: None,
agent_preset: None,
};
manager.store.save_thread(&thread)?;
@@ -21758,6 +21760,7 @@ async fn report_store_failure_names_the_item_file_without_terminalizing() -> Res
Ok(())
}
mod conversation_modes;
mod external_agent;
mod task_ownership;
@@ -0,0 +1,134 @@
use super::*;
use crate::conversation_modes::{CREATOR, CreatorAvailabilityGuard, ModeRefusal, STANDARD};
fn refusal_code(error: &anyhow::Error) -> &'static str {
error
.downcast_ref::<ModeRefusal>()
.expect("typed refusal")
.code
}
fn create_request(preset: Option<&str>) -> CreateThreadRequest {
CreateThreadRequest {
agent_preset: preset.map(str::to_string),
..Default::default()
}
}
#[test]
fn thread_record_without_agent_preset_loads_as_standard() -> Result<()> {
let thread = sample_thread("thr_modes_legacy");
let mut raw = serde_json::to_value(&thread)?;
assert!(raw.get("agent_preset").is_none());
let legacy: ThreadRecord = serde_json::from_value(raw.clone())?;
assert_eq!(legacy.agent_preset, None);
assert_eq!(
crate::conversation_modes::effective_id(legacy.agent_preset.as_deref()),
STANDARD
);
raw["agent_preset"] = json!(CREATOR);
let creator: ThreadRecord = serde_json::from_value(raw)?;
assert_eq!(creator.agent_preset.as_deref(), Some(CREATOR));
Ok(())
}
#[tokio::test]
async fn mode_is_set_at_creation_and_refused_when_unknown_or_unavailable() -> Result<()> {
let tmp = tempfile::tempdir()?;
let manager = test_manager(tmp.path().to_path_buf())?;
{
let _available = CreatorAvailabilityGuard::set(None);
let created = manager.create_thread(create_request(Some(CREATOR))).await?;
assert_eq!(created.agent_preset.as_deref(), Some(CREATOR));
let standard = manager
.create_thread(create_request(Some(STANDARD)))
.await?;
assert_eq!(standard.agent_preset, None);
let unknown = manager
.create_thread(create_request(Some("minimal")))
.await
.expect_err("unknown id");
assert_eq!(refusal_code(&unknown), ModeRefusal::UNKNOWN_MODE);
}
let _missing = CreatorAvailabilityGuard::set(Some("no runtime"));
let unavailable = manager
.create_thread(create_request(Some(CREATOR)))
.await
.expect_err("unavailable");
assert_eq!(refusal_code(&unavailable), ModeRefusal::MODE_UNAVAILABLE);
assert_eq!(unavailable.to_string(), "no runtime");
let table = crate::conversation_modes::current_table(manager.extension_host_options()).await;
assert!(table.modes[0].available && table.modes[0].reason.is_none());
assert!(!table.modes[1].available);
assert_eq!(table.modes[1].reason.as_deref(), Some("no runtime"));
Ok(())
}
#[tokio::test]
async fn mode_changes_only_before_the_first_turn_and_never_with_an_executor() -> Result<()> {
let tmp = tempfile::tempdir()?;
let manager = test_manager(tmp.path().to_path_buf())?;
let _available = CreatorAvailabilityGuard::set(None);
let thread = manager.create_thread(create_request(None)).await?;
let patch = |preset: &str| UpdateThreadRequest {
agent_preset: Some(preset.to_string()),
..Default::default()
};
let updated = manager.update_thread(&thread.id, patch(CREATOR)).await?;
assert_eq!(updated.agent_preset.as_deref(), Some(CREATOR));
let unknown = manager
.update_thread(&thread.id, patch("nope"))
.await
.expect_err("unknown id");
assert_eq!(refusal_code(&unknown), ModeRefusal::UNKNOWN_MODE);
let mut with_turn = manager.store.load_thread(&thread.id)?;
with_turn.latest_turn_id = Some("turn_first".to_string());
manager.store.save_thread(&with_turn)?;
let locked = manager
.update_thread(&thread.id, patch(STANDARD))
.await
.expect_err("locked");
assert_eq!(refusal_code(&locked), ModeRefusal::MODE_LOCKED);
manager.update_thread(&thread.id, patch(CREATOR)).await?;
let with_executor = CreateThreadRequest {
agent_preset: Some(CREATOR.to_string()),
executor: Some(CreateThreadExecutor {
kind: "acp".to_string(),
agent_id: "opencode".to_string(),
config: CreateThreadExecutorConfig::default(),
}),
..Default::default()
};
let refused = manager
.create_thread(with_executor)
.await
.expect_err("executor");
assert_eq!(
refusal_code(&refused),
ModeRefusal::MODE_NOT_SUPPORTED_WITH_EXECUTOR
);
Ok(())
}
#[tokio::test]
async fn creator_is_listed_unavailable_until_it_loads_its_tools() -> Result<()> {
let _real = CreatorAvailabilityGuard::unforced();
let tmp = tempfile::tempdir()?;
let manager = test_manager(tmp.path().to_path_buf())?;
let table = crate::conversation_modes::current_table(manager.extension_host_options()).await;
assert!(table.modes[0].available);
assert!(!table.modes[1].available);
assert_eq!(
table.modes[1].reason.as_deref(),
Some(crate::conversation_modes::CREATOR_NOT_READY_REASON)
);
let refused = manager
.create_thread(create_request(Some(CREATOR)))
.await
.expect_err("not ready");
assert_eq!(refusal_code(&refused), ModeRefusal::MODE_UNAVAILABLE);
Ok(())
}
+4
View File
@@ -2090,6 +2090,8 @@ pub struct SavedSession {
/// record resume, export, and the Runtime API read back.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub turn_outcomes: Vec<SavedTurnOutcome>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub agent_preset: Option<String>,
}
impl SavedSession {
pub fn capture_publication(
@@ -2302,6 +2304,7 @@ impl SavedSession {
composer_color: None,
last_auto_route: None,
turn_outcomes: Vec::new(),
agent_preset: None,
})
}
}
@@ -7079,6 +7082,7 @@ fn create_saved_session_inner(
composer_color: None,
last_auto_route: None,
turn_outcomes: Vec::new(),
agent_preset: None,
}
}
@@ -46,6 +46,7 @@
composer_color: None,
last_auto_route: None,
turn_outcomes: Vec::new(),
agent_preset: None,
};
manager.save_session(&session).expect("save");
}
@@ -96,6 +97,7 @@
composer_color: None,
last_auto_route: None,
turn_outcomes: Vec::new(),
agent_preset: None,
};
manager.save_session(&session).expect("save empty");
}
@@ -346,3 +346,19 @@
primary.join("sessions")
);
}
#[test]
fn saved_session_without_agent_preset_loads_and_round_trips_the_mode() {
let tmp = tempdir().expect("tempdir");
let manager = SessionManager::new(tmp.path().join("sessions")).expect("new");
let session = create_saved_session(&[], "test-model", tmp.path(), 0, None);
let mut raw = serde_json::to_value(&session).expect("serialize");
assert!(raw.get("agent_preset").is_none());
let legacy: SavedSession = serde_json::from_value(raw.clone()).expect("legacy load");
assert_eq!(legacy.agent_preset, None);
raw["agent_preset"] = serde_json::json!("creator");
let saved: SavedSession = serde_json::from_value(raw).expect("mode load");
manager.save_session(&saved).expect("save");
let loaded = manager.load_session(&saved.metadata.id).expect("load");
assert_eq!(loaded.agent_preset.as_deref(), Some("creator"));
}
+1 -1
View File
@@ -423,7 +423,7 @@ impl RlmTool {
let context_path = tempfile::TempPath::try_from_path(context_path).map_err(|e| {
ToolError::execution_failed(format!("rlm_open: failed to own staged context: {e}"))
})?;
let kernel = PythonRuntime::spawn_with_context(&context_path)
let kernel = PythonRuntime::spawn_with_context(&context_path, &context.workspace)
.await
.map_err(|e| ToolError::execution_failed(format!("rlm_open: {e}")))?;
// PythonRuntime now owns cleanup; a dropped startup kept the TempPath.
+4
View File
@@ -301,6 +301,10 @@ impl ToolSpec for StartRuntimeMcpServer {
// Refuse shell wrappers and anything outside the runtime allowlist.
if let Some(ref cmd) = parsed.config.command {
validate_runtime_command(cmd)?;
parsed.config.env.insert(
crate::tools::shell::AGENT_PROJECT_ENV.to_string(),
context.workspace.to_string_lossy().into_owned(),
);
}
// Reject shell metacharacters in arguments to prevent injection.
+13 -1
View File
@@ -71,6 +71,7 @@ use output::{
};
const READONLY_ENV_MARKER: &str = "CODEWHALE_INTERNAL_READONLY_ARGV";
pub const AGENT_PROJECT_ENV: &str = "CODEWHALE_AGENT_PROJECT";
#[cfg(unix)]
static PENDING_PERSISTENT_PROCESS_GROUPS: std::sync::OnceLock<
@@ -2209,13 +2210,19 @@ impl ShellManager {
program: &str,
args: Vec<String>,
cwd: &Path,
project: &Path,
timeout: Duration,
policy_override: Option<ExecutionSandboxPolicy>,
) -> ExecEnv {
let policy = policy_override.unwrap_or_else(|| self.sandbox_policy.clone());
let spec =
CommandSpec::program(program, args, cwd.to_path_buf(), timeout).with_policy(policy);
self.sandbox_manager.prepare(&spec)
let mut exec_env = self.sandbox_manager.prepare(&spec);
exec_env.env.insert(
AGENT_PROJECT_ENV.to_string(),
project.to_string_lossy().into_owned(),
);
exec_env
}
pub fn validate_background_job(
@@ -4492,6 +4499,7 @@ pub fn sandboxed_runner_command(
program,
args,
cwd,
&context.workspace,
timeout,
context.elevated_sandbox_policy.clone(),
);
@@ -6481,6 +6489,10 @@ impl ToolSpec for BashTool {
extra_env.insert("GIT_CONFIG_COUNT".to_string(), count.to_string());
extra_env.insert(READONLY_ENV_MARKER.to_string(), "1".to_string());
}
extra_env.insert(
AGENT_PROJECT_ENV.to_string(),
context.workspace.to_string_lossy().into_owned(),
);
let command_expense = infer_command_expense(command);
let heavy_permit = acquire_heavy_command_permit(command, context.cancel_token.as_ref())
+61
View File
@@ -6132,3 +6132,64 @@ fn a_tool_launcher_clamps_a_danger_full_access_escalation() {
"{error:#}"
);
}
#[cfg(unix)]
#[tokio::test]
async fn every_model_run_shell_command_carries_the_agent_project() {
let workspace = tempdir().expect("workspace");
let project = workspace.path().to_string_lossy().into_owned();
let command = "printf '%s' \"${CODEWHALE_AGENT_PROJECT-unset}\"";
let mut sandboxed = ToolContext::new(workspace.path());
sandboxed.auto_approve = true;
let mut full_access = ToolContext::new(workspace.path())
.with_elevated_sandbox_policy(ExecutionSandboxPolicy::DangerFullAccess);
full_access.auto_approve = true;
for context in [&sandboxed, &full_access] {
for tool in [BashTool::new("exec_shell"), BashTool::new("Bash")] {
let result = tool
.execute(json!({ "command": command }), context)
.await
.expect("shell runs");
assert_eq!(result.content.trim(), project, "{result:?}");
}
let result = LowercaseBashTool
.execute(json!({ "command": command }), context)
.await
.expect("bash runs");
assert_eq!(result.content.trim(), project, "{result:?}");
}
}
#[cfg(unix)]
#[tokio::test]
async fn persistent_terminal_commands_carry_the_agent_project() {
use crate::tools::terminal_session::{TerminalResetTool, TerminalRunTool, TerminalSendTool};
let workspace = tempdir().expect("workspace");
let marked = format!("MARK={}", workspace.path().display());
let mut context = ToolContext::new(workspace.path())
.with_elevated_sandbox_policy(ExecutionSandboxPolicy::DangerFullAccess);
context.auto_approve = true;
let session = format!("agent-mark-{}", uuid::Uuid::new_v4().simple());
let print = "printf 'MARK=%s\\n' \"${CODEWHALE_AGENT_PROJECT-unset}\"";
let run = TerminalRunTool
.execute(
json!({ "command": print, "session": session, "timeout_secs": 30 }),
&context,
)
.await
.expect("terminal/run");
assert!(run.content.contains(&marked), "{}", run.content);
let sent = TerminalSendTool
.execute(
json!({ "session": session, "text": format!("{print}\n"), "wait_ms": 2000 }),
&context,
)
.await
.expect("terminal/send");
assert!(sent.content.contains(&marked), "{}", sent.content);
let _ = TerminalResetTool
.execute(json!({ "session": session }), &context)
.await;
}
+5 -1
View File
@@ -339,6 +339,7 @@ fn pty_sandbox_policy(
fn prepare_pty_shell(
shell: &str,
workspace: &std::path::Path,
project: &std::path::Path,
policy: codewhale_sandbox::SandboxPolicy,
shell_manager: &crate::tools::shell::SharedShellManager,
) -> Result<(codewhale_sandbox::ExecEnv, bool), String> {
@@ -349,6 +350,7 @@ fn prepare_pty_shell(
shell,
vec!["-i".to_string()],
workspace,
project,
Duration::from_secs(24 * 60 * 60),
Some(policy.clone()),
);
@@ -380,6 +382,7 @@ fn create_session(
let shell = "/bin/sh".to_string();
#[cfg(not(any(test, feature = "test-support")))]
let shell = std::env::var("SHELL").unwrap_or_else(|_| "/bin/sh".to_string());
let project = workspace.to_path_buf();
let workspace = canonical(workspace);
let durable_path = durable_path(owner, name, &workspace)?;
let previous = load_durable(&durable_path).map(|mut record| {
@@ -400,7 +403,7 @@ fn create_session(
.map_err(|e| format!("failed to open PTY: {e}"))?;
let (prepared, sandbox_confined) =
prepare_pty_shell(&shell, &workspace, policy.clone(), shell_manager)?;
prepare_pty_shell(&shell, &workspace, &project, policy.clone(), shell_manager)?;
let child = prepared
.spawn_pty(pair.slave, &prepared.cwd)
.map_err(|e| format!("failed to start shell {shell}: {e}"))?;
@@ -1748,6 +1751,7 @@ mod tests {
let (prepared, confined) = prepare_pty_shell(
"/bin/sh",
Path::new("/tmp"),
Path::new("/tmp"),
codewhale_sandbox::SandboxPolicy::DangerFullAccess,
&test_shell_manager(),
)
+47 -1
View File
@@ -608,7 +608,11 @@ fn start_background_gates(
};
let command = render_gate_command(program, &gate.args)?;
let env: HashMap<String, String> = gate.env.into_iter().collect();
let mut env: HashMap<String, String> = gate.env.into_iter().collect();
env.insert(
crate::tools::shell::AGENT_PROJECT_ENV.to_string(),
context.workspace.to_string_lossy().into_owned(),
);
let spawn_result = {
let mut manager = context
.shell_manager
@@ -1918,6 +1922,48 @@ mod tests {
assert!(metadata.get("task_updates").is_none(), "{metadata}");
}
#[cfg(unix)]
#[tokio::test]
async fn verifier_gates_carry_the_agent_project_in_the_foreground_and_background() {
let tmp = tempdir().expect("tempdir");
let ctx = ToolContext::new(tmp.path());
let marked = format!("MARK={}", tmp.path().display());
let gate = json!({
"name": "agent-mark",
"program": "sh",
"args": ["-c", "printf 'MARK=%s' \"${CODEWHALE_AGENT_PROJECT-unset}\""]
});
let foreground = RunVerifiersTool
.execute(
json!({ "profile": "auto", "commands": [gate.clone()] }),
&ctx,
)
.await
.expect("foreground verifier");
assert!(
foreground.content.contains(&marked),
"{}",
foreground.content
);
let background = RunVerifiersTool
.execute(
json!({ "profile": "auto", "background": true, "commands": [gate] }),
&ctx,
)
.await
.expect("background verifier");
let parsed: RunVerifiersBackgroundOutput =
serde_json::from_str(&background.content).expect("background output json");
let task_id = parsed.jobs[0].task_id.as_deref().expect("task id");
let output = wait_for_completed_shell(
&mut ctx.shell_manager.lock().expect("shell manager"),
task_id,
);
assert!(output.stdout.contains(&marked), "{output:?}");
}
#[tokio::test]
async fn run_verifiers_background_starts_shell_jobs_and_returns_task_ids() {
let tmp = tempdir().expect("tempdir");
+113 -40
View File
@@ -192,12 +192,14 @@ pub struct PythonRuntime {
broken: Option<String>,
}
pub const AGENT_PROJECT_ENV: &str = "CODEWHALE_AGENT_PROJECT";
impl PythonRuntime {
/// Spawn a REPL with no `context` variable and no LLM helpers wired up.
/// Used by the agent loop for inline `repl` blocks the model emits in
/// regular conversation.
pub async fn new() -> Result<Self, String> {
Self::spawn_inner(None, Some(ROUND_TIMEOUT)).await
pub async fn new(project: &Path) -> Result<Self, String> {
Self::spawn_inner(None, project, Some(ROUND_TIMEOUT)).await
}
/// Compatibility shim — older RLM code path used to pass a state file.
@@ -217,12 +219,13 @@ impl PythonRuntime {
/// Spawn a REPL with the long input preloaded from a file. Used by the
/// RLM turn loop.
pub async fn spawn_with_context(context_path: &Path) -> Result<Self, String> {
Self::spawn_inner(Some(context_path), None).await
pub async fn spawn_with_context(context_path: &Path, project: &Path) -> Result<Self, String> {
Self::spawn_inner(Some(context_path), project, None).await
}
async fn spawn_inner(
context_path: Option<&Path>,
project: &Path,
round_timeout: Option<Duration>,
) -> Result<Self, String> {
let session_id = Uuid::new_v4().simple().to_string();
@@ -241,14 +244,16 @@ impl PythonRuntime {
.stderr(Stdio::piped())
.kill_on_drop(true);
let context_env = context_path
.map(|path| {
vec![(
OsString::from("RLM_CONTEXT_FILE"),
path.as_os_str().to_os_string(),
)]
})
.unwrap_or_default();
let mut context_env = vec![(
OsString::from(AGENT_PROJECT_ENV),
project.as_os_str().to_os_string(),
)];
if let Some(path) = context_path {
context_env.push((
OsString::from("RLM_CONTEXT_FILE"),
path.as_os_str().to_os_string(),
));
}
child_env::apply_to_tokio_command(&mut cmd, context_env);
let mut child = cmd
@@ -1246,9 +1251,35 @@ mod tests {
path
}
#[tokio::test]
async fn a_command_run_from_the_kernel_carries_the_agent_project() {
let project = std::env::temp_dir().join(format!("agent-project-{}", Uuid::new_v4()));
let mut rt = PythonRuntime::new(&project).await.expect("spawn");
let round = rt
.execute(
"import subprocess, sys\n\
out = subprocess.run([sys.executable, '-c', \
'import os; print(\"MARK=\" + os.environ.get(\"CODEWHALE_AGENT_PROJECT\", \"unset\"))'], \
capture_output=True, text=True).stdout\n\
print(out)",
)
.await
.expect("execute");
assert!(
round
.stdout
.contains(&format!("MARK={}", project.display())),
"{}",
round.stdout
);
rt.shutdown().await;
}
#[tokio::test]
async fn spawns_and_executes_simple_print() {
let mut rt = PythonRuntime::new().await.expect("spawn");
let mut rt = PythonRuntime::new(&std::env::temp_dir())
.await
.expect("spawn");
let round = rt.execute("print('hello world')").await.expect("execute");
assert!(round.stdout.contains("hello world"));
assert!(!round.has_error);
@@ -1259,7 +1290,9 @@ mod tests {
#[tokio::test]
async fn non_utf8_stdout_decodes_lossy_and_runtime_survives() {
let mut rt = PythonRuntime::new().await.expect("spawn");
let mut rt = PythonRuntime::new(&std::env::temp_dir())
.await
.expect("spawn");
let round = rt
.execute(
"import sys\n\
@@ -1277,7 +1310,9 @@ mod tests {
#[tokio::test]
async fn variables_persist_across_rounds() {
let mut rt = PythonRuntime::new().await.expect("spawn");
let mut rt = PythonRuntime::new(&std::env::temp_dir())
.await
.expect("spawn");
rt.execute("x = [1, 2, 3]").await.expect("r1");
rt.execute("x.append(99)").await.expect("r2");
let round = rt.execute("print(x)").await.expect("r3");
@@ -1287,7 +1322,9 @@ mod tests {
#[tokio::test]
async fn imports_persist_across_rounds() {
let mut rt = PythonRuntime::new().await.expect("spawn");
let mut rt = PythonRuntime::new(&std::env::temp_dir())
.await
.expect("spawn");
rt.execute("import math").await.expect("r1");
let round = rt.execute("print(math.pi)").await.expect("r2");
assert!(round.stdout.contains("3.14"));
@@ -1297,7 +1334,7 @@ mod tests {
#[tokio::test]
async fn context_loads_from_file() {
let path = write_temp_context("the quick brown fox");
let mut rt = PythonRuntime::spawn_with_context(&path)
let mut rt = PythonRuntime::spawn_with_context(&path, &std::env::temp_dir())
.await
.expect("spawn");
let round = rt
@@ -1312,7 +1349,7 @@ mod tests {
#[tokio::test]
async fn context_aliases_keep_common_content_name_bounded() {
let path = write_temp_context("aleph-style");
let mut rt = PythonRuntime::spawn_with_context(&path)
let mut rt = PythonRuntime::spawn_with_context(&path, &std::env::temp_dir())
.await
.expect("spawn");
let round = rt
@@ -1325,7 +1362,9 @@ mod tests {
#[tokio::test]
async fn replacing_context_keeps_kernel_variables_and_refreshes_helpers() {
let mut rt = PythonRuntime::new().await.expect("spawn");
let mut rt = PythonRuntime::new(&std::env::temp_dir())
.await
.expect("spawn");
rt.execute("remembered = {'answer': 42}")
.await
.expect("seed persistent variable");
@@ -1351,7 +1390,7 @@ mod tests {
#[tokio::test]
async fn context_chunk_helpers_report_full_coverage() {
let path = write_temp_context("abcdefghijklmnopqrstuvwxyz");
let mut rt = PythonRuntime::spawn_with_context(&path)
let mut rt = PythonRuntime::spawn_with_context(&path, &std::env::temp_dir())
.await
.expect("spawn");
let round = rt
@@ -1369,7 +1408,7 @@ mod tests {
#[tokio::test]
async fn bounded_input_helpers_work() {
let path = write_temp_context("alpha\nbeta needle\ngamma needle\nomega");
let mut rt = PythonRuntime::spawn_with_context(&path)
let mut rt = PythonRuntime::spawn_with_context(&path, &std::env::temp_dir())
.await
.expect("spawn");
let round = rt
@@ -1394,7 +1433,7 @@ mod tests {
#[tokio::test]
async fn new_chunk_helper_reports_full_coverage() {
let path = write_temp_context("abcdefghijklmnopqrstuvwxyz");
let mut rt = PythonRuntime::spawn_with_context(&path)
let mut rt = PythonRuntime::spawn_with_context(&path, &std::env::temp_dir())
.await
.expect("spawn");
let round = rt
@@ -1411,7 +1450,9 @@ mod tests {
#[tokio::test]
async fn finalize_helper_is_captured_directly() {
let mut rt = PythonRuntime::new().await.expect("spawn");
let mut rt = PythonRuntime::new(&std::env::temp_dir())
.await
.expect("spawn");
let round = rt
.execute("finalize('computed answer', confidence='high')")
.await
@@ -1430,7 +1471,9 @@ mod tests {
#[tokio::test]
async fn finalize_preserves_json_values_for_handles() {
let mut rt = PythonRuntime::new().await.expect("spawn");
let mut rt = PythonRuntime::new(&std::env::temp_dir())
.await
.expect("spawn");
let round = rt
.execute("finalize({'answer': 42, 'items': ['a', 'b']})")
.await
@@ -1450,7 +1493,9 @@ mod tests {
#[tokio::test]
async fn sub_query_accepts_timeout_keyword_for_agent_guesses() {
let bridge = StubBridge::new();
let mut rt = PythonRuntime::new().await.expect("spawn");
let mut rt = PythonRuntime::new(&std::env::temp_dir())
.await
.expect("spawn");
let round = rt
.run(
"answer = sub_query('summarize', timeout_secs=2)\nprint(answer)",
@@ -1471,7 +1516,7 @@ mod tests {
#[tokio::test]
async fn rlm_context_runtime_has_no_fixed_round_timeout() {
let path = write_temp_context("long input");
let rt = PythonRuntime::spawn_with_context(&path)
let rt = PythonRuntime::spawn_with_context(&path, &std::env::temp_dir())
.await
.expect("spawn");
assert!(
@@ -1483,7 +1528,9 @@ mod tests {
#[tokio::test]
async fn timed_out_round_kills_the_interpreter_and_refuses_later_rounds() {
let mut rt = PythonRuntime::new().await.expect("spawn");
let mut rt = PythonRuntime::new(&std::env::temp_dir())
.await
.expect("spawn");
rt.round_timeout = Some(Duration::from_millis(300));
let error = rt
.execute("import time\ntime.sleep(30)\nprint('late output')")
@@ -1509,7 +1556,9 @@ mod tests {
#[tokio::test]
async fn another_rounds_done_sentinel_does_not_end_this_round() {
let mut rt = PythonRuntime::new().await.expect("spawn");
let mut rt = PythonRuntime::new(&std::env::temp_dir())
.await
.expect("spawn");
let round = rt
.execute("print(_DONE + '999')\nprint('still this round')")
.await
@@ -1527,14 +1576,18 @@ mod tests {
#[tokio::test]
async fn inline_runtime_keeps_bounded_round_timeout() {
let rt = PythonRuntime::new().await.expect("spawn");
let rt = PythonRuntime::new(&std::env::temp_dir())
.await
.expect("spawn");
assert_eq!(rt.round_timeout(), Some(ROUND_TIMEOUT));
rt.shutdown().await;
}
#[tokio::test]
async fn legacy_final_is_captured() {
let mut rt = PythonRuntime::new().await.expect("spawn");
let mut rt = PythonRuntime::new(&std::env::temp_dir())
.await
.expect("spawn");
let round = rt
.execute("FINAL('the answer is 42')")
.await
@@ -1545,7 +1598,9 @@ mod tests {
#[tokio::test]
async fn legacy_final_var_is_captured() {
let mut rt = PythonRuntime::new().await.expect("spawn");
let mut rt = PythonRuntime::new(&std::env::temp_dir())
.await
.expect("spawn");
rt.execute("answer = 'computed'").await.expect("r1");
let round = rt.execute("FINAL_VAR('answer')").await.expect("r2");
assert_eq!(round.final_value.as_deref(), Some("computed"));
@@ -1554,7 +1609,9 @@ mod tests {
#[tokio::test]
async fn errors_are_reported_without_killing_runtime() {
let mut rt = PythonRuntime::new().await.expect("spawn");
let mut rt = PythonRuntime::new(&std::env::temp_dir())
.await
.expect("spawn");
let r1 = rt.execute("raise ValueError('boom')").await.expect("r1");
assert!(r1.has_error);
assert!(r1.full_stdout.contains("boom") || r1.stdout.contains("boom"));
@@ -1569,7 +1626,9 @@ mod tests {
let bridge = StubBridge::new();
let calls = Arc::clone(&bridge.calls);
let mut rt = PythonRuntime::new().await.expect("spawn");
let mut rt = PythonRuntime::new(&std::env::temp_dir())
.await
.expect("spawn");
let round = rt
.run("print(llm_query('hello'))", Some(&bridge))
.await
@@ -1624,7 +1683,9 @@ mod tests {
/// set, so the calling code never saw the text.
#[tokio::test]
async fn rlm_query_keeps_partial_answer_from_an_exhausted_child() {
let mut rt = PythonRuntime::new().await.expect("spawn");
let mut rt = PythonRuntime::new(&std::env::temp_dir())
.await
.expect("spawn");
let round = rt
.run(
"print(rlm_query('go'))\nfor r in rlm_query_batched(['a', 'b'], dependency_mode='independent'):\n print(r)",
@@ -1649,7 +1710,9 @@ mod tests {
let bridge = StubBridge::new();
let calls = Arc::clone(&bridge.calls);
let mut rt = PythonRuntime::new().await.expect("spawn");
let mut rt = PythonRuntime::new(&std::env::temp_dir())
.await
.expect("spawn");
let round = rt
.run("print(sub_query('hello from sub'))", Some(&bridge))
.await
@@ -1674,7 +1737,9 @@ mod tests {
#[tokio::test]
async fn rpc_dispatcher_round_trips_batch() {
let bridge = StubBridge::new();
let mut rt = PythonRuntime::new().await.expect("spawn");
let mut rt = PythonRuntime::new(&std::env::temp_dir())
.await
.expect("spawn");
let round = rt
.run(
"outs = llm_query_batched(['a','b','c'], dependency_mode='independent', safety_note='same independent classification')\n\
@@ -1693,7 +1758,9 @@ mod tests {
#[tokio::test]
async fn batched_helpers_require_independence_declaration() {
let bridge = StubBridge::new();
let mut rt = PythonRuntime::new().await.expect("spawn");
let mut rt = PythonRuntime::new(&std::env::temp_dir())
.await
.expect("spawn");
let round = rt
.run(
"outs = sub_query_batch('summarize', [{'text': 'a'}, {'text': 'b'}])\n\
@@ -1715,7 +1782,9 @@ mod tests {
#[tokio::test]
async fn dependent_batch_mode_points_to_sequence_helper() {
let bridge = StubBridge::new();
let mut rt = PythonRuntime::new().await.expect("spawn");
let mut rt = PythonRuntime::new(&std::env::temp_dir())
.await
.expect("spawn");
let round = rt
.run(
"outs = llm_query_batched(['migrate A', 'migrate B'], dependency_mode='sequential')\n\
@@ -1739,7 +1808,9 @@ mod tests {
let bridge = StubBridge::new();
let calls = Arc::clone(&bridge.calls);
let mut rt = PythonRuntime::new().await.expect("spawn");
let mut rt = PythonRuntime::new(&std::env::temp_dir())
.await
.expect("spawn");
let round = rt
.run(
"outs = sub_query_sequence('process this step', [{'text': 'A'}, {'text': 'B'}])\n\
@@ -1766,7 +1837,9 @@ mod tests {
#[tokio::test]
async fn no_dispatcher_returns_unavailable_sentinel() {
let mut rt = PythonRuntime::new().await.expect("spawn");
let mut rt = PythonRuntime::new(&std::env::temp_dir())
.await
.expect("spawn");
let round = rt.execute("print(llm_query('hi'))").await.expect("execute");
assert!(
round.stdout.contains("[llm_query error:") || round.stdout.contains("no LLM bridge"),
+2
View File
@@ -8,6 +8,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
- **Git tool on older git:** the Git tool's `review_scope` action no longer passes `--no-lazy-fetch`, which git older than 2.44 rejects, so it failed on every call there. It sets `GIT_NO_LAZY_FETCH=1` instead, which keeps the same protection on newer git and is ignored by older git.
- **Plugins from a shell:** `codewhale plugins trust <name>` prints the same capability review as `/plugin trust` with its bundle review token and, in a terminal, asks once. `--review-token <token> --yes` trusts without a prompt while that token still matches the installed content and capabilities; the token ties trust to the exact content, does not show that anyone read the review, and is not the host-service review token. A `--yes` trust is recorded as the command line and grants no host service. Every command the agent runs (shell, persistent terminal, verifier and test runs, the Python kernel, MCP servers it starts) now carries `CODEWHALE_AGENT_PROJECT` with the session's project, while hooks and configured MCP servers do not; with it, a trust or enable is recorded as the agent's, for that project only, even when a prompt is answered through a terminal, and the agent may trust only a plugin it installed from a folder inside the project. `plugins enable`, `disable` and `revoke` work the same way, each with `--json`, and `plugins status` shows who approved. The agent's shell commands that run them go through the session's approval like any other shell command, so a Full Access agent can trust and enable a plugin it wrote; the mark is a label the agent could remove in Full Access. (CW-7891)
- **Sandbox:** a managed configuration can set `tool_launcher` (Linux) to run every sandboxed tool command as another user, in front of bubblewrap; commands then run inside the sandbox or not at all, masks are computed for that user, `/tmp` is private per command, and `GET /v1/runtime/info` reports `sandbox.kind` and `sandbox.tool_launcher`. Without the key nothing changes. A managed configuration named by `CODEWHALE_MANAGED_CONFIG_PATH` is now applied on every load; a configuration whose provider comes from a plugin no longer loads with the managed file skipped, it fails to load instead.
- **Receipts:** a terminal session's receipt (`/receipts`, `codewhale receipts`) now shows the provider's token counts and the same cost report `/cost` prints, saying "unknown" and why when the route's price is not known, never a guess. A Runtime thread's receipt shows token counts only; its cost line follows in a later release. `codewhale metrics` now counts usage saved in terminal sessions instead of reporting none. Sessions saved by 0.10.2 carry only a combined token total.
- **ACP server on the official SDK:** The ACP server (`codewhale serve --acp`) now runs on the official Agent Client Protocol SDK. Its messages follow the official v1 types, and it no longer advertises the non-standard `modelSelection` capability or session `models` field. Session list entries carry `createdAt` and `messageCount` under `_meta.codewhale`, terminal sign-in methods are advertised only to clients that enabled terminal authentication, a notification is never answered, a permission request always carries text, and session list and configuration requests are served while a turn runs, response ids are echoed unchanged for every client (the Zed-only string rewrite is gone), and `initialize` without `protocolVersion` is refused with `-32602`.
@@ -15,6 +16,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- **Creator skills:** Codewhale now ships DeepSeek Harness's four plugin-authoring skills (`cordis-plugin-development`, `editing-cordis-compositions`, `cordis-composition-reference`, `agent-experience`) unmodified, with their reference and template files. A bundled skill can now carry files: `load_skill` prints the skill's base directory and a short Codewhale host-notes block after DSH's text. A copy you edited is never overwritten on upgrade. Source-level only: the Creator plugin switch and agent entry that make these useful land in later slices.
- **Creator skills:** Codewhale now ships DeepSeek Harness's four plugin-authoring skills (`cordis-plugin-development`, `editing-cordis-compositions`, `cordis-composition-reference`, `agent-experience`) bundled, unmodified, with their reference and template files and DeepSeek's MIT notice beside each. They stay hidden from the model until Creator mode. A bundled skill can now carry files; `load_skill` prints its base directory and a short Codewhale host-notes block, and a file you edited is never overwritten on upgrade.
- **Website home page, rebuilt around one first screen:** the headline stays "The model can be anyone's. The agent should be yours.", centred, with one plain sentence under it that says what Codewhale is (an open-source coding agent for your terminal, written in Rust, working with any model and able to split a task across several agents), the install command, and one wide picture of a real terminal session that runs off the bottom of the first screen. A row of facts under the picture takes its counts of provider routes and models from the published release, and the page says several agents share a task only while the release has it. The route of where the product is going moves lower and smaller, and the home page names no other tools. The new text in the 17 non-English languages is machine-drafted and is labelled as such in the footer; the Arabic page now reads the opening sentence right to left.
- **Conversation modes (Engine):** `GET /v1/modes` lists two built-in modes, `standard` (default) and `creator`, each with `id`, `name`, `description`, `source` and `available` (plus `reason` when not; Creator is listed unavailable, "not ready in this build", until it loads its own tools, and then also when no Node.js or Bun plugin runtime is found). A thread's mode is `agent_preset`, set on `POST /v1/threads` or by `PATCH /v1/threads/{id}` until the thread has a turn, saved on the thread record and the terminal session file and shown in `GET /v1/threads/summary`; records without it load as `standard`. An unknown or unavailable id, a change after the first turn, or a mode together with another vendor's agent is refused with `error.code` `unknown_mode`, `mode_unavailable`, `mode_locked` or `mode_not_supported_with_executor`. Source only: Creator loads nothing extra yet and no menu uses it.
- **Website home page:** the page is reorganized around what a visitor does: a dated OrcaRouter offer strip with a visible referral tag, one proof strip (release, stars, contributors), a models section with the newest first-party models, one capabilities section that includes Computer Use, an "Everything is a plugin" teaser and a community panel whose live GitHub numbers fill in from the browser and hide when unavailable. Unused home copy was removed from all 18 site languages.
- **Website home page, rebuilt around one first screen:** the headline stays "The model can be anyone's. The agent should be yours.", centred, with one plain sentence under it that says what Codewhale is (an open-source coding agent for your terminal, written in Rust, working with any model and able to split a task across several agents), the install command, and one wide picture of a real terminal session that runs off the bottom of the first screen. A row of facts under the picture takes its counts of provider routes and models from the published release, and the page says several agents share a task only while the release has it. The route of where the product is going moves lower and smaller, and the home page names no other tools. This replaces the earlier reorganization of the home page, so the dated OrcaRouter offer strip and the community panel whose GitHub numbers filled in from the browser are no longer on it; the models and contribute pages keep their own content. The new text in the 17 non-English languages is machine-drafted and is labelled as such in the footer; the Arabic page now reads the opening sentence right to left.
- **Website home page, rebuilt around one first screen:** the headline stays "The model can be anyone's. The agent should be yours.", centred, with one plain sentence under it that says what Codewhale is (an open-source coding agent for your terminal, written in Rust, working with any model and able to split a task across several agents), the install command, and one wide picture of a real terminal session that runs off the bottom of the first screen. A row of facts under the picture takes its counts of provider routes and models from the published release, and the facts row lists many agents on one task only while the release has it. The route of where the product is going moves lower and smaller, and the home page names no other coding agents. This replaces the earlier reorganization of the home page, so the community panel whose GitHub numbers filled in from the browser is no longer on it, and the dated OrcaRouter offer strip, English only, with its referral disclosure, stays below the first screen until it ends; the models and contribute pages keep their own content. The new text in the 17 non-English languages is machine-drafted and is labelled as such in the footer; the Arabic page now reads the opening sentence right to left.
+1 -1
View File
@@ -3845,7 +3845,7 @@ fn portable_mcp_server(
}
/// Convert a TUI-owned loaded plugin into the portable full detail.
fn portable_detail(plugin: &codewhale_plugins::types::LoadedPlugin) -> PluginDetail {
pub(crate) fn portable_detail(plugin: &codewhale_plugins::types::LoadedPlugin) -> PluginDetail {
let mcp_servers = plugin
.manifest
.mcp_servers
@@ -0,0 +1,429 @@
use std::io::{self, BufRead, IsTerminal, Write};
use std::path::{Path, PathBuf};
use std::sync::Arc;
use anyhow::{Result, anyhow, bail};
use codewhale_command_contract::facets::CommandPresentationContext;
use codewhale_localization::{Locale, MessageId, tr};
use codewhale_plugins::PluginRegistry;
use codewhale_plugins::host_services::{PersonVia, PluginApprover, ReviewApproval, approver_label};
use codewhale_plugins::install::PluginInstallSource;
use codewhale_plugins::types::LoadedPlugin;
use super::render::{ReviewTextContext, escape_review_text, render_bundle_detail};
use crate::commands::contract::{apply_named_replacements, portable_detail, resolve_message_id};
const AGENT_MARK: &str = "CODEWHALE_AGENT_PROJECT";
const INSTALLED_FROM: &str = ".installed-from";
pub(crate) fn agent_project() -> Option<PathBuf> {
std::env::var_os(AGENT_MARK)
.filter(|value| !value.is_empty())
.map(PathBuf::from)
}
pub(crate) trait Console {
fn interactive(&self) -> bool;
fn agent_marked(&self) -> bool;
fn print(&mut self, text: &str);
fn confirm(&mut self, prompt: &str) -> bool;
}
pub(crate) struct ProcessConsole;
impl Console for ProcessConsole {
fn interactive(&self) -> bool {
io::stdin().is_terminal() && io::stdout().is_terminal() && io::stderr().is_terminal()
}
fn agent_marked(&self) -> bool {
agent_project().is_some()
}
fn print(&mut self, text: &str) {
println!("{text}");
}
fn confirm(&mut self, prompt: &str) -> bool {
let mut stderr = io::stderr();
let _ = write!(stderr, "{prompt} [y/N] ");
let _ = stderr.flush();
let mut answer = String::new();
io::stdin().lock().read_line(&mut answer).is_ok()
&& matches!(answer.trim().to_ascii_lowercase().as_str(), "y" | "yes")
}
}
struct Presentation(Locale);
impl CommandPresentationContext for Presentation {
fn translate(&self, key: &str, replacements: &[(&str, &str)]) -> Result<String, String> {
let id = resolve_message_id(key).ok_or("unknown translation key")?;
apply_named_replacements(&tr(self.0, id), replacements)
.ok_or_else(|| "invalid translation replacement contract".to_string())
}
fn language_name(&self) -> &'static str {
self.0.translation_target_name()
}
}
fn text(value: &str) -> String {
escape_review_text(value, ReviewTextContext::Plain)
}
fn shell_word(value: &str) -> String {
if !value.is_empty()
&& value
.chars()
.all(|ch| ch.is_ascii_alphanumeric() || matches!(ch, '.' | '_' | '-' | '/' | ':'))
{
value.to_string()
} else {
format!("'{}'", value.replace('\'', "'\\''"))
}
}
fn find<'a>(registry: &'a PluginRegistry, locale: Locale, name: &str) -> Result<&'a LoadedPlugin> {
registry.get(name).ok_or_else(|| {
anyhow!(tr(locale, MessageId::CmdPluginBundleNotFound).replace("{name}", &text(name)))
})
}
fn command_line_approver(registry: &PluginRegistry, marked: bool) -> PluginApprover {
PluginApprover::command_line(marked.then(|| registry.workspace()))
}
fn host_service_lines(
registry: &PluginRegistry,
plugin: &LoadedPlugin,
marked: bool,
) -> Vec<String> {
let approver = command_line_approver(registry, marked);
let mut lines = registry
.host_service_review_for(plugin.name(), &approver)
.map(|review| review.text())
.unwrap_or_default();
if !lines.is_empty() && !marked {
lines.push("A trust from the command line grants none of these host services.".to_string());
}
lines
}
fn review_text(
registry: &PluginRegistry,
plugin: &LoadedPlugin,
locale: Locale,
marked: bool,
) -> String {
let mut output = render_bundle_detail(
&mut Presentation(locale),
&portable_detail(plugin),
true,
ReviewTextContext::Plain,
);
if !output.ends_with('\n') {
output.push('\n');
}
for line in host_service_lines(registry, plugin, marked) {
output.push_str(&text(&line));
output.push('\n');
}
output.push_str(&format!("Bundle review token: {}", plugin.review_token()));
output
}
fn trust_command(plugin: &LoadedPlugin) -> String {
format!(
"codewhale plugins trust {} --review-token {} --yes",
shell_word(&text(plugin.name())),
plugin.review_token()
)
}
fn review_json(
registry: &PluginRegistry,
plugin: &LoadedPlugin,
locale: Locale,
marked: bool,
) -> serde_json::Value {
let mut record = crate::plugin_cli_record(plugin);
if let Some(object) = record.as_object_mut() {
object.insert("action".to_string(), "review".into());
object.insert("trusted_by_this_command".to_string(), false.into());
object.insert(
"bundle_review_token".to_string(),
plugin.review_token().into(),
);
object.insert(
"review".to_string(),
review_text(registry, plugin, locale, marked).into(),
);
}
record
}
fn trust_as(
registry: &mut Arc<PluginRegistry>,
locale: Locale,
name: &str,
approver: PluginApprover,
) -> Result<()> {
let host_services = match approver {
PluginApprover::Agent(_) => {
installed_by_the_agent(find(registry, locale, name)?, registry.workspace())?;
registry
.host_service_review_for(name, &approver)
.map(|review| review.token)
}
PluginApprover::Person { .. } | PluginApprover::CommandLine => None,
};
Arc::make_mut(registry)
.trust_reviewed(
name,
ReviewApproval {
approved_by: approver,
host_services,
},
)
.map_err(|error| failed(locale, &error))
}
fn installed_by_the_agent(plugin: &LoadedPlugin, project: &Path) -> Result<()> {
let source = std::fs::read_to_string(plugin.canonical_root.join(INSTALLED_FROM))
.ok()
.and_then(|body| serde_json::from_str::<serde_json::Value>(&body).ok())
.and_then(|marker| marker.get("spec")?.as_str().map(str::to_string))
.and_then(|spec| PluginInstallSource::parse(&spec).ok());
let folder = match source {
Some(PluginInstallSource::LocalPath(folder) | PluginInstallSource::Dsh(folder))
if folder.is_absolute() =>
{
Some(folder)
}
_ => None,
};
let inside = folder.is_some_and(|folder| {
match (
std::fs::canonicalize(folder),
std::fs::canonicalize(project),
) {
(Ok(folder), Ok(project)) => folder.starts_with(project),
_ => false,
}
});
if !inside {
bail!(
"The agent may approve only a plugin it installed with `codewhale install` from a folder inside its project, and `{}` was not installed that way. Nothing was changed; ask the person to review it.",
text(plugin.name())
);
}
Ok(())
}
fn failed(locale: Locale, error: &str) -> anyhow::Error {
anyhow!(tr(locale, MessageId::CmdPluginActionFailed).replace("{error}", &text(error)))
}
fn report(
registry: &PluginRegistry,
locale: Locale,
name: &str,
action: &str,
json: bool,
console: &mut dyn Console,
) -> Result<()> {
let plugin = find(registry, locale, name)?;
let inactive = plugin
.inventory
.unsupported_labels()
.into_iter()
.map(str::to_string)
.collect::<Vec<_>>();
if json {
console.print(&serde_json::to_string_pretty(&serde_json::json!({
"name": text(plugin.name()),
"action": action,
"state": plugin.state_label(),
"trust": plugin.trust_status.as_str(),
"enabled": plugin.enabled,
"active": plugin.active(),
"approved_by": plugin.approved_by,
"enabled_by": plugin.enabled_by,
"host_services": plugin.host_services,
"content_hash": plugin.content_hash,
"capability_hash": plugin.capability_hash,
"inactive_components": inactive,
}))?);
} else {
console.print(
&tr(locale, MessageId::CmdPluginBundleMutationSuccess)
.replace("{name}", &text(plugin.name()))
.replace("{action}", action),
);
if action == "trusted" {
console.print(&format!(
"Recorded as {}.",
approver_label(plugin.approved_by.as_ref())
));
}
if action == "enabled" && !inactive.is_empty() {
console.print(&format!(
"Compatibility: {}. Supported declarative components are active; inactive: {}.",
plugin.compatibility().as_str(),
inactive.join(", ")
));
}
}
Ok(())
}
pub(crate) struct TrustRequest {
pub name: String,
pub review_token: Option<String>,
pub yes: bool,
pub json: bool,
}
pub(crate) fn trust(
registry: &mut Arc<PluginRegistry>,
locale: Locale,
request: TrustRequest,
console: &mut dyn Console,
) -> Result<()> {
let TrustRequest {
name,
review_token,
yes,
json,
} = request;
let marked = console.agent_marked();
let plugin = find(registry, locale, &name)?;
let review_again = format!(
"run `codewhale plugins trust {}` and read the new review",
shell_word(&text(&name))
);
let prompted = if marked {
command_line_approver(registry, true)
} else {
PluginApprover::person(PersonVia::Terminal)
};
let Some(presented) = review_token else {
if yes {
bail!(
"`--yes` only skips the confirmation prompt. Trust also needs --review-token, the bundle review token that names the exact content to trust; to see it, {review_again}"
);
}
if json {
console.print(&serde_json::to_string_pretty(&review_json(
registry, plugin, locale, marked,
))?);
return Ok(());
}
console.print(&review_text(registry, plugin, locale, marked));
if !console.interactive() {
console.print(&format!(
"\nNot trusted. The bundle review token ties trust to exactly the content shown above; it does not record that anyone read this review. A trust made with it is recorded as {}. To trust, run:\n {}",
approver_label(Some(&command_line_approver(registry, marked))),
trust_command(plugin)
));
return Ok(());
}
if !console.confirm("\nTrust this bundle with exactly the capabilities above?") {
console.print("Not trusted.");
return Ok(());
}
trust_as(registry, locale, &name, prompted)?;
return report(registry, locale, &name, "trusted", json, console);
};
if presented.split('.').next() != Some(plugin.content_hash.as_str()) {
bail!(
"Plugin bundle `{}` content changed since this bundle review token was issued (current content hash {}); {review_again}",
text(&name),
plugin.content_hash,
);
}
if presented != plugin.review_token() {
bail!(
"Bundle review token does not match this bundle's current content and capability set; {review_again}"
);
}
let approver = if yes {
command_line_approver(registry, marked)
} else {
if json || !console.interactive() {
bail!(
"Trust needs confirmation: pass --yes with the bundle review token, or run in a terminal to be shown the review and asked"
);
}
console.print(&review_text(registry, plugin, locale, marked));
if !console.confirm("\nTrust this bundle with exactly the capabilities above?") {
console.print("Not trusted.");
return Ok(());
}
prompted
};
trust_as(registry, locale, &name, approver)?;
report(registry, locale, &name, "trusted", json, console)
}
pub(crate) fn enable(
registry: &mut Arc<PluginRegistry>,
locale: Locale,
name: &str,
json: bool,
console: &mut dyn Console,
) -> Result<()> {
let plugin = find(registry, locale, name)?;
if !plugin.trusted() {
if let Some(error) = registry.state_error() {
bail!(
"Plugin state could not be read, so the trust of `{}` is unknown and nothing was changed: {}",
text(name),
text(error)
);
}
bail!(
"Plugin bundle `{}` is not trusted (trust: {}), so it cannot be enabled. Review it first with `codewhale plugins trust {}`.",
text(name),
plugin.trust_status.as_str(),
shell_word(&text(name))
);
}
let approver = command_line_approver(registry, console.agent_marked());
Arc::make_mut(registry)
.enable_by(name, approver)
.map_err(|error| failed(locale, &error))?;
report(registry, locale, name, "enabled", json, console)
}
pub(crate) fn disable(
registry: &mut Arc<PluginRegistry>,
locale: Locale,
name: &str,
json: bool,
console: &mut dyn Console,
) -> Result<()> {
find(registry, locale, name)?;
Arc::make_mut(registry)
.disable(name)
.map_err(|error| failed(locale, &error))?;
report(registry, locale, name, "disabled", json, console)
}
pub(crate) fn revoke(
registry: &mut Arc<PluginRegistry>,
locale: Locale,
name: &str,
json: bool,
console: &mut dyn Console,
) -> Result<()> {
find(registry, locale, name)?;
Arc::make_mut(registry)
.revoke_trust(name)
.map_err(|error| failed(locale, &error))?;
report(registry, locale, name, "trust-revoked", json, console)
}
@@ -41,6 +41,7 @@ use crate::tui::app::App;
use crate::tui::app::AppAction;
pub(crate) mod dsh_import;
pub(crate) mod headless;
mod kimi_import;
pub(crate) mod legacy;
pub(crate) mod marketplace;
@@ -1069,3 +1069,441 @@ fn doctor_reports_read_only_then_fix_retires_stale_records_with_a_backup() {
assert!(usage.is_error);
assert!(usage.message.unwrap().contains("Usage: /plugin doctor"));
}
#[derive(Default)]
struct ScriptedConsole {
interactive: bool,
agent_marked: bool,
answer: bool,
printed: Vec<String>,
prompts: Vec<(String, usize)>,
}
impl headless::Console for ScriptedConsole {
fn interactive(&self) -> bool {
self.interactive
}
fn agent_marked(&self) -> bool {
self.agent_marked
}
fn print(&mut self, text: &str) {
self.printed.push(text.to_string());
}
fn confirm(&mut self, prompt: &str) -> bool {
self.prompts.push((prompt.to_string(), self.printed.len()));
self.answer
}
}
fn mark_installed_from(bundle: &Path, source: &Path) {
fs::create_dir_all(source).unwrap();
fs::write(
bundle.join(".installed-from"),
serde_json::json!({ "spec": format!("path:{}", source.display()) }).to_string(),
)
.unwrap();
}
fn write_skill_bundle(root: &Path, name: &str, asks: &str) -> std::path::PathBuf {
let bundle = root.join(".codewhale/plugins").join(name);
fs::create_dir_all(bundle.join("skills/hello")).unwrap();
fs::write(
bundle.join("plugin.toml"),
format!(
"schema_version = 1\n[plugin]\nname = {name:?}\nversion = \"1.0.0\"\n[skills]\npath = \"skills\"\n{asks}"
),
)
.unwrap();
fs::write(
bundle.join("skills/hello/SKILL.md"),
"---\nname: hello\ndescription: hi\n---\nbody\n",
)
.unwrap();
bundle
}
fn headless_trust(
registry: &mut std::sync::Arc<codewhale_plugins::PluginRegistry>,
review_token: Option<&str>,
yes: bool,
json: bool,
console: &mut ScriptedConsole,
) -> anyhow::Result<()> {
headless_trust_named(registry, "review-mcp", review_token, yes, json, console)
}
fn headless_trust_named(
registry: &mut std::sync::Arc<codewhale_plugins::PluginRegistry>,
name: &str,
review_token: Option<&str>,
yes: bool,
json: bool,
console: &mut ScriptedConsole,
) -> anyhow::Result<()> {
headless::trust(
registry,
Locale::En,
headless::TrustRequest {
name: name.to_string(),
review_token: review_token.map(str::to_string),
yes,
json,
},
console,
)
}
#[test]
fn headless_review_is_the_terminal_review_and_a_terminal_asks_once() {
let _lock = crate::test_support::lock_test_env();
let root = TempDir::new().unwrap();
let _home = crate::test_support::EnvVarGuard::set("CODEWHALE_HOME", root.path().join("home"));
write_mcp_review_bundle(root.path());
let (mut app, _temp) = create_test_app(root.path());
let Some(AppAction::OpenCommandReview { content, .. }) =
plugins_with_kimi_home_override(&mut app, Some("trust review-mcp"), None).action
else {
panic!("trust opens a confirmation control");
};
assert!(content.contains("Activation boundary:"), "{content}");
assert!(content.contains("timeouts="), "{content}");
let mut registry = app.plugin_registry.clone();
let token = registry.get("review-mcp").unwrap().review_token();
let mut piped = ScriptedConsole::default();
headless_trust(&mut registry, None, false, false, &mut piped).unwrap();
assert!(piped.prompts.is_empty());
assert!(
piped.printed[0].starts_with(&content),
"{:?}",
piped.printed
);
assert!(piped.printed[0].ends_with(&format!("Bundle review token: {token}")));
assert!(piped.printed[1].contains("does not record that anyone read"));
assert!(!registry.get("review-mcp").unwrap().trusted());
let mut declined = ScriptedConsole {
interactive: true,
..ScriptedConsole::default()
};
headless_trust(&mut registry, None, false, false, &mut declined).unwrap();
assert_eq!(declined.prompts.len(), 1);
assert!(!registry.get("review-mcp").unwrap().trusted());
let mut missing_yes = ScriptedConsole::default();
let error = headless_trust(&mut registry, Some(&token), false, false, &mut missing_yes)
.unwrap_err()
.to_string();
assert!(error.contains("--yes"), "{error}");
assert!(missing_yes.prompts.is_empty());
let mut json_never_prompts = ScriptedConsole {
interactive: true,
answer: true,
..ScriptedConsole::default()
};
assert!(
headless_trust(
&mut registry,
Some(&token),
false,
true,
&mut json_never_prompts
)
.is_err()
);
assert!(json_never_prompts.prompts.is_empty());
assert!(!registry.get("review-mcp").unwrap().trusted());
let mut accepted = ScriptedConsole {
interactive: true,
answer: true,
..ScriptedConsole::default()
};
headless_trust(&mut registry, None, false, false, &mut accepted).unwrap();
assert_eq!(accepted.prompts.len(), 1, "one question, one answer");
assert_eq!(
accepted.prompts[0].1, 1,
"the review is printed before the question"
);
assert!(accepted.printed[0].starts_with(&content));
assert!(registry.get("review-mcp").unwrap().trusted());
assert!(!registry.get("review-mcp").unwrap().enabled);
std::sync::Arc::make_mut(&mut registry)
.revoke_trust("review-mcp")
.unwrap();
let mut with_token = ScriptedConsole {
interactive: true,
answer: true,
..ScriptedConsole::default()
};
headless_trust(&mut registry, Some(&token), false, false, &mut with_token).unwrap();
assert_eq!(with_token.prompts.len(), 1);
assert_eq!(
with_token.prompts[0].1, 1,
"a token alone never hides the review"
);
assert!(with_token.printed[0].starts_with(&content));
assert!(registry.get("review-mcp").unwrap().trusted());
}
#[test]
fn headless_trust_records_the_command_line_the_person_or_the_marked_agent() {
use codewhale_plugins::host_services::{PersonVia, PluginApprover};
let _lock = crate::test_support::lock_test_env();
let root = TempDir::new().unwrap();
let _home = crate::test_support::EnvVarGuard::set("CODEWHALE_HOME", root.path().join("home"));
write_mcp_review_bundle(root.path());
mark_installed_from(
&root.path().join(".codewhale/plugins/review-mcp"),
&root.path().join("src/review-mcp"),
);
let (app, _temp) = create_test_app(root.path());
let mut registry = app.plugin_registry.clone();
let token = registry.get("review-mcp").unwrap().review_token();
let marked_agent = PluginApprover::command_line(Some(registry.workspace()));
let approved_by = |registry: &codewhale_plugins::PluginRegistry| {
registry.get("review-mcp").unwrap().approved_by.clone()
};
let enabled_by = |registry: &codewhale_plugins::PluginRegistry| {
registry.get("review-mcp").unwrap().enabled_by.clone()
};
let mut unmarked = ScriptedConsole::default();
headless_trust(&mut registry, Some(&token), true, false, &mut unmarked).unwrap();
assert_eq!(approved_by(&registry), Some(PluginApprover::CommandLine));
assert!(
unmarked
.printed
.iter()
.any(|line| line.contains("trusted from the command line with --yes")),
"{:?}",
unmarked.printed
);
headless::enable(
&mut registry,
Locale::En,
"review-mcp",
false,
&mut unmarked,
)
.unwrap();
assert_eq!(enabled_by(&registry), Some(PluginApprover::CommandLine));
let mut person = ScriptedConsole {
interactive: true,
answer: true,
..ScriptedConsole::default()
};
headless_trust(&mut registry, None, false, false, &mut person).unwrap();
assert_eq!(
approved_by(&registry),
Some(PluginApprover::person(PersonVia::Terminal))
);
assert!(!registry.get("review-mcp").unwrap().enabled);
headless::revoke(
&mut registry,
Locale::En,
"review-mcp",
false,
&mut unmarked,
)
.unwrap();
let marked = || ScriptedConsole {
agent_marked: true,
..ScriptedConsole::default()
};
let mut review = marked();
headless_trust(&mut registry, None, false, false, &mut review).unwrap();
assert!(
review.printed[1].contains("approved by the agent from a shell command"),
"{:?}",
review.printed
);
assert_eq!(approved_by(&registry), None);
let mut agent = marked();
headless_trust(&mut registry, Some(&token), true, true, &mut agent).unwrap();
assert_eq!(approved_by(&registry), Some(marked_agent.clone()));
assert!(agent.prompts.is_empty());
let reported: serde_json::Value = serde_json::from_str(&agent.printed[0]).unwrap();
assert_eq!(reported["approved_by"]["kind"], "agent");
assert_eq!(reported["approved_by"]["posture"], "command_line");
assert_eq!(
reported["approved_by"]["workspace"],
registry.workspace().to_string_lossy().as_ref()
);
headless::enable(&mut registry, Locale::En, "review-mcp", false, &mut agent).unwrap();
assert_eq!(enabled_by(&registry), Some(marked_agent));
assert!(registry.get("review-mcp").unwrap().active());
headless::disable(&mut registry, Locale::En, "review-mcp", false, &mut agent).unwrap();
headless::revoke(&mut registry, Locale::En, "review-mcp", false, &mut agent).unwrap();
assert!(!registry.get("review-mcp").unwrap().trusted());
}
#[test]
fn headless_trust_passes_the_host_service_token_only_for_the_marked_agent() {
use codewhale_plugins::host_services::{HostService, PluginApprover, TestOffer};
let _lock = crate::test_support::lock_test_env();
let _offer = TestOffer::only(&[HostService::Network]);
let root = TempDir::new().unwrap();
let _home = crate::test_support::EnvVarGuard::set("CODEWHALE_HOME", root.path().join("home"));
let bundle = write_skill_bundle(
root.path(),
"asks-network",
"[capabilities]\nhost_services = [\"network\"]\n",
);
mark_installed_from(&bundle, &root.path().join("src/asks-network"));
let (app, _temp) = create_test_app(root.path());
let mut registry = app.plugin_registry.clone();
let plugin = registry.get("asks-network").unwrap();
assert_eq!(plugin.inventory.host_services, vec![HostService::Network]);
let token = plugin.review_token();
let host_service_token = registry
.host_service_review_for("asks-network", &PluginApprover::CommandLine)
.unwrap()
.token;
assert_ne!(token, host_service_token);
let mut unmarked = ScriptedConsole::default();
headless_trust_named(
&mut registry,
"asks-network",
None,
false,
false,
&mut unmarked,
)
.unwrap();
let review = &unmarked.printed[0];
assert!(review.contains("Network"), "{review}");
assert!(
review.contains("grants none of these host services"),
"{review}"
);
assert!(
review.contains(&format!("Bundle review token: {token}")),
"{review}"
);
assert!(!review.contains(&host_service_token), "{review}");
headless_trust_named(
&mut registry,
"asks-network",
Some(&token),
true,
false,
&mut unmarked,
)
.unwrap();
let plugin = registry.get("asks-network").unwrap();
assert_eq!(plugin.approved_by, Some(PluginApprover::CommandLine));
assert!(plugin.host_services.is_empty());
headless::revoke(
&mut registry,
Locale::En,
"asks-network",
false,
&mut unmarked,
)
.unwrap();
let mut marked = ScriptedConsole {
agent_marked: true,
..ScriptedConsole::default()
};
headless_trust_named(
&mut registry,
"asks-network",
Some(&token),
true,
false,
&mut marked,
)
.unwrap();
let plugin = registry.get("asks-network").unwrap();
assert_eq!(
plugin.approved_by,
Some(PluginApprover::command_line(Some(registry.workspace())))
);
assert_eq!(plugin.host_services, vec![HostService::Network]);
}
#[test]
fn headless_marked_trust_needs_an_install_from_a_folder_in_the_project() {
use codewhale_plugins::host_services::PluginApprover;
let _lock = crate::test_support::lock_test_env();
let root = TempDir::new().unwrap();
let elsewhere = TempDir::new().unwrap();
let _home = crate::test_support::EnvVarGuard::set("CODEWHALE_HOME", root.path().join("home"));
write_skill_bundle(root.path(), "hand-placed", "");
let outside = write_skill_bundle(root.path(), "from-outside", "");
mark_installed_from(&outside, &elsewhere.path().join("src"));
let remote = write_skill_bundle(root.path(), "from-github", "");
fs::write(
remote.join(".installed-from"),
r#"{"spec":"github:someone/plugin"}"#,
)
.unwrap();
let inside = write_skill_bundle(root.path(), "from-inside", "");
mark_installed_from(&inside, &root.path().join("src/from-inside"));
let (app, _temp) = create_test_app(root.path());
let mut registry = app.plugin_registry.clone();
let marked = || ScriptedConsole {
agent_marked: true,
interactive: true,
answer: true,
..ScriptedConsole::default()
};
for name in ["hand-placed", "from-outside", "from-github"] {
let token = registry.get(name).unwrap().review_token();
for review_token in [Some(token.as_str()), None] {
let error = headless_trust_named(
&mut registry,
name,
review_token,
review_token.is_some(),
false,
&mut marked(),
)
.unwrap_err()
.to_string();
assert!(error.contains("ask the person"), "{name}: {error}");
assert!(!registry.get(name).unwrap().trusted(), "{name}");
}
headless_trust_named(
&mut registry,
name,
Some(&token),
true,
false,
&mut ScriptedConsole::default(),
)
.unwrap();
assert_eq!(
registry.get(name).unwrap().approved_by,
Some(PluginApprover::CommandLine)
);
}
let token = registry.get("from-inside").unwrap().review_token();
headless_trust_named(
&mut registry,
"from-inside",
Some(&token),
true,
false,
&mut marked(),
)
.unwrap();
assert_eq!(
registry.get("from-inside").unwrap().approved_by,
Some(PluginApprover::command_line(Some(registry.workspace())))
);
}
+95 -2
View File
@@ -147,7 +147,7 @@ enum Commands {
SessionDiagnostics(SessionDiagnosticsArgs),
#[command(about = "Install a local, GitHub or version-pinned npm plugin bundle")]
Install { source: String },
#[command(about = "Inspect or restage reviewed plugin bundles")]
#[command(about = "Inspect, review, trust, enable, disable or revoke plugin bundles")]
Plugins {
#[command(subcommand)]
command: PluginsCommand,
@@ -285,6 +285,49 @@ enum PluginsCommand {
#[arg(long)]
json: bool,
},
#[command(
about = "Show a plugin's capability review, or trust the exact content a review token names",
long_about = "Without --review-token, prints the capability review and its bundle review token and changes nothing; in a terminal it then asks whether to trust. With --review-token, trusts only if the bundle review token still matches the installed content and capability set. That token ties trust to the exact content; it is not a record that anyone read the review, and it is not the host-service review token, which this command never asks for. A trust made with --yes is recorded as the command line, and an answered prompt as you. When the process carries the agent's mark (CODEWHALE_AGENT_PROJECT, set on every command the agent runs), the trust is the agent's, for that project only, and only for a plugin installed from a folder inside it. Trust never enables the plugin."
)]
Trust {
#[arg(value_name = "NAME", help = "Plugin bundle name")]
name: String,
#[arg(
long,
value_name = "TOKEN",
help = "The bundle review token (<content-hash>.<capability-hash>) printed by the review; trust is refused if the content or capability set has changed"
)]
review_token: Option<String>,
#[arg(
long,
short = 'y',
help = "Skip the confirmation prompt; needs --review-token"
)]
yes: bool,
#[arg(long, help = "Print the review or the result as JSON; never prompts")]
json: bool,
},
#[command(about = "Enable a trusted plugin")]
Enable {
#[arg(value_name = "NAME", help = "Plugin bundle name")]
name: String,
#[arg(long, help = "Print the result as JSON")]
json: bool,
},
#[command(about = "Disable a plugin; its trust is kept")]
Disable {
#[arg(value_name = "NAME", help = "Plugin bundle name")]
name: String,
#[arg(long, help = "Print the result as JSON")]
json: bool,
},
#[command(about = "Revoke a plugin's trust; it stays inactive until reviewed again")]
Revoke {
#[arg(value_name = "NAME", help = "Plugin bundle name")]
name: String,
#[arg(long, help = "Print the result as JSON")]
json: bool,
},
}
/// Subcommands of `codewhale sessions`. Without one, the command falls back
@@ -2762,7 +2805,8 @@ async fn run_async_main_dispatch(
}
Commands::Plugins { command } => tokio::task::spawn_blocking(move || {
let _config = load_structural_config_from_cli(&cli)?;
let workspace = resolve_workspace(&cli);
let workspace = crate::commands::groups::plugins::headless::agent_project()
.unwrap_or_else(|| resolve_workspace(&cli));
let registry = plugin_discovery.inspect_registry_for_workspace(&workspace);
run_plugins_cli(command, registry)
})
@@ -9356,6 +9400,9 @@ fn plugin_cli_record(plugin: &codewhale_plugins::types::LoadedPlugin) -> serde_j
"trust": plugin.trust_status.as_str(),
"enabled": plugin.enabled,
"active": plugin.active(),
"approved_by": plugin.approved_by,
"enabled_by": plugin.enabled_by,
"outside_reach": plugin.outside_reach,
"staged": plugin.staged_root.is_some(),
"applicable": plugin.applicable,
"compatibility": plugin.compatibility().as_str(),
@@ -9389,6 +9436,7 @@ fn run_plugins_cli(
command: PluginsCommand,
mut registry: Arc<codewhale_plugins::PluginRegistry>,
) -> Result<()> {
use crate::commands::groups::plugins::headless;
use codewhale_localization::{MessageId, resolve_locale, tr};
let settings = codewhale_runtime::settings::Settings::load_read_only().unwrap_or_default();
let locale = resolve_locale(&settings.locale);
@@ -9452,6 +9500,14 @@ fn run_plugins_cli(
plugin.content_hash,
plugin.capability_hash
);
if plugin.trusted() {
println!(
" {}",
codewhale_plugins::host_services::approver_label(
plugin.approved_by.as_ref()
)
);
}
for diagnostic in &plugin.diagnostics {
print_plugin_cli_diagnostic(diagnostic);
}
@@ -9468,6 +9524,43 @@ fn run_plugins_cli(
}
}
}
PluginsCommand::Trust {
name,
review_token,
yes,
json,
} => headless::trust(
&mut registry,
locale,
headless::TrustRequest {
name,
review_token,
yes,
json,
},
&mut headless::ProcessConsole,
)?,
PluginsCommand::Enable { name, json } => headless::enable(
&mut registry,
locale,
&name,
json,
&mut headless::ProcessConsole,
)?,
PluginsCommand::Disable { name, json } => headless::disable(
&mut registry,
locale,
&name,
json,
&mut headless::ProcessConsole,
)?,
PluginsCommand::Revoke { name, json } => headless::revoke(
&mut registry,
locale,
&name,
json,
&mut headless::ProcessConsole,
)?,
PluginsCommand::Restage { name, json } => {
Arc::make_mut(&mut registry)
.restage(&name)
+1
View File
@@ -9572,6 +9572,7 @@ fn saved_session_with_messages(messages: Vec<Message>) -> SavedSession {
composer_color: None,
last_auto_route: None,
turn_outcomes: Vec::new(),
agent_preset: None,
}
}
+1
View File
@@ -28,6 +28,7 @@ mod integration_mock_llm;
mod issue_report_acceptance;
mod lifecycle_outbox_exec;
mod palette_audit;
mod plugin_trust_cli;
mod protocol_recovery;
mod reasoning_content_replayed_after_tool_call;
mod shell_denial_acceptance;
@@ -0,0 +1,466 @@
use std::path::Path;
use std::process::{Command, Output, Stdio};
use serde_json::Value;
use tempfile::TempDir;
struct Fixture {
home: TempDir,
workspace: TempDir,
}
impl Fixture {
fn new() -> Self {
let home = TempDir::new().expect("home");
std::fs::create_dir_all(home.path().join(".codewhale")).expect("config dir");
Self {
home,
workspace: TempDir::new().expect("workspace"),
}
}
fn marked(&self) -> [(&'static str, &str); 1] {
[(
"CODEWHALE_AGENT_PROJECT",
self.workspace.path().to_str().expect("utf-8 temp path"),
)]
}
fn run_with(&self, args: &[&str], env: &[(&str, &str)]) -> Output {
self.run_in(self.workspace.path(), args, env)
}
fn status_in(&self, workspace: &Path) -> Value {
let output = self.run_in(workspace, &["plugins", "status", "demo", "--json"], &[]);
assert!(output.status.success(), "{output:?}");
let value: Value = serde_json::from_slice(&output.stdout).expect("status json");
value["plugins"][0].clone()
}
fn run_in(&self, workspace: &Path, args: &[&str], env: &[(&str, &str)]) -> Output {
let mut command = Command::new(crate::binary::codewhale());
command.env_clear();
for key in [
"PATH",
"TMPDIR",
"TEMP",
"TMP",
"TERM",
"LANG",
"LC_ALL",
"SystemRoot",
"WINDIR",
] {
if let Some(value) = std::env::var_os(key) {
command.env(key, value);
}
}
command
.current_dir(workspace)
.arg("--workspace")
.arg(workspace)
.arg("--no-project-config")
.args(args)
.env("HOME", self.home.path())
.env("USERPROFILE", self.home.path())
.envs(env.iter().copied())
.env("XDG_CONFIG_HOME", self.home.path().join(".config"))
.env(
"CODEWHALE_CONFIG_PATH",
self.home.path().join(".codewhale").join("config.toml"),
)
.env("RUST_LOG", "warn")
.stdin(Stdio::null())
.output()
.expect("run codewhale")
}
fn ok(&self, args: &[&str]) -> Output {
self.ok_with(args, &[])
}
fn ok_with(&self, args: &[&str], env: &[(&str, &str)]) -> Output {
let output = self.run_with(args, env);
assert!(
output.status.success(),
"{args:?} failed\nstdout:\n{}\nstderr:\n{}",
String::from_utf8_lossy(&output.stdout),
String::from_utf8_lossy(&output.stderr)
);
output
}
fn refused(&self, args: &[&str], needle: &str) {
self.refused_with(args, &[], needle);
}
fn refused_with(&self, args: &[&str], env: &[(&str, &str)], needle: &str) -> String {
let output = self.run_with(args, env);
let stderr = String::from_utf8_lossy(&output.stderr);
assert!(
!output.status.success(),
"{args:?} must be refused\nstdout:\n{}",
String::from_utf8_lossy(&output.stdout)
);
assert!(
stderr.contains(needle),
"{args:?}: expected `{needle}` in:\n{stderr}"
);
stderr.into_owned()
}
fn status(&self) -> Value {
let output = self.ok(&["plugins", "status", "demo", "--json"]);
let value: Value = serde_json::from_slice(&output.stdout).expect("status json");
value["plugins"][0].clone()
}
}
fn write_bundle(parent: &Path) -> std::path::PathBuf {
let source = parent.join("demo");
std::fs::create_dir_all(source.join("skills").join("hello")).unwrap();
std::fs::write(
source.join("plugin.toml"),
"schema_version = 1\n[plugin]\nname = \"demo\"\nversion = \"1.0.0\"\n[skills]\npath = \"skills\"\n",
)
.unwrap();
std::fs::write(
source.join("skills").join("hello").join("SKILL.md"),
"---\nname: hello\ndescription: hi\n---\nbody\n",
)
.unwrap();
source
}
fn review(fixture: &Fixture) -> String {
let output = fixture.ok(&["plugins", "trust", "demo", "--json"]);
let value: Value = serde_json::from_slice(&output.stdout).expect("review json");
assert!(
value["review"]
.as_str()
.is_some_and(|review| review.contains("Activation boundary:")),
"{value}"
);
value["bundle_review_token"].as_str().unwrap().to_string()
}
#[test]
fn headless_refusals_need_no_terminal_and_change_nothing() {
let fixture = Fixture::new();
write_bundle(&fixture.home.path().join(".codewhale").join("plugins"));
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let state_dir = fixture.home.path().join(".codewhale").join("plugins");
let mode = |mode| {
std::fs::set_permissions(&state_dir, std::fs::Permissions::from_mode(mode)).unwrap()
};
mode(0o755);
let unreadable = fixture.refused_with(&["plugins", "enable", "demo"], &[], "is unknown");
assert!(!unreadable.contains("plugins trust"), "{unreadable}");
mode(0o700);
}
assert_ne!(fixture.status()["trust"], "trusted");
fixture.refused(
&["plugins", "enable", "demo"],
if cfg!(unix) { "is not trusted" } else { "demo" },
);
let text = fixture.ok(&["plugins", "trust", "demo"]);
let text = String::from_utf8_lossy(&text.stdout);
assert!(text.contains("Activation boundary:"), "{text}");
assert!(text.contains("does not record that anyone read"), "{text}");
let token = review(&fixture);
assert!(
text.contains(&format!("Bundle review token: {token}")),
"{text}"
);
assert!(
text.contains("trusted from the command line with --yes"),
"{text}"
);
let (content, capability) = token.split_once('.').expect("two digests");
fixture.refused(&["plugins", "trust", "demo", "--yes"], "--review-token");
fixture.refused(
&["plugins", "trust", "demo", "--review-token", &token],
"Trust needs confirmation",
);
fixture.refused(
&[
"plugins",
"trust",
"demo",
"--review-token",
&format!("{content}.{}", "0".repeat(64)),
"--yes",
],
"Bundle review token does not match",
);
fixture.refused(
&[
"plugins",
"trust",
"demo",
"--review-token",
&format!("{}.{capability}", "0".repeat(64)),
"--yes",
],
"content changed",
);
fixture.refused_with(
&["plugins", "enable", "demo"],
&fixture.marked(),
if cfg!(unix) { "is not trusted" } else { "demo" },
);
let marked_review = fixture.ok_with(&["plugins", "trust", "demo"], &fixture.marked());
let marked_review = String::from_utf8_lossy(&marked_review.stdout);
assert!(
marked_review.contains("approved by the agent from a shell command"),
"{marked_review}"
);
let plugin = fixture.status();
assert_ne!(plugin["trust"], "trusted");
assert_eq!(plugin["enabled"], false);
}
#[cfg(unix)]
#[test]
fn headless_install_review_trust_enable_disable_revoke_journey() {
let fixture = Fixture::new();
let source = write_bundle(&fixture.workspace.path().join("source"));
fixture.ok(&["install", source.to_str().unwrap()]);
let plugin = fixture.status();
assert_eq!(plugin["enabled"], false);
assert_ne!(plugin["trust"], "trusted");
fixture.refused(&["plugins", "enable", "demo"], "codewhale plugins trust");
let token = review(&fixture);
assert_ne!(
fixture.status()["trust"],
"trusted",
"review alone must not trust"
);
let trust = [
"plugins",
"trust",
"demo",
"--review-token",
&token,
"--yes",
];
fixture.ok(&trust);
let plugin = fixture.status();
assert_eq!(plugin["trust"], "trusted");
assert_eq!(plugin["enabled"], false);
assert_eq!(plugin["approved_by"]["kind"], "command_line");
fixture.refused_with(
&["plugins", "enable", "demo"],
&fixture.marked(),
"approved for every project",
);
assert_eq!(fixture.status()["enabled"], false);
fixture.ok(&["plugins", "enable", "demo"]);
let plugin = fixture.status();
assert_eq!(plugin["enabled"], true);
assert_eq!(plugin["active"], true);
assert_eq!(plugin["enabled_by"]["kind"], "command_line");
fixture.ok(&["plugins", "disable", "demo"]);
assert_eq!(fixture.status()["enabled"], false);
let skill = fixture
.home
.path()
.join(".codewhale/plugins/demo/skills/hello/SKILL.md");
std::fs::write(&skill, "---\nname: hello\ndescription: hi\n---\nchanged\n").unwrap();
fixture.refused(&trust, "content changed");
assert_ne!(fixture.status()["trust"], "trusted");
let token = review(&fixture);
fixture.ok_with(
&[
"plugins",
"trust",
"demo",
"--review-token",
&token,
"--yes",
],
&fixture.marked(),
);
let plugin = fixture.status();
assert_eq!(plugin["approved_by"]["kind"], "agent");
assert_eq!(plugin["approved_by"]["posture"], "command_line");
assert_eq!(plugin["outside_reach"], false);
fixture.ok_with(&["plugins", "enable", "demo"], &fixture.marked());
let plugin = fixture.status();
assert_eq!(plugin["active"], true);
assert_eq!(plugin["enabled_by"]["kind"], "agent");
fixture.ok(&["plugins", "revoke", "demo"]);
let plugin = fixture.status();
assert_ne!(plugin["trust"], "trusted");
assert_eq!(plugin["active"], false);
}
#[cfg(unix)]
fn trust_args(token: &str) -> [&str; 6] {
["plugins", "trust", "demo", "--review-token", token, "--yes"]
}
#[cfg(unix)]
#[test]
fn an_agents_trust_and_enable_reach_only_its_own_project() {
let fixture = Fixture::new();
let source = write_bundle(&fixture.workspace.path().join("source"));
fixture.ok(&["install", source.to_str().unwrap()]);
let other = TempDir::new().expect("other project");
let token = review(&fixture);
fixture.ok_with(&trust_args(&token), &fixture.marked());
fixture.ok_with(&["plugins", "enable", "demo"], &fixture.marked());
let here = fixture.status();
assert_eq!(here["approved_by"]["kind"], "agent", "{here}");
assert_eq!(here["enabled_by"]["kind"], "agent", "{here}");
assert_eq!(here["active"], true, "{here}");
let there = fixture.status_in(other.path());
assert_eq!(there["outside_reach"], true, "{there}");
assert_eq!(there["active"], false, "{there}");
}
#[cfg(unix)]
#[test]
fn the_agent_trusts_only_a_plugin_it_installed_from_its_project() {
let fixture = Fixture::new();
let source = write_bundle(&fixture.home.path().join("downloads"));
fixture.ok(&["install", source.to_str().unwrap()]);
let token = review(&fixture);
fixture.refused_with(&trust_args(&token), &fixture.marked(), "ask the person");
assert_ne!(fixture.status()["trust"], "trusted");
fixture.ok(&trust_args(&token));
assert_eq!(fixture.status()["approved_by"]["kind"], "command_line");
}
#[cfg(target_os = "linux")]
#[test]
fn a_prompt_answered_in_a_pseudo_terminal_is_the_agents_when_marked() {
use std::io::Write;
let fixture = Fixture::new();
let source = write_bundle(&fixture.workspace.path().join("source"));
fixture.ok(&["install", source.to_str().unwrap()]);
let quoted = |value: &str| format!("'{}'", value.replace('\'', "'\\''"));
let answer_in_pty = |env: &[(&str, &str)]| {
let line = [
quoted(crate::binary::codewhale().to_str().unwrap()),
"--workspace".to_string(),
quoted(fixture.workspace.path().to_str().unwrap()),
"--no-project-config plugins trust demo".to_string(),
]
.join(" ");
let mut command = Command::new("script");
command.env_clear();
for key in ["PATH", "TMPDIR", "LANG", "LC_ALL"] {
if let Some(value) = std::env::var_os(key) {
command.env(key, value);
}
}
let mut child = command
.current_dir(fixture.workspace.path())
.args(["-qec", line.as_str(), "/dev/null"])
.env("TERM", "xterm")
.env("HOME", fixture.home.path())
.env("XDG_CONFIG_HOME", fixture.home.path().join(".config"))
.env(
"CODEWHALE_CONFIG_PATH",
fixture.home.path().join(".codewhale").join("config.toml"),
)
.env("RUST_LOG", "warn")
.envs(env.iter().copied())
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
.expect("script(1) runs");
child.stdin.take().unwrap().write_all(b"y\n").unwrap();
let output = child.wait_with_output().expect("script(1) finishes");
assert!(output.status.success(), "{output:?}");
};
answer_in_pty(&[]);
let person = fixture.status();
assert_eq!(person["approved_by"]["kind"], "person", "{person}");
fixture.ok(&["plugins", "revoke", "demo"]);
answer_in_pty(&fixture.marked());
let agent = fixture.status();
assert_eq!(agent["approved_by"]["kind"], "agent", "{agent}");
assert_eq!(agent["approved_by"]["posture"], "command_line", "{agent}");
}
#[cfg(unix)]
#[tokio::test]
async fn a_prompt_answered_through_the_agents_terminal_tools_is_the_agents() {
use codewhale_engine::tools::spec::{ToolContext, ToolSpec};
use codewhale_engine::tools::terminal_session::{
TerminalResetTool, TerminalRunTool, TerminalSendTool, TerminalWaitTool,
};
let fixture = Fixture::new();
let source = write_bundle(&fixture.workspace.path().join("source"));
fixture.ok(&["install", source.to_str().unwrap()]);
let quoted = |value: &Path| format!("'{}'", value.display().to_string().replace('\'', "'\\''"));
let home = fixture.home.path();
let command = format!(
"HOME={} USERPROFILE={} XDG_CONFIG_HOME={} CODEWHALE_CONFIG_PATH={} RUST_LOG=warn {} --workspace {} --no-project-config plugins trust demo",
quoted(home),
quoted(home),
quoted(&home.join(".config")),
quoted(&home.join(".codewhale").join("config.toml")),
quoted(&crate::binary::codewhale()),
quoted(fixture.workspace.path()),
);
let mut context = ToolContext::new(fixture.workspace.path())
.with_elevated_sandbox_policy(codewhale_sandbox::SandboxPolicy::DangerFullAccess);
context.auto_approve = true;
let session = "plugin-trust-prompt";
let asked = TerminalRunTool
.execute(
serde_json::json!({ "command": command, "session": session, "timeout_secs": 5 }),
&context,
)
.await
.expect("terminal/run");
assert!(asked.content.contains("[y/N]"), "{}", asked.content);
TerminalSendTool
.execute(
serde_json::json!({ "session": session, "text": "y\n", "wait_ms": 500 }),
&context,
)
.await
.expect("terminal/send");
let done = TerminalWaitTool
.execute(
serde_json::json!({ "session": session, "timeout_secs": 60 }),
&context,
)
.await
.expect("terminal/wait");
let _ = TerminalResetTool
.execute(serde_json::json!({ "session": session }), &context)
.await;
let plugin = fixture.status();
assert_eq!(
plugin["approved_by"]["kind"], "agent",
"{plugin}\n{}",
done.content
);
assert_eq!(plugin["approved_by"]["posture"], "command_line", "{plugin}");
}
+18 -4
View File
@@ -26,7 +26,11 @@ files or use the network.
its answer in the transcript with its origin, `extension:hello-extension`.
An authoring agent should stop after installation and validation and present
the review to the person. Do not trust or enable a bundle automatically.
the review to the person, unless it runs in Full Access and the plugin is one
it wrote and installed from a folder in its project: then it may trust and
enable it for that project with `codewhale plugins trust` and `enable`. Those
shell commands go through the session's approval like any other: Ask asks the
person, Auto-Review's reviewer judges, and Full Access runs them.
## Locations, installation and trust
@@ -646,13 +650,23 @@ a plugin's trust shows it:
| Approver | How it happens | Reach |
| --- | --- | --- |
| You | the Plugins page, `/plugin trust`, or an approval card | every project |
| You | the Plugins page, `/plugin trust`, an approval card, or the prompt of `codewhale plugins trust` in your terminal | every project |
| The agent, in Full Access or Auto-Review | the agent's plugin tool, bound to the token of the review composed for that call and to the thread, turn and tool call that made it | only the project named in the record |
| The agent, from a shell command | `codewhale plugins trust … --yes` run by a process that carries the agent's mark | only the project named in the record |
| The command line | `codewhale plugins trust … --yes` without the mark: no review was shown there, so it grants no service | every project |
The agent's mark is a label on the agent's shell commands, not a lock: in Full
Access the agent can remove it, and the record then says "the command line".
The agent's mark is `CODEWHALE_AGENT_PROJECT`, which Codewhale sets to the
session's project on every command the agent runs (shell, persistent terminal,
verifier and test runs, the Python kernel, MCP servers it starts), whatever the sandbox or
permission mode. Hooks, MCP servers from your configuration and the terminal
app's `/plugin trust` carry no mark. With it, the command records the agent, even for a prompt
answered through a terminal, and trusts only a plugin installed from a folder
inside that project (the bundle's `.installed-from` record). It is a label, not
a lock: in Full Access the agent could remove it, and the record then says "the
command line". The `--review-token` that the command
takes is the bundle review token (`<content-hash>.<capability-hash>`), not the
host-service review token: an unmarked trust passes no host-service token, and
a marked one passes the token of the review composed for the agent.
A plugin the agent approved is active only in the project its record names,
and its code does not run in the host process of the plugins you reviewed:
until it has a host of its own, its native code is refused with a diagnostic
+42
View File
@@ -301,6 +301,48 @@ until normal runtime loading. `codew plugins restage <name> [--json]` performs
an explicit verified repair. Use `/plugin reload` in an already running
terminal session to refresh its registry after a repair from another process.
The same review and state changes are available from a shell:
- `codew plugins trust <name> [--json]` prints the review that `/plugin trust`
shows, with its bundle review token, and changes nothing. In a terminal it
then asks once whether to trust.
- `codew plugins trust <name> --review-token <token> --yes` trusts without a
prompt, and only if the bundle review token still matches the installed
content and capability set. The token is `<content-hash>.<capability-hash>`
and ties trust to that exact content. It is not a record that anyone read
the review, and `plugins status --json` prints both of its halves. It is
also not the host-service review token described in
[EXTENSIONS.md](EXTENSIONS.md): the command never asks for that one.
- `codew plugins enable|disable|revoke <name> [--json]` make the same state
changes as the slash commands. Trust never enables a bundle.
Every trust records who approved it, and `plugins status` shows it. Every
command the agent runs carries the agent's mark, the variable
`CODEWHALE_AGENT_PROJECT` set to the session's project, whatever the sandbox
or permission mode: shell commands, persistent terminal sessions, verifier and
test runs, code run in the Python kernel (`repl` blocks and RLM), and MCP
servers the agent starts with `start_mcp_server`. Processes
Codewhale starts that are not the agent's commands carry no mark: hooks, MCP
servers from your configuration, and `/plugin trust` typed in the terminal
app. With the mark, a trust or an `enable` is recorded as the
agent's, for that project only, including a prompt answered through a
terminal; it is never recorded as yours. The agent may trust only a plugin it
installed with `codewhale install` from a folder inside that project, read
from the bundle's `.installed-from` record; anything else is refused and left
to you. That record shows where the files came from, not who wrote them.
Without the mark, a `--yes` trust is recorded as the command line and grants
no host service, and a prompt answered in your own terminal is recorded as
yours.
The agent's shell commands that run `plugins trust` or `plugins enable` go
through the session's approval like any other shell command: Ask asks you,
Auto-Review's reviewer judges, and Full Access runs them, so a Full Access
agent can trust and enable a plugin it wrote, for its own project. The mark is
a label, not a lock: in Full Access the agent could remove it, which shows in
the command it runs. Where an OS sandbox applies, it keeps plugin state
read-only, so a trust from inside it fails when it writes. `disable` and
`revoke` are never held for you.
(`/plugin install`, `update`, and `uninstall` place, replace, and remove the
bits themselves and always drop into this same review — see
[PLUGINS.md](PLUGINS.md). `/plugin suggest` ranks installed bundles and
+34
View File
@@ -1481,6 +1481,7 @@ plus workspace metadata:
"preview": "The TUI footer should count project MCP servers...",
"model": "deepseek-v4-pro",
"mode": "agent",
"agent_preset": "standard",
"branch": "feature/runtime-api",
"head": "abc1234",
"dirty": false,
@@ -1518,6 +1519,38 @@ Thread list and summary responses remain flat in v0.8.40, so clients that need
a graph should reconstruct it from events instead of assuming list order is a
complete tree.
### Conversation modes
`GET /v1/modes` lists the conversation modes a client can offer when it starts
a conversation:
```json
{
"default": "standard",
"modes": [
{"id": "standard", "name": "Standard", "description": "The everyday Codewhale agent with its normal tools and skills.", "source": "builtin", "available": true},
{"id": "creator", "name": "Creator", "description": "Build, inspect and test Codewhale plugins in this conversation, with the plugin authoring tools and guides loaded.", "source": "builtin", "available": false, "reason": "Creator mode is not ready in this build."}
]
}
```
`reason` appears only when `available` is `false`; a client lists the row
disabled and shows it. A thread's mode is its `agent_preset` string. Set it on
`POST /v1/threads`, or with `PATCH /v1/threads/{id}` while the thread has no
turn. A thread record with no `agent_preset` is `standard`, and the thread
record omits the field for `standard`; rows from `GET /v1/threads/summary`
always carry it. This field stores and reports the mode; what a mode loads is
not part of it.
Refusals carry `error.code`:
| `error.code` | Status | Meaning |
| --- | --- | --- |
| `unknown_mode` | `400` | The id is not in `GET /v1/modes`. Never replaced by `standard`. |
| `mode_unavailable` | `424` | The row is listed with `available: false`; the message is its `reason`. |
| `mode_locked` | `409` | The thread already has a turn, so its mode cannot change. |
| `mode_not_supported_with_executor` | `400` | `agent_preset` with `executor` (another vendor's agent). |
### Read markers
`POST /v1/threads/{id}/read` with `{"item_id": "<item id>"}` records that a
@@ -2256,6 +2289,7 @@ This persisted project decision is separate from a thread’s `trust_mode` / Ful
- `GET /v1/workspace/files/search?query=<partial>&limit=<1-100>` (see workspace file suggestions above)
- `GET /v1/workspace/files?path=<dir>&limit=<1-2000>`, `GET /v1/workspace/files/read?path=<file>&offset=&limit=`
and `PUT /v1/workspace/files` (see workspace files and session artifacts above)
- `GET /v1/modes` (see Conversation modes)
- `GET /v1/skills`
- `GET /v1/skills/{name}` — one skill's routing metadata (`source`,
`invocation`, `aliases`, `bundled_tier`, `enabled`) plus its full
+2 -1
View File
@@ -129,6 +129,7 @@ registration index.
| GET | `/v1/lsp` |
| DELETE / GET / POST | `/v1/memory` |
| GET | `/v1/memory/{id}` |
| GET | `/v1/modes` |
| GET / PATCH / POST | `/v1/operate` |
| POST | `/v1/operate/auto-merge/check` |
| POST | `/v1/operate/cancel` |
@@ -262,4 +263,4 @@ registration index.
| GET / POST | `/v1/worktrees` |
| POST | `/v1/worktrees/remove` |
The source contains 242 distinct literal paths in this table.
The source contains 243 distinct literal paths in this table.
+12
View File
@@ -26,6 +26,18 @@
命令、监听器和提示词片段,保留计数。
协助编写插件的 agent 应在安装和验证后把审查交给本人,不应自动信任或启用插件。
运行 `codewhale plugins trust` 或 `codewhale plugins enable` 的 shell 命令和其他
shell 命令一样经过会话审批:Ask 询问本人,Auto-Review 由审查模型判断,Full Access
直接运行,因此 Full Access 下的 agent 可以信任并启用它自己编写的插件。每次信任都记录
批准者。agent 运行的每条命令(shell 命令、持久终端、验证与测试运行、Python 内核里运行的代码、它用
`start_mcp_server` 启动的 MCP 服务器)都带有 agent 标记 `CODEWHALE_AGENT_PROJECT`,值为会话
所在项目,与沙箱和权限模式无关;钩子、配置文件中的 MCP 服务器和终端应用里的 `/plugin trust`
不带标记。带有标记时,信任和启用都记为该 agent,仅限该项目,即使
提示是通过终端回答的也一样,绝不记为本人;而且只能信任从该项目内的文件夹用
`codewhale install` 安装的插件(依据插件包的 `.installed-from` 记录),否则拒绝并交给本人。
没有标记时,`--yes` 记为命令行,不授予任何宿主服务;在本人终端里回答提示记为本人。这个
标记只是标签而不是锁:在 Full Access 下 agent 可以把它去掉,此时记录显示为命令行。`--review-token` 是插件包审查令牌
(`<content-hash>.<capability-hash>`),不是宿主服务审查令牌。
## 入口、审查与生命周期
+21
View File
@@ -34,6 +34,27 @@ Computer Use 通过官方插件目录安装为普通用户级插件,独立于
安装后须审查并启用。Mac 原生助手单独安装,负责系统权限设置和电脑控制指示器。
详见 [Computer Use 指南](https://github.com/codewhale-hq/codewhale-cu-plugin)。
在 shell 中也可以审查和更改插件状态:`codew plugins trust <name> [--json]` 显示与
`/plugin trust` 相同的审查和插件包审查令牌,不做任何更改,在终端中随后询问一次是否信任;
`codew plugins trust <name> --review-token <token> --yes` 仅在该令牌仍与已安装的内容和
能力集合一致时才信任。插件包审查令牌是 `<content-hash>.<capability-hash>`,把信任绑定到
确切的内容,但不能证明有人读过审查,也不是宿主服务审查令牌。
`codew plugins enable|disable|revoke <name> [--json]` 与斜杠命令做同样的更改,信任不会
启用插件包。
每次信任都会记录批准者,`plugins status` 会显示。agent 运行的每条命令(shell 命令、持久终端、
验证与测试运行、Python 内核里运行的代码、它用 `start_mcp_server` 启动的 MCP 服务器)都带有 agent 标记
`CODEWHALE_AGENT_PROJECT`,值为会话所在项目,与沙箱和权限模式无关;钩子、配置文件中的 MCP
服务器和终端应用里的 `/plugin trust` 不带标记。带有标记时,信任和
`enable` 都记为该 agent,仅限该项目,即使提示是通过终端回答的也一样,绝不记为本人;agent
只能信任它从该项目内的文件夹用 `codewhale install` 安装的插件(依据 `.installed-from` 记录,
它说明文件从哪里来,而不是谁编写的),否则拒绝并交给本人。没有标记时,`--yes` 记为命令行,
不授予任何宿主服务;在本人终端里回答提示记为本人。agent 运行这两个命令的 shell 命令和其他
shell 命令一样经过会话审批:Ask 询问本人,Auto-Review 由审查模型判断,Full Access 直接运行,
所以 Full Access 下的 agent 可以为它自己的项目信任并启用它编写的插件。这个标记只是标签而
不是锁:在 Full Access 下 agent 可以把它去掉。在适用操作系统沙箱的地方,
插件状态是只读的,沙箱内的信任会在写入时失败。`disable` 和 `revoke` 从不需要本人批准。
用户级插件优先于工作区级插件。符号链接形式的根目录、清单、组件路径和
嵌套组件文件一律失败关闭。历史内置快照和回执保留,供旧二进制使用;
当前 Engine 不再创建或发现内置 Computer Use 副本。
+31
View File
@@ -1056,6 +1056,7 @@ Runtime 前缀恢复验证与后续写入所需的完整文档条件保持独立
"preview": "The TUI footer should count project MCP servers...",
"model": "deepseek-v4-pro",
"mode": "agent",
"agent_preset": "standard",
"branch": "feature/runtime-api",
"head": "abc1234",
"dirty": false,
@@ -1087,6 +1088,35 @@ fork 还可能包含 `backtrack_depth_from_tail` 与 `dropped_turn_id`,而
图的客户端应当从事件重建它,而不是假定列表顺序就是
一棵完整的树。
### 对话模式
`GET /v1/modes` 列出客户端在开始对话时可提供的对话模式:
```json
{
"default": "standard",
"modes": [
{"id": "standard", "name": "Standard", "description": "The everyday Codewhale agent with its normal tools and skills.", "source": "builtin", "available": true},
{"id": "creator", "name": "Creator", "description": "Build, inspect and test Codewhale plugins in this conversation, with the plugin authoring tools and guides loaded.", "source": "builtin", "available": false, "reason": "Creator mode is not ready in this build."}
]
}
```
仅当 `available` 为 `false` 时才有 `reason`;客户端应将该行显示为禁用并展示原因。
线程的模式是其 `agent_preset` 字符串,可在 `POST /v1/threads` 时设置,或在线程尚无
任何回合时通过 `PATCH /v1/threads/{id}` 修改。没有 `agent_preset` 的线程记录即
`standard`(线程记录对 `standard` 省略该字段;`GET /v1/threads/summary` 的每一行总是
带有它)。该字段只存储并报告模式,不决定模式加载什么。
拒绝响应带有 `error.code`:
| `error.code` | 状态 | 含义 |
| --- | --- | --- |
| `unknown_mode` | `400` | 该 id 不在 `GET /v1/modes` 中;绝不会静默改为 `standard`。 |
| `mode_unavailable` | `424` | 该行以 `available: false` 列出;消息即其 `reason`。 |
| `mode_locked` | `409` | 线程已有回合,模式不可再更改。 |
| `mode_not_supported_with_executor` | `400` | `agent_preset` 与 `executor`(其他厂商的代理)同时出现。 |
### 已读标记
`POST /v1/threads/{id}/read` 携带 `{"item_id": "<item id>"}`,记录客户端已把对话
@@ -1660,6 +1690,7 @@ direction 为空、等待 lead 计划、缺少凭据或有
- `GET /v1/workspace/files/search?query=<partial>&limit=<1-100>`(参见上文的工作区文件建议)
- `GET /v1/workspace/files?path=<dir>&limit=<1-2000>`、`GET /v1/workspace/files/read?path=<file>&offset=&limit=`
与 `PUT /v1/workspace/files`(参见上文的工作区文件与会话工件)
- `GET /v1/modes`(参见对话模式)
- `GET /v1/skills`
- `GET /v1/skills/{name}` — 返回路由元数据(`source`、`invocation`、`aliases`、
`bundled_tier`、`enabled`)及完整 `SKILL.md`,让客户端为自己的下一回合组成激活指令。
+3
View File
@@ -450,6 +450,9 @@
"crates/tui/src/commands/groups/core/acceptance.rs": {
"std_fs": 1
},
"crates/tui/src/commands/groups/plugins/headless.rs": {
"std_fs": 3
},
"crates/tui/src/commands/groups/project/init.rs": {
"std_fs": 13
},
+10 -10
View File
@@ -313,8 +313,8 @@
},
{
"path": "docs/PLUGIN_BUNDLES.md",
"sha256": "5901d95a9a294590d19cf736140986967470eea02782776f250c1bb929608b65",
"content": "# Plugin bundles\n\n> 阅读简体中文版:[zh_hans/PLUGIN_BUNDLES.md](zh_hans/PLUGIN_BUNDLES.md)。\n\nCodewhale supports a deliberately small plugin-bundle boundary. The boundary\nwas drawn in v0.9.1 and is extended deliberately in v0.9.10: a bundle may\ncontribute declarative Skills, MCP configuration, Commands, Agent profiles,\nand Hooks through Codewhale's existing engines. Unsupported declarations stay\ninventoried instead of disabling a mixed bundle. Discovery alone never\nexecutes, enables, trusts, downloads, updates, or installs anything.\n\nThis document owns the bundle formats, discovery,\nvalidation, and the trust/enable/runtime contract. [PLUGINS.md](PLUGINS.md)\nowns how bits get onto and off disk — the `/plugin install`, `update`,\n`uninstall`, and `suggest` on-ramp added in v0.9.4 (#5182). Compatible Claude Code bundles use the same native adapters; their supported subset is\n[CLAUDE_PLUGIN_COMPAT.md](CLAUDE_PLUGIN_COMPAT.md).\nFor a runnable native example and explicit OpenCode/DSH data conversion, see\n[Write your first plugin](PLUGIN_AUTHORING.md).\n\n## Discovery and precedence\n\nCodewhale scans only its own roots, looking in each `<name>/` directory for a\nmanifest named `plugin.json` (the native Agent Plugins v1.0.0 format, since\nv0.9.4), `kimi.plugin.json` (the compatible Kimi Skills/MCP subset, since\nv0.9.8), `plugin.toml` (the legacy Codewhale format, still fully readable), or\n`.claude-plugin/plugin.json` (the compatible Claude subset, since v0.9.13):\n\n- User: `~/.codewhale/plugins/<name>/`\n- Workspace: `<workspace>/.codewhale/plugins/<name>/`\n\nA bundle that publishes multiple formats is read through `plugin.json` first,\nthen `kimi.plugin.json`, then the legacy `plugin.toml`, then\n`.claude-plugin/plugin.json`.\nComputer Use installs from the first-party catalog as a normal user plugin.\nIt updates independently of the Engine and requires review and enablement.\nThe native Mac helper is installed separately for permission setup and the\ncomputer-control indicator. See the [Computer Use guide](https://github.com/codewhale-hq/codewhale-cu-plugin).\n\nUser plugins take precedence over workspace plugins. Symbolic-link roots,\nmanifests, component paths, and nested component files fail closed.\nHistorical built-in snapshots and receipts remain untouched for older binaries;\nthe current Engine neither creates nor discovers a built-in Computer Use copy.\nInstall and review the catalog plugin once when upgrading from that layout.\n\nNew user and workspace bundles are always untrusted and disabled. Discovery is\nread-only and does not inspect any other application's extension or credential\ndirectories: ambient roots such as `.claude/plugins` or `.cursor/plugins` are\nnever scanned.\n\nPre-v0.9.1 `overrides.json` enablement was intentionally not imported as\ntrust; every bundle activates only through the content-hash and\n`codewhale-plugin-capabilities-v3` activation-policy review below.\n\n## Manifest\n\nAll supported encodings parse into the same internal manifest, so validation, hashing,\nreview, and runtime behavior are identical downstream. On-disk auto-migration\nbetween them is deliberately not performed; `/plugin export <name>\n<target-dir>` publishes a loaded bundle as a spec-valid Agent Plugins v1.0.0\ndirectory without modifying the installed one.\n\n### `plugin.json` (Agent Plugins v1.0.0)\n\nThe standard's manifest root is closed: `$schema`, `name`, and the optional\nwell-known fields (`version`, `description`, `author`, `homepage`,\n`repository`, `license`, `keywords`, `extensions`). An unknown root key is a\nparse error. Client-specific data lives under `extensions`, keyed by\nreverse-domain namespace: unknown vendor namespaces are ignored, never\nrejected — that is what lets a bundle authored for another client load here —\nwhile unknown keys inside Codewhale's own `extensions[\"net.codewhale\"]`\nnamespace are rejected rather than silently dropped.\n\nNames follow the standard's rule: 1–64 lowercase ASCII letters, digits, or\ninternal single `-`/`.`, starting and ending alphanumeric, never `--` or `..`.\nA `skills/` directory in the bundle root is picked up automatically; other\ncomponent locations, `capabilities`, `when`, and `display_name` ride in\n`extensions[\"net.codewhale\"]`.\n\nMCP servers cannot live in `plugin.json` (the root is closed); they live in a\nsibling `mcp.json` under `mcpServers`, with `stdio`, `streamable-http`, or\n`sse` transports (`type` may be omitted and is inferred from `command` vs\n`url`). Codewhale-only server options — timeouts, tool filters, env-backed\ncredentials, enablement — ride per-server under `extensions[\"net.codewhale\"]`.\nThe `env` names `PLUGIN_ROOT` and `PLUGIN_DATA` are reserved by the standard\nfor the host runtime and are rejected in plugin definitions.\n\n### `plugin.toml` (legacy Codewhale format)\n\n```toml\nschema_version = 1\n\n[plugin]\nname = \"example\"\nversion = \"0.1.0\Line truncated
"sha256": "2940573c8b4d10034ea7799ab8acae14d096bbc9bed57110a72b4c7c10289a65",
"content": "# Plugin bundles\n\n> 阅读简体中文版:[zh_hans/PLUGIN_BUNDLES.md](zh_hans/PLUGIN_BUNDLES.md)。\n\nCodewhale supports a deliberately small plugin-bundle boundary. The boundary\nwas drawn in v0.9.1 and is extended deliberately in v0.9.10: a bundle may\ncontribute declarative Skills, MCP configuration, Commands, Agent profiles,\nand Hooks through Codewhale's existing engines. Unsupported declarations stay\ninventoried instead of disabling a mixed bundle. Discovery alone never\nexecutes, enables, trusts, downloads, updates, or installs anything.\n\nThis document owns the bundle formats, discovery,\nvalidation, and the trust/enable/runtime contract. [PLUGINS.md](PLUGINS.md)\nowns how bits get onto and off disk — the `/plugin install`, `update`,\n`uninstall`, and `suggest` on-ramp added in v0.9.4 (#5182). Compatible Claude Code bundles use the same native adapters; their supported subset is\n[CLAUDE_PLUGIN_COMPAT.md](CLAUDE_PLUGIN_COMPAT.md).\nFor a runnable native example and explicit OpenCode/DSH data conversion, see\n[Write your first plugin](PLUGIN_AUTHORING.md).\n\n## Discovery and precedence\n\nCodewhale scans only its own roots, looking in each `<name>/` directory for a\nmanifest named `plugin.json` (the native Agent Plugins v1.0.0 format, since\nv0.9.4), `kimi.plugin.json` (the compatible Kimi Skills/MCP subset, since\nv0.9.8), `plugin.toml` (the legacy Codewhale format, still fully readable), or\n`.claude-plugin/plugin.json` (the compatible Claude subset, since v0.9.13):\n\n- User: `~/.codewhale/plugins/<name>/`\n- Workspace: `<workspace>/.codewhale/plugins/<name>/`\n\nA bundle that publishes multiple formats is read through `plugin.json` first,\nthen `kimi.plugin.json`, then the legacy `plugin.toml`, then\n`.claude-plugin/plugin.json`.\nComputer Use installs from the first-party catalog as a normal user plugin.\nIt updates independently of the Engine and requires review and enablement.\nThe native Mac helper is installed separately for permission setup and the\ncomputer-control indicator. See the [Computer Use guide](https://github.com/codewhale-hq/codewhale-cu-plugin).\n\nUser plugins take precedence over workspace plugins. Symbolic-link roots,\nmanifests, component paths, and nested component files fail closed.\nHistorical built-in snapshots and receipts remain untouched for older binaries;\nthe current Engine neither creates nor discovers a built-in Computer Use copy.\nInstall and review the catalog plugin once when upgrading from that layout.\n\nNew user and workspace bundles are always untrusted and disabled. Discovery is\nread-only and does not inspect any other application's extension or credential\ndirectories: ambient roots such as `.claude/plugins` or `.cursor/plugins` are\nnever scanned.\n\nPre-v0.9.1 `overrides.json` enablement was intentionally not imported as\ntrust; every bundle activates only through the content-hash and\n`codewhale-plugin-capabilities-v3` activation-policy review below.\n\n## Manifest\n\nAll supported encodings parse into the same internal manifest, so validation, hashing,\nreview, and runtime behavior are identical downstream. On-disk auto-migration\nbetween them is deliberately not performed; `/plugin export <name>\n<target-dir>` publishes a loaded bundle as a spec-valid Agent Plugins v1.0.0\ndirectory without modifying the installed one.\n\n### `plugin.json` (Agent Plugins v1.0.0)\n\nThe standard's manifest root is closed: `$schema`, `name`, and the optional\nwell-known fields (`version`, `description`, `author`, `homepage`,\n`repository`, `license`, `keywords`, `extensions`). An unknown root key is a\nparse error. Client-specific data lives under `extensions`, keyed by\nreverse-domain namespace: unknown vendor namespaces are ignored, never\nrejected — that is what lets a bundle authored for another client load here —\nwhile unknown keys inside Codewhale's own `extensions[\"net.codewhale\"]`\nnamespace are rejected rather than silently dropped.\n\nNames follow the standard's rule: 1–64 lowercase ASCII letters, digits, or\ninternal single `-`/`.`, starting and ending alphanumeric, never `--` or `..`.\nA `skills/` directory in the bundle root is picked up automatically; other\ncomponent locations, `capabilities`, `when`, and `display_name` ride in\n`extensions[\"net.codewhale\"]`.\n\nMCP servers cannot live in `plugin.json` (the root is closed); they live in a\nsibling `mcp.json` under `mcpServers`, with `stdio`, `streamable-http`, or\n`sse` transports (`type` may be omitted and is inferred from `command` vs\n`url`). Codewhale-only server options — timeouts, tool filters, env-backed\ncredentials, enablement — ride per-server under `extensions[\"net.codewhale\"]`.\nThe `env` names `PLUGIN_ROOT` and `PLUGIN_DATA` are reserved by the standard\nfor the host runtime and are rejected in plugin definitions.\n\n### `plugin.toml` (legacy Codewhale format)\n\n```toml\nschema_version = 1\n\n[plugin]\nname = \"example\"\nversion = \"0.1.0\Line truncated
},
{
"path": "docs/PLUGIN_MARKETPLACE.md",
@@ -323,8 +323,8 @@
},
{
"path": "docs/EXTENSIONS.md",
"sha256": "272fafeeea6ab4fe6b27d370bc03079f6c87e81e21b960e224d926185850c784",
"content": "# Writing an extension tool or command\n\nThe experimental TypeScript extension host runs reviewed plugin code in a\nshared Node process (or, as an opt-in, Bun). Rust still owns sessions, tool admission, approval and\nexecution. Extensions currently contribute tools and slash commands; they\ncannot provide an approval service, run a second agent loop or replace\nbuilt-in tools or commands.\n\n## Quick start\n\nStart with [hello-extension](examples/plugins/hello-extension/hello.mts) and its\n[plugin.json](examples/plugins/hello-extension/plugin.json). The example is\nexecuted unchanged by the host tests. It returns a greeting and does not read\nfiles or use the network.\n\n1. Explicitly enable `[features] extension_host = true` in your configuration,\n or start Codewhale with `codewhale --enable extension_host` (the flag goes\n before any subcommand).\n2. Install the example's directory using `/plugin install <local-directory>`.\n3. Run `/plugin validate hello-extension`, then `/plugin show hello-extension`.\n4. Run `/plugin enable hello-extension` to open its content/capability review.\n Read the review and personally run its exact `/plugin trust <token>` command,\n then enable the plugin. Trust alone does not execute the code.\n5. Ask Codewhale to use `hello_greet`. The tool follows the normal approval gate.\n6. Run `/hello-greet Codewhale`. The command runs when you type it, and shows\n its answer in the transcript with its origin, `extension:hello-extension`.\n\nAn authoring agent should stop after installation and validation and present\nthe review to the person. Do not trust or enable a bundle automatically.\n\n## Locations, installation and trust\n\nUser bundles live under `~/.codewhale/plugins/`; workspace bundles live under\n`<workspace>/.codewhale/plugins/`. New bundles use Agent Plugins v1.0.0\n`plugin.json`. Declare `extensions[\"net.codewhale\"].native.path` as one regular\n`.mjs`, `.js` or `.mts` file inside the bundle. Directories and other extensions\nfail validation when the host is enabled. With the flag off, native entries\nremain inventory-only.\n\nA plugin may split its code across several entries with `native.paths` (up to\n64, alone or beside `path`). They are activated in the order declared, as the\nfibers of one plugin: they share one owner, so one disable, review change or\ncrash tears all of them down together. Each entry is a separate module with its\nown `apply`, and tool and command names must be unique across them. If an entry\nfails to activate (throws, requires a service the host does not provide, or has\na registration refused), only that entry is withdrawn: entries that already\nactivated stay registered, later entries still activate, and the plugin fails\nonly when no entry activates. Listing the same file twice is one entry.\n\n## Runtime\n\n`[extension_host] runtime` selects what runs the host. Node is the default.\nBun is an opt-in: it is qualified on macOS only, and becomes the default only\nafter an explicit, recorded cutover.\n\n```toml\n[extension_host]\nruntime = \"node\" # default: Node only\n# runtime = \"bun\" # Bun only; fails rather than falling back to Node\n# runtime = \"auto\" # Bun >= 1.4.0 if one is found and starts, otherwise Node\n# bun = \"~/.bun/bin/bun\" # the only Bun tried when set\n# node = \"/opt/homebrew/bin/node\" # the only Node tried when set\n# mcp_backend = \"rust\" # temporary opt-out; default is \"host\"\n```\n\nThe default `mcp_backend = \"host\"` uses the pinned MCP SDK independently of\n`[features] extension_host`, which controls optional Native extensions. Existing\nstdio and HTTP servers use modern discovery when supported; explicitly configured\nlegacy SSE servers use their legacy handshake. Rust retains process and network\nauthority, credential resolution, catalog admission, tool approval and cancellation.\nThe SDK receives opaque session selectors and exact operation grants. Modern\nrequests carry Rust-approved protocol metadata and headers without a session id.\n\nA typed discovery refusal can admit a legacy handshake. Only an explicit HTTP\ntransport refusal during that handshake can admit legacy SSE with a fresh exact\nRust grant. Legacy HTTP retains its GET preflight and bounded OAuth refresh;\nmodern HTTP does not inherit those session or resend behaviors. Incomplete result\nflows are reported without automatically repeating the operation. Credentials,\nconfigured URLs, OAuth browser/login operations and Computer Use decision keys\nstay in Rust.\n\nBoth backends share recorded semantic MCP goldens and retain separate transport\ngoldens, including discovery, authentication and session evidence. The Host default\nrequires those fixtures and the actual broker acceptance checks, plus the platform\nisolation and measured gates in Ops CURRENT_DECISIONS §26. An explicit\n`mcp_backend = \"rust\"` remains available for one release after the qualified default\nflip, before the native protocoLine truncated
"sha256": "45dc87bc8293156cc221a0d1181ddcdfe6970caef3874d2127a5b86ba2b32d9d",
"content": "# Writing an extension tool or command\n\nThe experimental TypeScript extension host runs reviewed plugin code in a\nshared Node process (or, as an opt-in, Bun). Rust still owns sessions, tool admission, approval and\nexecution. Extensions currently contribute tools and slash commands; they\ncannot provide an approval service, run a second agent loop or replace\nbuilt-in tools or commands.\n\n## Quick start\n\nStart with [hello-extension](examples/plugins/hello-extension/hello.mts) and its\n[plugin.json](examples/plugins/hello-extension/plugin.json). The example is\nexecuted unchanged by the host tests. It returns a greeting and does not read\nfiles or use the network.\n\n1. Explicitly enable `[features] extension_host = true` in your configuration,\n or start Codewhale with `codewhale --enable extension_host` (the flag goes\n before any subcommand).\n2. Install the example's directory using `/plugin install <local-directory>`.\n3. Run `/plugin validate hello-extension`, then `/plugin show hello-extension`.\n4. Run `/plugin enable hello-extension` to open its content/capability review.\n Read the review and personally run its exact `/plugin trust <token>` command,\n then enable the plugin. Trust alone does not execute the code.\n5. Ask Codewhale to use `hello_greet`. The tool follows the normal approval gate.\n6. Run `/hello-greet Codewhale`. The command runs when you type it, and shows\n its answer in the transcript with its origin, `extension:hello-extension`.\n\nAn authoring agent should stop after installation and validation and present\nthe review to the person, unless it runs in Full Access and the plugin is one\nit wrote and installed from a folder in its project: then it may trust and\nenable it for that project with `codewhale plugins trust` and `enable`. Those\nshell commands go through the session's approval like any other: Ask asks the\nperson, Auto-Review's reviewer judges, and Full Access runs them.\n\n## Locations, installation and trust\n\nUser bundles live under `~/.codewhale/plugins/`; workspace bundles live under\n`<workspace>/.codewhale/plugins/`. New bundles use Agent Plugins v1.0.0\n`plugin.json`. Declare `extensions[\"net.codewhale\"].native.path` as one regular\n`.mjs`, `.js` or `.mts` file inside the bundle. Directories and other extensions\nfail validation when the host is enabled. With the flag off, native entries\nremain inventory-only.\n\nA plugin may split its code across several entries with `native.paths` (up to\n64, alone or beside `path`). They are activated in the order declared, as the\nfibers of one plugin: they share one owner, so one disable, review change or\ncrash tears all of them down together. Each entry is a separate module with its\nown `apply`, and tool and command names must be unique across them. If an entry\nfails to activate (throws, requires a service the host does not provide, or has\na registration refused), only that entry is withdrawn: entries that already\nactivated stay registered, later entries still activate, and the plugin fails\nonly when no entry activates. Listing the same file twice is one entry.\n\n## Runtime\n\n`[extension_host] runtime` selects what runs the host. Node is the default.\nBun is an opt-in: it is qualified on macOS only, and becomes the default only\nafter an explicit, recorded cutover.\n\n```toml\n[extension_host]\nruntime = \"node\" # default: Node only\n# runtime = \"bun\" # Bun only; fails rather than falling back to Node\n# runtime = \"auto\" # Bun >= 1.4.0 if one is found and starts, otherwise Node\n# bun = \"~/.bun/bin/bun\" # the only Bun tried when set\n# node = \"/opt/homebrew/bin/node\" # the only Node tried when set\n# mcp_backend = \"rust\" # temporary opt-out; default is \"host\"\n```\n\nThe default `mcp_backend = \"host\"` uses the pinned MCP SDK independently of\n`[features] extension_host`, which controls optional Native extensions. Existing\nstdio and HTTP servers use modern discovery when supported; explicitly configured\nlegacy SSE servers use their legacy handshake. Rust retains process and network\nauthority, credential resolution, catalog admission, tool approval and cancellation.\nThe SDK receives opaque session selectors and exact operation grants. Modern\nrequests carry Rust-approved protocol metadata and headers without a session id.\n\nA typed discovery refusal can admit a legacy handshake. Only an explicit HTTP\ntransport refusal during that handshake can admit legacy SSE with a fresh exact\nRust grant. Legacy HTTP retains its GET preflight and bounded OAuth refresh;\nmodern HTTP does not inherit those session or resend behaviors. Incomplete result\nflows are reported without automatically repeating the operation. Credentials,\nconfigured URLs, OAuth browser/login operations and Computer Use decision keys\nstay in Rust.\n\nBoth backends share recorded semantic MCP goldens and retain separate transport\ngoldens, including discovery, authentication and sessioLine truncated
}
]
},
@@ -334,8 +334,8 @@
"sources": [
{
"path": "docs/RUNTIME_API.md",
"sha256": "d2d2d6211c35fe02ce6ec48c881879fe7dd9a45f1bd4ba1b7d64b9df663ef3ec",
"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": "aa431fd8e623203508227c20dd0d34c577fee4d33e1a4d9bb69161c8c5df6ce7",
"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
}
]
},
@@ -403,13 +403,13 @@
"sources": [
{
"path": "docs/RUNTIME_API.md",
"sha256": "d2d2d6211c35fe02ce6ec48c881879fe7dd9a45f1bd4ba1b7d64b9df663ef3ec",
"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": "aa431fd8e623203508227c20dd0d34c577fee4d33e1a4d9bb69161c8c5df6ce7",
"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",
"sha256": "6aa9cf20c05e9d155687a2b34f11d1ecd4d6a2c29c6295305f077c5a0671a4fe",
"content": "# Runtime HTTP route inventory\n\nThis table is generated from literal route registrations in\n`crates/app-server/src/runtime_api.rs`. It names the paths and methods that\nthe current source registers, including native-client and compatibility\nsurfaces. A registered path is not a grant of permission or a promise that\na feature is enabled in your installed version.\n\nThe [Runtime API contract](RUNTIME_API.md) explains request fields, events,\nauthorization and compatibility. This inventory is a navigation index, not\na replacement for those schemas; routes used by the native client can have\nclient-specific contracts. Provider calls, file mutations and connected-service\nactions remain subject to their documented authority boundaries.\n\nRun `node web/scripts/check-runtime-routes.mjs --write` after changing a\nregistration; `npm run check:docs` refuses a stale inventory. Dynamic nested\nrouters, fallback handlers and static-asset mounts are outside this literal\nregistration index.\n\n| HTTP method | Path |\n| --- | --- |\n| GET | `/` |\n| GET | `/__codewhale/bootstrap/{nonce}` |\n| GET | `/__codewhale/mobile/bootstrap/{nonce}` |\n| POST | `/__codewhale/mobile/session` |\n| POST | `/__codewhale/mobile/stream-ticket` |\n| POST | `/__codewhale/web/stream-ticket` |\n| GET | `/assets/codewhale-192.png` |\n| GET | `/assets/codewhale-web.css` |\n| GET | `/assets/codewhale-web.js` |\n| GET | `/health` |\n| GET | `/mobile` |\n| GET | `/mobile/` |\n| DELETE / GET / PUT | `/v1/account/model-access` |\n| POST | `/v1/agent-mail` |\n| GET | `/v1/agent-runs` |\n| GET | `/v1/agent-runs/{run_id}` |\n| POST | `/v1/agent-runs/{run_id}/cancel` |\n| GET | `/v1/agents/external` |\n| GET | `/v1/agents/external/{id}/options` |\n| GET | `/v1/agents/external/quota` |\n| GET | `/v1/approvals` |\n| POST | `/v1/approvals/{approval_id}` |\n| GET | `/v1/apps/avatars` |\n| GET | `/v1/apps/avatars/{handle}/atlases/{page}` |\n| GET / POST | `/v1/apps/marketplaces` |\n| DELETE / GET | `/v1/apps/marketplaces/{name}` |\n| POST | `/v1/apps/marketplaces/{name}/install` |\n| POST | `/v1/apps/mcp/desktop-browser` |\n| GET / POST | `/v1/apps/mcp/imports` |\n| GET / POST | `/v1/apps/mcp/servers` |\n| DELETE / GET / PATCH | `/v1/apps/mcp/servers/{name}` |\n| POST | `/v1/apps/mcp/servers/{name}/disable` |\n| POST | `/v1/apps/mcp/servers/{name}/enable` |\n| POST | `/v1/apps/mcp/servers/{name}/reconnect` |\n| GET | `/v1/apps/mcp/tools` |\n| GET | `/v1/apps/plugins` |\n| DELETE / GET | `/v1/apps/plugins/{selector}` |\n| POST | `/v1/apps/plugins/{selector}/disable` |\n| POST | `/v1/apps/plugins/{selector}/enable` |\n| POST | `/v1/apps/plugins/{selector}/restage` |\n| POST | `/v1/apps/plugins/{selector}/revoke` |\n| ANY | `/v1/apps/plugins/{selector}/routes/{*path}` |\n| POST | `/v1/apps/plugins/{selector}/trust` |\n| GET | `/v1/apps/plugins/{selector}/ui` |\n| GET | `/v1/apps/plugins/{selector}/ui/{*path}` |\n| POST | `/v1/apps/plugins/{selector}/update` |\n| GET | `/v1/apps/plugins/catalog` |\n| POST | `/v1/apps/plugins/catalog/{id}/disable` |\n| POST | `/v1/apps/plugins/catalog/{id}/enable` |\n| PUT | `/v1/apps/plugins/catalog/{id}/settings` |\n| POST | `/v1/apps/plugins/import/dsh/preview` |\n| POST | `/v1/apps/plugins/install` |\n| GET / POST | `/v1/automations` |\n| DELETE / GET / PATCH | `/v1/automations/{id}` |\n| GET | `/v1/automations/{id}/missed` |\n| PUT | `/v1/automations/{id}/missed/policy` |\n| POST | `/v1/automations/{id}/missed/run` |\n| POST | `/v1/automations/{id}/missed/skip` |\n| POST | `/v1/automations/{id}/pause` |\n| POST | `/v1/automations/{id}/resume` |\n| POST | `/v1/automations/{id}/run` |\n| GET | `/v1/automations/{id}/runs` |\n| GET | `/v1/automations/missed` |\n| GET | `/v1/changes` |\n| GET / POST | `/v1/cloud` |\n| POST | `/v1/cloud/attach` |\n| GET | `/v1/commands` |\n| GET / POST | `/v1/config` |\n| POST | `/v1/config/reload` |\n| GET | `/v1/constitution` |\n| POST | `/v1/constitution/preview` |\n| GET | `/v1/crashes` |\n| GET | `/v1/crashes/{name}` |\n| GET | `/v1/definition` |\n| GET | `/v1/diagnostics` |\n| GET | `/v1/diff` |\n| POST | `/v1/fleet/placement` |\n| GET | `/v1/fleet/profiles` |\n| GET | `/v1/fleet/profiles/{id}/document` |\n| GET / POST | `/v1/fleet/runs` |\n| GET | `/v1/fleet/runs/{run_id}` |\n| GET | `/v1/fleet/runs/{run_id}/events` |\n| GET | `/v1/fleet/runs/{run_id}/events/replay` |\n| GET | `/v1/fleet/runs/{run_id}/receipts` |\n| GET | `/v1/fleet/runs/{run_id}/receipts/{task_id}` |\n| GET | `/v1/fleet/runs/{run_id}/receipts/{task_id}/evidence` |\n| POST | `/v1/fleet/runs/{run_id}/start` |\n| POST | `/v1/fleet/runs/{run_id}/stop` |\n| GET | `/v1/fleet/runs/{run_id}/workers` |\n| GET | `/v1/fleet/workers/{worker_id}` |\n| POST | `/v1/fleet/workers/{worker_id}/interrupt` |\n| POST | `/v1/fleet/workers/{worker_id}/restart` |\n| POST | `/v1/fleet/workers/{worker_id}/stop` |\n| GET | `/v1/git` |\n| POST | `/v1/git/branch` |\n| POST | `/v1/git/commit` |\n| POST | `/v1/git/discard` |\nLine truncated
"sha256": "1a21c05b0249c2ff225f7b554e1e610fc12122ab3da52e6ac7ceb06c90448984",
"content": "# Runtime HTTP route inventory\n\nThis table is generated from literal route registrations in\n`crates/app-server/src/runtime_api.rs`. It names the paths and methods that\nthe current source registers, including native-client and compatibility\nsurfaces. A registered path is not a grant of permission or a promise that\na feature is enabled in your installed version.\n\nThe [Runtime API contract](RUNTIME_API.md) explains request fields, events,\nauthorization and compatibility. This inventory is a navigation index, not\na replacement for those schemas; routes used by the native client can have\nclient-specific contracts. Provider calls, file mutations and connected-service\nactions remain subject to their documented authority boundaries.\n\nRun `node web/scripts/check-runtime-routes.mjs --write` after changing a\nregistration; `npm run check:docs` refuses a stale inventory. Dynamic nested\nrouters, fallback handlers and static-asset mounts are outside this literal\nregistration index.\n\n| HTTP method | Path |\n| --- | --- |\n| GET | `/` |\n| GET | `/__codewhale/bootstrap/{nonce}` |\n| GET | `/__codewhale/mobile/bootstrap/{nonce}` |\n| POST | `/__codewhale/mobile/session` |\n| POST | `/__codewhale/mobile/stream-ticket` |\n| POST | `/__codewhale/web/stream-ticket` |\n| GET | `/assets/codewhale-192.png` |\n| GET | `/assets/codewhale-web.css` |\n| GET | `/assets/codewhale-web.js` |\n| GET | `/health` |\n| GET | `/mobile` |\n| GET | `/mobile/` |\n| DELETE / GET / PUT | `/v1/account/model-access` |\n| POST | `/v1/agent-mail` |\n| GET | `/v1/agent-runs` |\n| GET | `/v1/agent-runs/{run_id}` |\n| POST | `/v1/agent-runs/{run_id}/cancel` |\n| GET | `/v1/agents/external` |\n| GET | `/v1/agents/external/{id}/options` |\n| GET | `/v1/agents/external/quota` |\n| GET | `/v1/approvals` |\n| POST | `/v1/approvals/{approval_id}` |\n| GET | `/v1/apps/avatars` |\n| GET | `/v1/apps/avatars/{handle}/atlases/{page}` |\n| GET / POST | `/v1/apps/marketplaces` |\n| DELETE / GET | `/v1/apps/marketplaces/{name}` |\n| POST | `/v1/apps/marketplaces/{name}/install` |\n| POST | `/v1/apps/mcp/desktop-browser` |\n| GET / POST | `/v1/apps/mcp/imports` |\n| GET / POST | `/v1/apps/mcp/servers` |\n| DELETE / GET / PATCH | `/v1/apps/mcp/servers/{name}` |\n| POST | `/v1/apps/mcp/servers/{name}/disable` |\n| POST | `/v1/apps/mcp/servers/{name}/enable` |\n| POST | `/v1/apps/mcp/servers/{name}/reconnect` |\n| GET | `/v1/apps/mcp/tools` |\n| GET | `/v1/apps/plugins` |\n| DELETE / GET | `/v1/apps/plugins/{selector}` |\n| POST | `/v1/apps/plugins/{selector}/disable` |\n| POST | `/v1/apps/plugins/{selector}/enable` |\n| POST | `/v1/apps/plugins/{selector}/restage` |\n| POST | `/v1/apps/plugins/{selector}/revoke` |\n| ANY | `/v1/apps/plugins/{selector}/routes/{*path}` |\n| POST | `/v1/apps/plugins/{selector}/trust` |\n| GET | `/v1/apps/plugins/{selector}/ui` |\n| GET | `/v1/apps/plugins/{selector}/ui/{*path}` |\n| POST | `/v1/apps/plugins/{selector}/update` |\n| GET | `/v1/apps/plugins/catalog` |\n| POST | `/v1/apps/plugins/catalog/{id}/disable` |\n| POST | `/v1/apps/plugins/catalog/{id}/enable` |\n| PUT | `/v1/apps/plugins/catalog/{id}/settings` |\n| POST | `/v1/apps/plugins/import/dsh/preview` |\n| POST | `/v1/apps/plugins/install` |\n| GET / POST | `/v1/automations` |\n| DELETE / GET / PATCH | `/v1/automations/{id}` |\n| GET | `/v1/automations/{id}/missed` |\n| PUT | `/v1/automations/{id}/missed/policy` |\n| POST | `/v1/automations/{id}/missed/run` |\n| POST | `/v1/automations/{id}/missed/skip` |\n| POST | `/v1/automations/{id}/pause` |\n| POST | `/v1/automations/{id}/resume` |\n| POST | `/v1/automations/{id}/run` |\n| GET | `/v1/automations/{id}/runs` |\n| GET | `/v1/automations/missed` |\n| GET | `/v1/changes` |\n| GET / POST | `/v1/cloud` |\n| POST | `/v1/cloud/attach` |\n| GET | `/v1/commands` |\n| GET / POST | `/v1/config` |\n| POST | `/v1/config/reload` |\n| GET | `/v1/constitution` |\n| POST | `/v1/constitution/preview` |\n| GET | `/v1/crashes` |\n| GET | `/v1/crashes/{name}` |\n| GET | `/v1/definition` |\n| GET | `/v1/diagnostics` |\n| GET | `/v1/diff` |\n| POST | `/v1/fleet/placement` |\n| GET | `/v1/fleet/profiles` |\n| GET | `/v1/fleet/profiles/{id}/document` |\n| GET / POST | `/v1/fleet/runs` |\n| GET | `/v1/fleet/runs/{run_id}` |\n| GET | `/v1/fleet/runs/{run_id}/events` |\n| GET | `/v1/fleet/runs/{run_id}/events/replay` |\n| GET | `/v1/fleet/runs/{run_id}/receipts` |\n| GET | `/v1/fleet/runs/{run_id}/receipts/{task_id}` |\n| GET | `/v1/fleet/runs/{run_id}/receipts/{task_id}/evidence` |\n| POST | `/v1/fleet/runs/{run_id}/start` |\n| POST | `/v1/fleet/runs/{run_id}/stop` |\n| GET | `/v1/fleet/runs/{run_id}/workers` |\n| GET | `/v1/fleet/workers/{worker_id}` |\n| POST | `/v1/fleet/workers/{worker_id}/interrupt` |\n| POST | `/v1/fleet/workers/{worker_id}/restart` |\n| POST | `/v1/fleet/workers/{worker_id}/stop` |\n| GET | `/v1/git` |\n| POST | `/v1/git/branch` |\n| POST | `/v1/git/commit` |\n| POST | `/v1/git/discard` |\nLine truncated
}
]
},