feat(runtime): reconcile reviewed plugins and unify native account setup [skip ci]

Restore missing plugin stages only from receipt-matching bytes under the
existing state writer, with generation and policy checks. Refuse changed
existing stages. Reuse normal reload/MCP invalidation for explicit restage;
add read-only shell status and bounded authenticated DSH asset grants.
Expose current-server MCP backoff without treating an absent backoff as health.

Share profile-scoped Rust account custody across terminal and native hosts,
keep local provider setup optional, and make ordinary logout session-only.
Fence browser imports and cleanup by exact revision; preserve ambiguous or
newer sessions. Keep definitely unadopted device grants in cleanup custody.

Validation: six-crate Mac all-target check exit0, 4,270 source entries
unchanged, 35m37s, no disk stop. Grok Linux raw evidence verified: secrets
account6 and plugins202 passed, one existing real-home rehearsal ignored;
all4,270 entries exactly matched that Mac freeze. Existing command-crate
boundary gate passed; 2,816 keys complete in all15 TUI locales.

The subsequent two-file private account cleanup has independent source
review and exact pre/post hash checks; Linux verification is in progress.
Only that cleanup and the already-landed visual-change changelog entry differ
from the passing Mac compile snapshot. No new tests or code comments added.

Qualification is incomplete: production recovery/asset HTTP smoke, published
plugin, Electron, live sign-in, cross-platform/package and final release gates
remain. This local candidate commit is not a release or publication.
This commit is contained in:
CodeWhale Bot committed 2026-11-07 02:32:26 -08:00
1 parent 0ced14d36b
commit c49fd2c331
73 files changed
+4480 -651

No files matched your search

+23 -6
View File
@@ -23,11 +23,12 @@ until the matching tag, packages, checksums, and release assets exist.
available; full-tool turns, subscriptions, bidirectional interactions, durable
settlements, TCP WebSocket and the complete SDK remain unfinished.
- **DSH plugins and MCP lifecycle:** compatibility with unmodified DeepSeek
Harness plugins remains in progress. Automatic restoration of a missing
runtime snapshot is unfinished and may only use canonical source bytes that
still match its receipt. Existing mismatched or tampered snapshots refuse
and require review. Authenticated DSH UI asset transport and revocation,
and an end-to-end published-plugin journey remain unfinished.
Harness plugins remains in progress. The source candidate restores missing
runtime snapshots only from canonical bytes matching the current review,
generation and policy. Existing mismatched or tampered snapshots refuse
and require review. Native lifecycle qualification, authenticated DSH UI
transport and revocation in Electron, and an end-to-end published-plugin
journey remain unfinished.
Downgrading a newly saved plugin marketplace catalog remains unqualified.
- **ChatGPT sign-in on Windows:** the reported 0.10.2 post-browser identity
verification failure has not been confirmed resolved in the reporter's
@@ -58,6 +59,18 @@ until the matching tag, packages, checksums, and release assets exist.
### Added
- **Inspect and repair plugin snapshots:** `codew plugins status [name]
[--json]` reports bundle state and diagnostics without changing plugin
stores. Runtime loading restores a missing snapshot only from bytes matching
its enabled review and policy; `codew plugins restage <name>` or
`/plugin restage <name>` retries explicitly. Recovery preserves review and
generation and refuses an existing changed snapshot. Native and published
plugin qualification remains open for this source candidate.
- **Shared optional account sign-in:** `/connect` offers Codewhale sign-in or
on-computer provider setup. Desktop and terminal use the same profile-scoped
Rust account session; sign-in preserves the selected model and local keys.
Ordinary logout removes the account session, while `logout --all` explicitly
clears saved model credentials. Real sign-in acceptance remains open.
- **Refresh model catalogs during use:** Runtime provider refresh now checks
public Models.dev metadata alongside the exact provider's model list;
`force_catalog=true` bypasses the public cache TTL and retry backoff.
@@ -72,7 +85,7 @@ until the matching tag, packages, checksums, and release assets exist.
Codewhale. See [provider setup](docs/PROVIDERS.md).
- **Worktrees for parallel workspaces:** `GET /v1/worktrees` lists a repository's worktrees, `POST /v1/worktrees` creates a branch and its own checkout under `<repository>/.codewhale/worktrees/` through the existing lane provisioning, and `POST /v1/worktrees/remove` archives a clean managed one without `--force`, keeping the branch. A client starts a thread in the new folder, so several agents can work on one project without sharing files. (codewhale-hq/codewhale-app#3500)
- **Thread-scoped snapshots:** `GET /v1/snapshots` and `POST /v1/snapshots/{id}/restore` accept `?thread_id=` and act on that thread's workspace under the same restore admission rule. (codewhale-hq/codewhale-app#3500)
- **DSH plugins can carry their own client UI:** a reviewed DSH plugin closure may now ship its own Node/Bun dependency tree, CommonJS/CTS entries and UI-only source bundles. The Engine exposes a trusted and enabled plugin's client entry and assets through two loopback-only, read-only routes with bounded sizes, confined paths and safe MIME/no-store responses; no other plugin route gains an unauthenticated path. (#2, #3)
- **DSH plugins can carry their own client UI:** a reviewed DSH plugin closure may now ship its own Node/Bun dependency tree, CommonJS/CTS entries and UI-only source bundles. The Engine's loopback UI descriptor and asset routes require runtime and workspace authorization; exact asset bytes also require the current opaque grant. Responses keep bounded sizes, confined paths and safe MIME/no-store behavior. The desktop host binds grants to individual plugin frames; real Electron transport and teardown qualification remains open. (#2, #3)
- **Present final files:** the read-only `present` tool takes 1–32 existing
workspace files, streams their SHA-256 and byte counts, and returns an
auditable handoff receipt without copying, moving or uploading them. It is
@@ -163,6 +176,10 @@ until the matching tag, packages, checksums, and release assets exist.
### Changed
- **Brighter terminal working states:** the Ocean palette has stronger accents,
and active work uses a restrained left-to-right text sweep. Quiet and
reduced-motion settings keep stable working indicators. The shared
`codewhale-ratatui` kit carries the same palette and motion updates.
- **Managed ACP requirements outrank Full Access:** when organization
requirements manage approval policy, ACP no longer lets `--full-access`
override them. The TUI already locked YOLO this way; ACP now matches.
Generated
+4
View File
@@ -845,6 +845,7 @@ name = "codewhale-account-cli"
version = "0.10.3"
dependencies = [
"anyhow",
"chrono",
"clap",
"codewhale-config",
"codewhale-release",
@@ -888,6 +889,7 @@ dependencies = [
"base64 0.23.1",
"chrono",
"clap",
"codewhale-account-cli",
"codewhale-agent",
"codewhale-agent-contract",
"codewhale-app-server-protocol",
@@ -1639,6 +1641,7 @@ dependencies = [
"tempfile",
"thiserror 2.0.21",
"tracing",
"uuid",
]
[[package]]
@@ -1765,6 +1768,7 @@ dependencies = [
"base64 0.23.1",
"chrono",
"clap",
"codewhale-account-cli",
"codewhale-agent-contract",
"codewhale-app-server",
"codewhale-build-support",
+1
View File
@@ -13,6 +13,7 @@ workspace = true
[dependencies]
anyhow.workspace = true
clap.workspace = true
chrono.workspace = true
codewhale-config = { path = "../config", version = "0.10.3" }
codewhale-release = { path = "../release", version = "0.10.3" }
codewhale-runtime = { path = "../runtime", version = "0.10.3" }
+139 -81
View File
@@ -31,6 +31,12 @@ use serde::{Deserialize, Serialize, de::DeserializeOwned};
mod computer;
pub mod dispatch;
pub mod machine;
mod session;
pub use session::{
AccountAuthLease, AccountClient, AccountLogin, AccountLoginCancellation, AccountLoginPoll,
AccountLoginPrompt, AccountModel, AccountModels, AccountStatus, account_api_base,
};
mod work;
const MAX_RESPONSE_BYTES: u64 = 256 * 1024;
@@ -844,6 +850,7 @@ pub(crate) trait CloudTransport {
fn execute(&self, request: CloudRequest) -> Result<CloudResponse>;
}
#[derive(Clone)]
struct ReqwestTransport {
base: Url,
client: reqwest::blocking::Client,
@@ -877,7 +884,13 @@ impl CloudTransport for ReqwestTransport {
.strip_prefix("/v1/files/")
.and_then(|path| path.strip_suffix("/content"))
.is_some_and(|id| validate_account_file_id(id).is_ok());
let max_response_bytes = if file_download {
let max_response_bytes = if request.method == HttpMethod::Get
&& matches!(
request.path.as_str(),
"/api/account/model-api" | "/v1/models"
) {
4 * 1024 * 1024
} else if file_download {
MAX_FILE_DOWNLOAD_BYTES
} else if matches!(request.method, HttpMethod::Get | HttpMethod::Post)
&& request
@@ -1009,6 +1022,7 @@ impl<'a, T: CloudTransport> CloudClient<'a, T> {
fn poll_device(
&self,
device: &DeviceStart,
snapshot: &AccountSessionSnapshot,
timeout: Duration,
sleep: &mut dyn FnMut(Duration),
) -> Result<AuthBundle> {
@@ -1024,35 +1038,82 @@ impl<'a, T: CloudTransport> CloudClient<'a, T> {
)
.interval_seconds(Some(device.interval))
.max_interval_seconds(10)
.run(sleep, || {
let response = self.transport.execute(CloudRequest {
method: HttpMethod::Post,
path: "/api/cli/device/token".to_string(),
bearer: None,
body: Some(json_body(&DeviceTokenRequest {
device_code: &device.device_code,
})?),
})?;
match response.status {
200 => {
let bundle: AuthBundle = parse_json_body(&response.body)?;
validate_auth_bundle(&bundle)?;
Ok(DevicePollOutcome::Complete(bundle))
}
202 => Ok(DevicePollOutcome::Pending),
_ => Err(response_error(&response)),
}
})?;
self.save_auth(bundle.clone())?;
.run(sleep, || self.poll_device_once(device))?;
if self
.account_store
.save_if_unchanged(snapshot, bundle.clone())?
.is_none()
{
bail!("The signed-in account changed. Refresh and try again.");
}
let _ = self.revoke_previous_session(snapshot, &bundle);
Ok(bundle)
}
fn revoke_previous_session(
&self,
previous: &AccountSessionSnapshot,
next: &AuthBundle,
) -> bool {
let previous = match previous.load() {
Ok(Some(previous)) => previous.bundle,
Ok(None) => return true,
Err(_) => return false,
};
if previous
.session
.as_ref()
.zip(next.session.as_ref())
.is_some_and(|(old, new)| old.id == new.id)
{
return true;
}
self.revoke_session_bundle(&previous)
}
fn revoke_session_bundle(&self, bundle: &AuthBundle) -> bool {
let Ok(body) = json_body(&RefreshRequest {
refresh_token: &bundle.refresh_token,
}) else {
return false;
};
self.transport
.execute(CloudRequest {
method: HttpMethod::Post,
path: "/api/auth/logout".into(),
bearer: None,
body: Some(body),
})
.is_ok_and(|response| (200..300).contains(&response.status))
}
fn poll_device_once(&self, device: &DeviceStart) -> Result<DevicePollOutcome<AuthBundle>> {
let response = self.transport.execute(CloudRequest {
method: HttpMethod::Post,
path: "/api/cli/device/token".to_string(),
bearer: None,
body: Some(json_body(&DeviceTokenRequest {
device_code: &device.device_code,
})?),
})?;
match response.status {
200 => {
let bundle: AuthBundle = parse_json_body(&response.body)?;
validate_auth_bundle(&bundle)?;
Ok(DevicePollOutcome::Complete(bundle))
}
202 => Ok(DevicePollOutcome::Pending),
_ => Err(response_error(&response)),
}
}
fn load_auth(&self) -> Result<Option<StoredCloudAuth>> {
self.account_store.load().context(
"the local Codewhale account session is unreadable; run `codewhale account logout` and sign in again",
)
}
#[cfg(test)]
fn save_auth(&self, bundle: AuthBundle) -> Result<()> {
self.account_store
.save(bundle)
@@ -1569,9 +1630,13 @@ impl<'a, T: CloudTransport> CloudClient<'a, T> {
fn logout(&self) -> Result<bool> {
let snapshot = self.account_store.snapshot()?;
self.logout_from_snapshot(&snapshot)
}
fn logout_from_snapshot(&self, snapshot: &AccountSessionSnapshot) -> Result<bool> {
self.account_store
.with_transaction(|transaction| -> Result<bool> {
if !transaction.matches(&snapshot) {
if !transaction.matches(snapshot) {
bail!("The signed-in account changed. Refresh and try again.");
}
let stored = match transaction.load() {
@@ -1626,7 +1691,7 @@ impl<'a, T: CloudTransport> CloudClient<'a, T> {
body: Option<Vec<u8>>,
snapshot: AccountSessionSnapshot,
) -> Result<(CloudResponse, AccountSessionSnapshot)> {
let Some(mut stored) = snapshot.load()? else {
let Some(stored) = snapshot.load()? else {
bail!("Not signed in. Run `codewhale login` first");
};
let first = self.transport.execute(CloudRequest {
@@ -1638,12 +1703,34 @@ impl<'a, T: CloudTransport> CloudClient<'a, T> {
if first.status != 401 {
return Ok((first, snapshot));
}
// Serialize the refresh HTTP request itself with native/CLI writers:
// two processes must not spend the same rotating refresh token.
let renewed = self
.account_store
let renewed = self.refresh_snapshot(&snapshot)?;
let Some((next, next_snapshot)) = renewed else {
bail!("The Codewhale account session expired. Run `codewhale login` again");
};
// The rotated token is durable before a potentially failing retry.
let retried = self.transport.execute(CloudRequest {
method,
path: path.into(),
bearer: Some(next.access_token),
body,
})?;
if retried.status == 401 {
self.account_store.clear_if_unchanged(&next_snapshot)?;
bail!("The Codewhale account session expired. Run `codewhale login` again");
}
Ok((retried, next_snapshot))
}
fn refresh_snapshot(
&self,
snapshot: &AccountSessionSnapshot,
) -> Result<Option<(AuthBundle, AccountSessionSnapshot)>> {
let Some(mut stored) = snapshot.load()? else {
return Ok(None);
};
self.account_store
.with_transaction(|transaction| -> Result<_> {
if !transaction.matches(&snapshot) {
if !transaction.matches(snapshot) {
bail!("The signed-in account changed. Refresh and try again.");
}
let refresh = self.transport.execute(CloudRequest {
@@ -1685,22 +1772,7 @@ impl<'a, T: CloudTransport> CloudClient<'a, T> {
}
transaction.replace(next.clone())?;
Ok(Some((next, transaction.snapshot())))
})?;
let Some((next, next_snapshot)) = renewed else {
bail!("The Codewhale account session expired. Run `codewhale login` again");
};
// The rotated token is durable before a potentially failing retry.
let retried = self.transport.execute(CloudRequest {
method,
path: path.into(),
bearer: Some(next.access_token),
body,
})?;
if retried.status == 401 {
self.account_store.clear_if_unchanged(&next_snapshot)?;
bail!("The Codewhale account session expired. Run `codewhale login` again");
}
Ok((retried, next_snapshot))
})
}
/// Whether an interactive session exists for this profile and origin.
@@ -3113,6 +3185,17 @@ pub fn run_account_login(
)
}
pub fn run_account_logout(profile: Option<&str>, config: &mut ConfigStore) -> Result<()> {
run(
CloudArgs {
api_base: None,
command: CloudCommand::Logout,
},
profile,
config,
)
}
pub fn reject_inline_api_key(api_key: Option<&str>) -> Result<()> {
if api_key.is_some() {
bail!(
@@ -3122,40 +3205,6 @@ pub fn reject_inline_api_key(api_key: Option<&str>) -> Result<()> {
Ok(())
}
/// On account sign-in, point a never-configured local route at the managed
/// Codewhale provider so chat works immediately. A provider the user chose
/// explicitly, or one that already has a local key (config, secret store or
/// environment), is left alone: the default provider is DeepSeek, so equality
/// with the default cannot tell "never configured" from "chose DeepSeek".
fn select_managed_route_on_login<W: Write>(
config: &mut ConfigStore,
provider_secrets: &Secrets,
out: &mut W,
) -> Result<()> {
let explicit_provider = config
.original_body()
.and_then(|body| body.parse::<toml::Table>().ok())
.is_some_and(|table| table.contains_key("provider"));
let has_local_key =
resolve_local_key(config, provider_secrets, config.config.provider)?.is_some();
if config.config.provider == ProviderKind::default() && !explicit_provider && !has_local_key {
config.config.provider = ProviderKind::Codewhale;
config.config.model = Some("auto".to_string());
config.save()?;
writeln!(
out,
"Using your Codewhale account route (provider codewhale, model auto)."
)?;
} else {
writeln!(
out,
"Keeping your configured {} route.",
config.config.provider.as_str()
)?;
}
Ok(())
}
#[allow(clippy::too_many_arguments)]
fn run_with<T: CloudTransport, W: Write>(
command: CloudCommand,
@@ -3174,6 +3223,7 @@ fn run_with<T: CloudTransport, W: Write>(
let client = CloudClient::new(transport, cloud_secrets, profile, api_base);
match command {
CloudCommand::Login(login) => {
let snapshot = client.account_store.begin_login()?;
let device = client.start_device()?;
validate_user_code(&device.user_code)?;
validate_verification_url(
@@ -3198,11 +3248,19 @@ fn run_with<T: CloudTransport, W: Write>(
"Browser could not be opened; use the URL and code above."
)?;
}
let _ =
client.poll_device(&device, Duration::from_secs(login.timeout_seconds), sleeper)?;
let _ = client.poll_device(
&device,
&snapshot,
Duration::from_secs(login.timeout_seconds),
sleeper,
)?;
let user = client.me()?;
write_account(out, "Signed in to Codewhale.", profile, api_base, &user)?;
select_managed_route_on_login(config, provider_secrets, out)?;
writeln!(
out,
"Keeping your configured {} route.",
config.config.provider.as_str()
)?;
Ok(())
}
CloudCommand::Status => match client.load_auth()? {
+688
View File
@@ -0,0 +1,688 @@
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, Ordering};
use std::time::{Duration, Instant};
use chrono::{DateTime, Utc};
use codewhale_secrets::account::{AccountDeviceAccess, AccountSessionState, RuntimeAccountInfo};
use super::*;
pub fn account_api_base(api_base: Option<&str>) -> Result<String> {
let requested_base = machine::resolve_api_base(
api_base,
std::env::var(machine::MACHINE_API_BASE_ENV).ok().as_deref(),
std::env::var(CLOUD_API_BASE_ENV).ok().as_deref(),
DEFAULT_API_BASE,
);
Ok(validate_api_base(&requested_base)?.display)
}
#[derive(Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct AccountStatus {
pub profile: String,
pub api_base: String,
pub account: RuntimeAccountInfo,
pub user: Option<CloudUser>,
pub revision: String,
pub import_allowed: bool,
}
pub struct AccountAuthLease {
pub bundle: AuthBundle,
pub snapshot: AccountSessionSnapshot,
}
#[derive(Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct AccountModel {
pub id: String,
pub label: String,
pub provider: String,
pub model: String,
pub protocol: String,
pub billing_mode: String,
pub is_default: bool,
}
pub struct AccountModels {
pub api_base: String,
pub account_id: String,
pub session_id: String,
pub expires_at: i64,
pub default_model: Option<String>,
pub models: Vec<AccountModel>,
pub credential: Option<String>,
pub snapshot: AccountSessionSnapshot,
}
#[derive(Clone)]
pub struct AccountClient {
transport: ReqwestTransport,
store: AccountSessionStore,
profile: String,
api_base: String,
}
impl AccountClient {
pub fn new(profile: Option<&str>, api_base: Option<&str>) -> Result<Self> {
let requested_base = account_api_base(api_base)?;
let base = validate_api_base(&requested_base)?;
let profile = normalized_profile(profile);
let secrets = cloud_session_secrets()?;
Ok(Self {
transport: ReqwestTransport::new(base.url)?,
store: AccountSessionStore::new(secrets, Some(&profile), &base.display),
profile,
api_base: base.display,
})
}
fn client(&self) -> CloudClient<'_, ReqwestTransport> {
CloudClient {
transport: &self.transport,
account_store: self.store.clone(),
}
}
fn revoke_session_later(&self, previous: Option<AccountSessionSnapshot>, bundle: AuthBundle) {
let client = self.clone();
let _ = std::thread::Builder::new()
.name("cw-account-revoke".into())
.spawn(move || {
let _ = match previous {
Some(previous) => client.client().revoke_previous_session(&previous, &bundle),
None => client.client().revoke_session_bundle(&bundle),
};
});
}
pub fn snapshot(&self) -> Result<AccountSessionSnapshot> {
Ok(self.store.snapshot()?)
}
pub fn status(&self) -> Result<AccountStatus> {
let snapshot = self.snapshot()?;
let user = snapshot.load()?.and_then(|stored| stored.bundle.user);
Ok(AccountStatus {
profile: self.profile.clone(),
api_base: self.api_base.clone(),
account: snapshot.runtime_info_at(Utc::now())?,
user,
revision: snapshot.fingerprint(),
import_allowed: snapshot.is_pristine(),
})
}
pub fn claim(&self) -> Result<AccountSessionSnapshot> {
Ok(self.store.begin_login()?)
}
pub fn claim_if_unchanged(
&self,
expected_revision: &str,
) -> Result<Option<AccountSessionSnapshot>> {
self.store.with_transaction(|transaction| {
if transaction.snapshot().fingerprint() != expected_revision {
return Ok(None);
}
Ok(Some(transaction.renew_revision()?))
})
}
pub fn cancel_claim(&self, expected_revision: &str) -> Result<bool> {
self.store.with_transaction(|transaction| {
if transaction.snapshot().fingerprint() != expected_revision {
return Ok(false);
}
transaction.renew_revision()?;
Ok(true)
})
}
pub fn import(
&self,
bundle: AuthBundle,
expected_revision: &str,
replace_existing: bool,
) -> Result<bool> {
self.import_with_receipt(bundle, expected_revision, replace_existing)
.map(|receipt| receipt.is_some())
}
pub fn import_with_receipt(
&self,
bundle: AuthBundle,
expected_revision: &str,
replace_existing: bool,
) -> Result<Option<AccountSessionSnapshot>> {
validate_auth_bundle(&bundle)?;
let expected = self.snapshot()?;
if expected.fingerprint() != expected_revision {
return Ok(None);
}
if !replace_existing && !expected.is_pristine() {
return Ok(None);
}
let Some(committed) = self.store.save_if_unchanged(&expected, bundle.clone())? else {
return Ok(None);
};
self.revoke_session_later(Some(expected), bundle);
Ok(Some(committed))
}
pub fn fresh_auth(&self, rejected_token: Option<&str>) -> Result<Option<AccountAuthLease>> {
let snapshot = self.snapshot()?;
let Some(stored) = snapshot.load()? else {
return Ok(None);
};
if matches!(
snapshot.runtime_info_at(Utc::now())?.state,
AccountSessionState::Expired | AccountSessionState::Revoked
) {
return Ok(None);
}
let refresh = rejected_token == Some(stored.bundle.access_token.as_str())
|| !stored
.bundle
.session
.as_ref()
.and_then(|session| DateTime::parse_from_rfc3339(&session.expires_at).ok())
.is_some_and(|expires| expires.timestamp() > Utc::now().timestamp() + 120);
let (bundle, snapshot) = if refresh {
let Some(renewed) = self.client().refresh_snapshot(&snapshot)? else {
return Ok(None);
};
renewed
} else {
(stored.bundle, snapshot)
};
let current = self.store.with_transaction(|transaction| {
Ok::<_, anyhow::Error>(transaction.matches(&snapshot))
})?;
Ok(current.then_some(AccountAuthLease { bundle, snapshot }))
}
pub fn logout(&self) -> Result<bool> {
self.client().logout()
}
pub fn logout_if_unchanged(&self, snapshot: &AccountSessionSnapshot) -> Result<bool> {
self.client().logout_from_snapshot(snapshot)
}
pub fn connect_models(&self) -> Result<AccountModels> {
self.connect_models_with_cancel(&AtomicBool::new(false))
}
pub fn connect_models_with_cancel(&self, cancelled: &AtomicBool) -> Result<AccountModels> {
if cancelled.load(Ordering::Acquire) {
bail!("Codewhale account model connection cancelled");
}
let lease = self
.fresh_auth(None)?
.ok_or_else(|| anyhow!("Sign in to Codewhale before connecting account models"))?;
let (response, snapshot) = self.client().execute_from_snapshot(
HttpMethod::Get,
"/api/account/model-api",
None,
lease.snapshot,
)?;
let catalog: serde_json::Value = expect_json(response, &[200])?;
let data = catalog
.get("data")
.and_then(serde_json::Value::as_array)
.filter(|rows| rows.len() <= 8192)
.ok_or_else(|| anyhow!("The Codewhale account model catalog was invalid"))?;
let mut models = Vec::new();
for row in data {
let facts = &row["codewhale"];
if facts["usable"].as_bool() != Some(true) {
continue;
}
if facts["protocol"].as_str() != Some("chat-completions")
|| facts["billingMode"].as_str() != Some("byok_external")
{
continue;
}
let id = model_text(row, "id")?;
let provider = model_text(facts, "provider")?;
let model = model_text(facts, "model")?;
if id != format!("{provider}/{model}") {
bail!("The Codewhale account model catalog was invalid");
}
models.push(AccountModel {
id,
label: facts["label"]
.as_str()
.map(|label| printable_max(label, 200))
.filter(|label| !label.is_empty())
.unwrap_or_else(|| model.clone()),
provider,
model,
protocol: model_text(facts, "protocol")?,
billing_mode: model_text(facts, "billingMode")?,
is_default: facts["default"].as_bool() == Some(true),
});
}
let stored = snapshot
.load()?
.ok_or_else(|| anyhow!("The signed-in account changed. Refresh and try again."))?;
let account_id = stored
.bundle
.user
.as_ref()
.map(|user| user.id.clone())
.filter(|id| !id.is_empty())
.ok_or_else(|| anyhow!("Sign in again before connecting account models"))?;
let session = stored
.bundle
.session
.as_ref()
.filter(|session| !session.id.is_empty())
.ok_or_else(|| anyhow!("Sign in again before connecting account models"))?;
let session_id = session.id.clone();
let access_expires = DateTime::parse_from_rfc3339(&session.expires_at)
.context("The account session expiry was invalid")?
.timestamp();
let refresh_expires = DateTime::parse_from_rfc3339(&session.refresh_expires_at)
.context("The account session expiry was invalid")?
.timestamp();
let default_model = catalog["codewhale"]["defaultModel"]
.as_str()
.filter(|id| models.iter().any(|model| model.id == *id))
.map(str::to_string)
.or_else(|| {
models
.iter()
.find(|model| model.is_default)
.map(|model| model.id.clone())
});
let mut result = AccountModels {
api_base: format!("{}/v1", self.api_base),
account_id: account_id.clone(),
session_id: session_id.clone(),
expires_at: 0,
default_model,
models,
credential: None,
snapshot,
};
if result.models.is_empty() {
let current = self.store.with_transaction(|transaction| {
Ok::<_, anyhow::Error>(transaction.matches(&result.snapshot))
})?;
if !current {
bail!("The signed-in account changed. Refresh and try again.");
}
return Ok(result);
}
if cancelled.load(Ordering::Acquire) {
bail!("Codewhale account model connection cancelled");
}
let device_id = self.store.device_id()?;
let mut issued_id: Option<String> = None;
let connected = self.store.with_transaction(|transaction| {
if !transaction.matches(&result.snapshot) || access_expires <= Utc::now().timestamp() {
bail!("The signed-in account changed. Refresh and try again.");
}
if cancelled.load(Ordering::Acquire) {
bail!("Codewhale account model connection cancelled");
}
let current = transaction.snapshot();
if let Some(access) = current.device_access()? {
if access.device_id != device_id {
bail!("The Codewhale device connection changed. Reconnect your account.");
}
machine::MachineKey::parse(&access.credential)?;
if access.expires_at > Utc::now().timestamp() {
let checked = self.transport.execute(CloudRequest {
method: HttpMethod::Get,
path: "/v1/models".into(),
bearer: Some(access.credential.clone()),
body: None,
})?;
match checked.status {
200 => return Ok((access, current)),
401 => {}
_ => return Err(response_error(&checked)),
}
}
}
if cancelled.load(Ordering::Acquire) {
bail!("Codewhale account model connection cancelled");
}
let response = self.transport.execute(CloudRequest {
method: HttpMethod::Post,
path: "/api/account/device-link".into(),
bearer: Some(stored.bundle.access_token.clone()),
body: Some(serde_json::to_vec(&serde_json::json!({
"deviceId": device_id,
"deviceName": "Codewhale",
"expectedAccountId": account_id,
}))?),
})?;
let linked: LinkedDevice = expect_json(response, &[201])?;
if linked.api_key.id.len() == 24
&& linked
.api_key
.id
.bytes()
.all(|byte| byte.is_ascii_hexdigit())
{
issued_id = Some(linked.api_key.id.clone());
}
let key = machine::MachineKey::parse(&linked.secret)?;
let expires_at = DateTime::parse_from_rfc3339(&linked.api_key.expires_at)
.context("The account device expiry was invalid")?
.timestamp();
if !linked.session_bound
|| linked.api_key.scopes.len() != 1
|| linked.api_key.scopes[0] != "models:infer"
|| linked.api_key.revoked_at.is_some()
|| linked.api_key.context.kind != "device"
|| linked.api_key.context.device_id != device_id
|| linked.api_key.context.session_id != session_id
|| key.head().strip_prefix("cwc_key_") != Some(linked.api_key.id.as_str())
|| expires_at <= Utc::now().timestamp()
|| expires_at > refresh_expires
{
bail!("The Codewhale device connection could not be verified");
}
let access = AccountDeviceAccess {
device_id: device_id.clone(),
credential: linked.secret,
api_key_id: linked.api_key.id,
account_id: account_id.clone(),
session_id: session_id.clone(),
expires_at,
};
transaction.set_device_access(access.clone())?;
Ok((access, transaction.snapshot()))
});
let (access, snapshot) = match connected {
Ok(connected) => connected,
Err(error) => {
if let Some(id) = issued_id {
let _ = self.transport.execute(CloudRequest {
method: HttpMethod::Delete,
path: format!("/api/account/api-keys/{id}"),
bearer: Some(stored.bundle.access_token),
body: None,
});
}
return Err(error);
}
};
result.expires_at = access.expires_at.min(access_expires);
result.credential = Some(access.credential);
result.snapshot = snapshot;
Ok(result)
}
pub fn start_login(&self, timeout: Duration) -> Result<AccountLogin> {
self.start_login_with_cancel(timeout, Arc::new(AtomicBool::new(false)))
}
pub fn start_login_with_cancel(
&self,
timeout: Duration,
cancelled: Arc<AtomicBool>,
) -> Result<AccountLogin> {
if timeout.is_zero() || timeout > Duration::from_secs(MAX_LOGIN_TIMEOUT_SECONDS) {
bail!("Invalid Codewhale account login timeout");
}
let expected = self.store.with_transaction(|transaction| {
if cancelled.load(Ordering::Acquire) {
bail!("Codewhale account login cancelled");
}
Ok::<_, anyhow::Error>(transaction.renew_revision()?)
})?;
let started = Instant::now();
if cancelled.load(Ordering::Acquire) {
bail!("Codewhale account login cancelled");
}
let device = self.client().start_device()?;
validate_device_code(&device.device_code)?;
validate_user_code(&device.user_code)?;
let verification_uri = validate_verification_url(
&device.verification_uri,
&self.api_base,
&device.user_code,
false,
)?;
let verification_uri_complete = validate_verification_url(
&device.verification_uri_complete,
&self.api_base,
&device.user_code,
true,
)?;
let lifetime = timeout.min(Duration::from_secs(
device.expires_in.clamp(1, MAX_LOGIN_TIMEOUT_SECONDS),
));
let interval = if device.interval == 0 {
codewhale_config::device_code::DEFAULT_POLL_INTERVAL_SECS
} else {
device.interval.min(10)
};
Ok(AccountLogin {
client: self.clone(),
expected,
prompt: AccountLoginPrompt {
user_code: device.user_code.clone(),
verification_uri,
verification_uri_complete,
expires_in: lifetime.as_secs(),
interval,
},
device,
cancelled: AccountLoginCancellation(cancelled),
deadline: started + lifetime,
next_poll: started,
pending_bundle: None,
finished: false,
})
}
}
fn model_text(value: &serde_json::Value, key: &str) -> Result<String> {
value[key]
.as_str()
.filter(|text| {
!text.is_empty()
&& text.len() <= 512
&& !text.chars().any(char::is_control)
&& !text.chars().any(char::is_whitespace)
})
.map(str::to_string)
.ok_or_else(|| anyhow!("The Codewhale account model catalog was invalid"))
}
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
struct LinkedDevice {
secret: String,
api_key: LinkedDeviceKey,
session_bound: bool,
}
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
struct LinkedDeviceKey {
id: String,
scopes: Vec<String>,
expires_at: String,
revoked_at: Option<String>,
context: LinkedDeviceContext,
}
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
struct LinkedDeviceContext {
kind: String,
device_id: String,
session_id: String,
}
#[derive(Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct AccountLoginPrompt {
pub user_code: String,
pub verification_uri: String,
pub verification_uri_complete: String,
pub expires_in: u64,
pub interval: u64,
}
#[derive(Clone)]
pub struct AccountLoginCancellation(Arc<AtomicBool>);
impl AccountLoginCancellation {
pub fn cancel(&self) {
self.0.store(true, Ordering::Release);
}
}
pub enum AccountLoginPoll {
Pending { retry_after: Duration },
Complete { user: CloudUser },
Cancelled,
Expired,
}
pub struct AccountLogin {
client: AccountClient,
expected: AccountSessionSnapshot,
device: DeviceStart,
prompt: AccountLoginPrompt,
cancelled: AccountLoginCancellation,
deadline: Instant,
next_poll: Instant,
pending_bundle: Option<AuthBundle>,
finished: bool,
}
impl AccountLogin {
pub fn prompt(&self) -> &AccountLoginPrompt {
&self.prompt
}
pub fn cancellation(&self) -> AccountLoginCancellation {
self.cancelled.clone()
}
pub fn poll(&mut self) -> Result<AccountLoginPoll> {
let result = self.poll_once();
if !matches!(result, Ok(AccountLoginPoll::Pending { .. })) {
self.finished = true;
}
if matches!(
result,
Ok(AccountLoginPoll::Cancelled | AccountLoginPoll::Expired)
) {
self.revoke_pending();
}
result
}
fn revoke_pending(&mut self) {
if let Some(bundle) = self.pending_bundle.take() {
self.client.revoke_session_later(None, bundle);
}
}
fn poll_once(&mut self) -> Result<AccountLoginPoll> {
if self.finished || self.cancelled.0.load(Ordering::Acquire) {
return Ok(AccountLoginPoll::Cancelled);
}
let now = Instant::now();
if now >= self.deadline {
return Ok(AccountLoginPoll::Expired);
}
if now < self.next_poll {
return Ok(AccountLoginPoll::Pending {
retry_after: (self.next_poll - now).min(self.deadline - now),
});
}
if !self.client.store.with_transaction(|transaction| {
Ok::<_, anyhow::Error>(transaction.matches(&self.expected))
})? {
return Ok(AccountLoginPoll::Cancelled);
}
if let Some(bundle) = self.pending_bundle.as_mut() {
if bundle.user.is_none() {
let response = self.client.transport.execute(CloudRequest {
method: HttpMethod::Get,
path: "/api/me".into(),
bearer: Some(bundle.access_token.clone()),
body: None,
})?;
bundle.user = Some(expect_json::<MeResponse>(response, &[200])?.user);
}
} else {
match self.client.client().poll_device_once(&self.device)? {
DevicePollOutcome::Complete(bundle) => self.pending_bundle = Some(bundle),
DevicePollOutcome::Pending | DevicePollOutcome::SlowDown { .. } => {
self.next_poll = Instant::now() + Duration::from_secs(self.prompt.interval);
return Ok(AccountLoginPoll::Pending {
retry_after: self
.next_poll
.min(self.deadline)
.saturating_duration_since(Instant::now()),
});
}
}
}
let bundle = self
.pending_bundle
.as_ref()
.ok_or_else(|| anyhow!("The Codewhale account authorization was unavailable"))?;
if bundle.user.is_none() {
return Ok(AccountLoginPoll::Pending {
retry_after: Duration::ZERO,
});
}
let Some(user) = bundle
.user
.as_ref()
.filter(|user| !user.id.trim().is_empty())
else {
bail!("The Codewhale service returned an account without an ID");
};
let user = user.clone();
validate_auth_bundle(&bundle)?;
if Instant::now() >= self.deadline {
return Ok(AccountLoginPoll::Expired);
}
let bundle = self
.pending_bundle
.take()
.ok_or_else(|| anyhow!("The Codewhale account authorization was unavailable"))?;
let committed = self.client.store.with_transaction(|transaction| {
if self.cancelled.0.load(Ordering::Acquire)
|| !transaction.matches(&self.expected)
|| Instant::now() >= self.deadline
{
return Ok::<_, anyhow::Error>(false);
}
transaction.replace(bundle.clone())?;
Ok(true)
})?;
if !committed {
self.client.revoke_session_later(None, bundle);
return Ok(AccountLoginPoll::Cancelled);
}
self.client
.revoke_session_later(Some(self.expected.clone()), bundle);
Ok(AccountLoginPoll::Complete { user })
}
}
impl Drop for AccountLogin {
fn drop(&mut self) {
if !self.finished {
self.cancelled.cancel();
}
self.revoke_pending();
}
}
+12 -9
View File
@@ -618,18 +618,18 @@ fn run_login(config: &mut ConfigStore, secrets: &Secrets) -> String {
}
#[test]
fn login_selects_managed_route_when_provider_is_default() {
fn login_keeps_the_default_route_when_no_model_is_connected() {
let (temp, mut config) = test_config();
assert_eq!(config.config.provider, ProviderKind::default());
let (secrets, _) = test_secrets();
let output = run_login(&mut config, &secrets);
assert!(
output.contains("Using your Codewhale account route"),
output.contains("Keeping your configured deepseek route."),
"{output}"
);
let saved = ConfigStore::load(Some(temp.path().join("config.toml"))).unwrap();
assert_eq!(saved.config.provider, ProviderKind::Codewhale);
assert_eq!(saved.config.model.as_deref(), Some("auto"));
assert_eq!(saved.config.provider, ProviderKind::default());
assert_eq!(saved.config.model, None);
}
#[test]
@@ -1348,7 +1348,10 @@ fn logout_recovers_from_a_corrupt_local_session_record() {
&mut sleeper,
)
.unwrap();
assert!(store.get(&slot).unwrap().is_none());
let cleared: serde_json::Value =
serde_json::from_str(&store.get(&slot).unwrap().unwrap()).unwrap();
assert_eq!(cleared["signedOut"], true);
assert!(cleared.get("bundle").is_none());
assert!(
!String::from_utf8(output)
.unwrap()
@@ -1388,10 +1391,10 @@ fn logout_clears_obsolete_or_wrong_origin_session_records() {
"{case} must continue to load as signed out"
);
assert!(!client.logout().unwrap());
assert!(
store.get(&slot).unwrap().is_none(),
"logout must scrub the {case} record"
);
let cleared: serde_json::Value =
serde_json::from_str(&store.get(&slot).unwrap().unwrap()).unwrap();
assert_eq!(cleared["signedOut"], true, "logout must scrub {case}");
assert!(cleared.get("bundle").is_none());
assert!(transport.requests().is_empty());
}
}
+1
View File
@@ -26,6 +26,7 @@ default = []
test-support = ["codewhale-engine/test-support"]
[dependencies]
codewhale-account-cli = { path = "../account-cli", version = "0.10.3" }
codewhale-app-server-protocol = { path = "../app-server-protocol", version = "0.10.3" }
anyhow.workspace = true
axum.workspace = true
+19 -4
View File
@@ -110,6 +110,7 @@ use codewhale_protocol::fleet::{
};
use codewhale_runtime::skill_state::SkillStateStore;
mod account;
mod auth;
mod avatars;
mod computer_display;
@@ -127,6 +128,7 @@ mod plans;
mod plugins;
pub(crate) mod review;
mod secrets;
pub use self::secrets::{SetAccountModelAccessRequest, install_account_model_access};
pub(crate) mod sessions;
mod targets;
mod terminal;
@@ -201,6 +203,7 @@ pub struct RuntimeApiState {
config: Arc<parking_lot::RwLock<Config>>,
workspace: PathBuf,
plugin_discovery: Arc<codewhale_plugins::PluginDiscoveryContext>,
plugin_ui_grants: Arc<plugins::PluginUiGrants>,
task_manager: SharedTaskManager,
runtime_threads: SharedRuntimeThreadManager,
cors_origins: Vec<String>,
@@ -1639,6 +1642,7 @@ struct McpServerEntry {
command: Option<String>,
url: Option<String>,
connected: bool,
has_connection_backoff: Option<bool>,
enabled_tools: Vec<String>,
disabled_tools: Vec<String>,
always_allow_tools: Vec<String>,
@@ -2338,6 +2342,7 @@ pub async fn run_http_server(
config: Arc::new(parking_lot::RwLock::new(config.clone())),
workspace,
plugin_discovery,
plugin_ui_grants: Arc::new(plugins::PluginUiGrants::default()),
task_manager: task_manager.clone(),
runtime_threads,
cors_origins: options.cors_origins.clone(),
@@ -3287,6 +3292,10 @@ pub fn build_router(state: RuntimeApiState) -> Router {
"/v1/apps/plugins/{selector}/ui",
get(plugins::get_plugin_ui),
)
.route(
"/v1/apps/plugins/{selector}/ui/{*path}",
get(plugins::get_plugin_ui_asset),
)
.route(
"/v1/apps/plugins/{selector}/update",
post(plugins::update_plugin_api),
@@ -3303,6 +3312,10 @@ pub fn build_router(state: RuntimeApiState) -> Router {
"/v1/apps/plugins/{selector}/disable",
post(plugins::disable_plugin_api),
)
.route(
"/v1/apps/plugins/{selector}/restage",
post(plugins::restage_plugin_api),
)
.route(
"/v1/apps/plugins/{selector}/revoke",
post(plugins::revoke_plugin_api),
@@ -3449,10 +3462,6 @@ pub fn build_router(state: RuntimeApiState) -> Router {
// listener here. The response already redacts account detail for
// unauthorized requests; it never mutates state.
.route("/v1/runtime/info", get(runtime_info))
.route(
"/v1/apps/plugins/{selector}/ui/{*path}",
get(plugins::get_plugin_ui_asset),
)
// Authenticates per handler: the display WS also takes a single-use
// ticket, and client-token minting is master-token only.
.merge(computer_display::router(
@@ -3460,6 +3469,7 @@ pub fn build_router(state: RuntimeApiState) -> Router {
state.runtime_token.clone(),
))
.merge(api_routes)
.merge(account::routes(&state))
.layer(cors_layer(&state.cors_origins))
.with_state(state)
}
@@ -7352,6 +7362,9 @@ async fn list_mcp_servers(
connected: pool
.as_ref()
.is_some_and(|pool| pool.connected_servers().contains(&name.as_str())),
has_connection_backoff: pool
.as_ref()
.and_then(|pool| pool.has_connection_backoff(&name, config.timeouts, &server_cfg)),
enabled_tools: server_cfg.enabled_tools.clone(),
disabled_tools: server_cfg.disabled_tools.clone(),
always_allow_tools: server_cfg.always_allow_tools.clone(),
@@ -7376,6 +7389,7 @@ async fn list_mcp_servers(
connected: pool
.as_ref()
.is_some_and(|pool| pool.connected_servers().contains(&name.as_str())),
has_connection_backoff: None,
enabled_tools: server_cfg.enabled_tools.clone(),
disabled_tools: server_cfg.disabled_tools.clone(),
always_allow_tools: server_cfg.always_allow_tools.clone(),
@@ -14891,6 +14905,7 @@ base_url = "http://127.0.0.1:9/v1"
config: Arc::new(parking_lot::RwLock::new(config)),
workspace: workspace.clone(),
plugin_discovery: codewhale_plugins::PluginDiscoveryContext::capture_pre_dotenv(),
plugin_ui_grants: Arc::new(plugins::PluginUiGrants::default()),
task_manager,
runtime_threads,
cors_origins: Vec::new(),
@@ -0,0 +1,274 @@
use axum::extract::{DefaultBodyLimit, State};
use axum::http::{HeaderValue, header};
use axum::middleware::{self, Next};
use axum::response::{IntoResponse, Response};
use axum::routing::{delete, get, post};
use axum::{Json, Router};
use codewhale_account_cli::{AccountClient, AccountStatus};
use codewhale_secrets::account::AccountAuthBundle;
use serde::Deserialize;
use serde_json::{Value, json};
use super::{ApiError, RuntimeApiState, auth};
pub(super) fn routes(state: &RuntimeApiState) -> Router<RuntimeApiState> {
Router::new()
.route("/v1/account/status", get(status))
.route("/v1/account/lease", post(lease))
.route("/v1/account/import", post(import))
.route("/v1/account/session", delete(logout))
.route("/v1/account/claim", post(claim).delete(cancel_claim))
.layer(DefaultBodyLimit::max(256 * 1024))
.route_layer(middleware::from_fn_with_state(
state.clone(),
auth::require_host_token,
))
.layer(middleware::from_fn(no_store))
}
async fn no_store(request: axum::extract::Request, next: Next) -> Response {
let mut response = next.run(request).await;
response.headers_mut().insert(
header::CACHE_CONTROL,
HeaderValue::from_static("private, no-store"),
);
response
}
fn client(state: &RuntimeApiState) -> Result<AccountClient, ApiError> {
AccountClient::new(
state.config_profile.as_deref(),
Some(&codewhale_engine::profile_constitution::runtime_account_api_base()),
)
.map_err(|_| ApiError::internal("Codewhale account storage is unavailable"))
}
fn receipt(status: AccountStatus) -> Value {
let display_name = status
.user
.as_ref()
.map(|user| {
if user.display_name.trim().is_empty() {
user.id.as_str()
} else {
user.display_name.as_str()
}
})
.map(|name| name.chars().take(120).collect::<String>());
json!({
"apiBase": status.api_base,
"profile": status.profile,
"state": status.account.state,
"revision": status.revision,
"accountId": status.account.account_id,
"sessionId": status.account.session_id,
"expiresAt": status.account.expires_at,
"displayName": display_name,
"importAllowed": status.import_allowed,
})
}
fn read_status(client: &AccountClient) -> Result<Value, ApiError> {
client
.status()
.map(receipt)
.map_err(|_| ApiError::internal("Codewhale account storage could not be read"))
}
async fn blocking(
operation: impl FnOnce() -> Result<Value, ApiError> + Send + 'static,
) -> Result<Response, ApiError> {
tokio::task::spawn_blocking(operation)
.await
.map_err(|_| ApiError::internal("Codewhale account operation failed"))?
.map(|body| Json(body).into_response())
}
async fn status(State(state): State<RuntimeApiState>) -> Result<Response, ApiError> {
blocking(move || read_status(&client(&state)?)).await
}
#[derive(Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
struct LeaseRequest {
rejected_access_token: Option<String>,
}
async fn lease(
State(state): State<RuntimeApiState>,
Json(request): Json<LeaseRequest>,
) -> Result<Response, ApiError> {
if request
.rejected_access_token
.as_ref()
.is_some_and(|token| token.len() > 64 * 1024)
{
return Err(ApiError::bad_request("Invalid rejected account credential"));
}
blocking(move || {
let client = client(&state)?;
let lease = client
.fresh_auth(request.rejected_access_token.as_deref())
.map_err(|_| ApiError::conflict("Reconnect your Codewhale account and try again"))?;
let mut status = read_status(&client)?;
if let Some(lease) = lease {
if status["revision"].as_str() != Some(lease.snapshot.fingerprint().as_str())
|| status["accountId"].as_str()
!= lease.bundle.user.as_ref().map(|user| user.id.as_str())
|| status["sessionId"].as_str()
!= lease
.bundle
.session
.as_ref()
.map(|session| session.id.as_str())
|| status["accountId"].as_str().is_none()
|| status["sessionId"].as_str().is_none()
{
return Err(ApiError::conflict(
"The Codewhale account changed; try again",
));
}
status["accessToken"] = json!(lease.bundle.access_token);
}
Ok(status)
})
.await
}
#[derive(Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
struct ImportRequest {
expected_revision: String,
auth: AccountAuthBundle,
#[serde(default)]
replace_existing: bool,
}
async fn import(
State(state): State<RuntimeApiState>,
Json(request): Json<ImportRequest>,
) -> Result<Response, ApiError> {
blocking(move || {
let client = client(&state)?;
let committed = client
.import_with_receipt(
request.auth,
&request.expected_revision,
request.replace_existing,
)
.map_err(|_| {
ApiError::internal("The account connection could not be confirmed; try again")
})?
.ok_or_else(|| {
ApiError::conflict("The Codewhale account changed; try again")
.with_code("account_import_superseded")
})?;
let stored = committed
.load()
.map_err(|_| ApiError::internal("The account connection could not be confirmed"))?
.ok_or_else(|| ApiError::internal("The account connection could not be confirmed"))?;
let revision = committed.fingerprint();
let mut status = receipt(AccountStatus {
profile: codewhale_secrets::account::normalize_account_profile(
state.config_profile.as_deref(),
),
api_base: stored.api_base,
account: committed
.runtime_info_at(chrono::Utc::now())
.map_err(|_| ApiError::internal("The account connection could not be confirmed"))?,
user: stored.bundle.user,
revision: revision.clone(),
import_allowed: false,
});
status["committedRevision"] = json!(revision);
Ok(status)
})
.await
}
async fn claim(
State(state): State<RuntimeApiState>,
Json(request): Json<CancelClaimRequest>,
) -> Result<Response, ApiError> {
blocking(move || {
let client = client(&state)?;
let claimed = client
.claim_if_unchanged(&request.expected_revision)
.map_err(|_| ApiError::internal("The account connection could not be started"))?
.ok_or_else(|| ApiError::conflict("The Codewhale account changed; try again"))?;
let status = read_status(&client)?;
if status["revision"].as_str() != Some(claimed.fingerprint().as_str()) {
return Err(ApiError::conflict(
"The Codewhale account changed; try again",
));
}
Ok(status)
})
.await
}
#[derive(Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
struct CancelClaimRequest {
expected_revision: String,
}
async fn cancel_claim(
State(state): State<RuntimeApiState>,
Json(request): Json<CancelClaimRequest>,
) -> Result<Response, ApiError> {
blocking(move || {
let client = client(&state)?;
let cancelled = client
.cancel_claim(&request.expected_revision)
.map_err(|_| ApiError::internal("The account connection could not be cancelled"))?;
let mut status = read_status(&client)?;
status["cancelled"] = json!(cancelled);
Ok(status)
})
.await
}
async fn logout(
State(state): State<RuntimeApiState>,
Json(request): Json<CancelClaimRequest>,
) -> Result<Response, ApiError> {
blocking(move || {
let client = client(&state)?;
let expected = client
.snapshot()
.map_err(|_| ApiError::internal("Codewhale account storage could not be read"))?;
if expected.fingerprint() != request.expected_revision {
return Err(ApiError::conflict(
"The Codewhale account changed; try again",
));
}
let expected_session = expected
.load()
.ok()
.flatten()
.and_then(|stored| stored.bundle.session)
.map(|session| session.id);
let expected_profile =
codewhale_secrets::account::normalize_account_profile(state.config_profile.as_deref());
let revoked = client
.logout_if_unchanged(&expected)
.map_err(|_| ApiError::internal("The Codewhale account could not be signed out"))?;
{
let config = state.config.read();
let mut access = config.account_model_access.write();
if access.as_ref().is_some_and(|access| {
expected_session.as_deref() == Some(access.session_id.as_str())
&& codewhale_secrets::account::normalize_account_profile(
access.profile.as_deref(),
) == expected_profile
}) {
access.take();
}
}
let mut status = read_status(&client)?;
status["remoteRevoked"] = json!(revoked);
Ok(status)
})
.await
}
+278 -224
View File
@@ -13,6 +13,8 @@
//! `/plugin marketplace` command: local catalog documents only, tiers are
//! display-only, and installs route through the same reviewed installer.
use std::collections::{BTreeMap, HashMap};
use std::io::Read;
use std::net::SocketAddr;
use std::path::{Path as FsPath, PathBuf};
use std::sync::Arc;
@@ -25,7 +27,7 @@ use axum::http::{HeaderMap, HeaderValue, StatusCode, Uri, header};
use axum::response::Response;
use serde::{Deserialize, Serialize};
use serde_json::Value;
use tokio::io::AsyncReadExt;
use sha2::{Digest, Sha256};
use codewhale_plugins::marketplace::document::{
CatalogInstallResolution, load_catalog_document, resolve_candidate_install,
@@ -179,6 +181,7 @@ pub(super) struct PluginUiSlotResponse {
pub(super) slot: String,
pub(super) base: String,
pub(super) entry: String,
pub(super) revision: String,
}
enum UiFileError {
@@ -506,87 +509,52 @@ fn ui_asset_response(bytes: Vec<u8>, content_type: &'static str) -> Response {
response
}
async fn ui_file_path(root: &FsPath, relative: &FsPath) -> Result<PathBuf, UiFileError> {
if relative.as_os_str().is_empty() {
return Err(UiFileError::Invalid("it is empty".into()));
}
let mut current = root.to_path_buf();
let mut last = None;
let mut components = relative.components().peekable();
while let Some(component) = components.next() {
let std::path::Component::Normal(part) = component else {
return Err(UiFileError::Invalid(
"it must be a relative path of literal components".into(),
));
};
current.push(part);
let metadata = match tokio::fs::symlink_metadata(&current).await {
Ok(metadata) => metadata,
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
return Err(UiFileError::Missing);
}
Err(error) => return Err(UiFileError::Internal(error.to_string())),
};
if codewhale_util::path_identity::metadata_is_link_or_reparse(&metadata) {
return Err(UiFileError::Invalid(
"it must not traverse symbolic links".into(),
));
}
if components.peek().is_some() {
if !metadata.is_dir() {
return Err(UiFileError::Missing);
}
} else {
last = Some(metadata);
}
}
let Some(metadata) = last else {
return Err(UiFileError::Invalid("it is empty".into()));
};
if !metadata.is_file() {
return Err(UiFileError::Invalid("it is not a regular file".into()));
}
let canonical =
tokio::fs::canonicalize(&current)
.await
.map_err(|error| match error.kind() {
std::io::ErrorKind::NotFound => UiFileError::Missing,
_ => UiFileError::Internal(error.to_string()),
})?;
if !canonical.starts_with(root) {
return Err(UiFileError::Invalid(
"it resolves outside the staged bundle".into(),
));
}
Ok(canonical)
#[derive(Clone)]
struct PluginUiGrant {
selector: String,
authority: codewhale_config::PluginAuthority,
state_file: Arc<std::fs::File>,
root: PathBuf,
root_file: Arc<std::fs::File>,
ui_prefix: PathBuf,
file_hashes: Arc<BTreeMap<PathBuf, String>>,
expires: std::time::Instant,
}
async fn ui_file_bytes(path: &FsPath) -> Result<Vec<u8>, UiFileError> {
let file = tokio::fs::File::open(path)
.await
.map_err(|error| match error.kind() {
std::io::ErrorKind::NotFound => UiFileError::Missing,
_ => UiFileError::Internal(error.to_string()),
})?;
let metadata = file
.metadata()
.await
.map_err(|error| UiFileError::Internal(error.to_string()))?;
if !metadata.is_file() {
return Err(UiFileError::Invalid("it is not a regular file".into()));
#[derive(Default)]
pub(super) struct PluginUiGrants {
entries: parking_lot::Mutex<HashMap<String, PluginUiGrant>>,
}
impl PluginUiGrants {
fn insert(&self, grant: PluginUiGrant) -> String {
let mut entries = self.entries.lock();
let now = std::time::Instant::now();
entries.retain(|_, value| value.expires > now);
if entries.len() >= 256 {
if let Some(oldest) = entries
.iter()
.min_by_key(|(_, value)| value.expires)
.map(|(key, _)| key.clone())
{
entries.remove(&oldest);
}
}
loop {
let revision = format!("\"{}\"", uuid::Uuid::new_v4());
if !entries.contains_key(&revision) {
entries.insert(revision.clone(), grant);
return revision;
}
}
}
if metadata.len() > PLUGIN_UI_MAX_BYTES {
return Err(UiFileError::TooLarge);
fn get(&self, revision: &str) -> Option<PluginUiGrant> {
let mut entries = self.entries.lock();
let now = std::time::Instant::now();
entries.retain(|_, value| value.expires > now);
entries.get(revision).cloned()
}
let mut file = file.take(PLUGIN_UI_MAX_BYTES + 1);
let mut bytes = Vec::with_capacity(metadata.len() as usize);
file.read_to_end(&mut bytes)
.await
.map_err(|error| UiFileError::Internal(error.to_string()))?;
if bytes.len() as u64 > PLUGIN_UI_MAX_BYTES {
return Err(UiFileError::TooLarge);
}
Ok(bytes)
}
fn ui_file_refusal(plugin: &LoadedPlugin, display: &str, error: UiFileError) -> ApiError {
@@ -610,97 +578,164 @@ fn ui_file_refusal(plugin: &LoadedPlugin, display: &str, error: UiFileError) ->
}
}
async fn ui_staged_root(plugin: &LoadedPlugin) -> Result<PathBuf, ApiError> {
let Some(staged_root) = plugin.staged_root.as_deref() else {
fn ui_root_matches(grant: &PluginUiGrant) -> Result<(), UiFileError> {
let metadata = std::fs::symlink_metadata(&grant.root)
.map_err(|error| UiFileError::Internal(error.to_string()))?;
if !metadata.is_dir() || codewhale_util::path_identity::metadata_is_link_or_reparse(&metadata) {
return Err(UiFileError::Invalid("the staged root changed".into()));
}
let (canonical, current) = codewhale_util::workspace_dir::open_workspace_directory(&grant.root)
.map_err(|error| UiFileError::Internal(error.to_string()))?;
if canonical != grant.root
|| !codewhale_util::files::same_file(&grant.root_file, &current)
.map_err(|error| UiFileError::Internal(error.to_string()))?
{
return Err(UiFileError::Invalid("the staged root changed".into()));
}
Ok(())
}
fn verified_ui_file(grant: &PluginUiGrant, relative: &FsPath) -> Result<Vec<u8>, UiFileError> {
let expected = grant
.file_hashes
.get(relative)
.ok_or(UiFileError::Missing)?;
ui_root_matches(grant)?;
let file = codewhale_util::fs_confined::open_read(&grant.root, &grant.root.join(relative))
.map_err(|error| match error.kind() {
std::io::ErrorKind::NotFound => UiFileError::Missing,
_ => UiFileError::Invalid(error.to_string()),
})?;
let metadata = file
.metadata()
.map_err(|error| UiFileError::Internal(error.to_string()))?;
if metadata.len() > PLUGIN_UI_MAX_BYTES {
return Err(UiFileError::TooLarge);
}
let mut bytes = Vec::with_capacity(metadata.len() as usize);
file.take(PLUGIN_UI_MAX_BYTES + 1)
.read_to_end(&mut bytes)
.map_err(|error| UiFileError::Internal(error.to_string()))?;
if bytes.len() as u64 > PLUGIN_UI_MAX_BYTES {
return Err(UiFileError::TooLarge);
}
let mut hash = Sha256::new();
hash.update(b"codewhale-plugin-file-bytes-v1\0");
hash.update(&bytes);
let actual = hash
.finalize()
.iter()
.map(|byte| format!("{byte:02x}"))
.collect::<String>();
if actual != *expected {
return Err(UiFileError::Invalid(
"the file no longer matches its reviewed bytes".into(),
));
}
ui_root_matches(grant)?;
Ok(bytes)
}
fn admit_ui_grant(state: &RuntimeApiState, grant: &PluginUiGrant) -> Result<(), ApiError> {
if grant.expires <= std::time::Instant::now() {
return Err(ui_stale_revision());
}
let registry = registry_for_state(state);
if !registry
.get(&grant.selector)
.is_some_and(LoadedPlugin::active)
|| registry.authority_for(&grant.selector).as_ref() != Some(&grant.authority)
{
return Err(ui_stale_revision());
}
codewhale_plugins::registry::capture_plugin_state_authority(
&grant.authority,
Some(&grant.state_file),
)
.map_err(|_| ui_stale_revision())?;
ui_root_matches(grant).map_err(|_| ui_stale_revision())?;
Ok(())
}
fn ui_stale_revision() -> ApiError {
ApiError {
status: StatusCode::PRECONDITION_FAILED,
message: "plugin UI authorization has expired or changed; reopen the plugin".into(),
code: None,
}
}
fn prepare_plugin_ui(
state: &RuntimeApiState,
selector: &str,
) -> Result<(String, PluginUiGrant), ApiError> {
let registry = registry_for_state(state);
let plugin = registry
.get(selector)
.cloned()
.ok_or_else(|| ApiError::not_found(format!("plugin '{selector}' not found")))?;
if !plugin.active() {
return Err(ApiError::bad_request(format!(
"plugin '{}' has no staged runtime snapshot; review, trust and enable it to stage its files",
"plugin '{}' must be active before loading its client UI",
plugin.name()
)));
};
let root = tokio::fs::canonicalize(staged_root)
.await
.map_err(|error| {
ApiError::not_found(format!(
"plugin '{}' staged runtime snapshot is unavailable: {error}",
plugin.name()
))
})?;
let metadata = tokio::fs::metadata(&root).await.map_err(|error| {
ApiError::not_found(format!(
"plugin '{}' staged runtime snapshot is unavailable: {error}",
}
let staged_root = plugin.staged_root.as_ref().ok_or_else(|| {
ApiError::bad_request(format!(
"plugin '{}' has no staged runtime snapshot",
plugin.name()
))
})?;
if !metadata.is_dir() {
return Err(ApiError::not_found(format!(
"plugin '{}' staged runtime snapshot is not a directory",
plugin.name()
)));
let authority = registry
.authority_for(selector)
.ok_or_else(|| ApiError::bad_request("plugin UI has no reviewed authority"))?;
let state_file = codewhale_plugins::registry::capture_plugin_state_authority(&authority, None)
.map_err(|_| ui_stale_revision())?;
codewhale_plugins::registry::verify_plugin_authority(&authority)
.map_err(|_| ui_stale_revision())?;
let validated =
codewhale_plugins::manifest::PluginManifest::validate_from_path(&authority.staged_manifest)
.map_err(|_| ui_stale_revision())?;
if validated.content_hash != authority.content_hash
|| validated.capability_hash != authority.capability_hash
{
return Err(ui_stale_revision());
}
Ok(root)
}
async fn gated_plugin_ui_root(
state: &RuntimeApiState,
selector: &str,
) -> Result<(LoadedPlugin, PathBuf), ApiError> {
let plugin = find_plugin(state, selector)?;
if !plugin.trusted() {
return Err(ApiError::bad_request(format!(
"plugin '{}' is not trusted; review its capabilities and trust it before loading its client UI",
plugin.name()
)));
let metadata = std::fs::symlink_metadata(staged_root).map_err(|_| ui_stale_revision())?;
if !metadata.is_dir() || codewhale_util::path_identity::metadata_is_link_or_reparse(&metadata) {
return Err(ui_stale_revision());
}
if !plugin.enabled {
return Err(ApiError::bad_request(format!(
"plugin '{}' is disabled; enable it after trust before loading its client UI",
plugin.name()
)));
let (root, root_file) = codewhale_util::workspace_dir::open_workspace_directory(staged_root)
.map_err(|_| ui_stale_revision())?;
if root != validated.canonical_root {
return Err(ui_stale_revision());
}
let root = ui_staged_root(&plugin).await?;
Ok((plugin, root))
}
async fn plugin_ui_manifest(
plugin: &LoadedPlugin,
staged_root: &FsPath,
) -> Result<Option<(PathBuf, PathBuf)>, ApiError> {
let mut grant = PluginUiGrant {
selector: selector.to_string(),
authority,
state_file: Arc::new(state_file),
root,
root_file: Arc::new(root_file),
ui_prefix: PathBuf::new(),
file_hashes: Arc::new(validated.file_hashes),
expires: std::time::Instant::now() + std::time::Duration::from_secs(3600),
};
let mut found = None;
for candidate in ["package.json", "source/package.json"] {
let relative = FsPath::new(candidate);
let display = ui_relative_display(relative);
match ui_file_path(staged_root, relative).await {
Ok(path) => {
let root = path.parent().unwrap_or(staged_root).to_path_buf();
return Ok(Some((root, path)));
}
Err(UiFileError::Missing) => {}
Err(error) => return Err(ui_file_refusal(plugin, &display, error)),
if grant.file_hashes.contains_key(relative) {
let bytes = verified_ui_file(&grant, relative)
.map_err(|error| ui_file_refusal(&plugin, candidate, error))?;
found = Some((relative.to_path_buf(), bytes));
break;
}
}
Ok(None)
}
async fn plugin_ui_declaration(
plugin: &LoadedPlugin,
staged_root: &FsPath,
) -> Result<(PathBuf, Value), ApiError> {
let Some((ui_root, manifest_path)) = plugin_ui_manifest(plugin, staged_root).await? else {
return Err(ApiError::not_found(format!(
"plugin '{}' declares no dsh.client UI; package.json is absent from its staged bundle and source/ directory",
plugin.name()
)));
};
let manifest_display = manifest_path
.strip_prefix(staged_root)
.map(ui_relative_display)
.unwrap_or_else(|_| "package.json".to_string());
let manifest_bytes = ui_file_bytes(&manifest_path)
.await
.map_err(|error| ui_file_refusal(plugin, &manifest_display, error))?;
let manifest: Value = serde_json::from_slice(&manifest_bytes).map_err(|error| {
let (manifest_path, bytes) = found.ok_or_else(|| ApiError::not_found(format!("plugin '{}' declares no dsh.client UI; package.json is absent from its staged bundle and source/ directory", plugin.name())).with_code("plugin_ui_not_declared"))?;
let manifest: Value = serde_json::from_slice(&bytes).map_err(|error| {
ApiError::bad_request(format!(
"plugin '{}' {manifest_display} is not valid JSON: {error}",
plugin.name()
"plugin '{}' {} is not valid JSON: {error}",
plugin.name(),
ui_relative_display(&manifest_path)
))
})?;
if manifest
@@ -708,12 +743,23 @@ async fn plugin_ui_declaration(
.and_then(|dsh| dsh.get("client"))
.is_none_or(Value::is_null)
{
return Err(ApiError::not_found(format!(
"plugin '{}' declares no dsh.client UI in {manifest_display}; add dsh.client and exports[\"./client\"] to expose client assets",
plugin.name()
)));
return Err(ApiError::not_found(format!("plugin '{}' declares no dsh.client UI in {}; add dsh.client and exports[\"./client\"] to expose client assets", plugin.name(), ui_relative_display(&manifest_path))).with_code("plugin_ui_not_declared"));
}
Ok((ui_root, manifest))
grant.ui_prefix = manifest_path
.parent()
.unwrap_or(FsPath::new(""))
.to_path_buf();
let raw = ui_client_entry(&manifest).unwrap_or_else(|| "client.js".into());
let relative = ui_relative_path(&raw).map_err(|reason| {
ApiError::bad_request(format!(
"plugin '{}' dsh.client entry '{raw}' is invalid: {reason}",
plugin.name()
))
})?;
verified_ui_file(&grant, &grant.ui_prefix.join(&relative))
.map_err(|error| ui_file_refusal(&plugin, &ui_relative_display(&relative), error))?;
admit_ui_grant(state, &grant)?;
Ok((ui_relative_display(&relative), grant))
}
fn plugin_ui_base(
@@ -874,7 +920,7 @@ async fn run_plugin_mutation(
})
}
/// Run a registry state mutation (`trust`/`enable`/`disable`/`revoke`)
/// Run a registry state mutation (`trust`/`enable`/`restage`/`disable`/`revoke`)
/// against a fresh registry, then invalidate the MCP pool. Trust is the only
/// one with a precondition beyond the registry's own checks: the request
/// token must match the bundle's review token.
@@ -901,6 +947,7 @@ async fn run_registry_mutation(
let action = match mutation {
RegistryMutation::Trust { .. } => "trusted",
RegistryMutation::Enable => "enabled",
RegistryMutation::Restage => "restaged",
RegistryMutation::Disable => "disabled",
RegistryMutation::Revoke => "trust-revoked",
};
@@ -909,6 +956,7 @@ async fn run_registry_mutation(
let result = match mutation {
RegistryMutation::Trust { .. } => registry.trust(selector),
RegistryMutation::Enable => registry.enable(selector),
RegistryMutation::Restage => registry.restage(selector),
RegistryMutation::Disable => registry.disable(selector),
RegistryMutation::Revoke => registry.revoke_trust(selector),
};
@@ -956,6 +1004,7 @@ async fn run_registry_mutation(
enum RegistryMutation<'a> {
Trust { token: &'a str },
Enable,
Restage,
Disable,
Revoke,
}
@@ -1205,83 +1254,79 @@ pub(super) async fn get_plugin_ui(
"slot '{slot}' is not supported; supported slot: 'customize.plugins.detail'"
)));
}
let (plugin, staged_root) = gated_plugin_ui_root(&state, &selector).await?;
let (ui_root, manifest) = plugin_ui_declaration(&plugin, &staged_root).await?;
let entry = match ui_client_entry(&manifest) {
Some(raw) => {
let relative = ui_relative_path(&raw).map_err(|reason| {
ApiError::bad_request(format!(
"plugin '{}' dsh.client entry '{raw}' is invalid: {reason}",
plugin.name()
))
})?;
match ui_file_path(&ui_root, &relative).await {
Ok(_) => ui_relative_display(&relative),
Err(UiFileError::Missing) => {
return Err(ApiError::not_found(format!(
"plugin '{}' dsh.client entry '{raw}' does not exist in its staged bundle; update the plugin or fix exports[\"./client\"]",
plugin.name()
)));
}
Err(error) => {
return Err(ui_file_refusal(
&plugin,
&ui_relative_display(&relative),
error,
));
}
}
}
None => {
let fallback = PathBuf::from("client.js");
match ui_file_path(&ui_root, &fallback).await {
Ok(_) => ui_relative_display(&fallback),
Err(UiFileError::Missing) => {
return Err(ApiError::not_found(format!(
"plugin '{}' declares dsh.client but has no client entry; set exports[\"./client\"] or add client.js to the staged bundle",
plugin.name()
)));
}
Err(error) => {
return Err(ui_file_refusal(
&plugin,
&ui_relative_display(&fallback),
error,
));
}
}
}
};
let state_for_prepare = state.clone();
let selector_for_prepare = selector.clone();
let (entry, grant) = tokio::task::spawn_blocking(move || {
prepare_plugin_ui(&state_for_prepare, &selector_for_prepare)
})
.await
.map_err(|_| ApiError::internal("plugin UI preparation failed"))??;
let base = plugin_ui_base(&state, &selector, &headers, &uri)?;
Ok(Json(PluginUiSlotResponse { slot, base, entry }))
let revision = state.plugin_ui_grants.insert(grant);
Ok(Json(PluginUiSlotResponse {
slot,
base,
entry,
revision,
}))
}
pub(super) async fn get_plugin_ui_asset(
State(state): State<RuntimeApiState>,
ConnectInfo(peer): ConnectInfo<SocketAddr>,
Path((selector, path)): Path<(String, String)>,
headers: HeaderMap,
) -> Result<Response, ApiError> {
if !peer.ip().is_loopback() {
return Err(ApiError::forbidden(
"plugin UI assets are available only from loopback",
));
}
let (plugin, staged_root) = gated_plugin_ui_root(&state, &selector).await?;
let (ui_root, _) = plugin_ui_declaration(&plugin, &staged_root).await?;
let relative = ui_relative_path(&path).map_err(|reason| {
ApiError::bad_request(format!(
"plugin '{}' UI path '{path}' is invalid: {reason}",
plugin.name()
))
let mut values = headers.get_all(header::IF_MATCH).iter();
let first = values.next().ok_or_else(|| ApiError {
status: StatusCode::PRECONDITION_REQUIRED,
message: "plugin UI revision is required".into(),
code: None,
})?;
let display = ui_relative_display(&relative);
let staged_path = ui_file_path(&ui_root, &relative)
.await
.map_err(|error| ui_file_refusal(&plugin, &display, error))?;
let bytes = ui_file_bytes(&staged_path)
.await
.map_err(|error| ui_file_refusal(&plugin, &display, error))?;
Ok(ui_asset_response(bytes, ui_content_type(&relative)))
if values.next().is_some() {
return Err(ui_stale_revision());
}
let revision = first.to_str().map_err(|_| ui_stale_revision())?.to_string();
let grant = state
.plugin_ui_grants
.get(&revision)
.ok_or_else(ui_stale_revision)?;
if grant.selector != selector || grant.authority.workspace != state.workspace {
return Err(ui_stale_revision());
}
let relative = ui_relative_path(&path).map_err(|reason| {
ApiError::bad_request(format!("plugin UI path '{path}' is invalid: {reason}"))
})?;
let content_type = ui_content_type(&relative);
let state_for_asset = state.clone();
let bytes = tokio::task::spawn_blocking(move || {
codewhale_plugins::registry::verify_plugin_authority(&grant.authority)
.map_err(|_| ui_stale_revision())?;
let bytes = verified_ui_file(&grant, &grant.ui_prefix.join(&relative)).map_err(
|error| match error {
UiFileError::Missing => {
ApiError::not_found("plugin UI file is not in the reviewed bundle")
}
UiFileError::TooLarge => {
ApiError::payload_too_large("plugin UI file exceeds the 8 MiB limit")
}
_ => ui_stale_revision(),
},
)?;
admit_ui_grant(&state_for_asset, &grant)?;
if state_for_asset.plugin_ui_grants.get(&revision).is_none() {
return Err(ui_stale_revision());
}
Ok::<_, ApiError>(bytes)
})
.await
.map_err(|_| ApiError::internal("plugin UI read failed"))??;
Ok(ui_asset_response(bytes, content_type))
}
/// `POST /v1/apps/plugins/install`
@@ -1400,6 +1445,15 @@ pub(super) async fn enable_plugin_api(
))
}
pub(super) async fn restage_plugin_api(
State(state): State<RuntimeApiState>,
Path(selector): Path<String>,
) -> Result<Json<PluginActionResponse>, ApiError> {
Ok(Json(
run_registry_mutation(&state, &selector, RegistryMutation::Restage).await?,
))
}
/// `POST /v1/apps/plugins/{selector}/disable`
pub(super) async fn disable_plugin_api(
State(state): State<RuntimeApiState>,
+12 -8
View File
@@ -83,12 +83,12 @@ fn account_model_access_receipt(config: &codewhale_runtime::config::Config) -> V
#[derive(Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub(super) struct SetAccountModelAccessRequest {
api_base: String,
session_id: String,
expected_session_id: Option<String>,
key: String,
expires_at: i64,
pub struct SetAccountModelAccessRequest {
pub api_base: String,
pub session_id: String,
pub expected_session_id: Option<String>,
pub key: String,
pub expires_at: i64,
}
#[derive(Deserialize)]
@@ -124,7 +124,7 @@ pub(super) async fn set_account_model_access(
.map_err(|_| ApiError::internal("account access update failed"))?
}
fn install_account_model_access(
pub fn install_account_model_access(
config: &codewhale_runtime::config::Config,
profile: Option<&str>,
request: SetAccountModelAccessRequest,
@@ -197,7 +197,11 @@ fn install_account_model_access(
.provider_config_for(&identity)
.is_some_and(|entry| entry.auth.is_some() || entry.api_key_env.is_some())
|| !credential_writeability(&existing, &identity).writable
|| codewhale_engine::provider_readiness::has_api_key_for(&existing, &identity)
|| !matches!(
codewhale_runtime::config::resolve_credential_source(&existing, &identity).source,
codewhale_runtime::credentials::CredentialSource::Missing { .. }
| codewhale_runtime::credentials::CredentialSource::AccountSession
)
{
return Err(ApiError::conflict(
"This Codewhale route already has its own credential.",
@@ -1397,6 +1397,7 @@ async fn build_test_server(
plugin_discovery: overrides
.plugin_discovery
.unwrap_or_else(codewhale_plugins::PluginDiscoveryContext::capture_pre_dotenv),
plugin_ui_grants: Arc::new(super::plugins::PluginUiGrants::default()),
task_manager: manager,
runtime_threads: runtime_threads.clone(),
cors_origins: Vec::new(),
+58 -18
View File
@@ -233,6 +233,12 @@ enum Commands {
Init(TuiPassthroughArgs),
#[command(about = "Install a plugin bundle without trusting or enabling it")]
Install(TuiPassthroughArgs),
#[command(
about = "Inspect or restage reviewed plugin bundles",
override_usage = "codewhale plugins <COMMAND>",
after_help = "Commands:\n status [NAME] [--json] Inspect plugin status and diagnostics\n restage <NAME> [--json] Restore an eligible reviewed plugin snapshot"
)]
Plugins(TuiPassthroughArgs),
/// Bootstrap MCP config and/or skills directories.
Setup(TuiPassthroughArgs),
/// Generate a remote Codewhale agent deploy bundle (cloud + chat bridge).
@@ -374,17 +380,16 @@ New integrations should prefer `codewhale app-server --http` for the full Engine
after_help = "The browser receives a one-time loopback bootstrap capability, never the Runtime token.\nThe capability is exchanged for a bounded, process-local HttpOnly, SameSite=Strict web session and then invalidated."
)]
Web(WebArgs),
/// Sign in to an existing Codewhale account; new accounts are not open.
/// Sign in to your Codewhale account through the browser.
#[command(
after_help = "New Codewhale accounts are not open, and local use does not require one.\nKeep provider keys on this machine with /provider or `codewhale auth set --provider <provider>`.\nSigning in does not upload existing local keys."
after_help = "Local use does not require a Codewhale account.\nConnect a model in the TUI with /connect, or run `codewhale auth set --provider <provider>`.\nSigning in preserves your selected model and does not upload existing local keys."
)]
Login(LoginArgs),
/// Remove saved authentication state (every provider key, OAuth login,
/// the Codewhale account session). Asks before deleting.
/// Sign out of Codewhale, keeping local model connections and work.
Logout(LogoutArgs),
/// Manage authentication credentials and provider mode.
Auth(AuthArgs),
/// Manage an existing Codewhale account; new accounts are not open.
/// Manage your Codewhale account and its available features.
#[command(visible_alias = "cloud")]
Account(codewhale_account_cli::CloudArgs),
/// Run a task on a Codewhale cloud computer using your configured model.
@@ -1768,7 +1773,12 @@ fn remote_setup_tui_args(args: RemoteSetupArgs) -> Vec<String> {
#[derive(Debug, Args)]
struct LogoutArgs {
/// Delete without the confirmation prompt (required when stdin is not a terminal).
#[arg(
long,
help = "Remove every saved provider key, OAuth login and account session; asks before deleting"
)]
all: bool,
/// Skip the confirmation for --all (required with --all when stdin is not a terminal).
#[arg(long, short = 'y', default_value_t = false)]
yes: bool,
}
@@ -2400,7 +2410,12 @@ fn run() -> Result<()> {
if !inherited_listener
&& !matches!(
cli.command.as_ref(),
Some(Commands::Update(_) | Commands::Uninstall { .. } | Commands::Doctor(_))
Some(
Commands::Update(_)
| Commands::Uninstall { .. }
| Commands::Doctor(_)
| Commands::Plugins(_)
)
)
&& !argv_requests_machine_output(&argv)
{
@@ -2589,6 +2604,11 @@ fn run() -> Result<()> {
resolve_runtime_for_diagnostic_dispatch(&store, &runtime_overrides);
run_tui_in_process(&cli, &resolved_runtime, tui_args("install", args))
}
Some(Commands::Plugins(args)) => {
let resolved_runtime =
resolve_runtime_for_diagnostic_dispatch(&store, &runtime_overrides);
run_tui_in_process(&cli, &resolved_runtime, tui_args("plugins", args))
}
Some(Commands::Setup(args)) => {
let resolved_runtime = if setup_is_status_report(&args) {
resolve_runtime_for_diagnostic_dispatch(&store, &runtime_overrides)
@@ -2713,8 +2733,12 @@ fn run() -> Result<()> {
)
}
Some(Commands::Logout(args)) => {
confirm_logout(args.yes)?;
run_logout_command(&mut store, cli.profile.as_deref())
if args.all {
confirm_logout(args.yes)?;
run_logout_command(&mut store, cli.profile.as_deref())
} else {
codewhale_account_cli::run_account_logout(cli.profile.as_deref(), &mut store)
}
}
Some(Commands::Auth(args)) => match args.command {
AuthCommand::PluginLogin { provider } => {
@@ -3231,8 +3255,8 @@ fn reject_legacy_login_provider_args(args: &LoginArgs) -> Result<()> {
const LOGOUT_CONFIRM_PROMPT: &str = "This deletes every saved provider API key and OAuth login, \
the Codewhale account session. Type 'yes' to log out: ";
/// `codewhale logout` wipes every provider credential at once, so it must not
/// run on a stray keystroke. Non-interactive callers opt in with `--yes`.
/// `codewhale logout --all` wipes every provider credential at once, so it must
/// not run on a stray keystroke. Non-interactive callers opt in with `--all --yes`.
fn confirm_logout(yes: bool) -> Result<()> {
if yes {
return Ok(());
@@ -3240,7 +3264,7 @@ fn confirm_logout(yes: bool) -> Result<()> {
if !io::stdin().is_terminal() {
bail!(
"logout would delete every saved provider key; nothing was deleted. \
Re-run with --yes to confirm non-interactively."
Re-run with --all --yes to confirm non-interactively."
);
}
confirm_logout_answer(&mut io::stdin().lock(), &mut io::stderr().lock())
@@ -10124,12 +10148,14 @@ verbosity = "concise"
"login help must describe account sign-in: {help}"
);
assert!(
help.contains("New Codewhale accounts are not open"),
"login help must not offer accounts that cannot be created: {help}"
help.contains("Local use does not require a Codewhale account"),
"login help must explain that local use does not require an account: {help}"
);
assert!(
help.contains("Signing in does not upload existing local keys"),
"login help must explain the local-key upload boundary: {help}"
help.contains(
"Signing in preserves your selected model and does not upload existing local keys"
),
"login help must explain model preservation and the local-key upload boundary: {help}"
);
}
@@ -12491,12 +12517,26 @@ verbosity = "concise"
let cli = parse_ok(&["codewhale", "logout", "--yes"]);
assert!(matches!(
cli.command,
Some(Commands::Logout(LogoutArgs { yes: true }))
Some(Commands::Logout(LogoutArgs {
yes: true,
all: false
}))
));
let cli = parse_ok(&["codewhale", "logout"]);
assert!(matches!(
cli.command,
Some(Commands::Logout(LogoutArgs { yes: false }))
Some(Commands::Logout(LogoutArgs {
yes: false,
all: false
}))
));
let cli = parse_ok(&["codewhale", "logout", "--all", "--yes"]);
assert!(matches!(
cli.command,
Some(Commands::Logout(LogoutArgs {
yes: true,
all: true
}))
));
}
-17
View File
@@ -414,26 +414,9 @@ pub struct CommandLoginStatus {
pub provider: String,
}
/// `/logout` result; each entry is `Ok(true)` cleared, `Ok(false)` absent,
/// `Err` the host error text.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct CommandLogoutOutcome {
pub provider: String,
pub account: Result<bool, String>,
pub legacy_sandbox: Result<bool, String>,
}
/// Failure to clear the active provider key during `/logout`.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct LogoutFailure {
pub provider: String,
pub error: String,
}
/// Account/provider session state for `/login` and `/logout`.
pub trait CommandLoginContext {
fn login_status(&self) -> CommandLoginStatus;
fn logout(&mut self) -> Result<CommandLogoutOutcome, LogoutFailure>;
/// `/connect [<provider>]` — plugin provider sign-in.
fn connect(&mut self, provider: Option<&str>) -> ConfigReply;
/// `/disconnect <provider>` — plugin provider sign-out.
+5
View File
@@ -994,6 +994,11 @@ pub trait CommandPluginContext {
/// Mutation: enable a bundle. Success means enabled; re-read `detail` for
/// the post-mutation compatibility note.
fn enable(&mut self, selector: &str) -> Result<(), String>;
#[doc = "Restore a missing stage from bytes matching the current trust receipt."]
fn restage(&mut self, selector: &str) -> Result<(), String> {
let _ = selector;
Err("Plugin restaging is not available in this host.".to_string())
}
/// Mutation: disable a bundle.
fn disable(&mut self, selector: &str) -> Result<(), String>;
/// Mutation: revoke trust.
+4
View File
@@ -129,6 +129,10 @@ pub enum ConfigAction {
/// A permission rule was safely removed; the host reloads active rules.
PermissionRulesChanged,
// Auth flows (no host observation needed).
OpenModelConnectionSetup,
StartAccountConnect,
StartAccountLogin,
StartAccountLogout,
StartXaiDeviceLogin,
StartClaudeLogin,
StartClaudeRevoke,
+54 -5
View File
@@ -1700,17 +1700,39 @@ fn secure_windows_owner_only_handle(file: &File, inherit_to_children: bool) -> R
#[cfg(windows)]
pub(crate) fn verify_windows_owner_only_handle(file: &File) -> Result<()> {
use windows_sys::Win32::Storage::FileSystem::FILE_ALL_ACCESS;
verify_windows_owner_only_handle_access(file, FILE_ALL_ACCESS, false)
}
#[cfg(windows)]
pub fn verify_windows_owner_only_read_execute_handle(file: &File) -> Result<()> {
use windows_sys::Win32::Storage::FileSystem::{FILE_GENERIC_EXECUTE, FILE_GENERIC_READ};
verify_windows_owner_only_handle_access(file, FILE_GENERIC_READ | FILE_GENERIC_EXECUTE, true)
}
#[cfg(windows)]
fn verify_windows_owner_only_handle_access(
file: &File,
expected_access: u32,
require_protected: bool,
) -> Result<()> {
use std::os::windows::io::AsRawHandle as _;
use windows_sys::Win32::Foundation::ERROR_SUCCESS;
use windows_sys::Win32::Foundation::{
ERROR_SUCCESS, GENERIC_ALL, GENERIC_EXECUTE, GENERIC_READ, GENERIC_WRITE,
};
use windows_sys::Win32::Security::Authorization::{
EXPLICIT_ACCESS_W, GRANT_ACCESS, GetExplicitEntriesFromAclW, GetSecurityInfo,
SE_FILE_OBJECT, SET_ACCESS, TRUSTEE_IS_SID,
};
use windows_sys::Win32::Security::{
ACL, DACL_SECURITY_INFORMATION, EqualSid, OWNER_SECURITY_INFORMATION, PSECURITY_DESCRIPTOR,
PSID,
ACL, DACL_SECURITY_INFORMATION, EqualSid, GetSecurityDescriptorControl,
OWNER_SECURITY_INFORMATION, PSECURITY_DESCRIPTOR, PSID, SE_DACL_PROTECTED,
};
use windows_sys::Win32::Storage::FileSystem::{
FILE_ALL_ACCESS, FILE_GENERIC_EXECUTE, FILE_GENERIC_READ, FILE_GENERIC_WRITE,
};
use windows_sys::Win32::Storage::FileSystem::FILE_ALL_ACCESS;
let user = CurrentWindowsUser::open()?;
let mut owner: PSID = std::ptr::null_mut();
@@ -1734,6 +1756,18 @@ pub(crate) fn verify_windows_owner_only_handle(file: &File) -> Result<()> {
.context("reading Codewhale-owned xAI OAuth security descriptor");
}
let _descriptor = WindowsLocalAllocation(descriptor.cast());
if require_protected {
let mut control = 0;
let mut revision = 0;
if unsafe { GetSecurityDescriptorControl(descriptor, &mut control, &mut revision) } == 0 {
return Err(std::io::Error::last_os_error())
.context("reading Codewhale-owned storage DACL protection");
}
anyhow::ensure!(
control & SE_DACL_PROTECTED != 0,
"Codewhale-owned storage DACL is not protected"
);
}
// SAFETY: `owner` is non-null; `user.sid()` is owned by `user`.
anyhow::ensure!(
!owner.is_null() && unsafe { EqualSid(owner, user.sid()) } != 0,
@@ -1760,13 +1794,28 @@ pub(crate) fn verify_windows_owner_only_handle(file: &File) -> Result<()> {
// SAFETY: `count == 1` proves the first returned entry is initialized.
let entry = unsafe { &*entries };
let trustee_sid: PSID = entry.Trustee.ptstrName.cast();
let normalized_access = |access: u32| {
let mut normalized =
access & !(GENERIC_ALL | GENERIC_EXECUTE | GENERIC_READ | GENERIC_WRITE);
for (generic, specific) in [
(GENERIC_ALL, FILE_ALL_ACCESS),
(GENERIC_EXECUTE, FILE_GENERIC_EXECUTE),
(GENERIC_READ, FILE_GENERIC_READ),
(GENERIC_WRITE, FILE_GENERIC_WRITE),
] {
if access & generic != 0 {
normalized |= specific;
}
}
normalized
};
// SAFETY: form and null checked in this expression; sid owned by `user`.
anyhow::ensure!(
entry.Trustee.TrusteeForm == TRUSTEE_IS_SID
&& !trustee_sid.is_null()
&& unsafe { EqualSid(trustee_sid, user.sid()) } != 0
&& matches!(entry.grfAccessMode, SET_ACCESS | GRANT_ACCESS)
&& entry.grfAccessPermissions == FILE_ALL_ACCESS,
&& normalized_access(entry.grfAccessPermissions) == normalized_access(expected_access),
"Codewhale-owned xAI OAuth DACL is not current-user-only"
);
Ok(())
+9 -2
View File
@@ -695,7 +695,9 @@ mod tests {
#[test]
fn reviewed_skill_root_parser_bounds_accumulation_and_parent_claims() {
let (_many, authority) = parser_bundle(129, 1);
let (many, authority) = parser_bundle(129, 1);
let _many_stage =
codewhale_plugins::test_fixture::finalize_staged_bundle(many.path()).unwrap();
assert!(
codewhale_plugins::discovery::load_staged_skill_root_snapshots(
&authority,
@@ -704,7 +706,9 @@ mod tests {
.unwrap_err()
.contains("candidate count")
);
let (_large, authority) = parser_bundle(8, 600 * 1024);
let (large, authority) = parser_bundle(8, 600 * 1024);
let _large_stage =
codewhale_plugins::test_fixture::finalize_staged_bundle(large.path()).unwrap();
assert!(
codewhale_plugins::discovery::load_staged_skill_root_snapshots(
&authority,
@@ -727,6 +731,8 @@ mod tests {
.unwrap();
authority.content_hash = validated.content_hash;
authority.capability_hash = validated.capability_hash;
let _one_stage =
codewhale_plugins::test_fixture::finalize_staged_bundle(one.path()).unwrap();
let snapshots = codewhale_plugins::discovery::load_staged_skill_root_snapshots(
&authority,
Path::new("skills"),
@@ -765,6 +771,7 @@ mod tests {
std::fs::write(&manifest, "schema_version = 1\n[plugin]\nname = \"bounded-skills\"\nversion = \"0.1.0\"\n[skills]\npath = \"skills\"\n[native]\npath = \"index.mjs\"\n").unwrap();
let validated =
codewhale_plugins::manifest::PluginManifest::validate_from_path(&manifest).unwrap();
let _stage = codewhale_plugins::test_fixture::finalize_staged_bundle(temp.path()).unwrap();
let declared = codewhale_plugins::discovery::load_staged_skill_snapshots(
&validated.canonical_root,
&validated.content_hash,
+23
View File
@@ -4524,6 +4524,29 @@ impl McpPool {
self.server_allowed(name) && self.needs_auth_servers.contains(name)
}
pub fn has_connection_backoff(
&self,
name: &str,
timeouts: McpTimeouts,
server: &McpServerConfig,
) -> Option<bool> {
let current = self.config.servers.get(name)?;
if !self.server_allowed(name)
|| !server.is_enabled()
|| !current.is_enabled()
|| self.connecting.contains(name)
|| self
.connections
.get(name)
.is_some_and(McpConnection::is_transport_ready)
|| mcp_configured_server_identity(self.config.timeouts, name, current)
!= mcp_configured_server_identity(timeouts, name, server)
{
return None;
}
Some(self.connect_backoff.contains_key(name))
}
/// The needs-auth server that owns a model tool name (`mcp_<server>_…`),
/// if any: the server the model is trying to reach with a real tool name
/// from a catalog built before its login lapsed. Longest configured name
+33 -3
View File
@@ -553,7 +553,7 @@
"CmdJobsDescription": "Inspecciona i controla els treballs Bash en segon pla",
"CmdLinksDescription": "Mostra enllaços de Codewhale, la comunitat i els proveïdors",
"CmdLoadDescription": "Carrega una sessió des d'un fitxer",
"CmdLogoutDescription": "Esborra la clau d'API i torna a la configuració",
"CmdLogoutDescription": "Tanca la sessió al Codewhale",
"CmdMcpDescription": "Obre o gestiona servidors MCP; init afegeix, doctor revisa",
"McpReloadAlreadyRunning": "La recàrrega MCP ja s'està executant; la barra d'estat en fa el seguiment.",
"McpRecommendedUnknownId": "ID MCP recomanat desconegut. Executa {recommendations_command} per revisar la llista seleccionada.",
@@ -681,7 +681,7 @@
"CmdThemeDescription": "Canvia de tema o obre el selector de temes",
"CmdProviderDescription": "Canvia el proveïdor i/o model actiu",
"CmdAuthDescription": "Gestiona els fluxos d'autenticació dels proveïdors",
"CmdLoginDescription": "Inicia sessió a Codewhale o desa claus de proveïdor",
"CmdLoginDescription": "Inicia la sessió al Codewhale",
"CmdQueueDescription": "Mostra o edita els missatges en cua",
"CmdQueueUsage": "Ús: /queue [list|send <n>|edit <n>|drop <n>|clear]",
"QueueRestoredFromPreviousSession": "S'han restaurat {count} missatges en cua d'una sessió anterior. No s'envien automàticament: /queue send <n> n'envia un, /queue clear els descarta.",
@@ -2784,5 +2784,35 @@
"PagerCopyHandoffLabel": "Traspàs del torn",
"PagerCopyAnswerLabel": "Resposta",
"PagerSearchMatch": "coincidència {current}/{total} (n/N)",
"ToolOutputShortened": "Sortida escurçada · {key} obre els detalls"
"ToolOutputShortened": "Sortida escurçada · {key} obre els detalls",
"AccountLoginTitle": "Inicia la sessió al Codewhale",
"AccountLoginStarting": "S’està iniciant la sessió…",
"AccountLoginWaiting": "Completa l’inici de sessió al navegador. També pots obrir l’enllaç en un altre dispositiu.",
"AccountLoginCode": "Codi: {code}",
"AccountLoginOpenBrowser": "Obre el navegador",
"AccountLoginCopyLink": "Copia l’enllaç",
"AccountLoginLink": "Enllaç d’inici de sessió",
"AccountLoginSuccess": "Has iniciat la sessió al Codewhale. Es manté el model seleccionat.",
"AccountLoginNeedsModel": "Has iniciat la sessió al Codewhale. Connecta un model per començar.",
"AccountLoginCancelled": "S’ha cancel·lat l’inici de sessió.",
"AccountLoginExpired": "L’inici de sessió ha esgotat el temps d’espera. Fes servir {command} per tornar-ho a provar.",
"AccountLoginFailed": "No s’ha pogut iniciar la sessió. Fes servir {command} per tornar-ho a provar.",
"AccountLogoutTitle": "Tanca la sessió al Codewhale",
"AccountLogoutStarting": "S’està tancant la sessió al Codewhale…",
"AccountLogoutSuccess": "Has tancat la sessió al Codewhale. Es mantenen els models i les converses locals.",
"AccountLogoutLocal": "Has tancat la sessió en aquest ordinador. No s’ha pogut confirmar la revocació al servidor.",
"AccountLogoutFailed": "No s’ha pogut tancar la sessió. Fes servir {command} per tornar-ho a provar.",
"AccountLogoutCancelled": "S’ha cancel·lat el tancament de sessió.",
"ConnectionSetupTitle": "Connecta un model",
"ConnectionSetupAccount": "Continua amb Codewhale",
"ConnectionSetupAccountDetail": "Inicia la sessió i consulta els models disponibles al teu compte.",
"ConnectionSetupLocal": "Connecta en aquest ordinador",
"ConnectionSetupLocalDetail": "Fes servir un compte de proveïdor, una clau d’API o un model local.",
"AccountModelsTitle": "Tria un model del compte",
"AccountModelsLoading": "S’està comprovant l’accés als models del compte…",
"AccountModelsUnavailable": "Aquí no hi ha cap model del compte disponible. Connecta un proveïdor en aquest ordinador o actualitza les connexions de models a l’aplicació Codewhale.",
"AccountModelsReady": "Connectat a {model} mitjançant el teu compte de Codewhale.",
"AccountModelsConnectFailed": "No s’han pogut connectar els models del compte: {error}",
"AccountModelsCancelled": "S’ha cancel·lat la configuració del model. Es manté el model actual.",
"AccountModelsLoadFailed": "No s’han pogut comprovar els models del compte. Torna-ho a provar o connecta un proveïdor en aquest ordinador."
}
+33 -3
View File
@@ -553,7 +553,7 @@
"CmdJobsDescription": "Bash-Hintergrundjobs ansehen und steuern",
"CmdLinksDescription": "Codewhale-, Community- und Provider-Links anzeigen",
"CmdLoadDescription": "Sitzung aus Datei laden",
"CmdLogoutDescription": "API-Schlüssel löschen und zum Setup zurückkehren",
"CmdLogoutDescription": "Von Codewhale abmelden",
"CmdMcpDescription": "MCP-Server öffnen/verwalten; init fügt hinzu, doctor prüft",
"McpReloadAlreadyRunning": "Ein MCP-Neuladen läuft bereits; die Statusleiste verfolgt es.",
"McpRecommendedUnknownId": "Unbekannte empfohlene MCP-ID. Mit {recommendations_command} kann die kuratierte Liste geprüft werden.",
@@ -681,7 +681,7 @@
"CmdThemeDescription": "Theme wechseln oder Theme-Auswahl öffnen",
"CmdProviderDescription": "Aktiven Provider und/oder Modell wechseln",
"CmdAuthDescription": "Provider-Authentifizierung verwalten",
"CmdLoginDescription": "Bei Codewhale anmelden oder Provider-Schlüssel speichern",
"CmdLoginDescription": "Bei Codewhale anmelden",
"CmdQueueDescription": "Nachrichten in der Warteschlange ansehen oder bearbeiten",
"CmdQueueUsage": "Verwendung: /queue [list|send <n>|edit <n>|drop <n>|clear]",
"QueueRestoredFromPreviousSession": "{count} Nachricht(en) aus einer früheren Sitzung wiederhergestellt. Sie werden nicht automatisch gesendet: /queue send <n> sendet eine, /queue clear verwirft sie.",
@@ -2784,5 +2784,35 @@
"PagerCopyHandoffLabel": "Zugübergabe",
"PagerCopyAnswerLabel": "Antwort",
"PagerSearchMatch": "Treffer {current}/{total} (n/N)",
"ToolOutputShortened": "Ausgabe gekürzt · {key} öffnet Details"
"ToolOutputShortened": "Ausgabe gekürzt · {key} öffnet Details",
"AccountLoginTitle": "Bei Codewhale anmelden",
"AccountLoginStarting": "Anmeldung wird gestartet…",
"AccountLoginWaiting": "Schließe die Anmeldung im Browser ab. Du kannst den Link auch auf einem anderen Gerät öffnen.",
"AccountLoginCode": "Code: {code}",
"AccountLoginOpenBrowser": "Browser öffnen",
"AccountLoginCopyLink": "Link kopieren",
"AccountLoginLink": "Anmeldelink",
"AccountLoginSuccess": "Bei Codewhale angemeldet. Das ausgewählte Modell bleibt unverändert.",
"AccountLoginNeedsModel": "Bei Codewhale angemeldet. Verbinde ein Modell, um loszulegen.",
"AccountLoginCancelled": "Anmeldung abgebrochen.",
"AccountLoginExpired": "Zeitüberschreitung bei der Anmeldung. Versuche es mit {command} erneut.",
"AccountLoginFailed": "Anmeldung fehlgeschlagen. Versuche es mit {command} erneut.",
"AccountLogoutTitle": "Von Codewhale abmelden",
"AccountLogoutStarting": "Abmeldung von Codewhale läuft…",
"AccountLogoutSuccess": "Von Codewhale abgemeldet. Lokale Modelle und Unterhaltungen bleiben unverändert.",
"AccountLogoutLocal": "Auf diesem Computer abgemeldet. Der Widerruf auf dem Server konnte nicht bestätigt werden.",
"AccountLogoutFailed": "Abmeldung fehlgeschlagen. Versuche es mit {command} erneut.",
"AccountLogoutCancelled": "Abmeldung abgebrochen.",
"ConnectionSetupTitle": "Ein Modell verbinden",
"ConnectionSetupAccount": "Mit Codewhale fortfahren",
"ConnectionSetupAccountDetail": "Melde dich an und prüfe die verfügbaren Modelle deines Kontos.",
"ConnectionSetupLocal": "Auf diesem Computer verbinden",
"ConnectionSetupLocalDetail": "Nutze ein Anbieterkonto, einen API-Schlüssel oder ein lokales Modell.",
"AccountModelsTitle": "Ein Kontomodell auswählen",
"AccountModelsLoading": "Zugriff auf Kontomodelle wird geprüft…",
"AccountModelsUnavailable": "Hier sind keine nutzbaren Kontomodelle verfügbar. Verbinde einen Anbieter auf diesem Computer oder aktualisiere deine Modellverbindungen in der Codewhale-App.",
"AccountModelsReady": "Über dein Codewhale-Konto mit {model} verbunden.",
"AccountModelsConnectFailed": "Kontomodelle konnten nicht verbunden werden: {error}",
"AccountModelsCancelled": "Modelleinrichtung abgebrochen. Dein aktuelles Modell bleibt unverändert.",
"AccountModelsLoadFailed": "Kontomodelle konnten nicht geprüft werden. Versuche es erneut oder verbinde einen Anbieter auf diesem Computer."
}
+33 -3
View File
@@ -556,7 +556,7 @@
"CmdJobsDescription": "Manage background shell jobs",
"CmdLinksDescription": "Show Codewhale, community, and provider links",
"CmdLoadDescription": "Load a session from file",
"CmdLogoutDescription": "Sign out and return to setup",
"CmdLogoutDescription": "Sign out of Codewhale",
"CmdMcpDescription": "Open or manage MCP servers; init adds one, doctor checks it",
"McpReloadAlreadyRunning": "MCP reload is already running; the status bar tracks it.",
"McpRecommendedUnknownId": "Unknown MCP suggestion. Run {recommendations_command} to see the list.",
@@ -684,7 +684,7 @@
"CmdThemeDescription": "Pick a theme",
"CmdProviderDescription": "Switch provider or model",
"CmdAuthDescription": "Manage provider sign-in",
"CmdLoginDescription": "Sign in to Codewhale or store provider keys",
"CmdLoginDescription": "Sign in to Codewhale",
"CmdQueueDescription": "See or edit queued messages",
"CmdQueueUsage": "Usage: /queue [list|send <n>|edit <n>|drop <n>|clear]",
"QueueRestoredFromPreviousSession": "Restored {count} queued message(s) from an earlier session. They are not sent automatically: /queue send <n> sends one, /queue clear discards them.",
@@ -2784,5 +2784,35 @@
"PagerCopyHandoffLabel": "Turn handoff",
"PagerCopyAnswerLabel": "Answer",
"PagerSearchMatch": "match {current}/{total} (n/N)",
"ToolOutputShortened": "Output shortened · {key} opens details"
"ToolOutputShortened": "Output shortened · {key} opens details",
"AccountLoginTitle": "Sign in to Codewhale",
"AccountLoginStarting": "Starting sign-in…",
"AccountLoginWaiting": "Finish signing in in your browser. You can also open the link on another device.",
"AccountLoginCode": "Code: {code}",
"AccountLoginOpenBrowser": "Open browser",
"AccountLoginCopyLink": "Copy link",
"AccountLoginLink": "Sign-in link",
"AccountLoginSuccess": "Signed in to Codewhale. Your selected model is unchanged.",
"AccountLoginNeedsModel": "Signed in to Codewhale. Connect a model to start.",
"AccountLoginCancelled": "Sign-in cancelled.",
"AccountLoginExpired": "Sign-in timed out. Use {command} to try again.",
"AccountLoginFailed": "Couldn’t sign in. Use {command} to try again.",
"AccountLogoutTitle": "Sign out of Codewhale",
"AccountLogoutStarting": "Signing out of Codewhale…",
"AccountLogoutSuccess": "Signed out of Codewhale. Local models and conversations are unchanged.",
"AccountLogoutLocal": "Signed out on this computer. Server revocation couldn’t be confirmed.",
"AccountLogoutFailed": "Couldn’t sign out. Use {command} to try again.",
"AccountLogoutCancelled": "Sign-out cancelled.",
"ConnectionSetupTitle": "Connect a model",
"ConnectionSetupAccount": "Continue with Codewhale",
"ConnectionSetupAccountDetail": "Sign in and check your available account models.",
"ConnectionSetupLocal": "Connect on this computer",
"ConnectionSetupLocalDetail": "Use a provider account, API key, or local model.",
"AccountModelsTitle": "Choose an account model",
"AccountModelsLoading": "Checking account model access…",
"AccountModelsUnavailable": "No usable account models are available here. Connect a provider on this computer, or update your model connections in the Codewhale app.",
"AccountModelsReady": "Connected to {model} through your Codewhale account.",
"AccountModelsConnectFailed": "Couldn’t connect account models: {error}",
"AccountModelsCancelled": "Model setup cancelled. Your current model is unchanged.",
"AccountModelsLoadFailed": "Couldn’t check account models. Try again or connect a provider on this computer."
}
+33 -3
View File
@@ -556,7 +556,7 @@
"CmdJobsDescription": "Inspeccionar y controlar trabajos de shell en segundo plano",
"CmdLinksDescription": "Mostrar enlaces de Codewhale, la comunidad y los proveedores",
"CmdLoadDescription": "Cargar la sesión desde un archivo",
"CmdLogoutDescription": "Limpiar la clave de API y volver a la configuración",
"CmdLogoutDescription": "Cerrar sesión en Codewhale",
"CmdMcpDescription": "Abrir o gestionar servidores MCP; init agrega, doctor revisa",
"McpReloadAlreadyRunning": "La recarga de MCP ya está en curso; la barra de estado la sigue.",
"McpRecommendedUnknownId": "ID de MCP recomendado desconocido. Ejecuta {recommendations_command} para revisar la lista seleccionada.",
@@ -684,7 +684,7 @@
"CmdThemeDescription": "Alternar entre tema claro y oscuro",
"CmdProviderDescription": "Cambiar el proveedor o el modelo",
"CmdAuthDescription": "Gestionar flujos de autenticación de proveedores",
"CmdLoginDescription": "Iniciar sesión en Codewhale o guardar claves de proveedor",
"CmdLoginDescription": "Iniciar sesión en Codewhale",
"CmdQueueDescription": "Ver o editar mensajes en cola",
"CmdQueueUsage": "Uso: /queue [list|send <n>|edit <n>|drop <n>|clear]",
"QueueRestoredFromPreviousSession": "Se restauraron {count} mensajes en cola de una sesión anterior. No se envían automáticamente: /queue send <n> envía uno, /queue clear los descarta.",
@@ -2784,5 +2784,35 @@
"PagerCopyHandoffLabel": "Traspaso del turno",
"PagerCopyAnswerLabel": "Respuesta",
"PagerSearchMatch": "coincidencia {current}/{total} (n/N)",
"ToolOutputShortened": "Salida acortada · {key} abre los detalles"
"ToolOutputShortened": "Salida acortada · {key} abre los detalles",
"AccountLoginTitle": "Iniciar sesión en Codewhale",
"AccountLoginStarting": "Iniciando sesión…",
"AccountLoginWaiting": "Completa el inicio de sesión en tu navegador. También puedes abrir el enlace en otro dispositivo.",
"AccountLoginCode": "Código: {code}",
"AccountLoginOpenBrowser": "Abrir navegador",
"AccountLoginCopyLink": "Copiar enlace",
"AccountLoginLink": "Enlace de inicio de sesión",
"AccountLoginSuccess": "Iniciaste sesión en Codewhale. Se conserva el modelo seleccionado.",
"AccountLoginNeedsModel": "Iniciaste sesión en Codewhale. Conecta un modelo para comenzar.",
"AccountLoginCancelled": "Inicio de sesión cancelado.",
"AccountLoginExpired": "El inicio de sesión agotó el tiempo de espera. Usa {command} para reintentar.",
"AccountLoginFailed": "No se pudo iniciar sesión. Usa {command} para reintentar.",
"AccountLogoutTitle": "Cerrar sesión en Codewhale",
"AccountLogoutStarting": "Cerrando sesión en Codewhale…",
"AccountLogoutSuccess": "Cerraste sesión en Codewhale. Se conservan los modelos y las conversaciones locales.",
"AccountLogoutLocal": "Cerraste sesión en esta computadora. No se pudo confirmar la revocación en el servidor.",
"AccountLogoutFailed": "No se pudo cerrar sesión. Usa {command} para reintentar.",
"AccountLogoutCancelled": "Cierre de sesión cancelado.",
"ConnectionSetupTitle": "Conectar un modelo",
"ConnectionSetupAccount": "Continuar con Codewhale",
"ConnectionSetupAccountDetail": "Inicia sesión y consulta los modelos disponibles en tu cuenta.",
"ConnectionSetupLocal": "Conectar en esta computadora",
"ConnectionSetupLocalDetail": "Usa una cuenta de proveedor, una clave de API o un modelo local.",
"AccountModelsTitle": "Elegir un modelo de la cuenta",
"AccountModelsLoading": "Comprobando el acceso a los modelos de la cuenta…",
"AccountModelsUnavailable": "No hay modelos de la cuenta disponibles aquí. Conecta un proveedor en esta computadora o actualiza tus conexiones de modelos en la app de Codewhale.",
"AccountModelsReady": "Conectado a {model} mediante tu cuenta de Codewhale.",
"AccountModelsConnectFailed": "No se pudieron conectar los modelos de la cuenta: {error}",
"AccountModelsCancelled": "Configuración del modelo cancelada. Se conserva tu modelo actual.",
"AccountModelsLoadFailed": "No se pudieron consultar los modelos de la cuenta. Reintenta o conecta un proveedor en esta computadora."
}
+33 -3
View File
@@ -553,7 +553,7 @@
"CmdJobsDescription": "Inspecter et contrôler les jobs Bash en arrière-plan",
"CmdLinksDescription": "Afficher les liens Codewhale, communauté et fournisseurs",
"CmdLoadDescription": "Charger une session depuis un fichier",
"CmdLogoutDescription": "Effacer la clé API et revenir à la configuration",
"CmdLogoutDescription": "Se déconnecter de Codewhale",
"CmdMcpDescription": "Ouvrir ou gérer les serveurs MCP ; init ajoute, doctor teste",
"McpReloadAlreadyRunning": "Un rechargement MCP est déjà en cours ; la barre d'état le suit.",
"McpRecommendedUnknownId": "ID MCP recommandé inconnu. Exécutez {recommendations_command} pour consulter la liste sélectionnée.",
@@ -681,7 +681,7 @@
"CmdThemeDescription": "Changer de thème ou ouvrir le sélecteur de thème",
"CmdProviderDescription": "Changer le fournisseur et/ou le modèle actif",
"CmdAuthDescription": "Gérer les flux d'authentification des fournisseurs",
"CmdLoginDescription": "Se connecter à Codewhale ou enregistrer des clés fournisseur",
"CmdLoginDescription": "Se connecter à Codewhale",
"CmdQueueDescription": "Voir ou modifier les messages en file",
"CmdQueueUsage": "Usage : /queue [list|send <n>|edit <n>|drop <n>|clear]",
"QueueRestoredFromPreviousSession": "{count} message(s) en file restauré(s) d'une session précédente. Ils ne partent pas automatiquement : /queue send <n> en envoie un, /queue clear les supprime.",
@@ -2784,5 +2784,35 @@
"PagerCopyHandoffLabel": "Passation du tour",
"PagerCopyAnswerLabel": "Réponse",
"PagerSearchMatch": "résultat {current}/{total} (n/N)",
"ToolOutputShortened": "Sortie raccourcie · {key} ouvre les détails"
"ToolOutputShortened": "Sortie raccourcie · {key} ouvre les détails",
"AccountLoginTitle": "Se connecter à Codewhale",
"AccountLoginStarting": "Démarrage de la connexion…",
"AccountLoginWaiting": "Terminez la connexion dans votre navigateur. Vous pouvez aussi ouvrir le lien sur un autre appareil.",
"AccountLoginCode": "Code : {code}",
"AccountLoginOpenBrowser": "Ouvrir le navigateur",
"AccountLoginCopyLink": "Copier le lien",
"AccountLoginLink": "Lien de connexion",
"AccountLoginSuccess": "Connecté à Codewhale. Le modèle sélectionné reste inchangé.",
"AccountLoginNeedsModel": "Connecté à Codewhale. Connectez un modèle pour commencer.",
"AccountLoginCancelled": "Connexion annulée.",
"AccountLoginExpired": "La connexion a expiré. Utilisez {command} pour réessayer.",
"AccountLoginFailed": "Impossible de se connecter. Utilisez {command} pour réessayer.",
"AccountLogoutTitle": "Se déconnecter de Codewhale",
"AccountLogoutStarting": "Déconnexion de Codewhale…",
"AccountLogoutSuccess": "Déconnecté de Codewhale. Les modèles et conversations locaux restent inchangés.",
"AccountLogoutLocal": "Déconnecté sur cet ordinateur. La révocation sur le serveur n’a pas pu être confirmée.",
"AccountLogoutFailed": "Impossible de se déconnecter. Utilisez {command} pour réessayer.",
"AccountLogoutCancelled": "Déconnexion annulée.",
"ConnectionSetupTitle": "Connecter un modèle",
"ConnectionSetupAccount": "Continuer avec Codewhale",
"ConnectionSetupAccountDetail": "Connectez-vous et consultez les modèles disponibles sur votre compte.",
"ConnectionSetupLocal": "Connecter sur cet ordinateur",
"ConnectionSetupLocalDetail": "Utilisez un compte fournisseur, une clé API ou un modèle local.",
"AccountModelsTitle": "Choisir un modèle du compte",
"AccountModelsLoading": "Vérification de l’accès aux modèles du compte…",
"AccountModelsUnavailable": "Aucun modèle du compte n’est utilisable ici. Connectez un fournisseur sur cet ordinateur ou mettez à jour vos connexions de modèles dans l’application Codewhale.",
"AccountModelsReady": "Connecté à {model} via votre compte Codewhale.",
"AccountModelsConnectFailed": "Impossible de connecter les modèles du compte : {error}",
"AccountModelsCancelled": "Configuration du modèle annulée. Votre modèle actuel reste inchangé.",
"AccountModelsLoadFailed": "Impossible de vérifier les modèles du compte. Réessayez ou connectez un fournisseur sur cet ordinateur."
}
+33 -3
View File
@@ -553,7 +553,7 @@
"CmdJobsDescription": "बैकग्राउंड Bash जॉब देखें और नियंत्रित करें",
"CmdLinksDescription": "Codewhale, समुदाय और प्रोवाइडर लिंक दिखाएँ",
"CmdLoadDescription": "फ़ाइल से सत्र लोड करें",
"CmdLogoutDescription": "API कुंजी साफ़ करें और सेटअप पर लौटें",
"CmdLogoutDescription": "Codewhale से साइन आउट करें",
"CmdMcpDescription": "MCP सर्वर खोलें या सँभालें; init जोड़ता है, doctor जाँचता है",
"McpReloadAlreadyRunning": "MCP पुनः लोड पहले से चल रहा है; स्थिति पट्टी इसे ट्रैक करती है।",
"McpRecommendedUnknownId": "अज्ञात सुझाई गई MCP ID। चुनी हुई सूची देखने के लिए {recommendations_command} चलाएँ।",
@@ -681,7 +681,7 @@
"CmdThemeDescription": "थीम बदलें या थीम चयनकर्ता खोलें",
"CmdProviderDescription": "सक्रिय प्रोवाइडर और/या मॉडल बदलें",
"CmdAuthDescription": "प्रोवाइडर प्रमाणीकरण प्रवाह प्रबंधित करें",
"CmdLoginDescription": "Codewhale में साइन इन करें या प्रदाता कुंजी सहेजें",
"CmdLoginDescription": "Codewhale में साइन इन करें",
"CmdQueueDescription": "कतारबद्ध संदेश देखें या संपादित करें",
"CmdQueueUsage": "उपयोग: /queue [list|send <n>|edit <n>|drop <n>|clear]",
"QueueRestoredFromPreviousSession": "पिछले सत्र के कतार के {count} संदेश बहाल किए गए। ये अपने आप नहीं भेजे जाते: /queue send <n> एक भेजता है, /queue clear इन्हें हटाता है।",
@@ -2784,5 +2784,35 @@
"PagerCopyHandoffLabel": "टर्न का हस्तांतरण",
"PagerCopyAnswerLabel": "उत्तर",
"PagerSearchMatch": "मिलान {current}/{total} (n/N)",
"ToolOutputShortened": "आउटपुट छोटा किया गया · {key} से विवरण खोलें"
"ToolOutputShortened": "आउटपुट छोटा किया गया · {key} से विवरण खोलें",
"AccountLoginTitle": "Codewhale में साइन इन करें",
"AccountLoginStarting": "साइन इन शुरू हो रहा है…",
"AccountLoginWaiting": "अपने ब्राउज़र में साइन इन पूरा करें। आप यह लिंक किसी दूसरे डिवाइस पर भी खोल सकते हैं।",
"AccountLoginCode": "कोड: {code}",
"AccountLoginOpenBrowser": "ब्राउज़र खोलें",
"AccountLoginCopyLink": "लिंक कॉपी करें",
"AccountLoginLink": "साइन इन लिंक",
"AccountLoginSuccess": "Codewhale में साइन इन हो गया। आपका चुना हुआ मॉडल नहीं बदला है।",
"AccountLoginNeedsModel": "Codewhale में साइन इन हो गया। शुरू करने के लिए मॉडल कनेक्ट करें।",
"AccountLoginCancelled": "साइन इन रद्द किया गया।",
"AccountLoginExpired": "साइन इन का समय समाप्त हो गया। फिर से कोशिश करने के लिए {command} इस्तेमाल करें।",
"AccountLoginFailed": "साइन इन नहीं हो सका। फिर से कोशिश करने के लिए {command} इस्तेमाल करें।",
"AccountLogoutTitle": "Codewhale से साइन आउट करें",
"AccountLogoutStarting": "Codewhale से साइन आउट हो रहा है…",
"AccountLogoutSuccess": "Codewhale से साइन आउट हो गया। स्थानीय मॉडल और बातचीत नहीं बदले हैं।",
"AccountLogoutLocal": "इस कंप्यूटर पर साइन आउट हो गया। सर्वर पर सत्र रद्द होने की पुष्टि नहीं हो सकी।",
"AccountLogoutFailed": "साइन आउट नहीं हो सका। फिर से कोशिश करने के लिए {command} इस्तेमाल करें।",
"AccountLogoutCancelled": "साइन आउट रद्द किया गया।",
"ConnectionSetupTitle": "मॉडल कनेक्ट करें",
"ConnectionSetupAccount": "Codewhale के साथ जारी रखें",
"ConnectionSetupAccountDetail": "साइन इन करके अपने खाते में उपलब्ध मॉडल देखें।",
"ConnectionSetupLocal": "इस कंप्यूटर पर कनेक्ट करें",
"ConnectionSetupLocalDetail": "प्रदाता खाता, API कुंजी या स्थानीय मॉडल इस्तेमाल करें।",
"AccountModelsTitle": "खाते का मॉडल चुनें",
"AccountModelsLoading": "खाते के मॉडल की पहुँच जाँची जा रही है…",
"AccountModelsUnavailable": "यहाँ खाते का कोई उपयोग योग्य मॉडल उपलब्ध नहीं है। इस कंप्यूटर पर प्रदाता कनेक्ट करें या Codewhale ऐप में अपने मॉडल कनेक्शन अपडेट करें।",
"AccountModelsReady": "आपके Codewhale खाते से {model} कनेक्ट हो गया।",
"AccountModelsConnectFailed": "खाते के मॉडल कनेक्ट नहीं हो सके: {error}",
"AccountModelsCancelled": "मॉडल सेटअप रद्द किया गया। आपका मौजूदा मॉडल नहीं बदला है।",
"AccountModelsLoadFailed": "खाते के मॉडल जाँचे नहीं जा सके। फिर से कोशिश करें या इस कंप्यूटर पर प्रदाता कनेक्ट करें।"
}
+33 -3
View File
@@ -553,7 +553,7 @@
"CmdJobsDescription": "Periksa dan kendalikan job Bash latar belakang",
"CmdLinksDescription": "Tampilkan tautan Codewhale, komunitas, dan penyedia",
"CmdLoadDescription": "Muat sesi dari file",
"CmdLogoutDescription": "Hapus kunci API dan kembali ke penyiapan",
"CmdLogoutDescription": "Keluar dari Codewhale",
"CmdMcpDescription": "Buka atau kelola server MCP; init menambah, doctor memeriksa",
"McpReloadAlreadyRunning": "Pemuatan ulang MCP sedang berjalan; bilah status melacaknya.",
"McpRecommendedUnknownId": "ID MCP rekomendasi tidak dikenal. Jalankan {recommendations_command} untuk memeriksa daftar pilihan.",
@@ -681,7 +681,7 @@
"CmdThemeDescription": "Ganti tema atau buka pemilih tema",
"CmdProviderDescription": "Ganti penyedia dan/atau model aktif",
"CmdAuthDescription": "Kelola alur autentikasi penyedia",
"CmdLoginDescription": "Masuk ke Codewhale atau simpan kunci penyedia",
"CmdLoginDescription": "Masuk ke Codewhale",
"CmdQueueDescription": "Lihat atau edit pesan dalam antrean",
"CmdQueueUsage": "Penggunaan: /queue [list|send <n>|edit <n>|drop <n>|clear]",
"QueueRestoredFromPreviousSession": "{count} pesan dalam antrean dari sesi sebelumnya dipulihkan. Pesan tidak dikirim otomatis: /queue send <n> mengirim satu, /queue clear membuangnya.",
@@ -2784,5 +2784,35 @@
"PagerCopyHandoffLabel": "Serah terima giliran",
"PagerCopyAnswerLabel": "Jawaban",
"PagerSearchMatch": "kecocokan {current}/{total} (n/N)",
"ToolOutputShortened": "Keluaran dipersingkat · {key} membuka detail"
"ToolOutputShortened": "Keluaran dipersingkat · {key} membuka detail",
"AccountLoginTitle": "Masuk ke Codewhale",
"AccountLoginStarting": "Memulai proses masuk…",
"AccountLoginWaiting": "Selesaikan proses masuk di browser. Anda juga dapat membuka tautan di perangkat lain.",
"AccountLoginCode": "Kode: {code}",
"AccountLoginOpenBrowser": "Buka browser",
"AccountLoginCopyLink": "Salin tautan",
"AccountLoginLink": "Tautan masuk",
"AccountLoginSuccess": "Berhasil masuk ke Codewhale. Model yang dipilih tetap sama.",
"AccountLoginNeedsModel": "Berhasil masuk ke Codewhale. Hubungkan model untuk memulai.",
"AccountLoginCancelled": "Proses masuk dibatalkan.",
"AccountLoginExpired": "Waktu masuk habis. Gunakan {command} untuk mencoba lagi.",
"AccountLoginFailed": "Tidak dapat masuk. Gunakan {command} untuk mencoba lagi.",
"AccountLogoutTitle": "Keluar dari Codewhale",
"AccountLogoutStarting": "Keluar dari Codewhale…",
"AccountLogoutSuccess": "Berhasil keluar dari Codewhale. Model dan percakapan lokal tetap sama.",
"AccountLogoutLocal": "Berhasil keluar di komputer ini. Pencabutan sesi di server belum dapat dikonfirmasi.",
"AccountLogoutFailed": "Tidak dapat keluar. Gunakan {command} untuk mencoba lagi.",
"AccountLogoutCancelled": "Proses keluar dibatalkan.",
"ConnectionSetupTitle": "Hubungkan model",
"ConnectionSetupAccount": "Lanjutkan dengan Codewhale",
"ConnectionSetupAccountDetail": "Masuk dan periksa model yang tersedia di akun Anda.",
"ConnectionSetupLocal": "Hubungkan di komputer ini",
"ConnectionSetupLocalDetail": "Gunakan akun penyedia, kunci API, atau model lokal.",
"AccountModelsTitle": "Pilih model akun",
"AccountModelsLoading": "Memeriksa akses model akun…",
"AccountModelsUnavailable": "Tidak ada model akun yang dapat digunakan di sini. Hubungkan penyedia di komputer ini atau perbarui koneksi model Anda di aplikasi Codewhale.",
"AccountModelsReady": "Terhubung ke {model} melalui akun Codewhale Anda.",
"AccountModelsConnectFailed": "Tidak dapat menghubungkan model akun: {error}",
"AccountModelsCancelled": "Penyiapan model dibatalkan. Model Anda saat ini tetap sama.",
"AccountModelsLoadFailed": "Tidak dapat memeriksa model akun. Coba lagi atau hubungkan penyedia di komputer ini."
}
+33 -3
View File
@@ -556,7 +556,7 @@
"CmdJobsDescription": "バックグラウンドのシェルジョブを確認・制御",
"CmdLinksDescription": "Codewhale、コミュニティ、プロバイダーのリンクを表示",
"CmdLoadDescription": "ファイルからセッションを読み込み",
"CmdLogoutDescription": "API キーを消去してセットアップに戻る",
"CmdLogoutDescription": "Codewhale からサインアウト",
"CmdMcpDescription": "MCP サーバを開く・管理する — init で追加、doctor で点検",
"McpReloadAlreadyRunning": "MCP の再読み込みは実行中です。ステータスバーが進行状況を示します。",
"McpRecommendedUnknownId": "推奨 MCP ID が不明です。{recommendations_command} で精選リストを確認してください。",
@@ -684,7 +684,7 @@
"CmdThemeDescription": "テーマを切り替え(ダーク/ライト/グレースケール/システム)",
"CmdProviderDescription": "プロバイダまたはモデルを切り替え",
"CmdAuthDescription": "プロバイダー認証フローを管理",
"CmdLoginDescription": "Codewhaleにサインインするか、プロバイダーキーを保存",
"CmdLoginDescription": "Codewhale にサインイン",
"CmdQueueDescription": "キューされたメッセージを確認・編集",
"CmdQueueUsage": "使用方法: /queue [list|send <n>|edit <n>|drop <n>|clear]",
"QueueRestoredFromPreviousSession": "前のセッションのキューのメッセージ {count} 件を復元しました。自動では送信されません。/queue send <n> で 1 件送信、/queue clear で破棄します。",
@@ -2784,5 +2784,35 @@
"PagerCopyHandoffLabel": "ターンの引き継ぎ",
"PagerCopyAnswerLabel": "回答",
"PagerSearchMatch": "一致 {current}/{total}(n/N)",
"ToolOutputShortened": "出力を短縮しました · {key} で詳細を開く"
"ToolOutputShortened": "出力を短縮しました · {key} で詳細を開く",
"AccountLoginTitle": "Codewhale にサインイン",
"AccountLoginStarting": "サインインを開始しています…",
"AccountLoginWaiting": "ブラウザーでサインインを完了してください。別の端末でリンクを開くこともできます。",
"AccountLoginCode": "コード:{code}",
"AccountLoginOpenBrowser": "ブラウザーを開く",
"AccountLoginCopyLink": "リンクをコピー",
"AccountLoginLink": "サインイン用リンク",
"AccountLoginSuccess": "Codewhale にサインインしました。選択中のモデルは変わりません。",
"AccountLoginNeedsModel": "Codewhale にサインインしました。モデルを接続して始めましょう。",
"AccountLoginCancelled": "サインインをキャンセルしました。",
"AccountLoginExpired": "サインインがタイムアウトしました。{command} で再試行してください。",
"AccountLoginFailed": "サインインできませんでした。{command} で再試行してください。",
"AccountLogoutTitle": "Codewhale からサインアウト",
"AccountLogoutStarting": "Codewhale からサインアウトしています…",
"AccountLogoutSuccess": "Codewhale からサインアウトしました。ローカルのモデルと会話は変わりません。",
"AccountLogoutLocal": "このコンピューターからサインアウトしました。サーバー側のセッション失効は確認できませんでした。",
"AccountLogoutFailed": "サインアウトできませんでした。{command} で再試行してください。",
"AccountLogoutCancelled": "サインアウトをキャンセルしました。",
"ConnectionSetupTitle": "モデルを接続",
"ConnectionSetupAccount": "Codewhale で続ける",
"ConnectionSetupAccountDetail": "サインインして、アカウントで利用できるモデルを確認します。",
"ConnectionSetupLocal": "このコンピューターで接続",
"ConnectionSetupLocalDetail": "プロバイダーのアカウント、API キー、またはローカルモデルを使います。",
"AccountModelsTitle": "アカウントのモデルを選択",
"AccountModelsLoading": "アカウントのモデルアクセスを確認しています…",
"AccountModelsUnavailable": "ここでは利用できるアカウントモデルがありません。このコンピューターでプロバイダーを接続するか、Codewhale アプリでモデル接続を更新してください。",
"AccountModelsReady": "Codewhale アカウント経由で {model} に接続しました。",
"AccountModelsConnectFailed": "アカウントモデルに接続できませんでした:{error}",
"AccountModelsCancelled": "モデル設定をキャンセルしました。現在のモデルは変わりません。",
"AccountModelsLoadFailed": "アカウントモデルを確認できませんでした。再試行するか、このコンピューターでプロバイダーを接続してください。"
}
+33 -3
View File
@@ -556,7 +556,7 @@
"CmdJobsDescription": "백그라운드 Bash 작업을 확인하고 제어합니다",
"CmdLinksDescription": "Codewhale, 커뮤니티 및 프로바이더 링크를 표시합니다",
"CmdLoadDescription": "파일에서 세션을 불러옵니다",
"CmdLogoutDescription": "API 키를 지우고 설정 화면으로 돌아갑니다",
"CmdLogoutDescription": "Codewhale 로그아웃",
"CmdMcpDescription": "MCP 서버를 열거나 관리합니다 — init 은 추가, doctor 는 점검",
"McpReloadAlreadyRunning": "MCP 다시 불러오기가 이미 실행 중입니다. 상태 표시줄에서 확인하세요.",
"McpRecommendedUnknownId": "알 수 없는 권장 MCP ID입니다. {recommendations_command} 명령으로 선별 목록을 확인하세요.",
@@ -684,7 +684,7 @@
"CmdThemeDescription": "테마를 전환하거나 테마 선택 화면을 엽니다",
"CmdProviderDescription": "현재 프로바이더 및/또는 모델을 전환합니다",
"CmdAuthDescription": "프로바이더 인증 흐름을 관리합니다",
"CmdLoginDescription": "Codewhale에 로그인하거나 프로바이더 키를 저장합니다",
"CmdLoginDescription": "Codewhale 로그인",
"CmdQueueDescription": "대기 중인 메시지를 확인하거나 편집합니다",
"CmdQueueUsage": "사용법: /queue [list|send <n>|edit <n>|drop <n>|clear]",
"QueueRestoredFromPreviousSession": "이전 세션의 대기 중인 메시지 {count}개를 복원했습니다. 자동으로 전송되지 않습니다. /queue send <n>으로 하나를 보내고 /queue clear로 삭제합니다.",
@@ -2784,5 +2784,35 @@
"PagerCopyHandoffLabel": "턴 인계 내용",
"PagerCopyAnswerLabel": "답변",
"PagerSearchMatch": "일치 {current}/{total} (n/N)",
"ToolOutputShortened": "출력이 줄어듦 · {key}로 세부 정보 열기"
"ToolOutputShortened": "출력이 줄어듦 · {key}로 세부 정보 열기",
"AccountLoginTitle": "Codewhale 로그인",
"AccountLoginStarting": "로그인을 시작하는 중…",
"AccountLoginWaiting": "브라우저에서 로그인을 완료하세요. 다른 기기에서 링크를 열어도 됩니다.",
"AccountLoginCode": "코드: {code}",
"AccountLoginOpenBrowser": "브라우저 열기",
"AccountLoginCopyLink": "링크 복사",
"AccountLoginLink": "로그인 링크",
"AccountLoginSuccess": "Codewhale에 로그인했습니다. 선택한 모델은 그대로 유지됩니다.",
"AccountLoginNeedsModel": "Codewhale에 로그인했습니다. 모델을 연결하여 시작하세요.",
"AccountLoginCancelled": "로그인을 취소했습니다.",
"AccountLoginExpired": "로그인 시간이 초과되었습니다. {command}로 다시 시도하세요.",
"AccountLoginFailed": "로그인하지 못했습니다. {command}로 다시 시도하세요.",
"AccountLogoutTitle": "Codewhale 로그아웃",
"AccountLogoutStarting": "Codewhale에서 로그아웃하는 중…",
"AccountLogoutSuccess": "Codewhale에서 로그아웃했습니다. 로컬 모델과 대화는 그대로 유지됩니다.",
"AccountLogoutLocal": "이 컴퓨터에서 로그아웃했습니다. 서버의 세션 해제는 확인하지 못했습니다.",
"AccountLogoutFailed": "로그아웃하지 못했습니다. {command}로 다시 시도하세요.",
"AccountLogoutCancelled": "로그아웃을 취소했습니다.",
"ConnectionSetupTitle": "모델 연결",
"ConnectionSetupAccount": "Codewhale로 계속",
"ConnectionSetupAccountDetail": "로그인하여 계정에서 사용할 수 있는 모델을 확인합니다.",
"ConnectionSetupLocal": "이 컴퓨터에서 연결",
"ConnectionSetupLocalDetail": "공급자 계정, API 키 또는 로컬 모델을 사용합니다.",
"AccountModelsTitle": "계정 모델 선택",
"AccountModelsLoading": "계정 모델 접근 권한을 확인하는 중…",
"AccountModelsUnavailable": "여기에서 사용할 수 있는 계정 모델이 없습니다. 이 컴퓨터에서 공급자를 연결하거나 Codewhale 앱에서 모델 연결을 업데이트하세요.",
"AccountModelsReady": "Codewhale 계정으로 {model}에 연결했습니다.",
"AccountModelsConnectFailed": "계정 모델을 연결하지 못했습니다: {error}",
"AccountModelsCancelled": "모델 설정을 취소했습니다. 현재 모델은 그대로 유지됩니다.",
"AccountModelsLoadFailed": "계정 모델을 확인하지 못했습니다. 다시 시도하거나 이 컴퓨터에서 공급자를 연결하세요."
}
+33 -3
View File
@@ -556,7 +556,7 @@
"CmdJobsDescription": "Inspecionar e controlar jobs de shell em segundo plano",
"CmdLinksDescription": "Exibir links do Codewhale, da comunidade e dos provedores",
"CmdLoadDescription": "Carregar a sessão de um arquivo",
"CmdLogoutDescription": "Limpar a chave de API e voltar à configuração",
"CmdLogoutDescription": "Sair do Codewhale",
"CmdMcpDescription": "Gerenciar servidores MCP; init adiciona um, doctor verifica",
"McpReloadAlreadyRunning": "A recarga do MCP já está em andamento; a barra de status a acompanha.",
"McpRecommendedUnknownId": "ID de MCP recomendado desconhecido. Execute {recommendations_command} para conferir a lista selecionada.",
@@ -684,7 +684,7 @@
"CmdThemeDescription": "Alternar tema: escuro, claro, tons de cinza ou sistema",
"CmdProviderDescription": "Trocar o provedor ou o modelo",
"CmdAuthDescription": "Gerenciar fluxos de autenticação de provedores",
"CmdLoginDescription": "Entrar no Codewhale ou salvar chaves de provedor",
"CmdLoginDescription": "Entrar no Codewhale",
"CmdQueueDescription": "Ver ou editar mensagens enfileiradas",
"CmdQueueUsage": "Uso: /queue [list|send <n>|edit <n>|drop <n>|clear]",
"QueueRestoredFromPreviousSession": "{count} mensagem(ns) enfileirada(s) de uma sessão anterior restaurada(s). Elas não são enviadas automaticamente: /queue send <n> envia uma, /queue clear as descarta.",
@@ -2784,5 +2784,35 @@
"PagerCopyHandoffLabel": "Passagem do turno",
"PagerCopyAnswerLabel": "Resposta",
"PagerSearchMatch": "ocorrência {current}/{total} (n/N)",
"ToolOutputShortened": "Saída encurtada · {key} abre os detalhes"
"ToolOutputShortened": "Saída encurtada · {key} abre os detalhes",
"AccountLoginTitle": "Entrar no Codewhale",
"AccountLoginStarting": "Iniciando login…",
"AccountLoginWaiting": "Conclua o login no navegador. Você também pode abrir o link em outro dispositivo.",
"AccountLoginCode": "Código: {code}",
"AccountLoginOpenBrowser": "Abrir navegador",
"AccountLoginCopyLink": "Copiar link",
"AccountLoginLink": "Link de login",
"AccountLoginSuccess": "Você entrou no Codewhale. O modelo selecionado foi mantido.",
"AccountLoginNeedsModel": "Você entrou no Codewhale. Conecte um modelo para começar.",
"AccountLoginCancelled": "Login cancelado.",
"AccountLoginExpired": "O login expirou. Use {command} para tentar novamente.",
"AccountLoginFailed": "Não foi possível entrar. Use {command} para tentar novamente.",
"AccountLogoutTitle": "Sair do Codewhale",
"AccountLogoutStarting": "Saindo do Codewhale…",
"AccountLogoutSuccess": "Você saiu do Codewhale. Os modelos e conversas locais foram mantidos.",
"AccountLogoutLocal": "Você saiu neste computador. Não foi possível confirmar a revogação no servidor.",
"AccountLogoutFailed": "Não foi possível sair. Use {command} para tentar novamente.",
"AccountLogoutCancelled": "Saída cancelada.",
"ConnectionSetupTitle": "Conectar um modelo",
"ConnectionSetupAccount": "Continuar com Codewhale",
"ConnectionSetupAccountDetail": "Entre e confira os modelos disponíveis na sua conta.",
"ConnectionSetupLocal": "Conectar neste computador",
"ConnectionSetupLocalDetail": "Use uma conta de provedor, chave de API ou modelo local.",
"AccountModelsTitle": "Escolher um modelo da conta",
"AccountModelsLoading": "Verificando o acesso aos modelos da conta…",
"AccountModelsUnavailable": "Não há modelos da conta disponíveis aqui. Conecte um provedor neste computador ou atualize suas conexões de modelos no aplicativo Codewhale.",
"AccountModelsReady": "Conectado a {model} pela sua conta Codewhale.",
"AccountModelsConnectFailed": "Não foi possível conectar os modelos da conta: {error}",
"AccountModelsCancelled": "Configuração do modelo cancelada. Seu modelo atual foi mantido.",
"AccountModelsLoadFailed": "Não foi possível verificar os modelos da conta. Tente novamente ou conecte um provedor neste computador."
}
+33 -3
View File
@@ -553,7 +553,7 @@
"CmdJobsDescription": "Просмотр и управление фоновыми заданиями Bash",
"CmdLinksDescription": "Показать ссылки на Codewhale, сообщество и провайдеров",
"CmdLoadDescription": "Загрузить сессию из файла",
"CmdLogoutDescription": "Удалить API-ключ и вернуться к настройке",
"CmdLogoutDescription": "Выйти из Codewhale",
"CmdMcpDescription": "Управление серверами MCP; init добавляет, doctor проверяет",
"McpReloadAlreadyRunning": "Перезагрузка MCP уже выполняется; строка состояния показывает прогресс.",
"McpRecommendedUnknownId": "Неизвестный идентификатор рекомендованного MCP. Выполните {recommendations_command}, чтобы просмотреть отобранный список.",
@@ -681,7 +681,7 @@
"CmdThemeDescription": "Сменить тему или открыть выбор темы",
"CmdProviderDescription": "Сменить активного провайдера и/или модель",
"CmdAuthDescription": "Управление способами аутентификации провайдеров",
"CmdLoginDescription": "Войти в Codewhale или сохранить ключи провайдера",
"CmdLoginDescription": "Войти в Codewhale",
"CmdQueueDescription": "Просмотр и редактирование сообщений в очереди",
"CmdQueueUsage": "Использование: /queue [list|send <n>|edit <n>|drop <n>|clear]",
"QueueRestoredFromPreviousSession": "Восстановлено сообщений в очереди из прошлого сеанса: {count}. Они не отправляются автоматически: /queue send <n> отправит одно, /queue clear удалит их.",
@@ -2784,5 +2784,35 @@
"PagerCopyHandoffLabel": "Передача хода",
"PagerCopyAnswerLabel": "Ответ",
"PagerSearchMatch": "совпадение {current}/{total} (n/N)",
"ToolOutputShortened": "Вывод сокращён · {key} открывает подробности"
"ToolOutputShortened": "Вывод сокращён · {key} открывает подробности",
"AccountLoginTitle": "Войти в Codewhale",
"AccountLoginStarting": "Начинаем вход…",
"AccountLoginWaiting": "Завершите вход в браузере. Ссылку также можно открыть на другом устройстве.",
"AccountLoginCode": "Код: {code}",
"AccountLoginOpenBrowser": "Открыть браузер",
"AccountLoginCopyLink": "Копировать ссылку",
"AccountLoginLink": "Ссылка для входа",
"AccountLoginSuccess": "Вы вошли в Codewhale. Выбранная модель не изменилась.",
"AccountLoginNeedsModel": "Вы вошли в Codewhale. Подключите модель, чтобы начать.",
"AccountLoginCancelled": "Вход отменён.",
"AccountLoginExpired": "Время входа истекло. Повторите попытку с помощью {command}.",
"AccountLoginFailed": "Не удалось войти. Повторите попытку с помощью {command}.",
"AccountLogoutTitle": "Выйти из Codewhale",
"AccountLogoutStarting": "Выход из Codewhale…",
"AccountLogoutSuccess": "Вы вышли из Codewhale. Локальные модели и переписки не изменились.",
"AccountLogoutLocal": "Вы вышли на этом компьютере. Отзыв сеанса на сервере не подтверждён.",
"AccountLogoutFailed": "Не удалось выйти. Повторите попытку с помощью {command}.",
"AccountLogoutCancelled": "Выход отменён.",
"ConnectionSetupTitle": "Подключить модель",
"ConnectionSetupAccount": "Продолжить с Codewhale",
"ConnectionSetupAccountDetail": "Войдите и проверьте доступные модели вашего аккаунта.",
"ConnectionSetupLocal": "Подключить на этом компьютере",
"ConnectionSetupLocalDetail": "Используйте аккаунт провайдера, API-ключ или локальную модель.",
"AccountModelsTitle": "Выбрать модель аккаунта",
"AccountModelsLoading": "Проверяем доступ к моделям аккаунта…",
"AccountModelsUnavailable": "Здесь нет доступных моделей аккаунта. Подключите провайдера на этом компьютере или обновите подключения моделей в приложении Codewhale.",
"AccountModelsReady": "Модель {model} подключена через ваш аккаунт Codewhale.",
"AccountModelsConnectFailed": "Не удалось подключить модели аккаунта: {error}",
"AccountModelsCancelled": "Настройка модели отменена. Текущая модель не изменилась.",
"AccountModelsLoadFailed": "Не удалось проверить модели аккаунта. Повторите попытку или подключите провайдера на этом компьютере."
}
+33 -3
View File
@@ -553,7 +553,7 @@
"CmdJobsDescription": "Переглянути й керувати фоновими завданнями Bash",
"CmdLinksDescription": "Показати посилання Codewhale, спільноти та провайдерів",
"CmdLoadDescription": "Завантажити сеанс з файлу",
"CmdLogoutDescription": "Очистити ключ API та повернутися до налаштування",
"CmdLogoutDescription": "Вийти з Codewhale",
"CmdMcpDescription": "Керування серверами MCP; init додає, doctor перевіряє",
"McpReloadAlreadyRunning": "Перезавантаження MCP уже триває; рядок стану показує поступ.",
"McpRecommendedUnknownId": "Невідомий ідентифікатор рекомендованого MCP. Виконайте {recommendations_command}, щоб переглянути відібраний список.",
@@ -681,7 +681,7 @@
"CmdThemeDescription": "Змінити тему або відкрити вибір теми",
"CmdProviderDescription": "Змінити активного провайдера та/або модель",
"CmdAuthDescription": "Керувати потоками автентифікації провайдерів",
"CmdLoginDescription": "Увійти в Codewhale або зберегти ключі провайдера",
"CmdLoginDescription": "Увійти в Codewhale",
"CmdQueueDescription": "Переглянути або редагувати повідомлення в черзі",
"CmdQueueUsage": "Використання: /queue [list|send <n>|edit <n>|drop <n>|clear]",
"QueueRestoredFromPreviousSession": "Відновлено повідомлень у черзі з попереднього сеансу: {count}. Вони не надсилаються автоматично: /queue send <n> надішле одне, /queue clear видалить їх.",
@@ -2784,5 +2784,35 @@
"PagerCopyHandoffLabel": "Передача ходу",
"PagerCopyAnswerLabel": "Відповідь",
"PagerSearchMatch": "збіг {current}/{total} (n/N)",
"ToolOutputShortened": "Вивід скорочено · {key} відкриває подробиці"
"ToolOutputShortened": "Вивід скорочено · {key} відкриває подробиці",
"AccountLoginTitle": "Увійти в Codewhale",
"AccountLoginStarting": "Починаємо вхід…",
"AccountLoginWaiting": "Завершіть вхід у браузері. Посилання також можна відкрити на іншому пристрої.",
"AccountLoginCode": "Код: {code}",
"AccountLoginOpenBrowser": "Відкрити браузер",
"AccountLoginCopyLink": "Копіювати посилання",
"AccountLoginLink": "Посилання для входу",
"AccountLoginSuccess": "Ви увійшли в Codewhale. Вибрана модель не змінилася.",
"AccountLoginNeedsModel": "Ви увійшли в Codewhale. Підключіть модель, щоб почати.",
"AccountLoginCancelled": "Вхід скасовано.",
"AccountLoginExpired": "Час входу минув. Повторіть спробу за допомогою {command}.",
"AccountLoginFailed": "Не вдалося увійти. Повторіть спробу за допомогою {command}.",
"AccountLogoutTitle": "Вийти з Codewhale",
"AccountLogoutStarting": "Вихід із Codewhale…",
"AccountLogoutSuccess": "Ви вийшли з Codewhale. Локальні моделі та розмови не змінилися.",
"AccountLogoutLocal": "Ви вийшли на цьому комп’ютері. Відкликання сеансу на сервері не підтверджено.",
"AccountLogoutFailed": "Не вдалося вийти. Повторіть спробу за допомогою {command}.",
"AccountLogoutCancelled": "Вихід скасовано.",
"ConnectionSetupTitle": "Підключити модель",
"ConnectionSetupAccount": "Продовжити з Codewhale",
"ConnectionSetupAccountDetail": "Увійдіть і перевірте доступні моделі вашого облікового запису.",
"ConnectionSetupLocal": "Підключити на цьому комп’ютері",
"ConnectionSetupLocalDetail": "Використайте обліковий запис провайдера, API-ключ або локальну модель.",
"AccountModelsTitle": "Вибрати модель облікового запису",
"AccountModelsLoading": "Перевіряємо доступ до моделей облікового запису…",
"AccountModelsUnavailable": "Тут немає доступних моделей облікового запису. Підключіть провайдера на цьому комп’ютері або оновіть підключення моделей у застосунку Codewhale.",
"AccountModelsReady": "Модель {model} підключено через ваш обліковий запис Codewhale.",
"AccountModelsConnectFailed": "Не вдалося підключити моделі облікового запису: {error}",
"AccountModelsCancelled": "Налаштування моделі скасовано. Поточна модель не змінилася.",
"AccountModelsLoadFailed": "Не вдалося перевірити моделі облікового запису. Повторіть спробу або підключіть провайдера на цьому комп’ютері."
}
+33 -3
View File
@@ -556,7 +556,7 @@
"CmdJobsDescription": "Kiểm tra và kiểm soát các lệnh chạy ngầm",
"CmdLinksDescription": "Hiển thị liên kết Codewhale, cộng đồng và nhà cung cấp",
"CmdLoadDescription": "Tải phiên làm việc từ tệp",
"CmdLogoutDescription": "Xóa khóa API và quay lại bước thiết lập",
"CmdLogoutDescription": "Đăng xuất Codewhale",
"CmdMcpDescription": "Mở hoặc quản lý máy chủ MCP; init thêm, doctor kiểm tra",
"McpReloadAlreadyRunning": "MCP đang tải lại; thanh trạng thái theo dõi tiến trình.",
"McpRecommendedUnknownId": "ID MCP được đề xuất không xác định. Chạy {recommendations_command} để xem danh sách tuyển chọn.",
@@ -684,7 +684,7 @@
"CmdThemeDescription": "Chuyển đổi giao diện hoặc mở bảng chọn giao diện",
"CmdProviderDescription": "Chuyển nhà cung cấp hoặc mô hình",
"CmdAuthDescription": "Quản lý quy trình xác thực nhà cung cấp",
"CmdLoginDescription": "Đăng nhập Codewhale hoặc lưu khóa nhà cung cấp",
"CmdLoginDescription": "Đăng nhập Codewhale",
"CmdQueueDescription": "Xem hoặc chỉnh sửa các tin nhắn đang chờ xử lý",
"CmdQueueUsage": "Cách dùng: /queue [list|send <n>|edit <n>|drop <n>|clear]",
"QueueRestoredFromPreviousSession": "Đã khôi phục {count} tin nhắn đang chờ từ phiên trước. Chúng không được gửi tự động: /queue send <n> gửi một tin, /queue clear để hủy chúng.",
@@ -2784,5 +2784,35 @@
"PagerCopyHandoffLabel": "Bàn giao lượt",
"PagerCopyAnswerLabel": "Câu trả lời",
"PagerSearchMatch": "kết quả {current}/{total} (n/N)",
"ToolOutputShortened": "Đầu ra đã rút gọn · {key} mở chi tiết"
"ToolOutputShortened": "Đầu ra đã rút gọn · {key} mở chi tiết",
"AccountLoginTitle": "Đăng nhập Codewhale",
"AccountLoginStarting": "Đang bắt đầu đăng nhập…",
"AccountLoginWaiting": "Hoàn tất đăng nhập trong trình duyệt. Bạn cũng có thể mở liên kết trên thiết bị khác.",
"AccountLoginCode": "Mã: {code}",
"AccountLoginOpenBrowser": "Mở trình duyệt",
"AccountLoginCopyLink": "Sao chép liên kết",
"AccountLoginLink": "Liên kết đăng nhập",
"AccountLoginSuccess": "Đã đăng nhập Codewhale. Mô hình đã chọn được giữ nguyên.",
"AccountLoginNeedsModel": "Đã đăng nhập Codewhale. Kết nối một mô hình để bắt đầu.",
"AccountLoginCancelled": "Đã hủy đăng nhập.",
"AccountLoginExpired": "Đăng nhập đã hết thời gian chờ. Dùng {command} để thử lại.",
"AccountLoginFailed": "Không thể đăng nhập. Dùng {command} để thử lại.",
"AccountLogoutTitle": "Đăng xuất Codewhale",
"AccountLogoutStarting": "Đang đăng xuất Codewhale…",
"AccountLogoutSuccess": "Đã đăng xuất Codewhale. Mô hình và hội thoại cục bộ được giữ nguyên.",
"AccountLogoutLocal": "Đã đăng xuất trên máy tính này. Chưa xác nhận được việc thu hồi phiên trên máy chủ.",
"AccountLogoutFailed": "Không thể đăng xuất. Dùng {command} để thử lại.",
"AccountLogoutCancelled": "Đã hủy đăng xuất.",
"ConnectionSetupTitle": "Kết nối mô hình",
"ConnectionSetupAccount": "Tiếp tục với Codewhale",
"ConnectionSetupAccountDetail": "Đăng nhập và xem các mô hình có sẵn trong tài khoản.",
"ConnectionSetupLocal": "Kết nối trên máy tính này",
"ConnectionSetupLocalDetail": "Dùng tài khoản nhà cung cấp, khóa API hoặc mô hình cục bộ.",
"AccountModelsTitle": "Chọn mô hình của tài khoản",
"AccountModelsLoading": "Đang kiểm tra quyền truy cập mô hình tài khoản…",
"AccountModelsUnavailable": "Không có mô hình tài khoản khả dụng tại đây. Hãy kết nối nhà cung cấp trên máy tính này hoặc cập nhật kết nối mô hình trong ứng dụng Codewhale.",
"AccountModelsReady": "Đã kết nối {model} qua tài khoản Codewhale của bạn.",
"AccountModelsConnectFailed": "Không thể kết nối mô hình tài khoản: {error}",
"AccountModelsCancelled": "Đã hủy thiết lập mô hình. Mô hình hiện tại được giữ nguyên.",
"AccountModelsLoadFailed": "Không thể kiểm tra mô hình tài khoản. Hãy thử lại hoặc kết nối nhà cung cấp trên máy tính này."
}
+33 -3
View File
@@ -556,7 +556,7 @@
"CmdJobsDescription": "查看并管理后台 shell 作业",
"CmdLinksDescription": "显示 Codewhale、社区与提供商链接",
"CmdLoadDescription": "从文件加载会话",
"CmdLogoutDescription": "清除 API 密钥并返回设置",
"CmdLogoutDescription": "退出 Codewhale",
"CmdMcpDescription": "打开或管理 MCP 服务器;init 添加服务器,doctor 检查服务器",
"McpReloadAlreadyRunning": "MCP 重新加载正在进行中,状态栏会显示进度。",
"McpRecommendedUnknownId": "未知的推荐 MCP ID。运行 {recommendations_command} 查看精选列表。",
@@ -684,7 +684,7 @@
"CmdThemeDescription": "切换主题或打开主题选择器",
"CmdProviderDescription": "切换提供商或模型",
"CmdAuthDescription": "管理提供商身份验证流程",
"CmdLoginDescription": "登录 Codewhale 或保存提供商密钥",
"CmdLoginDescription": "登录 Codewhale",
"CmdQueueDescription": "查看或编辑已排队的消息",
"CmdQueueUsage": "用法: /queue [list|send <n>|edit <n>|drop <n>|clear]",
"QueueRestoredFromPreviousSession": "已恢复上一会话中的 {count} 条排队消息。它们不会自动发送:/queue send <n> 发送其中一条,/queue clear 将其丢弃。",
@@ -2784,5 +2784,35 @@
"PagerCopyHandoffLabel": "轮次交接摘要",
"PagerCopyAnswerLabel": "答案",
"PagerSearchMatch": "匹配 {current}/{total}(n/N)",
"ToolOutputShortened": "输出已缩短 · 按 {key} 打开详情"
"ToolOutputShortened": "输出已缩短 · 按 {key} 打开详情",
"AccountLoginTitle": "登录 Codewhale",
"AccountLoginStarting": "正在开始登录…",
"AccountLoginWaiting": "请在浏览器中完成登录,也可以在其他设备上打开此链接。",
"AccountLoginCode": "验证码:{code}",
"AccountLoginOpenBrowser": "打开浏览器",
"AccountLoginCopyLink": "复制链接",
"AccountLoginLink": "登录链接",
"AccountLoginSuccess": "已登录 Codewhale,所选模型保持不变。",
"AccountLoginNeedsModel": "已登录 Codewhale,请连接模型以开始使用。",
"AccountLoginCancelled": "已取消登录。",
"AccountLoginExpired": "登录超时,请使用 {command} 重试。",
"AccountLoginFailed": "无法登录,请使用 {command} 重试。",
"AccountLogoutTitle": "退出 Codewhale",
"AccountLogoutStarting": "正在退出 Codewhale…",
"AccountLogoutSuccess": "已退出 Codewhale,本地模型和对话保持不变。",
"AccountLogoutLocal": "已在此电脑上退出登录,无法确认服务器端的会话撤销。",
"AccountLogoutFailed": "无法退出登录,请使用 {command} 重试。",
"AccountLogoutCancelled": "已取消退出登录。",
"ConnectionSetupTitle": "连接模型",
"ConnectionSetupAccount": "使用 Codewhale 继续",
"ConnectionSetupAccountDetail": "登录并查看账户中可用的模型。",
"ConnectionSetupLocal": "在此电脑上连接",
"ConnectionSetupLocalDetail": "使用提供商账户、API 密钥或本地模型。",
"AccountModelsTitle": "选择账户模型",
"AccountModelsLoading": "正在检查账户模型访问权限…",
"AccountModelsUnavailable": "此处没有可用的账户模型。请在此电脑上连接提供商,或在 Codewhale 应用中更新模型连接。",
"AccountModelsReady": "已通过 Codewhale 账户连接 {model}。",
"AccountModelsConnectFailed": "无法连接账户模型:{error}",
"AccountModelsCancelled": "已取消模型设置,当前模型保持不变。",
"AccountModelsLoadFailed": "无法检查账户模型。请重试或在此电脑上连接提供商。"
}
+33 -3
View File
@@ -554,7 +554,7 @@
"CmdJobsDescription": "檢視並管理後台 shell 作業",
"CmdLinksDescription": "顯示 Codewhale、社群與供應商連結",
"CmdLoadDescription": "從檔案載入工作階段",
"CmdLogoutDescription": "清除 API 金鑰並返回設定",
"CmdLogoutDescription": "登出 Codewhale",
"CmdMcpDescription": "開啟或管理 MCP 伺服器;init 新增伺服器,doctor 檢查伺服器",
"McpReloadAlreadyRunning": "MCP 重新載入正在進行中,狀態列會顯示進度。",
"McpRecommendedUnknownId": "未知的建議 MCP ID。執行 {recommendations_command} 查看精選清單。",
@@ -682,7 +682,7 @@
"CmdThemeDescription": "切換主題或開啟主題選擇器",
"CmdProviderDescription": "切換供應商或模型",
"CmdAuthDescription": "管理供應商驗證流程",
"CmdLoginDescription": "登入 Codewhale 或儲存供應商金鑰",
"CmdLoginDescription": "登入 Codewhale",
"CmdQueueDescription": "檢視或編輯已排隊的訊息",
"CmdQueueUsage": "用法: /queue [list|send <n>|edit <n>|drop <n>|clear]",
"QueueRestoredFromPreviousSession": "已還原上一個工作階段中的 {count} 則排隊訊息。它們不會自動送出:/queue send <n> 送出其中一則,/queue clear 將其捨棄。",
@@ -2784,5 +2784,35 @@
"PagerCopyHandoffLabel": "回合交接摘要",
"PagerCopyAnswerLabel": "答案",
"PagerSearchMatch": "符合 {current}/{total}(n/N)",
"ToolOutputShortened": "輸出已縮短 · 按 {key} 開啟詳情"
"ToolOutputShortened": "輸出已縮短 · 按 {key} 開啟詳情",
"AccountLoginTitle": "登入 Codewhale",
"AccountLoginStarting": "正在開始登入…",
"AccountLoginWaiting": "請在瀏覽器中完成登入,也可以在其他裝置上開啟此連結。",
"AccountLoginCode": "驗證碼:{code}",
"AccountLoginOpenBrowser": "開啟瀏覽器",
"AccountLoginCopyLink": "複製連結",
"AccountLoginLink": "登入連結",
"AccountLoginSuccess": "已登入 Codewhale,所選模型保持不變。",
"AccountLoginNeedsModel": "已登入 Codewhale,請連接模型以開始使用。",
"AccountLoginCancelled": "已取消登入。",
"AccountLoginExpired": "登入逾時,請使用 {command} 重試。",
"AccountLoginFailed": "無法登入,請使用 {command} 重試。",
"AccountLogoutTitle": "登出 Codewhale",
"AccountLogoutStarting": "正在登出 Codewhale…",
"AccountLogoutSuccess": "已登出 Codewhale,本機模型和對話保持不變。",
"AccountLogoutLocal": "已在此電腦上登出,無法確認伺服器端的工作階段撤銷。",
"AccountLogoutFailed": "無法登出,請使用 {command} 重試。",
"AccountLogoutCancelled": "已取消登出。",
"ConnectionSetupTitle": "連接模型",
"ConnectionSetupAccount": "使用 Codewhale 繼續",
"ConnectionSetupAccountDetail": "登入並查看帳戶中可用的模型。",
"ConnectionSetupLocal": "在此電腦上連接",
"ConnectionSetupLocalDetail": "使用供應商帳戶、API 金鑰或本機模型。",
"AccountModelsTitle": "選擇帳戶模型",
"AccountModelsLoading": "正在檢查帳戶模型存取權限…",
"AccountModelsUnavailable": "此處沒有可用的帳戶模型。請在此電腦上連接供應商,或在 Codewhale 應用程式中更新模型連接。",
"AccountModelsReady": "已透過 Codewhale 帳戶連接 {model}。",
"AccountModelsConnectFailed": "無法連接帳戶模型:{error}",
"AccountModelsCancelled": "已取消模型設定,目前的模型保持不變。",
"AccountModelsLoadFailed": "無法檢查帳戶模型。請重試或在此電腦上連接供應商。"
}
+62
View File
@@ -120,6 +120,38 @@ impl Locale {
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum MessageId {
AccountModelsLoadFailed,
ConnectionSetupTitle,
ConnectionSetupAccount,
ConnectionSetupAccountDetail,
ConnectionSetupLocal,
ConnectionSetupLocalDetail,
AccountModelsTitle,
AccountModelsLoading,
AccountModelsUnavailable,
AccountModelsReady,
AccountModelsConnectFailed,
AccountModelsCancelled,
AccountLoginTitle,
AccountLoginStarting,
AccountLoginWaiting,
AccountLoginCode,
AccountLoginOpenBrowser,
AccountLoginCopyLink,
AccountLoginLink,
AccountLoginSuccess,
AccountLoginNeedsModel,
AccountLoginCancelled,
AccountLoginExpired,
AccountLoginFailed,
AccountLogoutTitle,
AccountLogoutStarting,
AccountLogoutSuccess,
AccountLogoutLocal,
AccountLogoutFailed,
AccountLogoutCancelled,
ReasoningOnlyRetryBody,
OutputLimitAnswerRetryBody,
AnswerRetryActionBare,
@@ -3030,6 +3062,36 @@ pub enum MessageId {
}
pub const ALL_MESSAGE_IDS: &[MessageId] = &[
MessageId::AccountModelsLoadFailed,
MessageId::ConnectionSetupTitle,
MessageId::ConnectionSetupAccount,
MessageId::ConnectionSetupAccountDetail,
MessageId::ConnectionSetupLocal,
MessageId::ConnectionSetupLocalDetail,
MessageId::AccountModelsTitle,
MessageId::AccountModelsLoading,
MessageId::AccountModelsUnavailable,
MessageId::AccountModelsReady,
MessageId::AccountModelsConnectFailed,
MessageId::AccountModelsCancelled,
MessageId::AccountLoginTitle,
MessageId::AccountLoginStarting,
MessageId::AccountLoginWaiting,
MessageId::AccountLoginCode,
MessageId::AccountLoginOpenBrowser,
MessageId::AccountLoginCopyLink,
MessageId::AccountLoginLink,
MessageId::AccountLoginSuccess,
MessageId::AccountLoginNeedsModel,
MessageId::AccountLoginCancelled,
MessageId::AccountLoginExpired,
MessageId::AccountLoginFailed,
MessageId::AccountLogoutTitle,
MessageId::AccountLogoutStarting,
MessageId::AccountLogoutSuccess,
MessageId::AccountLogoutLocal,
MessageId::AccountLogoutFailed,
MessageId::AccountLogoutCancelled,
MessageId::ReasoningOnlyRetryBody,
MessageId::OutputLimitAnswerRetryBody,
MessageId::AnswerRetryActionBare,
+19 -5
View File
@@ -126,7 +126,10 @@ impl PluginDiscoveryContext {
}
#[must_use]
pub fn registry_for_workspace(self: &Arc<Self>, workspace: &Path) -> Arc<PluginRegistry> {
pub fn inspect_registry_for_workspace(
self: &Arc<Self>,
workspace: &Path,
) -> Arc<PluginRegistry> {
let config = DiscoveryConfig {
workspace: workspace.to_path_buf(),
user_plugins_dir: self.user_plugins_dir.clone(),
@@ -134,15 +137,26 @@ impl PluginDiscoveryContext {
builtin_plugin_dirs: self.builtin_plugin_dirs.to_vec(),
state_path: self.state_path.clone(),
};
let mut registry = super::discovery::discover_with_context(&config, Arc::clone(self));
Arc::new(super::discovery::discover_with_context(
&config,
Arc::clone(self),
))
}
#[must_use]
pub fn registry_for_workspace(self: &Arc<Self>, workspace: &Path) -> Arc<PluginRegistry> {
let mut registry = self.inspect_registry_for_workspace(workspace);
let registry_mut = Arc::make_mut(&mut registry);
// An upgrade re-roots the built-ins; keep their review (K4).
registry.carry_forward_builtin_trust();
registry_mut.carry_forward_builtin_trust();
// Only after carry-forward: retiring superseded built-in records is
// safe once this build holds its own. Unit tests call the collector
// directly so that no test ever mutates a home implicitly.
#[cfg(not(any(test, feature = "test-support")))]
registry.collect_garbage_at_startup();
Arc::new(registry)
registry_mut.collect_garbage_at_startup();
#[cfg(not(any(test, feature = "test-support")))]
registry_mut.reconcile_missing_stages();
registry
}
#[must_use]
+2
View File
@@ -594,6 +594,7 @@ fn load_staged_skill_snapshots_with_roots(
expected_capability_hash: &str,
roots: Option<&[PathBuf]>,
) -> Result<Vec<PluginSkillSnapshot>, String> {
let finalized = super::registry::verify_finalized_stage_root(staged_root, None)?;
let staged_manifest = resolve_manifest_path(staged_root)
.ok_or_else(|| "staged plugin has no plugin.json, .claude-plugin/plugin.json, kimi.plugin.json, or plugin.toml".to_string())?;
let validated = PluginManifest::validate_from_path(&staged_manifest)?;
@@ -623,6 +624,7 @@ fn load_staged_skill_snapshots_with_roots(
{
return Err("staged plugin changed while its Skill snapshots were parsed".to_string());
}
super::registry::verify_finalized_stage_root(staged_root, Some(&finalized))?;
Ok(snapshots)
}
+2
View File
@@ -59,6 +59,7 @@ pub fn metadata(
return None;
}
let root = plugin.staged_root.as_deref()?;
let finalized = super::registry::verify_finalized_stage_root(root, None).ok()?;
let path = Path::new(&preset.entry.path);
// Reuse the sole discovery-to-stage translator: it resolves both the
// root and entry, proves confinement and requires an actual Native entry.
@@ -79,6 +80,7 @@ pub fn metadata(
{
return None;
}
super::registry::verify_finalized_stage_root(root, Some(&finalized)).ok()?;
metadata_from_bytes(&bytes)
}
+465 -19
View File
@@ -257,6 +257,7 @@ impl PluginRegistry {
fn apply_state(&mut self) {
let state_path = self.state_path.clone();
let recovery_policy_path = self.managed_policy_path.clone();
// Hoisted so the loop below can borrow `self.plugins` mutably: a
// malformed policy fails closed (nothing stays enabled), a valid one
// forbids whatever it does not allow, and an absent one forbids nothing.
@@ -301,6 +302,19 @@ impl PluginRegistry {
staged_bundle_matches(root, &plugin.content_hash, &plugin.capability_hash)
})
.cloned();
if plugin.staged_root.is_some() {
plugin
.diagnostics
.retain(|item| item.code != "runtime-snapshot-recovery-failed");
if let (Some(state_path), Some(policy_path)) =
(state_path.as_deref(), recovery_policy_path.as_deref())
{
let key = recovery_attempt_key(state_path, policy_path, plugin);
if let Ok(mut attempts) = recovery_attempts().lock() {
attempts.remove(&key);
}
}
}
plugin
.diagnostics
.retain(|diagnostic| diagnostic.code != "runtime-snapshot-unavailable");
@@ -531,6 +545,163 @@ impl PluginRegistry {
})
}
pub fn restage(&mut self, selector: &str) -> Result<(), String> {
self.restage_inner(selector, false).map(drop)
}
fn restage_inner(&mut self, selector: &str, automatic: bool) -> Result<bool, String> {
if let Some(error) = &self.state_error {
return Err(format!("Plugin state is fail-closed: {error}"));
}
let plugin = self
.get(selector)
.cloned()
.ok_or_else(|| format!("Plugin bundle `{selector}` was not found"))?;
if !plugin.enabled
|| !plugin.trusted()
|| !plugin.applicable
|| !plugin.inventory.can_activate_supported_components()
|| plugin
.diagnostics
.iter()
.any(|item| item.level == PluginDiagnosticLevel::Error)
{
return Err("Recovery requires an enabled, reviewed, applicable plugin".to_string());
}
let state_path = self
.state_path
.clone()
.ok_or("Plugin state is unavailable")?;
let policy_path = self
.managed_policy_path
.clone()
.filter(|path| path.is_absolute())
.ok_or("Managed plugin policy source is unavailable")?;
let key = recovery_attempt_key(&state_path, &policy_path, &plugin);
validate_existing_plugin_state_parent(&state_path)?;
let lock_file = open_state_lock(&state_lock_path(&state_path), false)?;
let mut lock = fd_lock::RwLock::new(lock_file);
let guard = lock
.write()
.map_err(|error| format!("failed to lock plugin state: {error}"))?;
if automatic {
let previous = match recovery_attempts().lock() {
Ok(attempts) => attempts.get(&key).cloned(),
Err(_) => Some("Recovery attempt status is unavailable".to_string()),
};
if let Some(reason) = previous {
drop(guard);
self.set_recovery_diagnostic(&plugin.id, &reason);
return Ok(false);
}
}
let result = (|| {
let current = load_state_unlocked(&state_path)?;
let approved = current.plugins.get(&plugin.id).is_some_and(|entry| {
entry.enabled
&& entry.generation == plugin.state_generation
&& entry.trust.as_ref().is_some_and(|receipt| {
receipt.content_hash == plugin.content_hash
&& receipt.capability_hash == plugin.capability_hash
})
});
if !approved {
return Err("Plugin state or review changed before recovery".to_string());
}
verify_recovery_policy(&policy_path, &plugin.id)?;
if !staged_bundle_content_matches(
&plugin.canonical_root,
&plugin.content_hash,
&plugin.capability_hash,
) {
return Err("Reviewed plugin source no longer matches its receipt".to_string());
}
let staged = stage_bundle(&state_path, &plugin, StageIntent::Recovery)?;
if !staged_bundle_content_matches(
&plugin.canonical_root,
&plugin.content_hash,
&plugin.capability_hash,
) || !staged_bundle_matches(&staged, &plugin.content_hash, &plugin.capability_hash)
{
return Err(
"Plugin source or recovered snapshot changed during recovery".to_string(),
);
}
verify_recovery_policy(&policy_path, &plugin.id)?;
Ok(())
})();
if let Ok(mut attempts) = recovery_attempts().lock() {
if result.is_ok() {
attempts.remove(&key);
} else if automatic && let Err(error) = &result {
attempts.insert(key, error.clone());
}
}
drop(guard);
result?;
self.state = load_state(&state_path)?;
self.refresh_managed_policy();
if let Some(plugin) = self.plugins.get_mut(&plugin.id) {
plugin
.diagnostics
.retain(|item| item.code != "runtime-snapshot-recovery-failed");
}
self.apply_state();
if !self.get(selector).is_some_and(LoadedPlugin::active) {
return Err("Recovered snapshot is not active; refresh plugin state".to_string());
}
Ok(true)
}
fn set_recovery_diagnostic(&mut self, id: &PluginId, reason: &str) {
let Some(plugin) = self.plugins.get_mut(id) else {
return;
};
plugin
.diagnostics
.retain(|item| item.code != "runtime-snapshot-recovery-failed");
let path = self
.state_path
.as_deref()
.map(|state| runtime_stage_path(state, id, &plugin.content_hash));
plugin.diagnostics.push(PluginDiagnostic::warning(
"runtime-snapshot-recovery-failed",
format!("Plugin runtime snapshot could not be restored: {reason}"),
path,
));
}
#[cfg(not(any(test, feature = "test-support")))]
pub(super) fn reconcile_missing_stages(&mut self) {
if self.state_error.is_some() {
return;
}
let Some(state_path) = self.state_path.clone() else {
return;
};
let candidates = self.plugins.values().filter_map(|plugin| {
if !plugin.enabled || !plugin.trusted() || !plugin.applicable
|| !plugin.inventory.can_activate_supported_components()
|| plugin.diagnostics.iter().any(|item| item.level == PluginDiagnosticLevel::Error)
{
return None;
}
let destination = runtime_stage_path(&state_path, &plugin.id, &plugin.content_hash);
if !matches!(fs::symlink_metadata(&destination), Err(error) if error.kind() == std::io::ErrorKind::NotFound) {
return None;
}
Some(plugin.id.as_str().to_string())
}).collect::<Vec<_>>();
for selector in candidates {
if let Err(error) = self.restage_inner(&selector, true) {
tracing::warn!(plugin = %selector, %error, "reviewed plugin runtime snapshot was not restored");
if let Some(id) = self.resolve_id(&selector).cloned() {
self.set_recovery_diagnostic(&id, &error);
}
}
}
}
pub fn trust(&mut self, selector: &str) -> Result<(), String> {
let plugin = self
.get(selector)
@@ -541,7 +712,7 @@ impl PluginRegistry {
.state_path
.as_deref()
.ok_or_else(|| "Plugin registry has no persistence store".to_string())?;
stage_bundle(state_path, &plugin)?;
stage_bundle(state_path, &plugin, StageIntent::Review)?;
let receipt = TrustReceipt {
content_hash: plugin.content_hash.clone(),
capability_hash: plugin.capability_hash.clone(),
@@ -741,7 +912,7 @@ impl PluginRegistry {
// Staging is content-addressed and idempotent, so it runs before the
// state lock; the decision is re-derived from the locked state below.
if same_capabilities {
stage_bundle(&state_path, plugin)?;
stage_bundle(&state_path, plugin, StageIntent::Review)?;
}
let id = plugin.id.clone();
let name = plugin.name().to_string();
@@ -883,6 +1054,10 @@ fn load_state_unlocked(path: &Path) -> Result<PluginStateFile, String> {
let Some(file) = open_existing_regular_file(path, false)? else {
return Ok(PluginStateFile::default());
};
load_state_from_file(path, file)
}
fn load_state_from_file(path: &Path, file: fs::File) -> Result<PluginStateFile, String> {
let mut raw = String::new();
file.take(MAX_PLUGIN_STATE_BYTES + 1)
.read_to_string(&mut raw)
@@ -1485,6 +1660,54 @@ fn runtime_stage_path(state_path: &Path, id: &PluginId, content_hash: &str) -> P
}
fn staged_bundle_matches(root: &Path, content_hash: &str, capability_hash: &str) -> bool {
let Ok(finalized) = verify_finalized_stage_root(root, None) else {
return false;
};
staged_bundle_content_matches(root, content_hash, capability_hash)
&& verify_finalized_stage_root(root, Some(&finalized)).is_ok()
}
pub(super) fn verify_finalized_stage_root(
root: &Path,
expected: Option<&fs::File>,
) -> Result<fs::File, String> {
let metadata = fs::symlink_metadata(root)
.map_err(|error| format!("failed to inspect plugin runtime root: {error}"))?;
if !metadata.is_dir() || metadata_is_link_or_reparse(&metadata) {
return Err("Plugin runtime root is not an ordinary directory".to_string());
}
let (_, held) = codewhale_util::workspace_dir::open_workspace_directory(root)
.map_err(|error| format!("failed to retain plugin runtime root: {error}"))?;
if let Some(expected) = expected
&& !codewhale_util::files::same_file(expected, &held)
.map_err(|error| format!("failed to identify plugin runtime root: {error}"))?
{
return Err("Plugin runtime root changed while it was read".to_string());
}
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt as _;
let metadata = held
.metadata()
.map_err(|error| format!("failed to inspect retained plugin runtime root: {error}"))?;
if metadata.permissions().mode() & 0o7777 != 0o500 {
return Err("Plugin runtime root has not been finalized read-only".to_string());
}
}
#[cfg(windows)]
codewhale_config::private_directory::verify_windows_owner_only_read_execute_handle(&held)
.map_err(|_| "Plugin runtime root has not been finalized read-only".to_string())?;
#[cfg(not(any(unix, windows)))]
{
Err("Plugin runtime root finalization cannot be verified on this platform".to_string())
}
#[cfg(any(unix, windows))]
{
Ok(held)
}
}
fn staged_bundle_content_matches(root: &Path, content_hash: &str, capability_hash: &str) -> bool {
let Some(manifest_path) = super::agent_plugin::resolve_manifest_path(root) else {
return false;
};
@@ -1497,7 +1720,85 @@ fn staged_bundle_matches(root: &Path, content_hash: &str, capability_hash: &str)
})
}
fn stage_bundle(state_path: &Path, plugin: &LoadedPlugin) -> Result<PathBuf, String> {
type RecoveryAttemptKey = (PathBuf, PathBuf, String, String, String, u64);
fn recovery_attempt_key(
state_path: &Path,
policy_path: &Path,
plugin: &LoadedPlugin,
) -> RecoveryAttemptKey {
(
state_path.to_path_buf(),
policy_path.to_path_buf(),
plugin.id.as_str().to_string(),
plugin.content_hash.clone(),
plugin.capability_hash.clone(),
plugin.state_generation,
)
}
fn recovery_attempts() -> &'static std::sync::Mutex<BTreeMap<RecoveryAttemptKey, String>> {
static ATTEMPTS: std::sync::Mutex<BTreeMap<RecoveryAttemptKey, String>> =
std::sync::Mutex::new(BTreeMap::new());
&ATTEMPTS
}
fn verify_recovery_policy(policy_path: &Path, id: &PluginId) -> Result<(), String> {
match load_managed_policy(policy_path) {
ManagedPolicyOutcome::Absent => Ok(()),
ManagedPolicyOutcome::Loaded(policy) if policy.allows(id) => Ok(()),
ManagedPolicyOutcome::Loaded(_) => {
Err("Managed plugin policy forbids recovery".to_string())
}
ManagedPolicyOutcome::Invalid(_) => Err("Managed plugin policy is invalid".to_string()),
}
}
fn verify_recovery_path(state_path: &Path, destination: &Path) -> Result<(), String> {
let base = state_path
.parent()
.ok_or("Plugin state path has no parent")?;
let metadata = fs::symlink_metadata(base)
.map_err(|error| format!("Failed to inspect plugin state parent: {error}"))?;
if !metadata.is_dir() || metadata_is_link_or_reparse(&metadata) {
return Err("Plugin state parent is not an ordinary directory".to_string());
}
let base = base
.canonicalize()
.map_err(|error| format!("Failed to resolve plugin state parent: {error}"))?;
codewhale_util::files::reject_linked_path(&base, destination)
.map_err(|error| format!("Unsafe plugin runtime snapshot path: {error}"))
}
#[cfg(unix)]
fn open_recovery_directory(path: &Path) -> Result<fs::File, String> {
use std::os::unix::fs::OpenOptionsExt as _;
let file = OpenOptions::new()
.read(true)
.custom_flags(libc::O_DIRECTORY | libc::O_NOFOLLOW | libc::O_CLOEXEC)
.open(path)
.map_err(|error| format!("Failed to retain recovery directory: {error}"))?;
if !file
.metadata()
.map_err(|error| format!("Failed to inspect recovery directory: {error}"))?
.is_dir()
{
return Err("Recovery directory changed type".to_string());
}
Ok(file)
}
#[derive(Clone, Copy, PartialEq, Eq)]
enum StageIntent {
Review,
Recovery,
}
fn stage_bundle(
state_path: &Path,
plugin: &LoadedPlugin,
intent: StageIntent,
) -> Result<PathBuf, String> {
// Resolve the state directory before deriving the content-addressed path.
// On macOS an existing ancestor such as `/var` canonicalizes to
// `/private/var`; when the final `state/` directory does not exist yet,
@@ -1510,23 +1811,52 @@ fn stage_bundle(state_path: &Path, plugin: &LoadedPlugin) -> Result<PathBuf, Str
.ok_or_else(|| "plugin state path has no parent directory".to_string())?;
ensure_private_plugin_state_directory(state_parent)?;
let destination = runtime_stage_path(state_path, &plugin.id, &plugin.content_hash);
if destination.exists() {
if !staged_bundle_matches(&destination, &plugin.content_hash, &plugin.capability_hash) {
return Err(
if intent == StageIntent::Recovery {
verify_recovery_path(state_path, &destination)?;
}
match fs::symlink_metadata(&destination) {
Ok(metadata) => {
if !metadata.is_dir()
|| metadata_is_link_or_reparse(&metadata)
|| !staged_bundle_content_matches(
&destination,
&plugin.content_hash,
&plugin.capability_hash,
)
{
return Err(
"Existing Codewhale plugin runtime snapshot failed content validation; remove the exact .runtime entry and review again"
.to_string(),
);
}
if intent == StageIntent::Review {
// Trust is a mutating boundary, so it may upgrade an older verified
// snapshot to the finalized non-writable permission contract.
harden_staged_tree(&destination)?;
} else if !staged_bundle_matches(
&destination,
&plugin.content_hash,
&plugin.capability_hash,
) {
return Err("Existing plugin runtime snapshot is not finalized".to_string());
}
return Ok(destination.canonicalize().unwrap_or(destination));
}
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
Err(error) => {
return Err(format!(
"failed to inspect plugin runtime snapshot: {error}"
));
}
// Trust is a mutating boundary, so it may upgrade an older verified
// snapshot to the finalized non-writable permission contract.
harden_staged_tree(&destination)?;
return Ok(destination.canonicalize().unwrap_or(destination));
}
let parent = destination
.parent()
.ok_or_else(|| "plugin runtime snapshot has no parent".to_string())?;
ensure_private_runtime_parent(state_path, parent)?;
if intent == StageIntent::Recovery {
verify_recovery_path(state_path, &destination)?;
}
let temporary = parent.join(format!(".staging-{}", uuid::Uuid::new_v4().simple()));
fs::create_dir(&temporary)
.map_err(|e| format!("failed to create temporary plugin runtime snapshot: {e}"))?;
@@ -1534,7 +1864,8 @@ fn stage_bundle(state_path: &Path, plugin: &LoadedPlugin) -> Result<PathBuf, Str
let staged = (|| {
copy_bundle_tree(&plugin.canonical_root, &temporary)?;
if !staged_bundle_matches(&temporary, &plugin.content_hash, &plugin.capability_hash) {
if !staged_bundle_content_matches(&temporary, &plugin.content_hash, &plugin.capability_hash)
{
return Err(
"Plugin bundle changed while Codewhale was staging it; no runtime authority was granted"
.to_string(),
@@ -1546,12 +1877,36 @@ fn stage_bundle(state_path: &Path, plugin: &LoadedPlugin) -> Result<PathBuf, Str
// writable. The destination root is hardened immediately after the
// rename, before its path is returned or persisted as authority.
harden_staged_tree_contents(&temporary)?;
if let Err(error) = fs::rename(&temporary, &destination) {
if intent == StageIntent::Recovery {
verify_recovery_path(state_path, &destination)?;
}
#[cfg(unix)]
let recovery_root = if intent == StageIntent::Recovery {
Some(open_recovery_directory(&temporary)?)
} else {
None
};
if let Err(error) = super::builtin::publish_snapshot(&temporary, &destination) {
// Another process may have won the same content-addressed race.
// Reuse only after exact validation and hardening at this explicit
// mutation boundary; every other rename failure remains fatal.
if staged_bundle_matches(&destination, &plugin.content_hash, &plugin.capability_hash) {
harden_staged_tree(&destination)?;
if fs::symlink_metadata(&destination)
.is_ok_and(|metadata| metadata.is_dir() && !metadata_is_link_or_reparse(&metadata))
&& staged_bundle_content_matches(
&destination,
&plugin.content_hash,
&plugin.capability_hash,
)
{
if intent == StageIntent::Review {
harden_staged_tree(&destination)?;
} else if !staged_bundle_matches(
&destination,
&plugin.content_hash,
&plugin.capability_hash,
) {
return Err("Raced plugin runtime snapshot is not finalized".to_string());
}
return destination.canonicalize().map_err(|e| {
format!("failed to finalize raced plugin runtime snapshot path: {e}")
});
@@ -1560,12 +1915,31 @@ fn stage_bundle(state_path: &Path, plugin: &LoadedPlugin) -> Result<PathBuf, Str
"failed to activate content-addressed plugin runtime snapshot: {error}"
));
}
if intent == StageIntent::Recovery {
verify_recovery_path(state_path, &destination)?;
}
#[cfg(unix)]
if let Some(root) = recovery_root {
use std::os::unix::fs::PermissionsExt as _;
let published = open_recovery_directory(&destination)?;
if !codewhale_util::files::same_file(&root, &published)
.map_err(|error| format!("Failed to identify recovered snapshot: {error}"))?
{
return Err("Recovered snapshot changed before finalization".to_string());
}
root.set_permissions(fs::Permissions::from_mode(0o500))
.map_err(|error| format!("Failed to finalize recovered snapshot: {error}"))?;
verify_finalized_stage_root(&destination, Some(&root))?;
} else {
set_staged_read_only_directory(&destination)?;
}
#[cfg(not(unix))]
set_staged_read_only_directory(&destination)?;
destination
.canonicalize()
.map_err(|e| format!("failed to finalize plugin runtime snapshot path: {e}"))
})();
if staged.is_err() && temporary.exists() {
if intent == StageIntent::Review && staged.is_err() && temporary.exists() {
let _ = fs::remove_dir_all(&temporary);
}
staged
@@ -1988,7 +2362,7 @@ fn copy_bundle_directory_fd(
Ok(())
}
fn harden_staged_tree(path: &Path) -> Result<(), String> {
pub(super) fn harden_staged_tree(path: &Path) -> Result<(), String> {
let metadata = fs::symlink_metadata(path)
.map_err(|e| format!("failed to harden staged plugin content: {e}"))?;
if metadata_is_link_or_reparse(&metadata) {
@@ -2014,6 +2388,52 @@ fn harden_staged_tree(path: &Path) -> Result<(), String> {
Ok(())
}
#[cfg(all(unix, any(test, feature = "test-support")))]
pub(super) fn open_staged_fixture_target(path: &Path) -> Result<fs::File, String> {
use std::os::unix::fs::OpenOptionsExt as _;
OpenOptions::new()
.read(true)
.custom_flags(libc::O_NOFOLLOW | libc::O_NONBLOCK | libc::O_CLOEXEC)
.open(path)
.map_err(|error| format!("failed to retain staged fixture object: {error}"))
}
#[cfg(all(windows, any(test, feature = "test-support")))]
pub(super) fn open_staged_fixture_target(path: &Path) -> Result<fs::File, String> {
open_windows_acl_target(path, 0x0006_0081).map_err(WindowsAclTargetOpenError::into_message)
}
#[cfg(all(not(any(unix, windows)), any(test, feature = "test-support")))]
pub(super) fn open_staged_fixture_target(_path: &Path) -> Result<fs::File, String> {
Err("staged fixture permissions cannot be retained on this platform".to_string())
}
#[cfg(all(unix, any(test, feature = "test-support")))]
pub(super) fn restore_staged_fixture_target(
file: &fs::File,
permissions: &fs::Permissions,
) -> Result<(), String> {
file.set_permissions(permissions.clone())
.map_err(|error| format!("failed to restore staged fixture permissions: {error}"))
}
#[cfg(all(windows, any(test, feature = "test-support")))]
pub(super) fn restore_staged_fixture_target(
file: &fs::File,
_permissions: &fs::Permissions,
) -> Result<(), String> {
apply_windows_owner_only_acl(file, 0x001f_01ff, WindowsAclOwnerMode::VerifyCurrentUser)
}
#[cfg(all(not(any(unix, windows)), any(test, feature = "test-support")))]
pub(super) fn restore_staged_fixture_target(
_file: &fs::File,
_permissions: &fs::Permissions,
) -> Result<(), String> {
Err("staged fixture permissions cannot be restored on this platform".to_string())
}
fn harden_staged_tree_contents(path: &Path) -> Result<(), String> {
let metadata = fs::symlink_metadata(path)
.map_err(|e| format!("failed to harden staged plugin root: {e}"))?;
@@ -2439,6 +2859,9 @@ pub fn verify_plugin_component_authority(
/// Reduce the *number* of calls instead; see `McpConnection::is_transport_ready`.
pub fn verify_plugin_authority(authority: &PluginAuthority) -> Result<(), String> {
verify_plugin_state_authority(authority)?;
let staged_root = super::agent_plugin::plugin_root_for_manifest(&authority.staged_manifest)
.ok_or_else(|| "Plugin authority has no staged runtime root".to_string())?;
let finalized = verify_finalized_stage_root(staged_root, None)?;
for (label, manifest_path) in [
("reviewed source", &authority.source_manifest),
("Codewhale runtime snapshot", &authority.staged_manifest),
@@ -2459,13 +2882,20 @@ pub fn verify_plugin_authority(authority: &PluginAuthority) -> Result<(), String
));
}
}
Ok(())
verify_finalized_stage_root(staged_root, Some(&finalized)).map(drop)
}
/// Cheap cross-process revocation probe used while an established MCP request
/// is in flight. Full source/stage hashing is intentionally done before each
/// dispatch; the watcher rechecks the trusted policy source and locked state.
pub fn verify_plugin_state_authority(authority: &PluginAuthority) -> Result<(), String> {
capture_plugin_state_authority(authority, None).map(drop)
}
pub fn capture_plugin_state_authority(
authority: &PluginAuthority,
expected: Option<&fs::File>,
) -> Result<fs::File, String> {
let policy_path = authority
.managed_policy_source
.as_deref()
@@ -2509,7 +2939,23 @@ pub fn verify_plugin_state_authority(authority: &PluginAuthority) -> Result<(),
let _guard = lock
.read()
.map_err(|_| "Plugin authority state could not be read safely".to_string())?;
let state = load_state_unlocked(&authority.state_path).map_err(|_| {
let state_file = open_existing_regular_file(&authority.state_path, false)
.map_err(|_| "Plugin authority state could not be opened safely".to_string())?
.ok_or_else(|| "Plugin authority state is missing".to_string())?;
if let Some(expected) = expected {
if !codewhale_util::files::same_file(expected, &state_file)
.map_err(|_| "Plugin authority state identity could not be checked".to_string())?
{
return Err("Plugin authority state changed after UI authorization".to_string());
}
}
let state = load_state_from_file(
&authority.state_path,
state_file
.try_clone()
.map_err(|_| "Plugin authority state could not be read safely".to_string())?,
)
.map_err(|_| {
"Plugin authority state is invalid; the bundle is disabled fail-closed".to_string()
})?;
let active = state
@@ -2529,7 +2975,7 @@ pub fn verify_plugin_state_authority(authority: &PluginAuthority) -> Result<(),
authority.plugin_name
));
}
Ok(())
Ok(state_file)
}
#[cfg(test)]
+53 -1
View File
@@ -1,11 +1,63 @@
use std::fs;
use std::path::PathBuf;
use std::path::{Path, PathBuf};
use tempfile::TempDir;
use super::PluginRegistry;
use super::discovery::{DiscoveryConfig, discover_with_config};
pub struct FinalizedStagedBundle {
retained: Vec<(fs::File, fs::Permissions)>,
}
impl Drop for FinalizedStagedBundle {
fn drop(&mut self) {
let failures = self
.retained
.iter()
.filter_map(|(file, permissions)| {
super::registry::restore_staged_fixture_target(file, permissions).err()
})
.collect::<Vec<_>>();
if !failures.is_empty() {
let message = failures.join("; ");
if std::thread::panicking() {
eprintln!("failed to restore staged fixture permissions: {message}");
} else {
panic!("failed to restore staged fixture permissions: {message}");
}
}
}
}
pub fn finalize_staged_bundle(path: &Path) -> Result<FinalizedStagedBundle, String> {
fn retain(path: &Path, retained: &mut Vec<(fs::File, fs::Permissions)>) -> Result<(), String> {
let metadata = fs::symlink_metadata(path).map_err(|error| error.to_string())?;
if codewhale_util::path_identity::metadata_is_link_or_reparse(&metadata) {
return Err("staged fixture contains a link or reparse point".to_string());
}
let file = super::registry::open_staged_fixture_target(path)?;
let metadata = file.metadata().map_err(|error| error.to_string())?;
if !metadata.is_dir() && !metadata.is_file() {
return Err("staged fixture contains an unexpected object".to_string());
}
retained.push((file, metadata.permissions()));
if metadata.is_dir() {
for entry in fs::read_dir(path).map_err(|error| error.to_string())? {
retain(&entry.map_err(|error| error.to_string())?.path(), retained)?;
}
}
Ok(())
}
let mut fixture = FinalizedStagedBundle {
retained: Vec::new(),
};
retain(path, &mut fixture.retained)?;
super::registry::harden_staged_tree(path)?;
Ok(fixture)
}
pub struct DeclarativePluginFixture {
_temp: TempDir,
pub workspace: PathBuf,
+49 -10
View File
@@ -5985,26 +5985,65 @@ impl Config {
{
return None;
}
let access = self.account_model_access.read().clone()?;
if access.expires_at <= chrono::Utc::now().timestamp() {
if std::env::var(codewhale_secrets::account::ACCOUNT_API_BASE_ENV)
.ok()
.filter(|base| !base.trim().is_empty())
.is_some_and(|base| {
base.trim().trim_end_matches('/')
!= codewhale_secrets::account::DEFAULT_ACCOUNT_API_BASE
})
{
return None;
}
let access = self.account_model_access.read().clone();
let profile = self.account_profile.as_deref();
if access.as_ref().is_some_and(|access| {
codewhale_secrets::account::normalize_account_profile(access.profile.as_deref())
!= codewhale_secrets::account::normalize_account_profile(profile)
}) {
return None;
}
// Check the shared session again at use, so a late install cannot
// resurrect a session removed by another local process.
let secrets = codewhale_secrets::account::secure_account_session_secrets().ok()?;
let account = codewhale_secrets::account::AccountSessionStore::new(
let snapshot = codewhale_secrets::account::AccountSessionStore::new(
secrets,
access.profile.as_deref(),
profile,
codewhale_secrets::account::DEFAULT_ACCOUNT_API_BASE,
)
.runtime_info_at(chrono::Utc::now())
.snapshot()
.ok()?;
let now = chrono::Utc::now();
let account = snapshot.runtime_info_at(now).ok()?;
if access
.as_ref()
.is_some_and(|access| account.session_id.as_deref() != Some(access.session_id.as_str()))
{
return None;
}
if self
.required_account_model_owner
.as_ref()
.is_some_and(|owner| account.account_id.as_ref() != Some(owner))
{
return None;
}
if !self.session_provider_keys_only
&& matches!(
account.state,
codewhale_secrets::account::AccountSessionState::Authenticated
| codewhale_secrets::account::AccountSessionState::OfflineCached
)
&& let Some(cached) = snapshot.device_access().ok()?
&& cached.expires_at > now.timestamp()
&& account.account_id.as_deref() == Some(cached.account_id.as_str())
&& account.session_id.as_deref() == Some(cached.session_id.as_str())
{
return Some(cached.credential);
}
let access = access.filter(|access| access.expires_at > now.timestamp())?;
(account.state == codewhale_secrets::account::AccountSessionState::Authenticated
&& account.session_id.as_deref() == Some(access.session_id.as_str())
&& self
.required_account_model_owner
.as_ref()
.is_none_or(|owner| account.account_id.as_ref() == Some(owner)))
&& account.session_id.as_deref() == Some(access.session_id.as_str()))
.then(|| access.credential.expose_secret().to_string())
}
+1
View File
@@ -22,6 +22,7 @@ sha2.workspace = true
tempfile.workspace = true
thiserror = { workspace = true }
tracing = { workspace = true }
uuid.workspace = true
[target.'cfg(target_os = "macos")'.dependencies]
keyring = { version = "3", features = ["apple-native"] }
+280 -33
View File
@@ -132,6 +132,24 @@ pub struct StoredAccountAuth {
pub bundle: AccountAuthBundle,
}
#[derive(Clone, Deserialize, Serialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
#[doc = "A session-bound device key held inside the shared private account record."]
pub struct AccountDeviceAccess {
#[doc = "Stable device UUID scoped to the account profile and API origin."]
pub device_id: String,
#[doc = "Secret device credential; never include it in runtime receipts or diagnostics."]
pub credential: String,
#[doc = "Server identifier used to revoke this device key."]
pub api_key_id: String,
#[doc = "Account that owns the device key."]
pub account_id: String,
#[doc = "Parent authentication session that authorizes this key."]
pub session_id: String,
#[doc = "Device-key expiry as Unix seconds, bounded by the parent refresh lifetime."]
pub expires_at: i64,
}
/// Normalized account states exposed by the token-free Runtime API contract.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
#[serde(rename_all = "snake_case")]
@@ -222,13 +240,81 @@ pub struct AccountSessionSnapshot {
}
impl AccountSessionSnapshot {
#[doc = "Read validated device custody from this snapshot; the caller checks key expiry."]
pub fn device_access(&self) -> Result<Option<AccountDeviceAccess>, AccountSessionError> {
let Some(stored) = self.load()? else {
return Ok(None);
};
let value: serde_json::Value =
serde_json::from_str(self.raw.as_deref().unwrap_or_default())
.map_err(AccountSessionError::UnreadableRecord)?;
let Some(access) = value.get("deviceAccess") else {
return Ok(None);
};
let access: AccountDeviceAccess = serde_json::from_value(access.clone())
.map_err(AccountSessionError::UnreadableRecord)?;
validate_device_access(&stored.bundle, &access)?;
Ok(Some(access))
}
#[doc = "Whether the captured slot contains no record, including no logout tombstone."]
pub fn is_pristine(&self) -> bool {
self.raw.is_none()
}
#[doc = "Project this exact captured revision into token-free account state."]
pub fn runtime_info_at(
&self,
now: DateTime<Utc>,
) -> Result<RuntimeAccountInfo, AccountSessionError> {
Ok(match self.load()? {
Some(stored) => runtime_account_info_from_stored(stored, now),
None => RuntimeAccountInfo::signed_out(self.api_base.clone()),
})
}
#[doc = "Hash the slot, API origin and exact record bytes into an opaque revision."]
pub fn fingerprint(&self) -> String {
let mut digest = Sha256::new();
digest.update(b"codewhale-account-revision-v1\0");
digest.update(self.slot.as_bytes());
digest.update([0]);
digest.update(self.api_base.as_bytes());
digest.update([0]);
if let Some(raw) = &self.raw {
digest.update([1]);
digest.update(raw.as_bytes());
} else {
digest.update([0]);
}
digest
.finalize()
.iter()
.map(|byte| format!("{byte:02x}"))
.collect()
}
/// Decode and validate the captured record without re-reading storage.
pub fn load(&self) -> Result<Option<StoredAccountAuth>, AccountSessionError> {
let Some(raw) = &self.raw else {
return Ok(None);
};
let stored: StoredAccountAuth =
let value: serde_json::Value =
serde_json::from_str(raw).map_err(AccountSessionError::UnreadableRecord)?;
if value.get("signedOut").is_some() {
let signed_out: SignedOutAccount =
serde_json::from_value(value).map_err(AccountSessionError::UnreadableRecord)?;
if signed_out.schema_version != ACCOUNT_SESSION_SCHEMA_VERSION
|| signed_out.api_base != self.api_base
|| !signed_out.signed_out
|| uuid::Uuid::parse_str(&signed_out.revision).is_err()
{
return Err(AccountSessionError::InvalidCredentials);
}
return Ok(None);
}
let stored: StoredAccountAuth =
serde_json::from_value(value).map_err(AccountSessionError::UnreadableRecord)?;
if stored.schema_version != ACCOUNT_SESSION_SCHEMA_VERSION
|| stored.api_base != self.api_base
{
@@ -239,6 +325,92 @@ impl AccountSessionSnapshot {
}
}
#[derive(Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
struct SignedOutAccount {
schema_version: u8,
api_base: String,
signed_out: bool,
revision: String,
}
fn signed_out_record(api_base: &str) -> String {
serde_json::json!({
"schemaVersion": ACCOUNT_SESSION_SCHEMA_VERSION,
"apiBase": api_base,
"signedOut": true,
"revision": uuid::Uuid::new_v4().to_string(),
})
.to_string()
}
fn validate_device_access(
bundle: &AccountAuthBundle,
access: &AccountDeviceAccess,
) -> Result<(), AccountSessionError> {
let now = Utc::now();
let parent_expiry = bundle.session.as_ref().and_then(|session| {
(!matches!(
classify_session_state(session, now),
AccountSessionState::Expired | AccountSessionState::Revoked
))
.then(|| parse_rfc3339(&session.refresh_expires_at))
.flatten()
});
let credential_valid = access
.credential
.strip_prefix(&format!("cwc_key_{}_", access.api_key_id))
.is_some_and(|suffix| {
suffix.len() == 43
&& suffix
.bytes()
.all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'_' | b'-'))
});
if access.account_id.is_empty()
|| access.session_id.is_empty()
|| bundle.user.as_ref().map(|user| user.id.as_str()) != Some(access.account_id.as_str())
|| bundle.session.as_ref().map(|session| session.id.as_str())
!= Some(access.session_id.as_str())
|| uuid::Uuid::parse_str(&access.device_id).is_err()
|| access.expires_at <= 0
|| parent_expiry
.is_none_or(|expiry| expiry <= now || access.expires_at > expiry.timestamp())
|| !credential_valid
|| access.api_key_id.len() != 24
|| !access
.api_key_id
.bytes()
.all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte))
{
return Err(AccountSessionError::InvalidCredentials);
}
Ok(())
}
fn auth_record(
api_base: &str,
bundle: AccountAuthBundle,
previous: Option<&str>,
) -> Result<String, AccountSessionError> {
let access = previous
.and_then(|raw| serde_json::from_str::<serde_json::Value>(raw).ok())
.and_then(|value| value.get("deviceAccess").cloned())
.and_then(|value| serde_json::from_value::<AccountDeviceAccess>(value).ok())
.filter(|access| validate_device_access(&bundle, access).is_ok());
let mut value = serde_json::to_value(StoredAccountAuth {
schema_version: ACCOUNT_SESSION_SCHEMA_VERSION,
api_base: api_base.into(),
bundle,
})
.map_err(AccountSessionError::UnreadableRecord)?;
value["revision"] = uuid::Uuid::new_v4().to_string().into();
if let Some(access) = access {
value["deviceAccess"] =
serde_json::to_value(access).map_err(AccountSessionError::UnreadableRecord)?;
}
serde_json::to_string(&value).map_err(AccountSessionError::UnreadableRecord)
}
/// Locked account entry. Never reacquire its store lock from a callback.
pub struct AccountSessionTransaction<'a> {
raw: &'a mut Option<String>,
@@ -246,6 +418,33 @@ pub struct AccountSessionTransaction<'a> {
api_base: &'a str,
}
impl AccountSessionTransaction<'_> {
#[doc = "Renew the revision while preserving the current authentication state."]
pub fn renew_revision(&mut self) -> Result<AccountSessionSnapshot, AccountSessionError> {
match self.load()? {
Some(stored) => self.replace(stored.bundle)?,
None => self.clear(),
}
Ok(self.snapshot())
}
#[doc = "Store a validated device credential inside this locked account record."]
pub fn set_device_access(
&mut self,
access: AccountDeviceAccess,
) -> Result<(), AccountSessionError> {
let Some(stored) = self.load()? else {
return Err(AccountSessionError::InvalidCredentials);
};
validate_device_access(&stored.bundle, &access)?;
let raw = auth_record(self.api_base, stored.bundle, None)?;
let mut value: serde_json::Value =
serde_json::from_str(&raw).map_err(AccountSessionError::UnreadableRecord)?;
value["deviceAccess"] =
serde_json::to_value(access).map_err(AccountSessionError::UnreadableRecord)?;
*self.raw = Some(value.to_string());
Ok(())
}
/// Capture the exact current revision without unlocking.
pub fn snapshot(&self) -> AccountSessionSnapshot {
AccountSessionSnapshot {
@@ -267,19 +466,12 @@ impl AccountSessionTransaction<'_> {
/// Replace this entry on successful transaction completion.
pub fn replace(&mut self, bundle: AccountAuthBundle) -> Result<(), AccountSessionError> {
validate_account_auth_bundle(&bundle)?;
*self.raw = Some(
serde_json::to_string(&StoredAccountAuth {
schema_version: ACCOUNT_SESSION_SCHEMA_VERSION,
api_base: self.api_base.into(),
bundle,
})
.map_err(AccountSessionError::UnreadableRecord)?,
);
*self.raw = Some(auth_record(self.api_base, bundle, self.raw.as_deref())?);
Ok(())
}
/// Remove this entry on successful transaction completion.
pub fn clear(&mut self) {
*self.raw = None;
*self.raw = Some(signed_out_record(self.api_base));
}
}
@@ -311,6 +503,33 @@ impl AccountSessionStore {
})
}
#[doc = "Renew the shared revision to fence older login completions."]
pub fn begin_login(&self) -> Result<AccountSessionSnapshot, AccountSessionError> {
self.with_transaction(|transaction| transaction.renew_revision())
}
#[doc = "Read or create this profile and API origin's stable device UUID."]
pub fn device_id(&self) -> Result<String, AccountSessionError> {
let slot =
self.auth_slot
.replacen("codewhale-cloud-auth-v1-", "codewhale-device-id-v1-", 1);
self.secrets
.with_entry_transaction(&slot, |raw| {
let id = match raw {
Some(id) if uuid::Uuid::parse_str(id).is_ok() => id.clone(),
Some(_) => {
return Err(SecretsError::Keyring(
"Invalid Codewhale device identity".into(),
));
}
None => uuid::Uuid::new_v4().to_string(),
};
*raw = Some(id.clone());
Ok(id)
})
.map_err(AccountSessionError::from)
}
/// Save a renewed bundle only if the captured session has not changed.
/// Returns the exact committed revision, or None on a concurrent change.
pub fn save_if_unchanged(
@@ -322,12 +541,7 @@ impl AccountSessionStore {
if expected.slot != self.auth_slot || expected.api_base != self.api_base {
return Err(AccountSessionError::InvalidCredentials);
}
let raw = serde_json::to_string(&StoredAccountAuth {
schema_version: ACCOUNT_SESSION_SCHEMA_VERSION,
api_base: self.api_base.clone(),
bundle,
})
.map_err(AccountSessionError::UnreadableRecord)?;
let raw = auth_record(&self.api_base, bundle, expected.raw.as_deref())?;
if !self
.secrets
.compare_exchange(&self.auth_slot, expected.raw.as_deref(), Some(&raw))?
@@ -349,9 +563,11 @@ impl AccountSessionStore {
if expected.slot != self.auth_slot || expected.api_base != self.api_base {
return Err(AccountSessionError::InvalidCredentials);
}
Ok(self
.secrets
.compare_exchange(&self.auth_slot, expected.raw.as_deref(), None)?)
Ok(self.secrets.compare_exchange(
&self.auth_slot,
expected.raw.as_deref(),
Some(&signed_out_record(&self.api_base)),
)?)
}
/// Serialize an account lifecycle operation with every backend writer.
@@ -397,20 +613,15 @@ impl AccountSessionStore {
/// Validate and save an account bundle in the selected secure-store slot.
pub fn save(&self, bundle: AccountAuthBundle) -> Result<(), AccountSessionError> {
validate_account_auth_bundle(&bundle)?;
let stored = StoredAccountAuth {
schema_version: ACCOUNT_SESSION_SCHEMA_VERSION,
api_base: self.api_base.clone(),
bundle,
};
let raw = serde_json::to_string(&stored).map_err(AccountSessionError::UnreadableRecord)?;
self.secrets.set(&self.auth_slot, &raw)?;
Ok(())
self.with_transaction(|transaction| transaction.replace(bundle))
}
/// Remove only the selected profile/origin account session.
pub fn clear(&self) -> Result<(), AccountSessionError> {
self.secrets.delete(&self.auth_slot)?;
Ok(())
self.with_transaction(|transaction| {
transaction.clear();
Ok(())
})
}
/// Read a token-free runtime receipt at a caller-supplied clock instant.
@@ -418,10 +629,7 @@ impl AccountSessionStore {
&self,
now: DateTime<Utc>,
) -> Result<RuntimeAccountInfo, AccountSessionError> {
let Some(stored) = self.load()? else {
return Ok(RuntimeAccountInfo::signed_out(self.api_base.clone()));
};
Ok(runtime_account_info_from_stored(stored, now))
self.snapshot()?.runtime_info_at(now)
}
}
@@ -774,6 +982,8 @@ mod snapshot_tests {
refresh_token: format!("refresh-{id}"),
session: Some(AccountSession {
id: id.into(),
expires_at: "2099-01-01T00:00:00Z".into(),
refresh_expires_at: "2100-01-01T00:00:00Z".into(),
..Default::default()
}),
user: Some(AccountUser {
@@ -792,7 +1002,42 @@ mod snapshot_tests {
))),
] {
let store = AccountSessionStore::new(secrets, None, DEFAULT_ACCOUNT_API_BASE);
let pristine = store.snapshot().unwrap();
assert!(pristine.is_pristine());
let first_login = store.begin_login().unwrap();
assert!(first_login.load().unwrap().is_none());
store.clear().unwrap();
let signed_out = store.snapshot().unwrap();
assert!(!signed_out.is_pristine());
assert_ne!(pristine.fingerprint(), signed_out.fingerprint());
assert_ne!(first_login.fingerprint(), signed_out.fingerprint());
assert!(
store
.save_if_unchanged(&pristine, bundle("late-pristine-login"))
.unwrap()
.is_none()
);
assert!(
store
.save_if_unchanged(&first_login, bundle("late-claimed-login"))
.unwrap()
.is_none()
);
store.save(bundle("old")).unwrap();
store
.with_transaction(|transaction| {
transaction.set_device_access(AccountDeviceAccess {
device_id: uuid::Uuid::new_v4().to_string(),
credential: format!("cwc_key_{}_{}", "b".repeat(24), "A".repeat(43)),
api_key_id: "b".repeat(24),
account_id: "old".into(),
session_id: "old".into(),
expires_at: Utc::now().timestamp() + 3600,
})
})
.unwrap();
store.save(bundle("old")).unwrap();
assert!(store.snapshot().unwrap().device_access().unwrap().is_some());
let old = store.snapshot().unwrap();
let other = store.clone();
std::thread::spawn(move || other.save(bundle("new")))
@@ -810,9 +1055,11 @@ mod snapshot_tests {
store.load().unwrap().unwrap().bundle.user.unwrap().id,
"new"
);
assert!(store.snapshot().unwrap().device_access().unwrap().is_none());
let latest = store.snapshot().unwrap();
assert!(store.clear_if_unchanged(&latest).unwrap());
assert!(store.load().unwrap().is_none());
assert!(store.snapshot().unwrap().device_access().unwrap().is_none());
}
}
#[test]
+1
View File
@@ -82,6 +82,7 @@ doctest = false
codewhale-ratatui = { path = "../../vendor/codewhale-ratatui", version = "0.2.0" }
codewhale-sanitize = { path = "../sanitize", version = "0.10.3" }
anyhow.workspace = true
codewhale-account-cli = { path = "../account-cli", version = "0.10.3" }
codewhale-agent-contract = { path = "../agent-contract", version = "0.10.3" }
codewhale-app-server = { path = "../app-server", version = "0.10.3" }
codewhale-engine = { path = "../engine", version = "0.10.3" }
+8
View File
@@ -30,6 +30,10 @@ use codewhale_runtime::config::SearchProvider;
pub(crate) fn config_action_to_app_action(action: ConfigAction) -> AppAction {
match action {
ConfigAction::PermissionRulesChanged => AppAction::PermissionRulesChanged,
ConfigAction::OpenModelConnectionSetup => AppAction::OpenModelConnectionSetup,
ConfigAction::StartAccountConnect => AppAction::StartAccountConnect,
ConfigAction::StartAccountLogin => AppAction::StartAccountLogin,
ConfigAction::StartAccountLogout => AppAction::StartAccountLogout,
ConfigAction::StartXaiDeviceLogin => AppAction::StartXaiDeviceLogin,
ConfigAction::StartClaudeLogin => AppAction::StartClaudeLogin,
ConfigAction::StartClaudeRevoke => AppAction::StartClaudeRevoke,
@@ -87,6 +91,10 @@ pub(crate) fn config_action_to_app_action(action: ConfigAction) -> AppAction {
pub(crate) fn app_action_to_config_action(action: AppAction) -> Option<ConfigAction> {
Some(match action {
AppAction::PermissionRulesChanged => ConfigAction::PermissionRulesChanged,
AppAction::OpenModelConnectionSetup => ConfigAction::OpenModelConnectionSetup,
AppAction::StartAccountConnect => ConfigAction::StartAccountConnect,
AppAction::StartAccountLogin => ConfigAction::StartAccountLogin,
AppAction::StartAccountLogout => ConfigAction::StartAccountLogout,
AppAction::StartXaiDeviceLogin => ConfigAction::StartXaiDeviceLogin,
AppAction::StartClaudeLogin => ConfigAction::StartClaudeLogin,
AppAction::StartClaudeRevoke => ConfigAction::StartClaudeRevoke,
@@ -2884,37 +2884,6 @@ pub fn lsp_command(app: &mut App, arg: Option<&str>) -> CommandResult {
}
}
pub(crate) fn clear_local_account_session() -> Result<bool, String> {
use codewhale_secrets::account::{
ACCOUNT_API_BASE_ENV, AccountSessionStore, DEFAULT_ACCOUNT_API_BASE,
secure_account_session_secrets,
};
let secrets = secure_account_session_secrets().map_err(|error| error.to_string())?;
let api_base = std::env::var(ACCOUNT_API_BASE_ENV)
.ok()
.map(|value| value.trim().trim_end_matches('/').to_string())
.filter(|value| !value.is_empty())
.unwrap_or_else(|| DEFAULT_ACCOUNT_API_BASE.to_string());
let store = AccountSessionStore::new(secrets, None, &api_base);
let had = store.load().map_err(|error| error.to_string())?.is_some();
store.clear().map_err(|error| error.to_string())?;
Ok(had)
}
pub(crate) fn clear_legacy_sandbox_slot() -> Result<bool, String> {
let secrets = codewhale_secrets::Secrets::auto_detect();
let had = secrets
.get(codewhale_secrets::LEGACY_SANDBOX_TOKEN_SLOT)
.map_err(|error| error.to_string())?
.is_some_and(|value| !value.trim().is_empty());
if had {
secrets
.delete(codewhale_secrets::LEGACY_SANDBOX_TOKEN_SLOT)
.map_err(|error| error.to_string())?;
}
Ok(had)
}
#[cfg(test)]
mod tests {
use super::*;
+10 -1
View File
@@ -191,7 +191,7 @@ pub(crate) fn key_to_message_id(key: &'static str) -> Option<MessageId> {
"cmd_color_description" => MessageId::CmdColorDescription,
"cmd_compact_description" => MessageId::CmdCompactDescription,
"cmd_config_description" => MessageId::CmdConfigDescription,
"cmd_connect_description" => MessageId::CmdConnectDescription,
"cmd_connect_description" => MessageId::ConnectionSetupTitle,
"cmd_disconnect_description" => MessageId::CmdDisconnectDescription,
"cmd_constitution_description" => MessageId::CmdConstitutionDescription,
"cmd_context_description" => MessageId::CmdContextDescription,
@@ -4530,6 +4530,15 @@ impl CommandPluginContext for PluginAdapter<'_> {
Ok(())
}
fn restage(&mut self, selector: &str) -> Result<(), String> {
let mut app = self.host.app.borrow_mut();
std::sync::Arc::make_mut(&mut app.plugin_registry).restage(selector)?;
let workspace = app.workspace.clone();
app.plugin_registry = app.plugin_registry.rediscover_for_workspace(&workspace);
app.refresh_skill_cache();
Ok(())
}
fn disable(&mut self, selector: &str) -> Result<(), String> {
let mut app = self.host.app.borrow_mut();
std::sync::Arc::make_mut(&mut app.plugin_registry).disable(selector)?;
+16 -36
View File
@@ -10,9 +10,9 @@ use super::SharedCommandHost;
use crate::tui::app::ScreenMode;
use codewhale_command_contract::config::{
CommandAccountState, CommandClaudeApplyOutcome, CommandClaudeArtifacts, CommandClaudePlan,
CommandConfigContext, CommandLoginContext, CommandLoginStatus, CommandLogoutOutcome,
CommandPetContext, CommandPetControl, CommandPetLabel, CommandTrustContext,
CommandWorkSurfaceContext, CommandWorkflowSummary, ConfigReply, LogoutFailure, TrustPathError,
CommandConfigContext, CommandLoginContext, CommandLoginStatus, CommandPetContext,
CommandPetControl, CommandPetLabel, CommandTrustContext, CommandWorkSurfaceContext,
CommandWorkflowSummary, ConfigReply, TrustPathError,
};
use codewhale_command_contract::types::CommandScreenMode;
use codewhale_command_contract::types::{CommandRailPanel, CommandWorkSurfacePlacement};
@@ -335,18 +335,22 @@ fn is_enabled_plugin_provider(app: &crate::tui::app::App, provider: &str) -> boo
impl CommandLoginContext for LoginAdapter<'_> {
fn login_status(&self) -> CommandLoginStatus {
use codewhale_secrets::account::{
ACCOUNT_API_BASE_ENV, AccountSessionState, AccountSessionStore,
DEFAULT_ACCOUNT_API_BASE, secure_account_session_secrets,
AccountSessionState, AccountSessionStore, secure_account_session_secrets,
};
let app = self.host.app.borrow();
let api_base = std::env::var(ACCOUNT_API_BASE_ENV)
.ok()
.map(|value| value.trim().trim_end_matches('/').to_string())
.filter(|value| !value.is_empty())
.unwrap_or_else(|| DEFAULT_ACCOUNT_API_BASE.to_string());
let api_base = match codewhale_account_cli::account_api_base(None) {
Ok(base) => base,
Err(error) => {
return CommandLoginStatus {
account: CommandAccountState::Unavailable(error.to_string()),
api_base: String::new(),
provider: app.provider_identity_for_persistence().to_string(),
};
}
};
let account = match secure_account_session_secrets() {
Ok(secrets) => {
match AccountSessionStore::new(secrets, None, &api_base)
match AccountSessionStore::new(secrets, app.config_profile.as_deref(), &api_base)
.runtime_info_at(chrono::Utc::now())
{
Ok(info) => match info.state {
@@ -368,30 +372,6 @@ impl CommandLoginContext for LoginAdapter<'_> {
}
}
fn logout(&mut self) -> Result<CommandLogoutOutcome, LogoutFailure> {
let mut app = self.host.app.borrow_mut();
let provider = app.provider_identity_for_persistence().to_string();
match codewhale_runtime::config::clear_active_provider_api_key(&provider) {
Ok(()) => {
app.onboarding = crate::tui::app::OnboardingState::Provider;
app.onboarding_needs_api_key = true;
app.onboarding_provider = app.api_provider;
app.onboarding_missing_key_recovery = true;
app.api_key_env_only = false;
let account = crate::commands::config_main_host::clear_local_account_session();
let legacy_sandbox = crate::commands::config_main_host::clear_legacy_sandbox_slot();
Ok(CommandLogoutOutcome {
provider,
account,
legacy_sandbox,
})
}
Err(error) => Err(LogoutFailure {
provider,
error: error.to_string(),
}),
}
}
fn connect(
&mut self,
provider: Option<&str>,
@@ -408,7 +388,7 @@ impl CommandLoginContext for LoginAdapter<'_> {
},
),
Some(other) => ConfigReply::error(format!(
"Usage: /connect [<provider>]\nUnknown or inactive plugin provider: {other}\nCodewhale account sign-in is `/login`."
"Usage: /connect [account|<provider>]\nUnknown or inactive plugin provider: {other}\nCodewhale account sign-in is `/login`."
)),
}
}
+12 -45
View File
@@ -626,16 +626,21 @@ pub(super) const MODE_USAGE: &str = "/mode [work|plan|operate|1|2|3]";
/// `/pet` usage, shared with the registration metadata.
pub const PET_USAGE: &str = "/pet [on|off|inspect|status|appearance|style [smooth|pixel]|language [auto|en|zh-Hans|zh-Hant|ja]|window|source|export|sound [on|off]|avatar [whale|cove|braille|pixels|particles|whale-girl|key]|action [name|live]|view [name|live]]";
/// `/connect [<provider>]` — sign in to an enabled plugin provider.
pub fn connect(contexts: CommandContexts<'_>, arg: Option<&str>) -> ConfigCommandResult {
let raw = arg.map(str::trim).unwrap_or("");
if raw.split_whitespace().count() > 1 {
return ConfigCommandResult::error("Usage: /connect [<provider>]");
return ConfigCommandResult::error("Usage: /connect [account|<provider>]");
}
let mut parts = contexts.into_parts();
let Some(facet) = parts.login.as_deref_mut() else {
return missing("login");
};
if raw.is_empty() {
return ConfigCommandResult::action(ConfigAction::OpenModelConnectionSetup);
}
if matches!(raw, "account" | "codewhale") {
return ConfigCommandResult::action(ConfigAction::StartAccountConnect);
}
let provider = (!raw.is_empty()).then_some(raw);
let reply = facet.connect(provider);
to_result(reply, &mut parts, &PreparedTemplates::default())
@@ -889,8 +894,6 @@ fn rail_status(facet: &dyn CommandWorkSurfaceContext) -> String {
}
/// `/login [status|account|key]`.
/// `/login [status|account|key]`. The command composes the report; the login
/// facet returns the typed account state and provider identity.
pub fn login(contexts: CommandContexts<'_>, arg: Option<&str>) -> ConfigCommandResult {
let raw = arg.map(str::trim).unwrap_or("");
let token = raw.split_whitespace().next().unwrap_or("");
@@ -899,7 +902,8 @@ pub fn login(contexts: CommandContexts<'_>, arg: Option<&str>) -> ConfigCommandR
return missing("login");
};
match token {
"" | "status" => {
"" | "account" => ConfigCommandResult::action(ConfigAction::StartAccountLogin),
"status" => {
let status = facet.login_status();
let account = match status.account {
CommandAccountState::SignedOut => {
@@ -923,16 +927,7 @@ pub fn login(contexts: CommandContexts<'_>, arg: Option<&str>) -> ConfigCommandR
status.provider
))
}
"key" | "provider" => ConfigCommandResult::with_message_and_action(
"Open the provider picker to store an API key. Account sign-in is `codewhale login`.",
ConfigAction::OpenProviderPicker,
),
"account" => ConfigCommandResult::message(
"TUI cannot start the browser device flow without freezing the session.\n\
Run `codewhale login` (same as `codewhale account login`) in a terminal.\n\
Then `/login` to confirm the session landed."
.to_string(),
),
"key" | "provider" => ConfigCommandResult::action(ConfigAction::OpenProviderPicker),
other => ConfigCommandResult::error(format!(
"Usage: /login [status|account|key]\nUnknown argument: {other}\n\
To sign in to a plugin provider, use `/connect <provider>`."
@@ -940,39 +935,11 @@ pub fn login(contexts: CommandContexts<'_>, arg: Option<&str>) -> ConfigCommandR
}
}
/// `/logout`. The command composes the confirmation; the login facet clears the
/// provider key, account session and internal token and reports each result.
pub fn logout(contexts: CommandContexts<'_>, _arg: Option<&str>) -> ConfigCommandResult {
let mut parts = contexts.into_parts();
let Some(facet) = parts.login.as_deref_mut() else {
if contexts.into_parts().login.is_none() {
return missing("login");
};
match facet.logout() {
Ok(outcome) => {
let mut cleared = vec![format!("provider key ({})", outcome.provider)];
match outcome.account {
Ok(true) => cleared.push("Codewhale account session".to_string()),
Ok(false) => {}
Err(error) => cleared.push(format!("account session not cleared ({error})")),
}
match outcome.legacy_sandbox {
Ok(true) => cleared.push("legacy sandbox token".to_string()),
Ok(false) => {}
Err(error) => cleared.push(format!("legacy sandbox token not cleared ({error})")),
}
ConfigCommandResult::with_message_and_action(
format!(
"Cleared {}. Use `codewhale login` to sign in again, or `codewhale auth set --provider <id>` to store a provider key.",
cleared.join(", ")
),
ConfigAction::OpenProviderPicker,
)
}
Err(failure) => ConfigCommandResult::error(format!(
"Failed to clear API key for {}: {}",
failure.provider, failure.error
)),
}
ConfigCommandResult::action(ConfigAction::StartAccountLogout)
}
/// `/import-claude [--apply]`.
+1 -1
View File
@@ -39,7 +39,7 @@ static LOGIN_INFO: CommandInfo = CommandInfo {
static CONNECT_INFO: CommandInfo = CommandInfo {
name: "connect",
aliases: &[],
usage: "/connect [<provider>]",
usage: "/connect [account|<provider>]",
description_key: "cmd_connect_description",
};
static DISCONNECT_INFO: CommandInfo = CommandInfo {
@@ -67,7 +67,7 @@ impl CommandGroup for PluginsCommands {
pub(in crate::commands) const PLUGINS_INFO: CommandInfo = CommandInfo {
name: "plugin",
aliases: &["plugins", "extensions"],
usage: "/plugin [list|show|suggest|validate|export|install|import|update|uninstall|trust|enable|disable|revoke|reload|doctor|tools|marketplace|dismissals]",
usage: "/plugin [list|status|show|suggest|validate|export|install|import|update|uninstall|trust|enable|restage|disable|revoke|reload|doctor|tools|marketplace|dismissals]",
description_key: "cmd_plugin_description",
};
@@ -150,7 +150,10 @@ pub(super) fn plugins(
[] => CommandResult::action(AppAction::OpenExtensions {
tab: crate::tui::views::extensions::ExtensionsTab::Plugins,
}),
["list"] => list_bundles_and_legacy_tools(presentation, plugin),
["list"] | ["status"] => list_bundles_and_legacy_tools(presentation, plugin),
["status", selector] => show_bundle(presentation, plugin, selector),
["restage"] => CommandResult::error("Usage: /plugin restage <name>"),
["restage", selector] => mutate_bundle(presentation, plugin, selector, Mutation::Restage),
["help"] => CommandResult::message(format!(
"{}\n\n/plugin import kimi [list]\n/plugin import kimi approve <name> <content-hash>\n{}\n{}",
translate(presentation, "cmd_plugin_bundle_usage"),
@@ -985,6 +988,7 @@ fn network_denied_message(host: &str) -> String {
enum Mutation<'a> {
Trust(&'a str),
Enable,
Restage,
Disable,
Revoke,
}
@@ -1010,6 +1014,7 @@ fn mutate_bundle(
let result = match mutation {
Mutation::Trust(token) => plugin.trust(selector, token).map(|()| "trusted"),
Mutation::Enable => plugin.enable(selector).map(|()| "enabled"),
Mutation::Restage => plugin.restage(selector).map(|()| "restaged"),
Mutation::Disable => plugin.disable(selector).map(|()| "disabled"),
Mutation::Revoke => plugin.revoke_trust(selector).map(|()| "trust-revoked"),
};
+16 -7
View File
@@ -779,11 +779,17 @@ mod tests {
assert!(!message.contains("Daytona"), "{message}");
assert!(!message.contains("set-slot"), "{message}");
assert!(
execute("/login", &mut app)
.message
.expect("login status")
.contains("Codewhale login")
assert_eq!(
execute("/login", &mut app).action,
Some(AppAction::StartAccountLogin)
);
assert_eq!(
execute("/login account", &mut app).action,
Some(AppAction::StartAccountLogin)
);
assert_eq!(
execute("/logout", &mut app).action,
Some(AppAction::StartAccountLogout)
);
let key = execute("/login key", &mut app);
assert!(!key.is_error);
@@ -802,12 +808,15 @@ mod tests {
assert_eq!(
execute("/connect", &mut app).action,
Some(AppAction::OpenProviderPicker)
Some(AppAction::OpenModelConnectionSetup)
);
let connect = execute("/connect nothing-enabled", &mut app);
assert!(connect.is_error);
let err = connect.message.expect("usage");
assert!(err.contains("Usage: /connect [<provider>]"), "{err}");
assert!(
err.contains("Usage: /connect [account|<provider>]"),
"{err}"
);
assert!(err.contains("`/login`"), "{err}");
assert!(execute("/disconnect", &mut app).is_error);
assert!(execute("/disconnect nothing-enabled", &mut app).is_error);
+221 -1
View File
@@ -147,6 +147,11 @@ 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")]
Plugins {
#[command(subcommand)]
command: PluginsCommand,
},
/// Bootstrap MCP config and/or skills directories
Setup(SetupArgs),
/// Generate a remote Codewhale agent deploy bundle (cloud + chat bridge)
@@ -264,6 +269,24 @@ enum Commands {
},
}
#[derive(Subcommand, Debug, Clone)]
enum PluginsCommand {
#[command(about = "Inspect plugin status and diagnostics")]
Status {
#[arg(value_name = "NAME")]
name: Option<String>,
#[arg(long)]
json: bool,
},
#[command(about = "Restore an eligible reviewed plugin snapshot")]
Restage {
#[arg(value_name = "NAME")]
name: String,
#[arg(long)]
json: bool,
},
}
/// Subcommands of `codewhale sessions`. Without one, the command falls back
/// to listing sessions.
#[derive(Subcommand, Debug, Clone)]
@@ -2003,13 +2026,18 @@ fn run_with_args(options: RuntimeOptions, args: Vec<String>) -> Result<()> {
// every roster read in this process.
codewhale_engine::fleet::roster::set_project_agent_profiles_enabled(!cli.no_project_config);
let workspace = resolve_workspace(&cli);
let inspect_plugin_cli = matches!(cli.command.as_ref(), Some(Commands::Plugins { .. }));
let mut plugin_discovery = None;
let mut plugin_registry = None;
let (cli, command) = prepare_cli_startup(
cli,
|| {
let discovery = codewhale_plugins::PluginDiscoveryContext::capture_pre_dotenv();
plugin_registry = Some(discovery.registry_for_workspace(&workspace));
plugin_registry = Some(if inspect_plugin_cli {
discovery.inspect_registry_for_workspace(&workspace)
} else {
discovery.registry_for_workspace(&workspace)
});
plugin_discovery = Some(discovery);
},
|| warn_on_workspace_dotenv_result(&workspace),
@@ -2142,6 +2170,7 @@ fn diagnostic_worker_count(command: Option<&Commands>) -> Option<usize> {
) => true,
// Only the read-only status report; mutating setup keeps defaults.
Some(Commands::Setup(args)) => args.status,
Some(Commands::Plugins { .. }) => true,
_ => false,
};
capped.then_some(DIAGNOSTIC_WORKER_CAP)
@@ -2271,6 +2300,12 @@ fn telemetry_command_is_read_only(command: Option<&Commands>) -> bool {
| Commands::Receipts { .. }
)
) || matches!(command, Some(Commands::Setup(args)) if args.status)
|| matches!(
command,
Some(Commands::Plugins {
command: PluginsCommand::Status { .. }
})
)
}
/// Resolve the emit predicate and arm, once, before anything can record.
@@ -2564,6 +2599,14 @@ async fn run_async_main_dispatch(
)
.await
}
Commands::Plugins { command } => tokio::task::spawn_blocking(move || {
let _config = load_structural_config_from_cli(&cli)?;
let workspace = resolve_workspace(&cli);
let registry = plugin_discovery.inspect_registry_for_workspace(&workspace);
run_plugins_cli(command, registry)
})
.await
.context("Plugin command failed to finish")?,
Commands::Setup(args) => {
let config = load_config_from_cli(&cli)?;
let workspace = resolve_workspace(&cli);
@@ -3164,6 +3207,7 @@ fn prepare_cli_startup(
let command = cli.command.clone();
let should_load_dotenv = match command.as_ref() {
Some(Commands::Doctor(args)) => args.probe_api || args.probe_local,
Some(Commands::Plugins { .. }) => false,
_ => true,
};
if should_load_dotenv {
@@ -9098,6 +9142,182 @@ fn doctor_timeout_recovery_lines(config: &Config) -> Vec<String> {
lines
}
fn plugin_cli_text(value: &str) -> String {
use crate::commands::groups::plugins::render::{ReviewTextContext, escape_review_text};
escape_review_text(value, ReviewTextContext::Plain)
}
fn plugin_cli_diagnostic(
diagnostic: &codewhale_plugins::types::PluginDiagnostic,
) -> serde_json::Value {
use codewhale_plugins::types::PluginDiagnosticLevel;
let level = match diagnostic.level {
PluginDiagnosticLevel::Warning => "warning",
PluginDiagnosticLevel::Error => "error",
};
serde_json::json!({
"level": level,
"code": diagnostic.code,
"message": plugin_cli_text(&diagnostic.message),
"path": diagnostic.path.as_ref().map(|path| plugin_cli_text(&path.to_string_lossy())),
})
}
fn plugin_cli_record(plugin: &codewhale_plugins::types::LoadedPlugin) -> serde_json::Value {
serde_json::json!({
"name": plugin_cli_text(plugin.name()),
"id": plugin_cli_text(plugin.id.as_str()),
"version": plugin_cli_text(&plugin.manifest.plugin.version),
"source": plugin_cli_text(&plugin.canonical_root.to_string_lossy()),
"scope": plugin.scope.as_str(),
"origin": plugin.origin.as_str(),
"state": plugin.state_label(),
"trust": plugin.trust_status.as_str(),
"enabled": plugin.enabled,
"active": plugin.active(),
"staged": plugin.staged_root.is_some(),
"applicable": plugin.applicable,
"compatibility": plugin.compatibility().as_str(),
"content_hash": plugin.content_hash,
"capability_hash": plugin.capability_hash,
"diagnostics": plugin.diagnostics.iter().map(plugin_cli_diagnostic).collect::<Vec<_>>(),
})
}
fn print_plugin_cli_diagnostic(diagnostic: &codewhale_plugins::types::PluginDiagnostic) {
use codewhale_plugins::types::PluginDiagnosticLevel;
let level = match diagnostic.level {
PluginDiagnosticLevel::Warning => "warning",
PluginDiagnosticLevel::Error => "error",
};
let path = diagnostic
.path
.as_ref()
.map(|path| plugin_cli_text(&path.to_string_lossy()))
.unwrap_or_else(|| "-".to_string());
println!(
" level={} code={} message={} path={}",
level,
diagnostic.code,
plugin_cli_text(&diagnostic.message),
path
);
}
fn run_plugins_cli(
command: PluginsCommand,
mut registry: Arc<codewhale_plugins::PluginRegistry>,
) -> Result<()> {
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);
match command {
PluginsCommand::Status { name, json } => {
let plugins = match name.as_deref() {
Some(name) => vec![registry.get(name).ok_or_else(|| {
anyhow!(
tr(locale, MessageId::CmdPluginBundleNotFound)
.replace("{name}", &plugin_cli_text(name))
)
})?],
None => registry.list(),
};
if json {
let records = plugins
.iter()
.map(|plugin| plugin_cli_record(plugin))
.collect::<Vec<_>>();
let diagnostics = registry
.diagnostics()
.iter()
.map(plugin_cli_diagnostic)
.collect::<Vec<_>>();
println!(
"{}",
serde_json::to_string_pretty(&serde_json::json!({
"plugins": records,
"diagnostics": diagnostics,
}))?
);
} else {
if plugins.is_empty() {
println!("{}", tr(locale, MessageId::CmdPluginBundleNoneFound));
} else {
println!(
"{}",
tr(locale, MessageId::CmdPluginBundleListHeader)
.replace("{count}", &plugins.len().to_string())
);
}
for plugin in plugins {
println!(
" name={} id={} version={} scope={} origin={} state={} trust={} enabled={} active={} staged={} applicable={} compatibility={}",
plugin_cli_text(plugin.name()),
plugin_cli_text(plugin.id.as_str()),
plugin_cli_text(&plugin.manifest.plugin.version),
plugin.scope.as_str(),
plugin.origin.as_str(),
plugin.state_label(),
plugin.trust_status.as_str(),
plugin.enabled,
plugin.active(),
plugin.staged_root.is_some(),
plugin.applicable,
plugin.compatibility().as_str()
);
println!(
" source={} content_hash={} capability_hash={}",
plugin_cli_text(&plugin.canonical_root.to_string_lossy()),
plugin.content_hash,
plugin.capability_hash
);
for diagnostic in &plugin.diagnostics {
print_plugin_cli_diagnostic(diagnostic);
}
}
if !registry.diagnostics().is_empty() {
println!(
"{}",
tr(locale, MessageId::CmdPluginBundleDiagnosticsHeader)
.replace("{count}", &registry.diagnostics().len().to_string())
);
for diagnostic in registry.diagnostics() {
print_plugin_cli_diagnostic(diagnostic);
}
}
}
}
PluginsCommand::Restage { name, json } => {
Arc::make_mut(&mut registry)
.restage(&name)
.map_err(|error| {
anyhow!(
tr(locale, MessageId::CmdPluginActionFailed)
.replace("{error}", &plugin_cli_text(&error))
)
})?;
let name = plugin_cli_text(&name);
if json {
println!(
"{}",
serde_json::to_string_pretty(&serde_json::json!({
"name": name,
"action": "restaged",
}))?
);
} else {
println!(
"{}",
tr(locale, MessageId::CmdPluginBundleMutationSuccess)
.replace("{name}", &name)
.replace("{action}", "restaged")
);
}
}
}
Ok(())
}
fn run_features_command(config: &Config, command: FeaturesCli) -> Result<()> {
match command.command {
FeaturesSubcommand::List => {
+754
View File
@@ -0,0 +1,754 @@
use std::cell::RefCell;
use std::sync::mpsc::{self, Receiver, TryRecvError};
use std::sync::{
Arc,
atomic::{AtomicBool, Ordering},
};
use std::time::{Duration, Instant};
use codewhale_account_cli::{
AccountClient, AccountLogin, AccountLoginCancellation, AccountLoginPoll, AccountModels,
};
use codewhale_localization::{Locale, MessageId, tr};
use codewhale_palette as palette;
use crossterm::event::{
KeyCode, KeyEvent, KeyEventKind, KeyModifiers, MouseButton, MouseEvent, MouseEventKind,
};
use ratatui::{
buffer::Buffer,
layout::Rect,
style::{Modifier, Style},
text::{Line, Span},
widgets::{Block, Borders, Padding, Paragraph, Widget, Wrap},
};
use crate::tui::shell_key_routing::{ShellBindingId, binding};
use crate::tui::views::{
ActionHint, ModalKind, ModalView, ViewAction, ViewEvent, centered_modal_area,
render_modal_footer, render_modal_surface,
};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum AccountOperationOutcome {
Complete,
LocalSignOut,
Cancelled,
ModelCancelled,
Expired,
Failed,
}
enum WorkerResult {
Started(AccountLogin),
Polled(AccountLogin, AccountLoginPoll),
Models(AccountModels),
ModelsFailed,
SignedOut(bool),
SignedIn,
Cancelled,
Failed,
}
pub(crate) struct AccountLoginView {
locale: Locale,
signing_out: bool,
connecting_models: bool,
profile: Option<String>,
models: Option<AccountModels>,
models_unavailable: bool,
models_failed: bool,
worker: Option<Receiver<WorkerResult>>,
login: Option<AccountLogin>,
cancellation: Option<AccountLoginCancellation>,
url: Option<String>,
code: Option<String>,
next_poll: Instant,
selected: usize,
buttons: RefCell<Vec<(usize, Rect)>>,
cancelled: Arc<AtomicBool>,
}
impl AccountLoginView {
pub(crate) fn new(profile: Option<String>, signing_out: bool, locale: Locale) -> Self {
Self::start(profile, signing_out, false, locale)
}
pub(crate) fn connect(profile: Option<String>, locale: Locale) -> Self {
Self::start(profile, false, true, locale)
}
fn start(
profile: Option<String>,
signing_out: bool,
connecting_models: bool,
locale: Locale,
) -> Self {
let worker_profile = profile.clone();
let (sender, receiver) = mpsc::channel();
let cancelled = Arc::new(AtomicBool::new(false));
let worker_cancelled = cancelled.clone();
tokio::task::spawn_blocking(move || {
let result = AccountClient::new(worker_profile.as_deref(), None).and_then(|client| {
if worker_cancelled.load(Ordering::Acquire) {
return Ok(WorkerResult::Cancelled);
}
if signing_out {
let snapshot = client.snapshot()?;
if worker_cancelled.load(Ordering::Acquire) {
return Ok(WorkerResult::Cancelled);
}
client
.logout_if_unchanged(&snapshot)
.map(WorkerResult::SignedOut)
} else if matches!(
client.status()?.account.state,
codewhale_secrets::account::AccountSessionState::Authenticated
| codewhale_secrets::account::AccountSessionState::OfflineCached
) {
if connecting_models {
Ok(client
.connect_models_with_cancel(&worker_cancelled)
.map(WorkerResult::Models)
.unwrap_or(WorkerResult::ModelsFailed))
} else {
Ok(WorkerResult::SignedIn)
}
} else {
client
.start_login_with_cancel(
Duration::from_secs(
codewhale_account_cli::DEFAULT_LOGIN_TIMEOUT_SECONDS,
),
worker_cancelled.clone(),
)
.map(WorkerResult::Started)
}
});
let _ = sender.send(result.unwrap_or(WorkerResult::Failed));
});
Self {
locale,
signing_out,
connecting_models,
profile,
models: None,
models_unavailable: false,
models_failed: false,
worker: Some(receiver),
login: None,
cancellation: None,
url: None,
code: None,
next_poll: Instant::now(),
selected: 0,
buttons: RefCell::new(Vec::new()),
cancelled,
}
}
fn finish(&self, outcome: AccountOperationOutcome) -> ViewAction {
if self.signing_out && outcome == AccountOperationOutcome::Cancelled {
return ViewAction::Close;
}
let outcome = if self.connecting_models && outcome == AccountOperationOutcome::Cancelled {
AccountOperationOutcome::ModelCancelled
} else {
outcome
};
ViewAction::EmitAndClose(ViewEvent::AccountOperationCompleted {
signing_out: self.signing_out,
outcome,
})
}
fn open_browser(&self) {
if let Some(url) = self.url.clone() {
let cancelled = self.cancelled.clone();
tokio::task::spawn_blocking(move || {
if !cancelled.load(Ordering::Acquire) {
let _ = webbrowser::open(&url);
}
});
}
}
fn activate(&mut self, selected: usize) -> ViewAction {
if self.models_unavailable {
return match selected {
0 => {
self.load_models();
ViewAction::Redraw
}
1 => ViewAction::EmitAndClose(ViewEvent::AccountConnectionChoice {
account: Some(false),
}),
_ => self.finish(AccountOperationOutcome::Cancelled),
};
}
if let Some(models) = &mut self.models {
if let Some(model) = models.models.get(selected) {
let Some(key) = models.credential.take() else {
return self.finish(AccountOperationOutcome::Failed);
};
return ViewAction::EmitAndClose(ViewEvent::AccountModelSelected {
access: codewhale_runtime::config::AccountModelAccess {
session_id: models.session_id.clone(),
credential: codewhale_runtime::credentials::Credential::ApiKey { key },
expires_at: models.expires_at,
profile: self.profile.clone(),
},
api_base: models.api_base.clone(),
account_id: models.account_id.clone(),
expected_revision: models.snapshot.fingerprint(),
model: model.id.clone(),
});
}
return self.finish(AccountOperationOutcome::Cancelled);
}
match selected {
0 if !self.signing_out && self.url.is_some() => {
self.open_browser();
ViewAction::None
}
1 if !self.signing_out => self.url.as_ref().map_or(ViewAction::None, |url| {
ViewAction::Emit(ViewEvent::CopyToClipboard {
text: url.clone(),
label: tr(self.locale, MessageId::AccountLoginLink).into_owned(),
})
}),
_ => self.finish(AccountOperationOutcome::Cancelled),
}
}
fn load_models(&mut self) {
self.url = None;
self.code = None;
self.models = None;
self.models_unavailable = false;
self.models_failed = false;
self.selected = 0;
let profile = self.profile.clone();
let cancelled = self.cancelled.clone();
let (sender, receiver) = mpsc::channel();
self.worker = Some(receiver);
tokio::task::spawn_blocking(move || {
let result = AccountClient::new(profile.as_deref(), None).and_then(|client| {
if !matches!(
client.status()?.account.state,
codewhale_secrets::account::AccountSessionState::Authenticated
| codewhale_secrets::account::AccountSessionState::OfflineCached
) {
return client
.start_login_with_cancel(
Duration::from_secs(
codewhale_account_cli::DEFAULT_LOGIN_TIMEOUT_SECONDS,
),
cancelled.clone(),
)
.map(WorkerResult::Started);
}
Ok(client
.connect_models_with_cancel(&cancelled)
.map(WorkerResult::Models)
.unwrap_or(WorkerResult::ModelsFailed))
});
let _ = sender.send(result.unwrap_or(WorkerResult::ModelsFailed));
});
}
}
impl Drop for AccountLoginView {
fn drop(&mut self) {
self.cancelled.store(true, Ordering::Release);
if let Some(cancellation) = &self.cancellation {
cancellation.cancel();
}
}
}
impl ModalView for AccountLoginView {
fn kind(&self) -> ModalKind {
ModalKind::AccountLogin
}
fn set_locale(&mut self, locale: Locale) {
self.locale = locale;
}
fn as_any_mut(&mut self) -> &mut dyn std::any::Any {
self
}
fn handle_key(&mut self, key: KeyEvent) -> ViewAction {
if key.kind != KeyEventKind::Press {
return ViewAction::None;
}
if binding(ShellBindingId::AccountLoginCancel).matches(&key)
|| key.code == KeyCode::Char('c') && key.modifiers == KeyModifiers::CONTROL
{
return self.finish(AccountOperationOutcome::Cancelled);
}
if self.url.is_some() && binding(ShellBindingId::AccountLoginOpenBrowser).matches(&key) {
return self.activate(0);
}
if self.url.is_some() && binding(ShellBindingId::AccountLoginCopyLink).matches(&key) {
return self.activate(1);
}
if key
.modifiers
.intersects(KeyModifiers::CONTROL | KeyModifiers::ALT | KeyModifiers::SUPER)
{
return ViewAction::None;
}
let count = if let Some(models) = &self.models {
models.models.len() + 1
} else if self.url.is_some() || self.models_unavailable {
3
} else {
1
};
match key.code {
KeyCode::Tab | KeyCode::Down | KeyCode::Right => {
self.selected = (self.selected + 1) % count;
ViewAction::Redraw
}
KeyCode::BackTab | KeyCode::Up | KeyCode::Left => {
self.selected = (self.selected + count - 1) % count;
ViewAction::Redraw
}
KeyCode::Enter => self.activate(if count == 1 && self.models.is_none() {
2
} else {
self.selected
}),
_ => ViewAction::None,
}
}
fn handle_mouse(&mut self, mouse: MouseEvent) -> ViewAction {
if !matches!(mouse.kind, MouseEventKind::Down(MouseButton::Left)) {
return ViewAction::None;
}
let selected = self.buttons.borrow().iter().find_map(|(index, rect)| {
crate::tui::mouse_ui::mouse_hits_rect(mouse, Some(*rect)).then_some(*index)
});
selected.map_or(ViewAction::None, |selected| self.activate(selected))
}
fn occupied_region(&self, area: Rect) -> Rect {
centered_modal_area(area, 76, 18, 28, 8)
}
fn render(&self, area: Rect, buf: &mut Buffer) {
let popup = self.occupied_region(area);
render_modal_surface(area, popup, buf);
let title = if self.signing_out {
MessageId::AccountLogoutTitle
} else if self.connecting_models && self.url.is_none() {
MessageId::AccountModelsTitle
} else {
MessageId::AccountLoginTitle
};
let block = Block::default()
.title(tr(self.locale, title).into_owned())
.borders(Borders::ALL)
.border_style(Style::default().fg(palette::BORDER_COLOR))
.style(Style::default().bg(palette::WHALE_BG))
.padding(Padding::uniform(1));
let inner = block.inner(popup);
block.render(popup, buf);
let dismiss_id = if self.signing_out {
MessageId::CtxInspActionClose
} else {
MessageId::PickerActionCancel
};
let mut hints = Vec::new();
if self.models.is_some() || self.models_unavailable {
hints.push(ActionHint::new(
binding(ShellBindingId::AccountLoginNext).footer_chord,
tr(self.locale, MessageId::PickerActionMove),
));
hints.push(ActionHint::new(
binding(ShellBindingId::AccountLoginSelect).footer_chord,
tr(self.locale, MessageId::PickerActionApply),
));
}
hints.push(ActionHint::new(
binding(ShellBindingId::AccountLoginCancel).footer_chord,
tr(self.locale, dismiss_id),
));
let content = render_modal_footer(inner, buf, &hints);
let mut lines = Vec::new();
if let Some(code) = &self.code {
lines.push(Line::from(Span::styled(
tr(self.locale, MessageId::AccountLoginCode).replace("{code}", code),
Style::default()
.fg(palette::WHALE_ACTION)
.add_modifier(Modifier::BOLD),
)));
}
if let Some(url) = &self.url {
lines.push(Line::from(url.clone()));
}
lines.push(Line::from(
tr(
self.locale,
if self.signing_out {
MessageId::AccountLogoutStarting
} else if self.url.is_some() {
MessageId::AccountLoginWaiting
} else if self.models_unavailable && self.models_failed {
MessageId::AccountModelsLoadFailed
} else if self.models_unavailable {
MessageId::AccountModelsUnavailable
} else if self.connecting_models {
MessageId::AccountModelsLoading
} else {
MessageId::AccountLoginStarting
},
)
.into_owned(),
));
if let Some(models) = &self.models {
let billing = models
.models
.get(self.selected)
.map(|model| {
tr(self.locale, MessageId::ProviderExternalBillingLine)
.replace("{owner}", &model.provider)
})
.unwrap_or_default();
let paragraph = Paragraph::new(billing).wrap(Wrap { trim: false });
let billing_height = (paragraph.line_count(content.width.max(1)) as u16)
.min(content.height.saturating_sub(2));
paragraph.render(
Rect {
height: billing_height,
..content
},
buf,
);
let content = Rect {
y: content.y + billing_height,
height: content.height.saturating_sub(billing_height),
..content
};
let mut buttons = self.buttons.borrow_mut();
buttons.clear();
let count = models.models.len() + 1;
let visible = usize::from(content.height);
let start = self.selected.saturating_sub(visible.saturating_sub(1));
for (row, index) in (start..count).take(visible).enumerate() {
let label = models
.models
.get(index)
.map(|model| {
let mut clean = String::new();
codewhale_tui_kit::osc8::strip_ansi_into(
&format!("{} · {}", model.label, model.provider),
&mut clean,
);
clean
.chars()
.filter(|character| !character.is_control())
.take(160)
.collect::<String>()
})
.unwrap_or_else(|| tr(self.locale, dismiss_id).into_owned());
let rect = Rect::new(content.x, content.y + row as u16, content.width, 1);
let style = if index == self.selected {
codewhale_tui_kit::menu_style::selected_row_style()
} else {
Style::default().fg(palette::TEXT_PRIMARY)
};
Paragraph::new(label).style(style).render(rect, buf);
buttons.push((index, rect));
}
return;
}
let options: Vec<(usize, MessageId, Option<ShellBindingId>)> = if self.models_unavailable {
vec![
(0, MessageId::SetupActionRetry, None),
(1, MessageId::ConnectionSetupLocal, None),
(2, dismiss_id, Some(ShellBindingId::AccountLoginCancel)),
]
} else if self.url.is_some() {
vec![
(
0,
MessageId::AccountLoginOpenBrowser,
Some(ShellBindingId::AccountLoginOpenBrowser),
),
(
1,
MessageId::AccountLoginCopyLink,
Some(ShellBindingId::AccountLoginCopyLink),
),
(2, dismiss_id, Some(ShellBindingId::AccountLoginCancel)),
]
} else {
vec![(2, dismiss_id, Some(ShellBindingId::AccountLoginCancel))]
};
let options_height = options.len().min(usize::from(content.height)) as u16;
let text_area = Rect {
height: content.height.saturating_sub(options_height),
..content
};
Paragraph::new(lines)
.wrap(Wrap { trim: false })
.render(text_area, buf);
let mut buttons = self.buttons.borrow_mut();
buttons.clear();
for (row, (index, id, shortcut)) in options.iter().enumerate() {
let rect = Rect::new(
content.x,
content.bottom().saturating_sub(options_height) + row as u16,
content.width,
1,
);
let selected = *index == if options.len() == 1 { 2 } else { self.selected };
let style = if selected {
codewhale_tui_kit::menu_style::selected_row_style()
} else {
Style::default().fg(palette::TEXT_PRIMARY)
};
let chord = shortcut.map(|id| binding(id).footer_chord).unwrap_or("");
Paragraph::new(format!("{chord} {}", tr(self.locale, *id)))
.style(style)
.render(rect, buf);
buttons.push((*index, rect));
}
}
fn tick(&mut self) -> ViewAction {
let outcome = self.worker.as_ref().map(|worker| worker.try_recv());
match outcome {
Some(Ok(result)) => {
self.worker = None;
match result {
WorkerResult::Started(login) => {
self.cancellation = Some(login.cancellation());
self.url = Some(login.prompt().verification_uri_complete.clone());
self.code = Some(login.prompt().user_code.clone());
self.login = Some(login);
self.next_poll = Instant::now();
if std::env::var_os("SSH_CONNECTION").is_none()
&& std::env::var_os("SSH_TTY").is_none()
{
self.open_browser();
}
return ViewAction::Redraw;
}
WorkerResult::Polled(login, AccountLoginPoll::Pending { retry_after }) => {
self.login = Some(login);
self.next_poll = Instant::now() + retry_after;
}
WorkerResult::Polled(_, AccountLoginPoll::Complete { .. })
if self.connecting_models =>
{
self.load_models();
return ViewAction::Redraw;
}
WorkerResult::Models(models) => {
self.models_unavailable =
models.models.is_empty() || models.credential.is_none();
if !self.models_unavailable {
self.selected = models
.models
.iter()
.position(|model| Some(&model.id) == models.default_model.as_ref())
.unwrap_or(0);
self.models = Some(models);
}
return ViewAction::Redraw;
}
WorkerResult::ModelsFailed => {
self.models_unavailable = true;
self.models_failed = true;
return ViewAction::Redraw;
}
WorkerResult::Polled(_, AccountLoginPoll::Complete { .. })
| WorkerResult::SignedIn
| WorkerResult::SignedOut(true) => {
return self.finish(AccountOperationOutcome::Complete);
}
WorkerResult::SignedOut(false) => {
return self.finish(AccountOperationOutcome::LocalSignOut);
}
WorkerResult::Polled(_, AccountLoginPoll::Cancelled) => {
return self.finish(AccountOperationOutcome::Cancelled);
}
WorkerResult::Polled(_, AccountLoginPoll::Expired) => {
return self.finish(AccountOperationOutcome::Expired);
}
WorkerResult::Cancelled => {
return self.finish(AccountOperationOutcome::Cancelled);
}
WorkerResult::Failed => return self.finish(AccountOperationOutcome::Failed),
}
}
Some(Err(TryRecvError::Disconnected)) => {
return self.finish(AccountOperationOutcome::Failed);
}
Some(Err(TryRecvError::Empty)) | None => {}
}
if self.worker.is_none()
&& Instant::now() >= self.next_poll
&& let Some(mut login) = self.login.take()
{
let (sender, receiver) = mpsc::channel();
self.worker = Some(receiver);
tokio::task::spawn_blocking(move || {
let result = match login.poll() {
Ok(result) => WorkerResult::Polled(login, result),
Err(_) => WorkerResult::Failed,
};
let _ = sender.send(result);
});
}
ViewAction::None
}
}
pub(crate) struct ConnectionSetupView {
locale: Locale,
selected: usize,
buttons: RefCell<Vec<(usize, Rect)>>,
}
impl ConnectionSetupView {
pub(crate) fn new(locale: Locale) -> Self {
Self {
locale,
selected: 0,
buttons: RefCell::new(Vec::new()),
}
}
fn choose(&self, selected: Option<usize>) -> ViewAction {
ViewAction::EmitAndClose(ViewEvent::AccountConnectionChoice {
account: selected.map(|selected| selected == 0),
})
}
}
impl ModalView for ConnectionSetupView {
fn kind(&self) -> ModalKind {
ModalKind::AccountLogin
}
fn set_locale(&mut self, locale: Locale) {
self.locale = locale;
}
fn as_any_mut(&mut self) -> &mut dyn std::any::Any {
self
}
fn handle_key(&mut self, key: KeyEvent) -> ViewAction {
if key.kind != KeyEventKind::Press {
return ViewAction::None;
}
if binding(ShellBindingId::AccountLoginCancel).matches(&key)
|| key.code == KeyCode::Char('c') && key.modifiers == KeyModifiers::CONTROL
{
return self.choose(None);
}
if key
.modifiers
.intersects(KeyModifiers::CONTROL | KeyModifiers::ALT | KeyModifiers::SUPER)
{
return ViewAction::None;
}
match key.code {
KeyCode::Up | KeyCode::Down | KeyCode::Tab | KeyCode::BackTab => {
self.selected = 1 - self.selected;
ViewAction::Redraw
}
KeyCode::Enter => self.choose(Some(self.selected)),
_ => ViewAction::None,
}
}
fn handle_mouse(&mut self, mouse: MouseEvent) -> ViewAction {
if !matches!(mouse.kind, MouseEventKind::Down(MouseButton::Left)) {
return ViewAction::None;
}
let selected = self.buttons.borrow().iter().find_map(|(index, rect)| {
crate::tui::mouse_ui::mouse_hits_rect(mouse, Some(*rect)).then_some(*index)
});
selected.map_or(ViewAction::None, |selected| self.choose(Some(selected)))
}
fn occupied_region(&self, area: Rect) -> Rect {
centered_modal_area(area, 76, 13, 28, 8)
}
fn render(&self, area: Rect, buf: &mut Buffer) {
let popup = self.occupied_region(area);
render_modal_surface(area, popup, buf);
let block = Block::default()
.title(tr(self.locale, MessageId::ConnectionSetupTitle).into_owned())
.borders(Borders::ALL)
.border_style(Style::default().fg(palette::BORDER_COLOR))
.style(Style::default().bg(palette::WHALE_BG))
.padding(Padding::uniform(1));
let inner = block.inner(popup);
block.render(popup, buf);
let content = render_modal_footer(
inner,
buf,
&[
ActionHint::new(
binding(ShellBindingId::AccountLoginNext).footer_chord,
tr(self.locale, MessageId::PickerActionMove),
),
ActionHint::new(
binding(ShellBindingId::AccountLoginSelect).footer_chord,
tr(self.locale, MessageId::PickerActionApply),
),
ActionHint::new(
binding(ShellBindingId::AccountLoginCancel).footer_chord,
tr(self.locale, MessageId::PickerActionCancel),
),
],
);
let mut buttons = self.buttons.borrow_mut();
buttons.clear();
let rows = [
(
MessageId::ConnectionSetupAccount,
MessageId::ConnectionSetupAccountDetail,
),
(
MessageId::ConnectionSetupLocal,
MessageId::ConnectionSetupLocalDetail,
),
];
let height = content.height / 2;
for (index, (label, detail)) in rows.into_iter().enumerate() {
let rect = Rect::new(
content.x,
content.y + index as u16 * height,
content.width,
height,
);
let style = if self.selected == index {
codewhale_tui_kit::menu_style::selected_row_style()
} else {
Style::default().fg(palette::TEXT_PRIMARY)
};
Paragraph::new(vec![
Line::from(Span::styled(
tr(self.locale, label).into_owned(),
Style::default().add_modifier(Modifier::BOLD),
)),
Line::from(tr(self.locale, detail).into_owned()),
])
.style(style)
.wrap(Wrap { trim: false })
.render(rect, buf);
buttons.push((index, rect));
}
}
}
+4
View File
@@ -570,6 +570,10 @@ pub enum AppAction {
OpenDs4Setup,
/// Run the xAI/Grok device-code flow with the TUI temporarily suspended.
StartXaiDeviceLogin,
OpenModelConnectionSetup,
StartAccountConnect,
StartAccountLogin,
StartAccountLogout,
/// Run native ChatGPT PKCE sign-in with the TUI temporarily suspended.
StartChatgptPkceLogin,
StartChatgptRevoke,
+1
View File
@@ -11,6 +11,7 @@
// === Submodules ===
pub(crate) mod account_login;
pub mod active_cell;
pub(crate) mod agent_details;
pub(crate) mod agent_focus;
+52
View File
@@ -55,6 +55,7 @@ pub enum Focus {
pub enum FocusScope {
TerminalPanel,
RoutePicker,
AccountLogin,
PetHabitat,
Pet,
/// Only the sandbox-elevation decision card.
@@ -79,6 +80,7 @@ impl FocusScope {
pub fn admits(self, focus: Focus) -> bool {
match self {
Self::TerminalPanel => focus == Focus::TerminalPanel,
Self::AccountLogin => focus == Focus::Modal(ModalKind::AccountLogin),
Self::RoutePicker => matches!(
focus,
Focus::Modal(ModalKind::ProviderPicker | ModalKind::ModelPicker)
@@ -110,6 +112,11 @@ impl FocusScope {
/// Stable binding ids shared by handlers, footer hints, and help catalog.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ShellBindingId {
AccountLoginOpenBrowser,
AccountLoginCopyLink,
AccountLoginCancel,
AccountLoginSelect,
AccountLoginNext,
TerminalNew,
TerminalNext,
TerminalPrevious,
@@ -173,6 +180,21 @@ impl ShellBinding {
#[must_use]
pub fn matches(&self, key: &KeyEvent) -> bool {
match self.id {
ShellBindingId::AccountLoginSelect => {
key.code == KeyCode::Enter && key.modifiers.is_empty()
}
ShellBindingId::AccountLoginNext => {
matches!(key.code, KeyCode::Down | KeyCode::Tab) && key.modifiers.is_empty()
}
ShellBindingId::AccountLoginOpenBrowser => {
key.code == KeyCode::Char('o') && key.modifiers.is_empty()
}
ShellBindingId::AccountLoginCopyLink => {
key.code == KeyCode::Char('c') && key.modifiers.is_empty()
}
ShellBindingId::AccountLoginCancel => {
key.code == KeyCode::Esc && key.modifiers.is_empty()
}
ShellBindingId::TerminalNew => {
key.code == KeyCode::Char('n') && key.modifiers == KeyModifiers::CONTROL
}
@@ -259,6 +281,36 @@ pub fn route(focus: Focus, key: &KeyEvent) -> Option<ShellBindingId> {
/// Canonical shell bindings. Handlers and chrome read from here.
pub const SHELL_BINDINGS: &[ShellBinding] = &[
ShellBinding {
id: ShellBindingId::AccountLoginSelect,
catalog_chord: "Enter",
footer_chord: "Enter",
focus: FocusScope::AccountLogin,
},
ShellBinding {
id: ShellBindingId::AccountLoginNext,
catalog_chord: "Down / Tab",
footer_chord: "↓/Tab",
focus: FocusScope::AccountLogin,
},
ShellBinding {
id: ShellBindingId::AccountLoginOpenBrowser,
catalog_chord: "o",
footer_chord: "o",
focus: FocusScope::AccountLogin,
},
ShellBinding {
id: ShellBindingId::AccountLoginCopyLink,
catalog_chord: "c",
footer_chord: "c",
focus: FocusScope::AccountLogin,
},
ShellBinding {
id: ShellBindingId::AccountLoginCancel,
catalog_chord: "Esc",
footer_chord: "Esc",
focus: FocusScope::AccountLogin,
},
ShellBinding {
id: ShellBindingId::TerminalNew,
catalog_chord: "Ctrl+N",
+1
View File
@@ -337,6 +337,7 @@ pub(crate) enum OnboardingKeyRoute {
Quit,
/// Hand the key to the provider picker on the view stack.
ProviderPicker,
AccountLogin,
/// Take the advertised offline exit (#3927). Reachable from Provider
/// setup even while the provider picker owns the screen, so the choice is
/// never hidden behind a modal the user cannot satisfy.
+31
View File
@@ -2849,6 +2849,37 @@ async fn apply_command_result_inner(
AppAction::EditProjectHooks => {
edit_project_hooks_from_tui(terminal, app, config);
}
AppAction::OpenModelConnectionSetup => {
begin_first_connection_setup(app);
app.view_stack
.push(crate::tui::account_login::ConnectionSetupView::new(
app.ui_locale,
));
}
AppAction::StartAccountConnect => {
begin_first_connection_setup(app);
if app.view_stack.contains_kind(ModalKind::AccountLogin) {
app.view_stack.pop_through_kind(ModalKind::AccountLogin);
}
app.view_stack
.push(crate::tui::account_login::AccountLoginView::connect(
app.config_profile.clone(),
app.ui_locale,
));
}
AppAction::StartAccountLogin | AppAction::StartAccountLogout => {
if app.view_stack.top_kind() != Some(ModalKind::AccountLogin) {
if app.view_stack.contains_kind(ModalKind::AccountLogin) {
app.view_stack.pop_through_kind(ModalKind::AccountLogin);
}
app.view_stack
.push(crate::tui::account_login::AccountLoginView::new(
app.config_profile.clone(),
matches!(action, AppAction::StartAccountLogout),
app.ui_locale,
));
}
}
AppAction::StartXaiDeviceLogin => {
let _switched =
run_xai_device_login_from_tui(terminal, app, engine_handle, config).await?;
+4 -9
View File
@@ -2347,13 +2347,8 @@ pub(crate) async fn run_event_loop(
if app.onboarding == OnboardingState::Provider && app.view_stack.top_kind().is_none() {
let recover_configured_route = app.onboarding_recovers_configured_route();
open_onboarding_provider_picker(
app,
&*config,
&engine_handle,
recover_configured_route,
)
.await;
open_model_connection_setup(app, &*config, &engine_handle, recover_configured_route)
.await;
}
// Drain the version-check handle once; re-assign None so we
@@ -6349,7 +6344,7 @@ pub(crate) async fn run_event_loop(
// back to the list and only dismisses from the list, where
// `ProviderPickerDismissed` runs the same non-mutating
// onboarding back-transition the shell used to force.
OnboardingKeyRoute::ProviderPicker => {
OnboardingKeyRoute::ProviderPicker | OnboardingKeyRoute::AccountLogin => {
if key_shortcuts::is_paste_shortcut(&key)
&& paste_provider_picker_from_clipboard(app)
{
@@ -6431,7 +6426,7 @@ pub(crate) async fn run_event_loop(
OnboardingState::Provider => {
let recover_configured_route =
app.onboarding_recovers_configured_route();
open_onboarding_provider_picker(
open_model_connection_setup(
app,
config,
&engine_handle,
+164
View File
@@ -1927,6 +1927,170 @@ pub(crate) async fn handle_view_events(
ViewEvent::OpenTextPager { title, content } => {
open_text_pager(app, title, content);
}
ViewEvent::AccountModelSelected {
access,
api_base,
account_id,
expected_revision,
model,
} => {
if app.reject_setting_change_while_busy(MessageId::SettingSubjectProvider) {
continue;
}
begin_first_connection_setup(app);
let profile = app.config_profile.clone();
let config_for_install = config.clone();
let expected_session_id = config
.account_model_access
.read()
.as_ref()
.filter(|existing| existing.expires_at > chrono::Utc::now().timestamp())
.map(|existing| existing.session_id.clone());
let result = tokio::task::spawn_blocking(move || -> Result<()> {
anyhow::ensure!(
api_base
== codewhale_config::descriptors::defaults::DEFAULT_CODEWHALE_BASE_URL,
"Account models require the first-party Codewhale endpoint"
);
let client =
codewhale_account_cli::AccountClient::new(profile.as_deref(), None)?;
let status = client.status()?;
anyhow::ensure!(
status.api_base == codewhale_secrets::account::DEFAULT_ACCOUNT_API_BASE
&& status.revision == expected_revision
&& status.account.account_id.as_deref() == Some(account_id.as_str())
&& status.account.session_id.as_deref()
== Some(access.session_id.as_str()),
"The signed-in account changed. Reconnect account models"
);
codewhale_app_server::runtime_api::install_account_model_access(
&config_for_install,
profile.as_deref(),
codewhale_app_server::runtime_api::SetAccountModelAccessRequest {
api_base,
session_id: access.session_id,
expected_session_id,
key: access.credential.expose_secret().to_string(),
expires_at: access.expires_at,
},
)
.map_err(|error| anyhow::anyhow!(error.message))?;
Ok(())
})
.await
.map_err(|error| anyhow::anyhow!("{error}"))
.and_then(|result| result);
match result {
Ok(()) => {
let identity = config
.builtin_provider_identity(ProviderKind::Codewhale)
.map_err(anyhow::Error::msg)?;
let switched = switch_provider(
app,
engine_handle,
config,
identity,
Some(model.clone()),
)
.await;
if switched {
if app.onboarding == OnboardingState::Provider {
complete_provider_picker_onboarding(app, ProviderKind::Codewhale);
if app.onboarding_needs_api_key {
continue;
}
}
app.push_status_toast(
app.tr(MessageId::AccountModelsReady)
.replace("{model}", &model),
StatusToastLevel::Success,
Some(8_000),
);
}
}
Err(error) => app.push_status_toast(
app.tr(MessageId::AccountModelsConnectFailed)
.replace("{error}", &error.to_string()),
StatusToastLevel::Error,
Some(App::STICKY_ERROR_TTL_MS),
),
}
app.needs_redraw = true;
}
ViewEvent::AccountConnectionChoice { account } => {
if account.is_some() {
begin_first_connection_setup(app);
}
match account {
Some(true) => {
app.view_stack
.push(crate::tui::account_login::AccountLoginView::connect(
app.config_profile.clone(),
app.ui_locale,
))
}
Some(false) => open_provider_picker(app, config, engine_handle).await,
None if app.onboarding == OnboardingState::Provider => {
back_from_provider_onboarding(app)
}
None => {}
}
app.needs_redraw = true;
}
ViewEvent::AccountOperationCompleted {
signing_out,
outcome,
} => {
use crate::tui::account_login::AccountOperationOutcome;
let needs_model = !signing_out
&& outcome == AccountOperationOutcome::Complete
&& app.onboarding_needs_api_key;
let id = match (signing_out, outcome) {
(true, AccountOperationOutcome::Complete) => MessageId::AccountLogoutSuccess,
(true, AccountOperationOutcome::LocalSignOut) => MessageId::AccountLogoutLocal,
(true, AccountOperationOutcome::Cancelled) => MessageId::AccountLogoutCancelled,
(true, _) => MessageId::AccountLogoutFailed,
(false, AccountOperationOutcome::Complete) if needs_model => {
MessageId::AccountLoginNeedsModel
}
(false, AccountOperationOutcome::Complete) => MessageId::AccountLoginSuccess,
(false, AccountOperationOutcome::Cancelled) => MessageId::AccountLoginCancelled,
(false, AccountOperationOutcome::ModelCancelled) => {
MessageId::AccountModelsCancelled
}
(false, AccountOperationOutcome::Expired) => MessageId::AccountLoginExpired,
(false, _) => MessageId::AccountLoginFailed,
};
let level = match outcome {
AccountOperationOutcome::Failed | AccountOperationOutcome::Expired => {
StatusToastLevel::Error
}
AccountOperationOutcome::Complete => StatusToastLevel::Success,
_ => StatusToastLevel::Info,
};
if !signing_out
&& matches!(
outcome,
AccountOperationOutcome::Cancelled
| AccountOperationOutcome::ModelCancelled
| AccountOperationOutcome::Expired
| AccountOperationOutcome::Failed
)
&& app.onboarding == OnboardingState::Provider
{
app.onboarding_missing_key_recovery = true;
back_from_provider_onboarding(app);
}
let message = app
.tr(id)
.replace("{command}", if signing_out { "/logout" } else { "/login" });
app.push_status_toast(message, level, Some(8_000));
if needs_model {
begin_first_connection_setup(app);
open_model_connection_setup(app, config, engine_handle, false).await;
}
app.needs_redraw = true;
}
ViewEvent::CopyToClipboard { text, label } => {
if text.is_empty() {
app.status_message = Some(format!("{label} is empty"));
+17
View File
@@ -232,6 +232,23 @@ pub(crate) async fn cycle_permission_posture(
/// Either way the picker opens on the navigable list (#4763): onboarding never
/// drops a user straight into a key/OAuth prompt for a route they were not
/// shown.
pub(crate) async fn open_model_connection_setup(
app: &mut App,
config: &Config,
engine_handle: &EngineHandle,
recover_configured_route: bool,
) {
if recover_configured_route {
open_onboarding_provider_picker(app, config, engine_handle, true).await;
} else if app.view_stack.is_empty() {
app.view_stack
.push(crate::tui::account_login::ConnectionSetupView::new(
app.ui_locale,
));
app.needs_redraw = true;
}
}
pub(crate) async fn open_onboarding_provider_picker(
app: &mut App,
config: &Config,
+14
View File
@@ -68,6 +68,9 @@ pub(crate) fn onboarding_key_route(
if onboarding == OnboardingState::None {
return OnboardingKeyRoute::Legacy;
}
if top_kind == Some(ModalKind::AccountLogin) {
return OnboardingKeyRoute::AccountLogin;
}
if key.code == KeyCode::Char('c') && key.modifiers.contains(KeyModifiers::CONTROL) {
return OnboardingKeyRoute::Quit;
}
@@ -102,6 +105,17 @@ pub(crate) fn back_from_provider_onboarding(app: &mut App) {
app.status_message = None;
}
pub(crate) fn begin_first_connection_setup(app: &mut App) {
if app.onboarding == OnboardingState::None
&& app.onboarding_needs_api_key
&& !app.startup_route_configured
{
app.onboarding = OnboardingState::Provider;
app.onboarding_missing_key_recovery = true;
app.onboarding_provider = app.api_provider;
}
}
pub(crate) fn complete_provider_picker_onboarding(app: &mut App, provider: ProviderKind) {
// Ordinary `/provider` changes stay session-local until the operator
// answers the route-save prompt. Onboarding is different: choosing a
+9
View File
@@ -1086,6 +1086,15 @@ fn empty_bracketed_paste_attaches_the_clipboard_image() {
/// One representative terminal encoding per shell binding.
fn shell_binding_probe(id: ShellBindingId) -> KeyEvent {
match id {
ShellBindingId::AccountLoginSelect => KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE),
ShellBindingId::AccountLoginNext => KeyEvent::new(KeyCode::Down, KeyModifiers::NONE),
ShellBindingId::AccountLoginOpenBrowser => {
KeyEvent::new(KeyCode::Char('o'), KeyModifiers::NONE)
}
ShellBindingId::AccountLoginCopyLink => {
KeyEvent::new(KeyCode::Char('c'), KeyModifiers::NONE)
}
ShellBindingId::AccountLoginCancel => KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE),
ShellBindingId::TerminalNew => KeyEvent::new(KeyCode::Char('n'), KeyModifiers::CONTROL),
ShellBindingId::TerminalNext => KeyEvent::new(KeyCode::Down, KeyModifiers::ALT),
ShellBindingId::TerminalPrevious => KeyEvent::new(KeyCode::Up, KeyModifiers::ALT),
+15
View File
@@ -62,6 +62,7 @@ pub enum ModalKind {
Config,
ModelPicker,
ProviderPicker,
AccountLogin,
ModePicker,
FleetRoster,
FleetSetup,
@@ -710,6 +711,20 @@ pub enum ExtensionMenuVerb {
#[derive(Debug, Clone)]
pub enum ViewEvent {
AccountModelSelected {
access: codewhale_runtime::config::AccountModelAccess,
api_base: String,
account_id: String,
expected_revision: String,
model: String,
},
AccountConnectionChoice {
account: Option<bool>,
},
AccountOperationCompleted {
signing_out: bool,
outcome: crate::tui::account_login::AccountOperationOutcome,
},
CommandPaletteSelected {
action: CommandPaletteAction,
},
+13 -8
View File
@@ -428,12 +428,14 @@ equivalent is unset.
### Account provider keys
New Codewhale accounts are not open, and this guide does not recommend storing
provider keys in an account. Keep keys on your machine with `/provider` or
`codewhale auth set --provider <provider>`; local keys and local models never
need an account.
`/connect` offers Continue with Codewhale or Connect on this computer. The
account path signs in and lists usable model connections reported by the
account service. When none are ready, it offers retry or on-computer setup.
Sign-in alone does not supply model access or change the selected model.
Local provider keys and local models never need an account; use `/provider`
or `codewhale auth set --provider <provider>` to manage them.
If you already have an account, these commands manage keys saved there:
For an account that supports saved provider keys, these commands manage them:
```bash
codewhale account keys list # configured status, never secret values
@@ -443,9 +445,12 @@ codewhale account keys remove deepseek # remove the account's saved key
Login never uploads local keys, and removing an account key does not change
local copies or revoke the key at the provider.
The account session is separate from provider keys. `codewhale account status`
inspects it; `codewhale account logout` removes it for the selected `--profile`
without removing your provider keys. Account sessions use the private `0600`
The account session is separate from provider keys. `/login` signs in inside
the TUI; `codewhale login` does so from the shell. `codewhale account status`
inspects it. `/logout`, `codewhale logout` and `codewhale account logout` remove
only the account session for the selected `--profile`. The explicit
`codewhale logout --all` action also clears saved model credentials.
Account sessions use the private `0600`
Codewhale secrets file, scoped to the profile and account API origin; they do
not use the OS keychain. The older `codewhale cloud ...` spelling remains an
alias for account commands.
+14 -2
View File
@@ -351,8 +351,20 @@ direct. Multi-step delegation uses a compact Workflow plan with dependencies,
bounded scopes, and completion evidence passed between steps. Fleet configures
and manages those same sub-agents and their roles. One bounded, independent
task can use a direct agent; continued work reuses it through `followup`.
`codewhale dispatch "<task>"` (running work on a Codewhale cloud computer) is
not available: cloud computers and accounts are paused for new users.
`codewhale dispatch "<task>"` runs work on a Codewhale cloud computer when
your account has access. Cloud availability and admission come from the account
service.
Local work needs no Codewhale account. `/login` opens account sign-in inside
the TUI, with a browser link and code, Copy, and Cancel. `/login status` shows
account status; `/connect` connects a model provider or local model. Signing in
preserves your selected model and unsent message. An account connection is
separate from usable model access.
`/logout`, `codewhale logout` and `codewhale account logout` sign out of
Codewhale while keeping local model connections and conversations. To remove
all saved provider credentials as well, use `codewhale logout --all`; that
operation asks for confirmation (`--all --yes` confirms non-interactively).
For trusted workspaces where you intentionally want actions to proceed without
approval prompts, select the Full Access permission posture with `Shift+Tab`.
+17 -1
View File
@@ -277,6 +277,22 @@ Then run `/plugin enable example` again. Trust and enablement are separate:
the bundle remains inactive until reviewed again.
- `/plugin reload` rebuilds the current workspace registry when files have
changed on disk.
- `/plugin status [name]` shows the existing inventory or one bundle's state
and diagnostics. `/plugin restage <name>` restores a missing runtime snapshot
only when its source still matches the enabled review and current policy.
It preserves the receipt and generation; an existing changed snapshot is
refused and needs review.
Normal runtime loading also attempts that verified recovery for missing
snapshots. A failed automatic attempt keeps its reason and requires an explicit
retry for the same review. Recovery does not authorize changed source bytes.
From a shell, `codew plugins status [name] [--json]` inspects the same captured
plugin roots without carry-forward, cleanup or recovery writes. A built-in
whose review needs upgrade carry-forward can therefore appear unreviewed
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.
(`/plugin install`, `update`, and `uninstall` place, replace, and remove the
bits themselves and always drop into this same review — see
@@ -286,7 +302,7 @@ same next step without installing anything. Nothing is written into the
model's request to advertise plugins; the full offering policy is in
[PLUGINS.md](PLUGINS.md#how-codewhale-offers-plugins).)
Trust, enable, disable, revoke, and reload rebuild the current workspace's
Trust, enable, restage, disable, revoke, and reload rebuild the current workspace's
Skills, MCP, Commands, Agent profiles, and Hooks immediately. Each persisted
transition advances a per-bundle generation under a stable cross-process lock.
A generation change cancels in-flight MCP work, removes cached catalog
+21
View File
@@ -443,6 +443,27 @@ the Runtime token (or an explicitly insecure loopback server); the public
bootstrap response remains usable but reports `signed_out`. Signed-out local
Work remains supported and never allocates cloud compute implicitly.
The native host account routes below require the configured master token in
the Authorization header. Browser/mobile device sessions and insecure mode do
not authorize them. Responses use `Cache-Control: private, no-store`. These
routes are excluded from the desktop renderer's Engine request allowlist.
| Route | Native host operation |
| --- | --- |
| `GET /v1/account/status` | Token-free account state, exact API origin/profile, opaque revision and whether legacy import is permitted. |
| `POST /v1/account/claim` | Renew a login claim only for the supplied `expectedRevision`, preserving existing account identity. |
| `DELETE /v1/account/claim` | Cancel exactly the supplied `expectedRevision`; a newer claim is preserved. |
| `POST /v1/account/import` | Adopt the host's completed browser authorization with `expectedRevision` and `auth`; legacy import requires pristine storage, while explicit sign-in supplies `replaceExisting`. |
| `POST /v1/account/lease` | Return a short-lived access credential to native main memory; Rust owns refresh and durable custody. Optional `rejectedAccessToken` requests renewal only if that credential is still current. No refresh token is returned. |
| `DELETE /v1/account/session` | Sign out exactly the supplied `expectedRevision`, preserving provider credentials and local work. A changed revision returns conflict rather than signing out another account. |
The lease is sensitive native-host transport, separate from the token-free
runtime receipt above. Native clients retain it only in main memory and fence
responses by Engine connection and account/session identity. Shared account
records use revision nonces and signed-out tombstones so cancelled or stale
login completion cannot restore an account after logout. Older authenticated
records remain readable; older binaries fail closed on signed-out tombstones.
The `--stdio` control transport uses newline-delimited RPC. Request IDs are
strings or signed 64-bit integers. Output omits `jsonrpc`; input also accepts
unknown input fields, as the pinned Codex decoder does. Complete `initialize`
+1 -1
View File
@@ -744,7 +744,7 @@ owner = "crates/tui/src/commands/groups/core/home.rs"
[[feature]]
id = "account-login"
name = "Account login"
summary = "/login signs in to an optional Codewhale account; plugin providers connect with /connect."
summary = "Cancellable account sign-in inside the TUI; account sign-out preserves local model connections and work."
status = "stable"
surfaces = { tui = "stable" }
owner = "crates/tui/src/commands/groups/config/config.rs"
+5 -1
View File
@@ -226,7 +226,11 @@ Plan 模式是在陌生仓库里开始的最安全位置。它用于检查和决
Work 模式是大多数贡献工作的默认模式。它允许 Codewhale 读文件、跑检查、编辑文件,同时把有风险的动作留在审批门禁之后。
Operate 保持直接的工具面及其审批、沙箱、shell、ask 规则和仓库保护。小型或紧密耦合的工作直接处理。多步骤委派使用简洁的 Workflow 计划,明确依赖关系、工作范围,并在步骤间传递完成证据。Fleet 配置和管理的就是这些子智能体及其角色。一个范围明确、可独立完成的任务可以直接交给一个智能体;后续工作通过 `followup` 继续使用同一个智能体。`codewhale dispatch "<task>"`(在 Codewhale 云端计算机上运行工作)目前不可用:云端计算机和账户暂停向新用户开放。
Operate 保持直接的工具面及其审批、沙箱、shell、ask 规则和仓库保护。小型或紧密耦合的工作直接处理。多步骤委派使用简洁的 Workflow 计划,明确依赖关系、工作范围,并在步骤间传递完成证据。Fleet 配置和管理的就是这些子智能体及其角色。一个范围明确、可独立完成的任务可以直接交给一个智能体;后续工作通过 `followup` 继续使用同一个智能体。`codewhale dispatch "<task>"` 在账户拥有相应访问权限时使用 Codewhale 云端计算机运行工作;可用性和准入由账户服务决定。
本地工作无需 Codewhale 账户。`/login` 在 TUI 内开始账户登录,显示浏览器链接和验证码,并提供复制和取消操作。`/login status` 查看账户状态;`/connect` 连接模型提供商或本地模型。登录会保留已选择的模型和未发送的消息;账户已登录与模型可用是不同的状态。
`/logout`、`codewhale logout` 和 `codewhale account logout` 仅退出 Codewhale 账户,保留本地模型连接和对话。如需同时移除所有已保存的提供商凭据,请使用 `codewhale logout --all` 并确认;非交互使用可通过 `--all --yes` 确认。
对于你信任的工作区,如果你确实希望动作不经审批提示就继续,可以用 `Shift+Tab` 选择 Full Access(完全访问)权限级别。不要在你不信任的仓库里使用 Full Access。
+16 -16
View File
@@ -16,8 +16,8 @@
"sources": [
{
"path": "docs/GUIDE.md",
"sha256": "be6159665a61feef34f02166a1411d0e720ed894c16522e067afdb9b123f1d4a",
"content": "# Codewhale User Guide\n\nThis guide is for your first hour with Codewhale. It explains the main\nworkflow, the important safety controls, and where to go next when you need a\ncomplete reference.\n\nCodewhale has deeper reference documents for installation, configuration,\nproviders, modes, keybindings, tools, and operations. Use this page as a guided\nwalkthrough, then follow the \"Next\" links when you need every option.\n\n## 1. Welcome to Codewhale\n\nCodewhale is a terminal coding agent. You run it from a workspace, give it a\ntask, and it can use structured tools to inspect files, run commands, edit\ncode, and report back with evidence.\n\nThe important difference from a normal chat model is that Codewhale is built\naround a harness:\n\n- It keeps the active workspace and session visible.\n- It routes each turn through explicit modes and approval rules.\n- It shows tool calls in the transcript instead of hiding the work.\n- It can preserve sessions, fork conversations, and continue later.\n- It can run sub-agents for focused background work.\n\nYou can use Codewhale for small questions:\n\n```text\nExplain the authentication flow in this repository.\n```\n\nYou can also use it for multi-step work:\n\n```text\nFind the failing validation path, propose a fix, and wait for my approval\nbefore editing files.\n```\n\nFor a new repository, start conservatively. Ask Codewhale to explore and plan\nbefore asking it to change files. That gives you a reviewable path and makes it\neasier to catch wrong assumptions early.\n\nNext: [ARCHITECTURE.md](ARCHITECTURE.md) explains the internal harness and\nruntime model.\n\n## 2. First Launch\n\nFor a new macOS or Linux installation, use the official GitHub release.\nThe installer verifies the release checksums and provides the same runtime\nunder the `codewhale` and `codew` command names:\n\n```bash\ncurl -fsSL https://codewhale.net/install.sh | sh\n```\n\nWindows users should choose the matching installer or archive from\n[GitHub Releases](https://github.com/codewhale-hq/CodeWhale/releases/latest).\nFor an existing direct install, use `codewhale update --check`, then\n`codewhale update`. npm and Cargo remain secondary packaging routes; Cargo\nalso supports source builds where a compatible prebuilt is unavailable.\nFor occupied directories, package-managed installs, and PATH setup, follow\n[the installation and migration guide](INSTALL.md#recommended-official-github-releases).\nAndroid/Termux uses its own [preview archive or source-build path](INSTALL.md#android--termux-arm64).\n\nDocker is also available when you want an isolated runtime. The published\nimage is currently 0.10.0; to test current `main`, [build it from source](DOCKER.md#building-locally).\n\n```bash\ndocker volume create codewhale-home\ndocker run --rm -it \\\n -e DEEPSEEK_API_KEY=\"$DEEPSEEK_API_KEY\" \\\n -v codewhale-home:/home/codewhale/.codewhale \\\n -v \"$PWD:/workspace\" \\\n -w /workspace \\\n ghcr.io/hmbown/codewhale:latest\n```\n\nOnce the install directory is on PATH, launch Codewhale from the repository or\ndirectory you want it to work in:\n\n```bash\ncodewhale\n```\n\nFor the default GitHub installer destination, you can use\n`\"$HOME/.local/bin/codewhale\"` until that directory is on PATH.\n\nOn first launch, Codewhale opens the composer, even with no key or route.\nWith no key or route, workspace trust is asked once a provider is connected;\notherwise only when the folder requires a decision. A provider is asked for\nwhen you send your first message: the message stays in the box while you sign\nin or use your own key, and a local model is offered only when a local Ollama\nserver answers. The provider step also has an explicit offline route.\n\nEverything optional stays available after that. Use `/setup` for the\nprogressive setup and repair guide, `/settings` for the full typed editor, and\n`/constitution` when you want your own constitution on top of the bundled one.\nThe localized telemetry choice appears only after the workspace is ready and\ndoes not block the composer.\n\nDeepSeek is the default provider. If you want to configure its key before or\nafter the first launch, the most direct setup path is:\n\n```bash\ncodewhale auth set --provider deepseek\n```\n\nYou can also provide a key through the environment:\n\n```bash\nexport DEEPSEEK_API_KEY=\"your-key\"\ncodewhale\n```\n\nNew Codewhale config is stored under `~/.codewhale/config.toml`. Legacy\n`~/.deepseek/config.toml` files are still supported for users migrating from\nthe old name.\n\nUse `/constitution` to review or change standing guidance. After setup, run a\ndoctor check:\n\n```bash\ncodewhale doctor\n```\n\nUse the JSON form when you need a machine-readable report for an issue:\n\n```bash\ncodewhale doctor --json\n```\n\nBoth forms are offline by default. They report structural configuration and\nliteral unknown/not-probed credential states without loading workspace `.env`\ncredentials, openiLine truncated
"sha256": "7e2ac75c89723e95ef6c2d4ebd00368e1c82a78193a3258ffa969ea8214cd5f6",
"content": "# Codewhale User Guide\n\nThis guide is for your first hour with Codewhale. It explains the main\nworkflow, the important safety controls, and where to go next when you need a\ncomplete reference.\n\nCodewhale has deeper reference documents for installation, configuration,\nproviders, modes, keybindings, tools, and operations. Use this page as a guided\nwalkthrough, then follow the \"Next\" links when you need every option.\n\n## 1. Welcome to Codewhale\n\nCodewhale is a terminal coding agent. You run it from a workspace, give it a\ntask, and it can use structured tools to inspect files, run commands, edit\ncode, and report back with evidence.\n\nThe important difference from a normal chat model is that Codewhale is built\naround a harness:\n\n- It keeps the active workspace and session visible.\n- It routes each turn through explicit modes and approval rules.\n- It shows tool calls in the transcript instead of hiding the work.\n- It can preserve sessions, fork conversations, and continue later.\n- It can run sub-agents for focused background work.\n\nYou can use Codewhale for small questions:\n\n```text\nExplain the authentication flow in this repository.\n```\n\nYou can also use it for multi-step work:\n\n```text\nFind the failing validation path, propose a fix, and wait for my approval\nbefore editing files.\n```\n\nFor a new repository, start conservatively. Ask Codewhale to explore and plan\nbefore asking it to change files. That gives you a reviewable path and makes it\neasier to catch wrong assumptions early.\n\nNext: [ARCHITECTURE.md](ARCHITECTURE.md) explains the internal harness and\nruntime model.\n\n## 2. First Launch\n\nFor a new macOS or Linux installation, use the official GitHub release.\nThe installer verifies the release checksums and provides the same runtime\nunder the `codewhale` and `codew` command names:\n\n```bash\ncurl -fsSL https://codewhale.net/install.sh | sh\n```\n\nWindows users should choose the matching installer or archive from\n[GitHub Releases](https://github.com/codewhale-hq/CodeWhale/releases/latest).\nFor an existing direct install, use `codewhale update --check`, then\n`codewhale update`. npm and Cargo remain secondary packaging routes; Cargo\nalso supports source builds where a compatible prebuilt is unavailable.\nFor occupied directories, package-managed installs, and PATH setup, follow\n[the installation and migration guide](INSTALL.md#recommended-official-github-releases).\nAndroid/Termux uses its own [preview archive or source-build path](INSTALL.md#android--termux-arm64).\n\nDocker is also available when you want an isolated runtime. The published\nimage is currently 0.10.0; to test current `main`, [build it from source](DOCKER.md#building-locally).\n\n```bash\ndocker volume create codewhale-home\ndocker run --rm -it \\\n -e DEEPSEEK_API_KEY=\"$DEEPSEEK_API_KEY\" \\\n -v codewhale-home:/home/codewhale/.codewhale \\\n -v \"$PWD:/workspace\" \\\n -w /workspace \\\n ghcr.io/hmbown/codewhale:latest\n```\n\nOnce the install directory is on PATH, launch Codewhale from the repository or\ndirectory you want it to work in:\n\n```bash\ncodewhale\n```\n\nFor the default GitHub installer destination, you can use\n`\"$HOME/.local/bin/codewhale\"` until that directory is on PATH.\n\nOn first launch, Codewhale opens the composer, even with no key or route.\nWith no key or route, workspace trust is asked once a provider is connected;\notherwise only when the folder requires a decision. A provider is asked for\nwhen you send your first message: the message stays in the box while you sign\nin or use your own key, and a local model is offered only when a local Ollama\nserver answers. The provider step also has an explicit offline route.\n\nEverything optional stays available after that. Use `/setup` for the\nprogressive setup and repair guide, `/settings` for the full typed editor, and\n`/constitution` when you want your own constitution on top of the bundled one.\nThe localized telemetry choice appears only after the workspace is ready and\ndoes not block the composer.\n\nDeepSeek is the default provider. If you want to configure its key before or\nafter the first launch, the most direct setup path is:\n\n```bash\ncodewhale auth set --provider deepseek\n```\n\nYou can also provide a key through the environment:\n\n```bash\nexport DEEPSEEK_API_KEY=\"your-key\"\ncodewhale\n```\n\nNew Codewhale config is stored under `~/.codewhale/config.toml`. Legacy\n`~/.deepseek/config.toml` files are still supported for users migrating from\nthe old name.\n\nUse `/constitution` to review or change standing guidance. After setup, run a\ndoctor check:\n\n```bash\ncodewhale doctor\n```\n\nUse the JSON form when you need a machine-readable report for an issue:\n\n```bash\ncodewhale doctor --json\n```\n\nBoth forms are offline by default. They report structural configuration and\nliteral unknown/not-probed credential states without loading workspace `.env`\ncredentials, openiLine truncated
},
{
"path": "docs/KEYBINDINGS.md",
@@ -32,8 +32,8 @@
"sources": [
{
"path": "docs/CONFIGURATION.md",
"sha256": "61fac67413aac35a4a67f410e174122a4ee09befbe2f2248fd3f9a6e4f08d99a",
"content": "# Configuration\n\ncodewhale reads configuration from a TOML file plus environment variables.\nAt process startup it may also load literal built-in-provider credentials from\na workspace-local `.env` file. Use the tracked `.env.example` as the template;\ncopy it to `.env`, then add only credential values.\n\nA workspace is not configuration authority. Codewhale therefore ignores\nconfig/profile/home paths, provider/model/base-URL routing, MCP/plugin state,\napproval/sandbox/shell posture, executable paths, runtime settings, and every\nother non-credential `.env` entry. Variable expansion is rejected so a\nrepository cannot substitute an ambient secret into a credential value. Use\n`config.toml`, CLI flags, or values exported by the launching shell for those\nexplicit control-plane settings. `.env` is read from a stable regular-file\nhandle, is capped at 1 MiB, and symbolic links, reparse points, and multiply\nlinked files are rejected.\n\n## Reading and checking configuration from the CLI\n\n`codewhale config get <key>` reads scalar keys, whole tables such as `tools`,\nand nested paths such as `tools.user_input_timeout_seconds`. Displayed tables\nand nested values apply the same recursive credential redaction as `config dump`.\n\n`config set` supports its named scalar keys and the provider, route, and\nnotification commands. It refuses a key that nothing reads and suggests the\nnearest real key (`config set calm_mod on` names `calm_mode`). A settings.toml\nkey such as `calm_mode` or `tool_collapse` is validated and written to the\nuser-global settings.toml, not config.toml; `config get` reads it back from\nthere even when an old config.toml copy (which nothing reads) is still present,\nand names that copy so you can `config unset` it. Settings keys have no project\nscope, so `--project` refuses them. A config.toml key is checked against the\ntype its reader expects and stored as a TOML boolean or number where the\nreader needs one (`yolo = true`, `max_subagents = 4`); `reasoning_effort`\naccepts the same aliases as `/effort` (#6563). Other dotted writes fail before\nmodifying the file and name the TOML table to edit. For example, set a tools\ntimeout in the file as:\n\n```toml\n[tools]\nuser_input_timeout_seconds = 0\n```\n\n`codewhale config doctor` checks credential presence and endpoint shape, and\nwarns about each config.toml root key that nothing reads: a settings.toml key\nleft in config.toml is named as misplaced, and any other unread key gets a\ndid-you-mean. Keys read by any runtime reader are not reported. The warnings do\nnot fail the check, and a clean result does not validate every runtime setting's\nvalue (#6083, #6563).\n\n## Usage and execution statistics\n\n`codewhale stats` is an alias of `codewhale metrics`; both use the same local,\nread-only backend. Use `--json` for the structured `stats` report. For example:\n\n```sh\ncodewhale stats --days 7 --models 10 --tools 20 --roles 10\ncodewhale metrics --project /path/to/workspace --json\n```\n\n`--days N` accepts a positive number of days and conflicts with `--since`.\n`--since 24h` and the existing duration syntax remain available. `--models N`,\n`--tools N` and `--roles N` select the top positive N rows by known tokens,\nidentified tool executions and recorded Fleet tasks respectively. These limits\nonly shorten the display; they do not remove stored receipts.\n\nThe report separates saved-document and Runtime views, frozen native-source\nmoney and token subtotals by actual model, retained tool-result history\nestimates and outcomes, completed compaction observations, and recorded Fleet\nroles. USD and CNY stay separate. A cumulative correction replaces its proven\npredecessor; copied Session/Runtime receipts and inherited Runtime fork rows\nare not added as new provider work. Conflicting source versions are shown\nseparately and excluded from the subtotal. Saved legacy display totals remain\nvisible as document projections without being charged again globally.\n\nCoverage is explicit. A missing quote, usage receipt, tool outcome, output\nshape or recovery link is unknown, not zero. Empty scalar usage from older\ntransport records cannot prove a reported zero. Token counts describe known\nreported data; tool history tokens are estimates of retained text, not bytes\nproven to have been replayed on a provider request. Latest prepared-request\ncomponents and source-linked compaction/role economics are unavailable where\nthey were not captured. The report does not infer provider invoices or prove\ncomplete historical coverage.\n\n`--project WORKSPACE` filters current saved/thread/Fleet views. Original source\nworkspace is not yet captured, so native money and model subtotals are\nunavailable for that filter; mutable current metadata is not used to assign a\ncharge. Older receipt projections without reliable workspace scope are also\nunavailable under this filter. Without it, those legacy receipt views remain\nseparate fromLine truncated
"sha256": "618dd41d6edfb50b884d8634663b9d7ab75b1526a3f3e9121903631804e75e43",
"content": "# Configuration\n\ncodewhale reads configuration from a TOML file plus environment variables.\nAt process startup it may also load literal built-in-provider credentials from\na workspace-local `.env` file. Use the tracked `.env.example` as the template;\ncopy it to `.env`, then add only credential values.\n\nA workspace is not configuration authority. Codewhale therefore ignores\nconfig/profile/home paths, provider/model/base-URL routing, MCP/plugin state,\napproval/sandbox/shell posture, executable paths, runtime settings, and every\nother non-credential `.env` entry. Variable expansion is rejected so a\nrepository cannot substitute an ambient secret into a credential value. Use\n`config.toml`, CLI flags, or values exported by the launching shell for those\nexplicit control-plane settings. `.env` is read from a stable regular-file\nhandle, is capped at 1 MiB, and symbolic links, reparse points, and multiply\nlinked files are rejected.\n\n## Reading and checking configuration from the CLI\n\n`codewhale config get <key>` reads scalar keys, whole tables such as `tools`,\nand nested paths such as `tools.user_input_timeout_seconds`. Displayed tables\nand nested values apply the same recursive credential redaction as `config dump`.\n\n`config set` supports its named scalar keys and the provider, route, and\nnotification commands. It refuses a key that nothing reads and suggests the\nnearest real key (`config set calm_mod on` names `calm_mode`). A settings.toml\nkey such as `calm_mode` or `tool_collapse` is validated and written to the\nuser-global settings.toml, not config.toml; `config get` reads it back from\nthere even when an old config.toml copy (which nothing reads) is still present,\nand names that copy so you can `config unset` it. Settings keys have no project\nscope, so `--project` refuses them. A config.toml key is checked against the\ntype its reader expects and stored as a TOML boolean or number where the\nreader needs one (`yolo = true`, `max_subagents = 4`); `reasoning_effort`\naccepts the same aliases as `/effort` (#6563). Other dotted writes fail before\nmodifying the file and name the TOML table to edit. For example, set a tools\ntimeout in the file as:\n\n```toml\n[tools]\nuser_input_timeout_seconds = 0\n```\n\n`codewhale config doctor` checks credential presence and endpoint shape, and\nwarns about each config.toml root key that nothing reads: a settings.toml key\nleft in config.toml is named as misplaced, and any other unread key gets a\ndid-you-mean. Keys read by any runtime reader are not reported. The warnings do\nnot fail the check, and a clean result does not validate every runtime setting's\nvalue (#6083, #6563).\n\n## Usage and execution statistics\n\n`codewhale stats` is an alias of `codewhale metrics`; both use the same local,\nread-only backend. Use `--json` for the structured `stats` report. For example:\n\n```sh\ncodewhale stats --days 7 --models 10 --tools 20 --roles 10\ncodewhale metrics --project /path/to/workspace --json\n```\n\n`--days N` accepts a positive number of days and conflicts with `--since`.\n`--since 24h` and the existing duration syntax remain available. `--models N`,\n`--tools N` and `--roles N` select the top positive N rows by known tokens,\nidentified tool executions and recorded Fleet tasks respectively. These limits\nonly shorten the display; they do not remove stored receipts.\n\nThe report separates saved-document and Runtime views, frozen native-source\nmoney and token subtotals by actual model, retained tool-result history\nestimates and outcomes, completed compaction observations, and recorded Fleet\nroles. USD and CNY stay separate. A cumulative correction replaces its proven\npredecessor; copied Session/Runtime receipts and inherited Runtime fork rows\nare not added as new provider work. Conflicting source versions are shown\nseparately and excluded from the subtotal. Saved legacy display totals remain\nvisible as document projections without being charged again globally.\n\nCoverage is explicit. A missing quote, usage receipt, tool outcome, output\nshape or recovery link is unknown, not zero. Empty scalar usage from older\ntransport records cannot prove a reported zero. Token counts describe known\nreported data; tool history tokens are estimates of retained text, not bytes\nproven to have been replayed on a provider request. Latest prepared-request\ncomponents and source-linked compaction/role economics are unavailable where\nthey were not captured. The report does not infer provider invoices or prove\ncomplete historical coverage.\n\n`--project WORKSPACE` filters current saved/thread/Fleet views. Original source\nworkspace is not yet captured, so native money and model subtotals are\nunavailable for that filter; mutable current metadata is not used to assign a\ncharge. Older receipt projections without reliable workspace scope are also\nunavailable under this filter. Without it, those legacy receipt views remain\nseparate fromLine truncated
},
{
"path": "docs/CODEWHALE_AGENT.md",
@@ -64,8 +64,8 @@
"sources": [
{
"path": "docs/CONFIGURATION.md",
"sha256": "61fac67413aac35a4a67f410e174122a4ee09befbe2f2248fd3f9a6e4f08d99a",
"content": "# Configuration\n\ncodewhale reads configuration from a TOML file plus environment variables.\nAt process startup it may also load literal built-in-provider credentials from\na workspace-local `.env` file. Use the tracked `.env.example` as the template;\ncopy it to `.env`, then add only credential values.\n\nA workspace is not configuration authority. Codewhale therefore ignores\nconfig/profile/home paths, provider/model/base-URL routing, MCP/plugin state,\napproval/sandbox/shell posture, executable paths, runtime settings, and every\nother non-credential `.env` entry. Variable expansion is rejected so a\nrepository cannot substitute an ambient secret into a credential value. Use\n`config.toml`, CLI flags, or values exported by the launching shell for those\nexplicit control-plane settings. `.env` is read from a stable regular-file\nhandle, is capped at 1 MiB, and symbolic links, reparse points, and multiply\nlinked files are rejected.\n\n## Reading and checking configuration from the CLI\n\n`codewhale config get <key>` reads scalar keys, whole tables such as `tools`,\nand nested paths such as `tools.user_input_timeout_seconds`. Displayed tables\nand nested values apply the same recursive credential redaction as `config dump`.\n\n`config set` supports its named scalar keys and the provider, route, and\nnotification commands. It refuses a key that nothing reads and suggests the\nnearest real key (`config set calm_mod on` names `calm_mode`). A settings.toml\nkey such as `calm_mode` or `tool_collapse` is validated and written to the\nuser-global settings.toml, not config.toml; `config get` reads it back from\nthere even when an old config.toml copy (which nothing reads) is still present,\nand names that copy so you can `config unset` it. Settings keys have no project\nscope, so `--project` refuses them. A config.toml key is checked against the\ntype its reader expects and stored as a TOML boolean or number where the\nreader needs one (`yolo = true`, `max_subagents = 4`); `reasoning_effort`\naccepts the same aliases as `/effort` (#6563). Other dotted writes fail before\nmodifying the file and name the TOML table to edit. For example, set a tools\ntimeout in the file as:\n\n```toml\n[tools]\nuser_input_timeout_seconds = 0\n```\n\n`codewhale config doctor` checks credential presence and endpoint shape, and\nwarns about each config.toml root key that nothing reads: a settings.toml key\nleft in config.toml is named as misplaced, and any other unread key gets a\ndid-you-mean. Keys read by any runtime reader are not reported. The warnings do\nnot fail the check, and a clean result does not validate every runtime setting's\nvalue (#6083, #6563).\n\n## Usage and execution statistics\n\n`codewhale stats` is an alias of `codewhale metrics`; both use the same local,\nread-only backend. Use `--json` for the structured `stats` report. For example:\n\n```sh\ncodewhale stats --days 7 --models 10 --tools 20 --roles 10\ncodewhale metrics --project /path/to/workspace --json\n```\n\n`--days N` accepts a positive number of days and conflicts with `--since`.\n`--since 24h` and the existing duration syntax remain available. `--models N`,\n`--tools N` and `--roles N` select the top positive N rows by known tokens,\nidentified tool executions and recorded Fleet tasks respectively. These limits\nonly shorten the display; they do not remove stored receipts.\n\nThe report separates saved-document and Runtime views, frozen native-source\nmoney and token subtotals by actual model, retained tool-result history\nestimates and outcomes, completed compaction observations, and recorded Fleet\nroles. USD and CNY stay separate. A cumulative correction replaces its proven\npredecessor; copied Session/Runtime receipts and inherited Runtime fork rows\nare not added as new provider work. Conflicting source versions are shown\nseparately and excluded from the subtotal. Saved legacy display totals remain\nvisible as document projections without being charged again globally.\n\nCoverage is explicit. A missing quote, usage receipt, tool outcome, output\nshape or recovery link is unknown, not zero. Empty scalar usage from older\ntransport records cannot prove a reported zero. Token counts describe known\nreported data; tool history tokens are estimates of retained text, not bytes\nproven to have been replayed on a provider request. Latest prepared-request\ncomponents and source-linked compaction/role economics are unavailable where\nthey were not captured. The report does not infer provider invoices or prove\ncomplete historical coverage.\n\n`--project WORKSPACE` filters current saved/thread/Fleet views. Original source\nworkspace is not yet captured, so native money and model subtotals are\nunavailable for that filter; mutable current metadata is not used to assign a\ncharge. Older receipt projections without reliable workspace scope are also\nunavailable under this filter. Without it, those legacy receipt views remain\nseparate fromLine truncated
"sha256": "618dd41d6edfb50b884d8634663b9d7ab75b1526a3f3e9121903631804e75e43",
"content": "# Configuration\n\ncodewhale reads configuration from a TOML file plus environment variables.\nAt process startup it may also load literal built-in-provider credentials from\na workspace-local `.env` file. Use the tracked `.env.example` as the template;\ncopy it to `.env`, then add only credential values.\n\nA workspace is not configuration authority. Codewhale therefore ignores\nconfig/profile/home paths, provider/model/base-URL routing, MCP/plugin state,\napproval/sandbox/shell posture, executable paths, runtime settings, and every\nother non-credential `.env` entry. Variable expansion is rejected so a\nrepository cannot substitute an ambient secret into a credential value. Use\n`config.toml`, CLI flags, or values exported by the launching shell for those\nexplicit control-plane settings. `.env` is read from a stable regular-file\nhandle, is capped at 1 MiB, and symbolic links, reparse points, and multiply\nlinked files are rejected.\n\n## Reading and checking configuration from the CLI\n\n`codewhale config get <key>` reads scalar keys, whole tables such as `tools`,\nand nested paths such as `tools.user_input_timeout_seconds`. Displayed tables\nand nested values apply the same recursive credential redaction as `config dump`.\n\n`config set` supports its named scalar keys and the provider, route, and\nnotification commands. It refuses a key that nothing reads and suggests the\nnearest real key (`config set calm_mod on` names `calm_mode`). A settings.toml\nkey such as `calm_mode` or `tool_collapse` is validated and written to the\nuser-global settings.toml, not config.toml; `config get` reads it back from\nthere even when an old config.toml copy (which nothing reads) is still present,\nand names that copy so you can `config unset` it. Settings keys have no project\nscope, so `--project` refuses them. A config.toml key is checked against the\ntype its reader expects and stored as a TOML boolean or number where the\nreader needs one (`yolo = true`, `max_subagents = 4`); `reasoning_effort`\naccepts the same aliases as `/effort` (#6563). Other dotted writes fail before\nmodifying the file and name the TOML table to edit. For example, set a tools\ntimeout in the file as:\n\n```toml\n[tools]\nuser_input_timeout_seconds = 0\n```\n\n`codewhale config doctor` checks credential presence and endpoint shape, and\nwarns about each config.toml root key that nothing reads: a settings.toml key\nleft in config.toml is named as misplaced, and any other unread key gets a\ndid-you-mean. Keys read by any runtime reader are not reported. The warnings do\nnot fail the check, and a clean result does not validate every runtime setting's\nvalue (#6083, #6563).\n\n## Usage and execution statistics\n\n`codewhale stats` is an alias of `codewhale metrics`; both use the same local,\nread-only backend. Use `--json` for the structured `stats` report. For example:\n\n```sh\ncodewhale stats --days 7 --models 10 --tools 20 --roles 10\ncodewhale metrics --project /path/to/workspace --json\n```\n\n`--days N` accepts a positive number of days and conflicts with `--since`.\n`--since 24h` and the existing duration syntax remain available. `--models N`,\n`--tools N` and `--roles N` select the top positive N rows by known tokens,\nidentified tool executions and recorded Fleet tasks respectively. These limits\nonly shorten the display; they do not remove stored receipts.\n\nThe report separates saved-document and Runtime views, frozen native-source\nmoney and token subtotals by actual model, retained tool-result history\nestimates and outcomes, completed compaction observations, and recorded Fleet\nroles. USD and CNY stay separate. A cumulative correction replaces its proven\npredecessor; copied Session/Runtime receipts and inherited Runtime fork rows\nare not added as new provider work. Conflicting source versions are shown\nseparately and excluded from the subtotal. Saved legacy display totals remain\nvisible as document projections without being charged again globally.\n\nCoverage is explicit. A missing quote, usage receipt, tool outcome, output\nshape or recovery link is unknown, not zero. Empty scalar usage from older\ntransport records cannot prove a reported zero. Token counts describe known\nreported data; tool history tokens are estimates of retained text, not bytes\nproven to have been replayed on a provider request. Latest prepared-request\ncomponents and source-linked compaction/role economics are unavailable where\nthey were not captured. The report does not infer provider invoices or prove\ncomplete historical coverage.\n\n`--project WORKSPACE` filters current saved/thread/Fleet views. Original source\nworkspace is not yet captured, so native money and model subtotals are\nunavailable for that filter; mutable current metadata is not used to assign a\ncharge. Older receipt projections without reliable workspace scope are also\nunavailable under this filter. Without it, those legacy receipt views remain\nseparate fromLine truncated
},
{
"path": "docs/LEGACY_PATHS.md",
@@ -96,8 +96,8 @@
},
{
"path": "docs/CONFIGURATION.md",
"sha256": "61fac67413aac35a4a67f410e174122a4ee09befbe2f2248fd3f9a6e4f08d99a",
"content": "# Configuration\n\ncodewhale reads configuration from a TOML file plus environment variables.\nAt process startup it may also load literal built-in-provider credentials from\na workspace-local `.env` file. Use the tracked `.env.example` as the template;\ncopy it to `.env`, then add only credential values.\n\nA workspace is not configuration authority. Codewhale therefore ignores\nconfig/profile/home paths, provider/model/base-URL routing, MCP/plugin state,\napproval/sandbox/shell posture, executable paths, runtime settings, and every\nother non-credential `.env` entry. Variable expansion is rejected so a\nrepository cannot substitute an ambient secret into a credential value. Use\n`config.toml`, CLI flags, or values exported by the launching shell for those\nexplicit control-plane settings. `.env` is read from a stable regular-file\nhandle, is capped at 1 MiB, and symbolic links, reparse points, and multiply\nlinked files are rejected.\n\n## Reading and checking configuration from the CLI\n\n`codewhale config get <key>` reads scalar keys, whole tables such as `tools`,\nand nested paths such as `tools.user_input_timeout_seconds`. Displayed tables\nand nested values apply the same recursive credential redaction as `config dump`.\n\n`config set` supports its named scalar keys and the provider, route, and\nnotification commands. It refuses a key that nothing reads and suggests the\nnearest real key (`config set calm_mod on` names `calm_mode`). A settings.toml\nkey such as `calm_mode` or `tool_collapse` is validated and written to the\nuser-global settings.toml, not config.toml; `config get` reads it back from\nthere even when an old config.toml copy (which nothing reads) is still present,\nand names that copy so you can `config unset` it. Settings keys have no project\nscope, so `--project` refuses them. A config.toml key is checked against the\ntype its reader expects and stored as a TOML boolean or number where the\nreader needs one (`yolo = true`, `max_subagents = 4`); `reasoning_effort`\naccepts the same aliases as `/effort` (#6563). Other dotted writes fail before\nmodifying the file and name the TOML table to edit. For example, set a tools\ntimeout in the file as:\n\n```toml\n[tools]\nuser_input_timeout_seconds = 0\n```\n\n`codewhale config doctor` checks credential presence and endpoint shape, and\nwarns about each config.toml root key that nothing reads: a settings.toml key\nleft in config.toml is named as misplaced, and any other unread key gets a\ndid-you-mean. Keys read by any runtime reader are not reported. The warnings do\nnot fail the check, and a clean result does not validate every runtime setting's\nvalue (#6083, #6563).\n\n## Usage and execution statistics\n\n`codewhale stats` is an alias of `codewhale metrics`; both use the same local,\nread-only backend. Use `--json` for the structured `stats` report. For example:\n\n```sh\ncodewhale stats --days 7 --models 10 --tools 20 --roles 10\ncodewhale metrics --project /path/to/workspace --json\n```\n\n`--days N` accepts a positive number of days and conflicts with `--since`.\n`--since 24h` and the existing duration syntax remain available. `--models N`,\n`--tools N` and `--roles N` select the top positive N rows by known tokens,\nidentified tool executions and recorded Fleet tasks respectively. These limits\nonly shorten the display; they do not remove stored receipts.\n\nThe report separates saved-document and Runtime views, frozen native-source\nmoney and token subtotals by actual model, retained tool-result history\nestimates and outcomes, completed compaction observations, and recorded Fleet\nroles. USD and CNY stay separate. A cumulative correction replaces its proven\npredecessor; copied Session/Runtime receipts and inherited Runtime fork rows\nare not added as new provider work. Conflicting source versions are shown\nseparately and excluded from the subtotal. Saved legacy display totals remain\nvisible as document projections without being charged again globally.\n\nCoverage is explicit. A missing quote, usage receipt, tool outcome, output\nshape or recovery link is unknown, not zero. Empty scalar usage from older\ntransport records cannot prove a reported zero. Token counts describe known\nreported data; tool history tokens are estimates of retained text, not bytes\nproven to have been replayed on a provider request. Latest prepared-request\ncomponents and source-linked compaction/role economics are unavailable where\nthey were not captured. The report does not infer provider invoices or prove\ncomplete historical coverage.\n\n`--project WORKSPACE` filters current saved/thread/Fleet views. Original source\nworkspace is not yet captured, so native money and model subtotals are\nunavailable for that filter; mutable current metadata is not used to assign a\ncharge. Older receipt projections without reliable workspace scope are also\nunavailable under this filter. Without it, those legacy receipt views remain\nseparate fromLine truncated
"sha256": "618dd41d6edfb50b884d8634663b9d7ab75b1526a3f3e9121903631804e75e43",
"content": "# Configuration\n\ncodewhale reads configuration from a TOML file plus environment variables.\nAt process startup it may also load literal built-in-provider credentials from\na workspace-local `.env` file. Use the tracked `.env.example` as the template;\ncopy it to `.env`, then add only credential values.\n\nA workspace is not configuration authority. Codewhale therefore ignores\nconfig/profile/home paths, provider/model/base-URL routing, MCP/plugin state,\napproval/sandbox/shell posture, executable paths, runtime settings, and every\nother non-credential `.env` entry. Variable expansion is rejected so a\nrepository cannot substitute an ambient secret into a credential value. Use\n`config.toml`, CLI flags, or values exported by the launching shell for those\nexplicit control-plane settings. `.env` is read from a stable regular-file\nhandle, is capped at 1 MiB, and symbolic links, reparse points, and multiply\nlinked files are rejected.\n\n## Reading and checking configuration from the CLI\n\n`codewhale config get <key>` reads scalar keys, whole tables such as `tools`,\nand nested paths such as `tools.user_input_timeout_seconds`. Displayed tables\nand nested values apply the same recursive credential redaction as `config dump`.\n\n`config set` supports its named scalar keys and the provider, route, and\nnotification commands. It refuses a key that nothing reads and suggests the\nnearest real key (`config set calm_mod on` names `calm_mode`). A settings.toml\nkey such as `calm_mode` or `tool_collapse` is validated and written to the\nuser-global settings.toml, not config.toml; `config get` reads it back from\nthere even when an old config.toml copy (which nothing reads) is still present,\nand names that copy so you can `config unset` it. Settings keys have no project\nscope, so `--project` refuses them. A config.toml key is checked against the\ntype its reader expects and stored as a TOML boolean or number where the\nreader needs one (`yolo = true`, `max_subagents = 4`); `reasoning_effort`\naccepts the same aliases as `/effort` (#6563). Other dotted writes fail before\nmodifying the file and name the TOML table to edit. For example, set a tools\ntimeout in the file as:\n\n```toml\n[tools]\nuser_input_timeout_seconds = 0\n```\n\n`codewhale config doctor` checks credential presence and endpoint shape, and\nwarns about each config.toml root key that nothing reads: a settings.toml key\nleft in config.toml is named as misplaced, and any other unread key gets a\ndid-you-mean. Keys read by any runtime reader are not reported. The warnings do\nnot fail the check, and a clean result does not validate every runtime setting's\nvalue (#6083, #6563).\n\n## Usage and execution statistics\n\n`codewhale stats` is an alias of `codewhale metrics`; both use the same local,\nread-only backend. Use `--json` for the structured `stats` report. For example:\n\n```sh\ncodewhale stats --days 7 --models 10 --tools 20 --roles 10\ncodewhale metrics --project /path/to/workspace --json\n```\n\n`--days N` accepts a positive number of days and conflicts with `--since`.\n`--since 24h` and the existing duration syntax remain available. `--models N`,\n`--tools N` and `--roles N` select the top positive N rows by known tokens,\nidentified tool executions and recorded Fleet tasks respectively. These limits\nonly shorten the display; they do not remove stored receipts.\n\nThe report separates saved-document and Runtime views, frozen native-source\nmoney and token subtotals by actual model, retained tool-result history\nestimates and outcomes, completed compaction observations, and recorded Fleet\nroles. USD and CNY stay separate. A cumulative correction replaces its proven\npredecessor; copied Session/Runtime receipts and inherited Runtime fork rows\nare not added as new provider work. Conflicting source versions are shown\nseparately and excluded from the subtotal. Saved legacy display totals remain\nvisible as document projections without being charged again globally.\n\nCoverage is explicit. A missing quote, usage receipt, tool outcome, output\nshape or recovery link is unknown, not zero. Empty scalar usage from older\ntransport records cannot prove a reported zero. Token counts describe known\nreported data; tool history tokens are estimates of retained text, not bytes\nproven to have been replayed on a provider request. Latest prepared-request\ncomponents and source-linked compaction/role economics are unavailable where\nthey were not captured. The report does not infer provider invoices or prove\ncomplete historical coverage.\n\n`--project WORKSPACE` filters current saved/thread/Fleet views. Original source\nworkspace is not yet captured, so native money and model subtotals are\nunavailable for that filter; mutable current metadata is not used to assign a\ncharge. Older receipt projections without reliable workspace scope are also\nunavailable under this filter. Without it, those legacy receipt views remain\nseparate fromLine truncated
}
]
},
@@ -224,8 +224,8 @@
},
{
"path": "docs/CONFIGURATION.md",
"sha256": "61fac67413aac35a4a67f410e174122a4ee09befbe2f2248fd3f9a6e4f08d99a",
"content": "# Configuration\n\ncodewhale reads configuration from a TOML file plus environment variables.\nAt process startup it may also load literal built-in-provider credentials from\na workspace-local `.env` file. Use the tracked `.env.example` as the template;\ncopy it to `.env`, then add only credential values.\n\nA workspace is not configuration authority. Codewhale therefore ignores\nconfig/profile/home paths, provider/model/base-URL routing, MCP/plugin state,\napproval/sandbox/shell posture, executable paths, runtime settings, and every\nother non-credential `.env` entry. Variable expansion is rejected so a\nrepository cannot substitute an ambient secret into a credential value. Use\n`config.toml`, CLI flags, or values exported by the launching shell for those\nexplicit control-plane settings. `.env` is read from a stable regular-file\nhandle, is capped at 1 MiB, and symbolic links, reparse points, and multiply\nlinked files are rejected.\n\n## Reading and checking configuration from the CLI\n\n`codewhale config get <key>` reads scalar keys, whole tables such as `tools`,\nand nested paths such as `tools.user_input_timeout_seconds`. Displayed tables\nand nested values apply the same recursive credential redaction as `config dump`.\n\n`config set` supports its named scalar keys and the provider, route, and\nnotification commands. It refuses a key that nothing reads and suggests the\nnearest real key (`config set calm_mod on` names `calm_mode`). A settings.toml\nkey such as `calm_mode` or `tool_collapse` is validated and written to the\nuser-global settings.toml, not config.toml; `config get` reads it back from\nthere even when an old config.toml copy (which nothing reads) is still present,\nand names that copy so you can `config unset` it. Settings keys have no project\nscope, so `--project` refuses them. A config.toml key is checked against the\ntype its reader expects and stored as a TOML boolean or number where the\nreader needs one (`yolo = true`, `max_subagents = 4`); `reasoning_effort`\naccepts the same aliases as `/effort` (#6563). Other dotted writes fail before\nmodifying the file and name the TOML table to edit. For example, set a tools\ntimeout in the file as:\n\n```toml\n[tools]\nuser_input_timeout_seconds = 0\n```\n\n`codewhale config doctor` checks credential presence and endpoint shape, and\nwarns about each config.toml root key that nothing reads: a settings.toml key\nleft in config.toml is named as misplaced, and any other unread key gets a\ndid-you-mean. Keys read by any runtime reader are not reported. The warnings do\nnot fail the check, and a clean result does not validate every runtime setting's\nvalue (#6083, #6563).\n\n## Usage and execution statistics\n\n`codewhale stats` is an alias of `codewhale metrics`; both use the same local,\nread-only backend. Use `--json` for the structured `stats` report. For example:\n\n```sh\ncodewhale stats --days 7 --models 10 --tools 20 --roles 10\ncodewhale metrics --project /path/to/workspace --json\n```\n\n`--days N` accepts a positive number of days and conflicts with `--since`.\n`--since 24h` and the existing duration syntax remain available. `--models N`,\n`--tools N` and `--roles N` select the top positive N rows by known tokens,\nidentified tool executions and recorded Fleet tasks respectively. These limits\nonly shorten the display; they do not remove stored receipts.\n\nThe report separates saved-document and Runtime views, frozen native-source\nmoney and token subtotals by actual model, retained tool-result history\nestimates and outcomes, completed compaction observations, and recorded Fleet\nroles. USD and CNY stay separate. A cumulative correction replaces its proven\npredecessor; copied Session/Runtime receipts and inherited Runtime fork rows\nare not added as new provider work. Conflicting source versions are shown\nseparately and excluded from the subtotal. Saved legacy display totals remain\nvisible as document projections without being charged again globally.\n\nCoverage is explicit. A missing quote, usage receipt, tool outcome, output\nshape or recovery link is unknown, not zero. Empty scalar usage from older\ntransport records cannot prove a reported zero. Token counts describe known\nreported data; tool history tokens are estimates of retained text, not bytes\nproven to have been replayed on a provider request. Latest prepared-request\ncomponents and source-linked compaction/role economics are unavailable where\nthey were not captured. The report does not infer provider invoices or prove\ncomplete historical coverage.\n\n`--project WORKSPACE` filters current saved/thread/Fleet views. Original source\nworkspace is not yet captured, so native money and model subtotals are\nunavailable for that filter; mutable current metadata is not used to assign a\ncharge. Older receipt projections without reliable workspace scope are also\nunavailable under this filter. Without it, those legacy receipt views remain\nseparate fromLine truncated
"sha256": "618dd41d6edfb50b884d8634663b9d7ab75b1526a3f3e9121903631804e75e43",
"content": "# Configuration\n\ncodewhale reads configuration from a TOML file plus environment variables.\nAt process startup it may also load literal built-in-provider credentials from\na workspace-local `.env` file. Use the tracked `.env.example` as the template;\ncopy it to `.env`, then add only credential values.\n\nA workspace is not configuration authority. Codewhale therefore ignores\nconfig/profile/home paths, provider/model/base-URL routing, MCP/plugin state,\napproval/sandbox/shell posture, executable paths, runtime settings, and every\nother non-credential `.env` entry. Variable expansion is rejected so a\nrepository cannot substitute an ambient secret into a credential value. Use\n`config.toml`, CLI flags, or values exported by the launching shell for those\nexplicit control-plane settings. `.env` is read from a stable regular-file\nhandle, is capped at 1 MiB, and symbolic links, reparse points, and multiply\nlinked files are rejected.\n\n## Reading and checking configuration from the CLI\n\n`codewhale config get <key>` reads scalar keys, whole tables such as `tools`,\nand nested paths such as `tools.user_input_timeout_seconds`. Displayed tables\nand nested values apply the same recursive credential redaction as `config dump`.\n\n`config set` supports its named scalar keys and the provider, route, and\nnotification commands. It refuses a key that nothing reads and suggests the\nnearest real key (`config set calm_mod on` names `calm_mode`). A settings.toml\nkey such as `calm_mode` or `tool_collapse` is validated and written to the\nuser-global settings.toml, not config.toml; `config get` reads it back from\nthere even when an old config.toml copy (which nothing reads) is still present,\nand names that copy so you can `config unset` it. Settings keys have no project\nscope, so `--project` refuses them. A config.toml key is checked against the\ntype its reader expects and stored as a TOML boolean or number where the\nreader needs one (`yolo = true`, `max_subagents = 4`); `reasoning_effort`\naccepts the same aliases as `/effort` (#6563). Other dotted writes fail before\nmodifying the file and name the TOML table to edit. For example, set a tools\ntimeout in the file as:\n\n```toml\n[tools]\nuser_input_timeout_seconds = 0\n```\n\n`codewhale config doctor` checks credential presence and endpoint shape, and\nwarns about each config.toml root key that nothing reads: a settings.toml key\nleft in config.toml is named as misplaced, and any other unread key gets a\ndid-you-mean. Keys read by any runtime reader are not reported. The warnings do\nnot fail the check, and a clean result does not validate every runtime setting's\nvalue (#6083, #6563).\n\n## Usage and execution statistics\n\n`codewhale stats` is an alias of `codewhale metrics`; both use the same local,\nread-only backend. Use `--json` for the structured `stats` report. For example:\n\n```sh\ncodewhale stats --days 7 --models 10 --tools 20 --roles 10\ncodewhale metrics --project /path/to/workspace --json\n```\n\n`--days N` accepts a positive number of days and conflicts with `--since`.\n`--since 24h` and the existing duration syntax remain available. `--models N`,\n`--tools N` and `--roles N` select the top positive N rows by known tokens,\nidentified tool executions and recorded Fleet tasks respectively. These limits\nonly shorten the display; they do not remove stored receipts.\n\nThe report separates saved-document and Runtime views, frozen native-source\nmoney and token subtotals by actual model, retained tool-result history\nestimates and outcomes, completed compaction observations, and recorded Fleet\nroles. USD and CNY stay separate. A cumulative correction replaces its proven\npredecessor; copied Session/Runtime receipts and inherited Runtime fork rows\nare not added as new provider work. Conflicting source versions are shown\nseparately and excluded from the subtotal. Saved legacy display totals remain\nvisible as document projections without being charged again globally.\n\nCoverage is explicit. A missing quote, usage receipt, tool outcome, output\nshape or recovery link is unknown, not zero. Empty scalar usage from older\ntransport records cannot prove a reported zero. Token counts describe known\nreported data; tool history tokens are estimates of retained text, not bytes\nproven to have been replayed on a provider request. Latest prepared-request\ncomponents and source-linked compaction/role economics are unavailable where\nthey were not captured. The report does not infer provider invoices or prove\ncomplete historical coverage.\n\n`--project WORKSPACE` filters current saved/thread/Fleet views. Original source\nworkspace is not yet captured, so native money and model subtotals are\nunavailable for that filter; mutable current metadata is not used to assign a\ncharge. Older receipt projections without reliable workspace scope are also\nunavailable under this filter. Without it, those legacy receipt views remain\nseparate fromLine truncated
}
]
},
@@ -256,8 +256,8 @@
},
{
"path": "docs/CONFIGURATION.md",
"sha256": "61fac67413aac35a4a67f410e174122a4ee09befbe2f2248fd3f9a6e4f08d99a",
"content": "# Configuration\n\ncodewhale reads configuration from a TOML file plus environment variables.\nAt process startup it may also load literal built-in-provider credentials from\na workspace-local `.env` file. Use the tracked `.env.example` as the template;\ncopy it to `.env`, then add only credential values.\n\nA workspace is not configuration authority. Codewhale therefore ignores\nconfig/profile/home paths, provider/model/base-URL routing, MCP/plugin state,\napproval/sandbox/shell posture, executable paths, runtime settings, and every\nother non-credential `.env` entry. Variable expansion is rejected so a\nrepository cannot substitute an ambient secret into a credential value. Use\n`config.toml`, CLI flags, or values exported by the launching shell for those\nexplicit control-plane settings. `.env` is read from a stable regular-file\nhandle, is capped at 1 MiB, and symbolic links, reparse points, and multiply\nlinked files are rejected.\n\n## Reading and checking configuration from the CLI\n\n`codewhale config get <key>` reads scalar keys, whole tables such as `tools`,\nand nested paths such as `tools.user_input_timeout_seconds`. Displayed tables\nand nested values apply the same recursive credential redaction as `config dump`.\n\n`config set` supports its named scalar keys and the provider, route, and\nnotification commands. It refuses a key that nothing reads and suggests the\nnearest real key (`config set calm_mod on` names `calm_mode`). A settings.toml\nkey such as `calm_mode` or `tool_collapse` is validated and written to the\nuser-global settings.toml, not config.toml; `config get` reads it back from\nthere even when an old config.toml copy (which nothing reads) is still present,\nand names that copy so you can `config unset` it. Settings keys have no project\nscope, so `--project` refuses them. A config.toml key is checked against the\ntype its reader expects and stored as a TOML boolean or number where the\nreader needs one (`yolo = true`, `max_subagents = 4`); `reasoning_effort`\naccepts the same aliases as `/effort` (#6563). Other dotted writes fail before\nmodifying the file and name the TOML table to edit. For example, set a tools\ntimeout in the file as:\n\n```toml\n[tools]\nuser_input_timeout_seconds = 0\n```\n\n`codewhale config doctor` checks credential presence and endpoint shape, and\nwarns about each config.toml root key that nothing reads: a settings.toml key\nleft in config.toml is named as misplaced, and any other unread key gets a\ndid-you-mean. Keys read by any runtime reader are not reported. The warnings do\nnot fail the check, and a clean result does not validate every runtime setting's\nvalue (#6083, #6563).\n\n## Usage and execution statistics\n\n`codewhale stats` is an alias of `codewhale metrics`; both use the same local,\nread-only backend. Use `--json` for the structured `stats` report. For example:\n\n```sh\ncodewhale stats --days 7 --models 10 --tools 20 --roles 10\ncodewhale metrics --project /path/to/workspace --json\n```\n\n`--days N` accepts a positive number of days and conflicts with `--since`.\n`--since 24h` and the existing duration syntax remain available. `--models N`,\n`--tools N` and `--roles N` select the top positive N rows by known tokens,\nidentified tool executions and recorded Fleet tasks respectively. These limits\nonly shorten the display; they do not remove stored receipts.\n\nThe report separates saved-document and Runtime views, frozen native-source\nmoney and token subtotals by actual model, retained tool-result history\nestimates and outcomes, completed compaction observations, and recorded Fleet\nroles. USD and CNY stay separate. A cumulative correction replaces its proven\npredecessor; copied Session/Runtime receipts and inherited Runtime fork rows\nare not added as new provider work. Conflicting source versions are shown\nseparately and excluded from the subtotal. Saved legacy display totals remain\nvisible as document projections without being charged again globally.\n\nCoverage is explicit. A missing quote, usage receipt, tool outcome, output\nshape or recovery link is unknown, not zero. Empty scalar usage from older\ntransport records cannot prove a reported zero. Token counts describe known\nreported data; tool history tokens are estimates of retained text, not bytes\nproven to have been replayed on a provider request. Latest prepared-request\ncomponents and source-linked compaction/role economics are unavailable where\nthey were not captured. The report does not infer provider invoices or prove\ncomplete historical coverage.\n\n`--project WORKSPACE` filters current saved/thread/Fleet views. Original source\nworkspace is not yet captured, so native money and model subtotals are\nunavailable for that filter; mutable current metadata is not used to assign a\ncharge. Older receipt projections without reliable workspace scope are also\nunavailable under this filter. Without it, those legacy receipt views remain\nseparate fromLine truncated
"sha256": "618dd41d6edfb50b884d8634663b9d7ab75b1526a3f3e9121903631804e75e43",
"content": "# Configuration\n\ncodewhale reads configuration from a TOML file plus environment variables.\nAt process startup it may also load literal built-in-provider credentials from\na workspace-local `.env` file. Use the tracked `.env.example` as the template;\ncopy it to `.env`, then add only credential values.\n\nA workspace is not configuration authority. Codewhale therefore ignores\nconfig/profile/home paths, provider/model/base-URL routing, MCP/plugin state,\napproval/sandbox/shell posture, executable paths, runtime settings, and every\nother non-credential `.env` entry. Variable expansion is rejected so a\nrepository cannot substitute an ambient secret into a credential value. Use\n`config.toml`, CLI flags, or values exported by the launching shell for those\nexplicit control-plane settings. `.env` is read from a stable regular-file\nhandle, is capped at 1 MiB, and symbolic links, reparse points, and multiply\nlinked files are rejected.\n\n## Reading and checking configuration from the CLI\n\n`codewhale config get <key>` reads scalar keys, whole tables such as `tools`,\nand nested paths such as `tools.user_input_timeout_seconds`. Displayed tables\nand nested values apply the same recursive credential redaction as `config dump`.\n\n`config set` supports its named scalar keys and the provider, route, and\nnotification commands. It refuses a key that nothing reads and suggests the\nnearest real key (`config set calm_mod on` names `calm_mode`). A settings.toml\nkey such as `calm_mode` or `tool_collapse` is validated and written to the\nuser-global settings.toml, not config.toml; `config get` reads it back from\nthere even when an old config.toml copy (which nothing reads) is still present,\nand names that copy so you can `config unset` it. Settings keys have no project\nscope, so `--project` refuses them. A config.toml key is checked against the\ntype its reader expects and stored as a TOML boolean or number where the\nreader needs one (`yolo = true`, `max_subagents = 4`); `reasoning_effort`\naccepts the same aliases as `/effort` (#6563). Other dotted writes fail before\nmodifying the file and name the TOML table to edit. For example, set a tools\ntimeout in the file as:\n\n```toml\n[tools]\nuser_input_timeout_seconds = 0\n```\n\n`codewhale config doctor` checks credential presence and endpoint shape, and\nwarns about each config.toml root key that nothing reads: a settings.toml key\nleft in config.toml is named as misplaced, and any other unread key gets a\ndid-you-mean. Keys read by any runtime reader are not reported. The warnings do\nnot fail the check, and a clean result does not validate every runtime setting's\nvalue (#6083, #6563).\n\n## Usage and execution statistics\n\n`codewhale stats` is an alias of `codewhale metrics`; both use the same local,\nread-only backend. Use `--json` for the structured `stats` report. For example:\n\n```sh\ncodewhale stats --days 7 --models 10 --tools 20 --roles 10\ncodewhale metrics --project /path/to/workspace --json\n```\n\n`--days N` accepts a positive number of days and conflicts with `--since`.\n`--since 24h` and the existing duration syntax remain available. `--models N`,\n`--tools N` and `--roles N` select the top positive N rows by known tokens,\nidentified tool executions and recorded Fleet tasks respectively. These limits\nonly shorten the display; they do not remove stored receipts.\n\nThe report separates saved-document and Runtime views, frozen native-source\nmoney and token subtotals by actual model, retained tool-result history\nestimates and outcomes, completed compaction observations, and recorded Fleet\nroles. USD and CNY stay separate. A cumulative correction replaces its proven\npredecessor; copied Session/Runtime receipts and inherited Runtime fork rows\nare not added as new provider work. Conflicting source versions are shown\nseparately and excluded from the subtotal. Saved legacy display totals remain\nvisible as document projections without being charged again globally.\n\nCoverage is explicit. A missing quote, usage receipt, tool outcome, output\nshape or recovery link is unknown, not zero. Empty scalar usage from older\ntransport records cannot prove a reported zero. Token counts describe known\nreported data; tool history tokens are estimates of retained text, not bytes\nproven to have been replayed on a provider request. Latest prepared-request\ncomponents and source-linked compaction/role economics are unavailable where\nthey were not captured. The report does not infer provider invoices or prove\ncomplete historical coverage.\n\n`--project WORKSPACE` filters current saved/thread/Fleet views. Original source\nworkspace is not yet captured, so native money and model subtotals are\nunavailable for that filter; mutable current metadata is not used to assign a\ncharge. Older receipt projections without reliable workspace scope are also\nunavailable under this filter. Without it, those legacy receipt views remain\nseparate fromLine truncated
}
]
},
@@ -272,8 +272,8 @@
},
{
"path": "docs/PLUGIN_BUNDLES.md",
"sha256": "5595f972609b6fcc425b830eb13bfc75095107e77152149a1cd6f890751b205c",
"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": "bc178f6e0c60792f135476ea2e301796125089c5cae164f69af906212a56b9ce",
"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",
@@ -293,8 +293,8 @@
"sources": [
{
"path": "docs/RUNTIME_API.md",
"sha256": "e6de91a96541df2ef5fabd8e8516a6ca8082addff087dd6e7ed16d5ea77aadce",
"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": "f4aa394d8b349d7dcecbb85f55c3af4a08f77339728a8aec39b76b03b794f3ce",
"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
}
]
},
+2 -2
View File
@@ -29,8 +29,8 @@ and [upstream issue and recommended depth guard](https://github.com/micromatch/b
## Minimatch globparts amplification
Dependabot #244 reports [GHSA-g3hv-75wg-m249](https://github.com/advisories/GHSA-g3hv-75wg-m249)
against minimatch versions below 11.0.1. The upstream report identifies
The advisory [GHSA-g3hv-75wg-m249](https://github.com/advisories/GHSA-g3hv-75wg-m249) affects
minimatch versions below 11.0.1. The upstream report identifies
`firstPhasePreProcess` at optimization level 2 or higher. The installed 8.0.7
and 10.2.5 copies contain that path; the six 3.1.5 copies do not contain it.
The broad registry advisory remains visible for all of these old versions.